Request an app See an example

How it works

  1. Request an app on the Developer API page: its name and what it does with the logs.
  2. We review it. Once it is approved, generate its token on the app page. The token is shown once; keep it secret.
  3. Server owners turn your app on in their server settings under Apps, and choose what it may read: raw logs, parsed events, or both. Your app page lists every server that did. You get what the server logs from that moment on, never anything older.
  4. Read the data by polling the API with a cursor, or get it pushed to your webhook after every log pass. Both carry the same IDs, so you can mix them.

Data comes from the same log reads as the kill feed: the bot fetches each server's ADM log every few minutes, so new entries arrive in batches. Each server keeps about the last 20,000 entries per stream (a day or more for most servers, around 8 hours for the busiest).

Authentication

Send the app token as a bearer token on every request:

curl -H "Authorization: Bearer dzb_your_token" https://dayzbot.com/api/v1/servers

Generating a new token on the app page stops the old one at once.

Endpoints

Base URL: https://dayzbot.com/api/v1. Every answer is JSON.

RequestReturns
GET /meYour app: id, name, status, servers (how many have it on).
GET /serversServers that turned your app on: id (use it in the URLs below), serviceId (Nitrado), name, map, platform, scopes, enabledAt.
GET /servers/{id}/logsRaw ADM log lines. Needs the logs scope.
GET /servers/{id}/eventsParsed events. Needs the events scope. Optional filters: types=kill,hit, player=Name (matches the player or the victim).

Cursors: only what you have not seen

Logs and events are streams. Every entry has an id like 1760300012345-0 that only ever grows, and every response hands you a cursor:

  • No after: the newest limit entries (default 100, at most 500). Use it once to start.
  • after=<cursor>: the entries after that cursor, oldest first. Nothing is skipped, nothing comes twice.
  • Save cursor from each response and send it as after next time. With nothing new, data is empty and the cursor stays the same.
  • more: true means there is more right now: ask again straight away with the new cursor.
  • gap: true means entries after your cursor were already dropped (you were away longer than the server keeps). You get what is left.
GET /api/v1/servers/4350/logs?limit=30          → newest 30 lines,  "cursor": "1760300012345-0"
GET /api/v1/servers/4350/logs?after=1760300012345-0 → only newer lines, new cursor
GET /api/v1/servers/4350/logs?after=1760300099000-3 → "data": [] when nothing is new
{
  "data":   [ ... ],
  "cursor": "1760300099000-3",
  "more":   false,
  "gap":    false
}

Polling every 30 to 60 seconds is plenty: logs only arrive every few minutes.

Logs

One entry per ADM line, exactly as the server wrote it, so you can parse it your own way.

{
  "id":   "1760300012345-0",
  "time": 1760300011.0,
  "line": "20:50:41 | Player \"Bob\" (id=jHdf6De1Et...= pos=<2377.2, 10398.6, 273.3>)[HP: 25.5] hit by Player \"Tom\" (id=K_OA3z...= pos=<2375.7, 10401.6, 273.3>) into Torso(31) for 4.5 damage (MeleeFist_Heavy)"
}

time is the line's time in UTC (unix seconds), worked out from the server's clock; it is null until the bot knows the server's time zone. The line itself keeps the server's own clock.

Events

The same lines, already parsed. Fields that do not apply are left out.

typeWhat happened
killA player killed another player.
hitA player hit another player.
dmgDamage from anything but a player: infected, animals, falls, vehicles, explosions.
deathA player died, with the cause.
unconA player went unconscious.
wakeA player regained consciousness.
connectA player connected.
disconnectA player disconnected.
buildA player built, dismantled, placed or raised something.
posWhere a player is (at most one sample per player every 20 seconds).
FieldMeaning
id, time, serverClockStream ID (the cursor), UTC unix time, and the time as the server logged it.
type, playerEvent type and the player it is about (the killer for kill, the shooter for hit).
position{"x": .., "z": ..} map coordinates of player (z is north).
victim, victimPositionWho was killed or hit, and where.
weapon, distanceWeapon and distance in metres (kills and hits).
bodyPart, damage, healthWhere a hit or damage landed, how much, and the health (0-100) left after it.
source, via, countdmg only: what did the damage (Infected, Fall, a vehicle...), what it used, and how many hits in a row this one stands for.
causedeath only: bled out, suicide, fall, a weapon...
actionbuild only: what was built, dismantled, placed or raised.
{
  "id": "1760300012345-1", "time": 1760300012.0, "serverClock": "20:50:42",
  "type": "kill", "player": "Tom", "position": {"x": 2375.7, "z": 10401.6},
  "victim": "Bob", "victimPosition": {"x": 2377.2, "z": 10398.6},
  "weapon": "M4-A1", "distance": 3
}

