How it works
- Request an app on the Developer API page: its name and what it does with the logs.
- We review it. Once it is approved, generate its token on the app page. The token is shown once; keep it secret.
- 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.
- 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.
| Request | Returns |
|---|---|
GET /me | Your app: id, name, status, servers (how many have it on). |
GET /servers | Servers that turned your app on: id (use it in the URLs below), serviceId (Nitrado), name, map, platform, scopes, enabledAt. |
GET /servers/{id}/logs | Raw ADM log lines. Needs the logs scope. |
GET /servers/{id}/events | Parsed 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 newestlimitentries (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
cursorfrom each response and send it asafternext time. With nothing new,datais empty and the cursor stays the same. more: truemeans there is more right now: ask again straight away with the new cursor.gap: truemeans 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.
| type | What happened |
|---|---|
kill | A player killed another player. |
hit | A player hit another player. |
dmg | Damage from anything but a player: infected, animals, falls, vehicles, explosions. |
death | A player died, with the cause. |
uncon | A player went unconscious. |
wake | A player regained consciousness. |
connect | A player connected. |
disconnect | A player disconnected. |
build | A player built, dismantled, placed or raised something. |
pos | Where a player is (at most one sample per player every 20 seconds). |
| Field | Meaning |
|---|---|
id, time, serverClock | Stream ID (the cursor), UTC unix time, and the time as the server logged it. |
type, player | Event 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, victimPosition | Who was killed or hit, and where. |
weapon, distance | Weapon and distance in metres (kills and hits). |
bodyPart, damage, health | Where a hit or damage landed, how much, and the health (0-100) left after it. |
source, via, count | dmg only: what did the damage (Infected, Fall, a vehicle...), what it used, and how many hits in a row this one stands for. |
cause | death only: bled out, suicide, fall, a weapon... |
action | build 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 bodywith 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
idyou got asafter. - 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": "..."}}.
| Status | code | Meaning |
|---|---|---|
| 401 | missing_token, invalid_token | No token, or not a current one. |
| 403 | app_pending, app_suspended, ... | The app is not approved (any more). |
| 403 | scope_not_granted | The server did not give you this kind of data. |
| 404 | server_not_found | That server does not have your app on (or turned it off). |
| 422 | bad_cursor, bad_types | A malformed after, or an unknown event type. |
| 429 | rate_limited | Too 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.