Webhooks

Set a webhook URL (https) on your app page and the bot POSTs every server's new entries to it after each log pass, so you do not have to poll. Each delivery holds one server and the scopes that server gave you:

POST https://your.app/dayz-hook
Content-Type: application/json
X-DayZBot-Timestamp: 1760300100
X-DayZBot-Signature: sha256=5f1c...

{
  "app": 12,
  "server": {"id": 4350, "serviceId": 1234567, "name": "Murderhouse"},
  "logs":   [ {"id": "...", "time": ..., "line": "..."} ],
  "events": [ {"id": "...", "type": "kill", ...} ]
}
  • Check the signature: HMAC-SHA256 of timestamp + "." + raw body with your webhook secret (on the app page). Refuse anything older than 5 minutes.
  • A big log pass is split into several deliveries, in order, with at most 500 logs and 500 events each.
  • Answer with any 2xx within 10 seconds. Failed deliveries are not sent again: fill the hole with the API, using the last id you got as after.
  • After 100 failed deliveries in a row the webhook is switched off. Turn it back on from the app page.
# Python (Flask)
import hashlib, hmac, time
from flask import Flask, request, abort

SECRET = b"your-webhook-secret"
app = Flask(__name__)

@app.post("/dayz-hook")
def hook():
    ts = request.headers.get("X-DayZBot-Timestamp", "0")
    sig = request.headers.get("X-DayZBot-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected) or abs(time.time() - int(ts)) > 300:
        abort(401)
    payload = request.get_json()
    for event in payload.get("events", []):
        print(payload["server"]["name"], event["type"], event.get("player"))
    return "", 204
// Node (Express): read the raw body for the signature
const crypto = require("crypto");
app.post("/dayz-hook", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-DayZBot-Timestamp") || "0";
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.DAYZBOT_SECRET)
    .update(ts + "." + req.body).digest("hex");
  const sig = req.get("X-DayZBot-Signature") || "";
  if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
      || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
  const payload = JSON.parse(req.body);
  // ... payload.logs, payload.events
  res.sendStatus(204);
});

Limits & errors

  • 120 requests a minute per app, across all its servers. Over that: 429.
  • Errors look like {"error": {"code": "...", "message": "..."}}.
StatuscodeMeaning
401missing_token, invalid_tokenNo token, or not a current one.
403app_pending, app_suspended, ...The app is not approved (any more).
403scope_not_grantedThe server did not give you this kind of data.
404server_not_foundThat server does not have your app on (or turned it off).
422bad_cursor, bad_typesA malformed after, or an unknown event type.
429rate_limitedToo many requests; wait a minute.

Example: follow every server's kills

import json, os, time, requests

TOKEN = os.environ["DAYZBOT_TOKEN"]
API = "https://dayzbot.com/api/v1"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
STATE = "cursors.json"

cursors = json.load(open(STATE)) if os.path.exists(STATE) else {}

while True:
    for server in requests.get(f"{API}/servers", headers=HEADERS).json()["data"]:
        if "events" not in server["scopes"]:
            continue
        sid = str(server["id"])
        while True:
            params = {"types": "kill", "limit": 500}
            if sid in cursors:
                params["after"] = cursors[sid]
            page = requests.get(f"{API}/servers/{sid}/events", headers=HEADERS, params=params).json()
            for kill in page["data"]:
                print(server["name"], kill["player"], "killed", kill["victim"], "with", kill.get("weapon"))
            if page["cursor"]:
                cursors[sid] = page["cursor"]
            if not page["more"]:
                break
    json.dump(cursors, open(STATE, "w"))
    time.sleep(60)

Questions or ideas: ask in our Discord.