# Webhooks

dzbot tells your bot when something happens on your server — a kill, a new order, a ticket reply — instead of your bot asking every minute. Every delivery is signed, so your bot knows it comes from dzbot.

## Set up

In your server's admin area under **Developers**, open your [app](https://developers.dzbot.de/api/apps), then **Webhooks**: add your endpoint's URL (https, reachable from the internet) and pick the [events](https://developers.dzbot.de/sdk/events) it receives. Copy the signing secret. **Send test** delivers a sample right away.

An endpoint only receives events its app may read: each event names the rights that open it, and the app's rights ceiling has to hold one of them.

## Receive

Express — the signature is computed over the raw body, so mount `express.raw` on this route, before any JSON parser:

```
import express from 'express'
import { expressWebhook, isEvent } from '@dzbot/sdk'

const app = express()
app.post('/webhooks/dzbot', express.raw({ type: 'application/json' }), expressWebhook(process.env.DZBOT_WEBHOOK_SECRET, async (event) => {
  if (isEvent(event, 'log.player_kill')) {
    const { killer, victim, weapon, distance } = event.payload.data
    console.log(`${killer.username} killed ${victim.username} with ${weapon} at ${distance} m`)
  }
}))
```

Workers, Deno, Bun, Next.js route handlers — anything with `Request` and `Response`:

```
import { webhookHandler } from '@dzbot/sdk'

export const POST = webhookHandler(process.env.DZBOT_WEBHOOK_SECRET, async (event) => { /* … */ })
```

Both answer `400` on a bad signature, `200` once your handler is done and `500` when it throws — then dzbot tries again later. `verifyWebhook(rawBody, header, secret)` and `parseEvent(rawBody, header, secret)` are there for everything else.

## Delivery

A `POST` with `content-type: application/json`; the body is the event:

```
{
  "id": "9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a90",
  "name": "order.created",
  "organizationId": 7,
  "serverId": 12,
  "occurredAt": "2026-10-27T18:04:11.532+00:00",
  "payload": { "order": { "id": 4711, "code": "7KQM", "total": 2500 }, "player": { "id": 1609285, "username": "Survivor" } }
}
```

| Header | Meaning |
| --- | --- |
| `dzbot-signature` | `t=<unix seconds>,v1=<signature>` — see below |
| `dzbot-webhook-id` | The event's id. The same event can arrive twice; skip ids you have handled |
| `dzbot-event` | The event name |
| `user-agent` | `dzbot-webhooks/1` |

Answer with any 2xx within 10 seconds; do slow work after answering. Events from the game logs arrive as soon as dzbot has read them, which is the 5–15 minutes it takes Nitrado to hand out the logs.

## Signature

`v1` is the hex HMAC-SHA256 of `<t>.<raw body>` with your signing secret. Check it in constant time and reject deliveries whose `t` is more than 5 minutes off your clock.

```
// Node, without the SDK
import { createHmac, timingSafeEqual } from 'node:crypto'

const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const ok = timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - Number(t)) <= 300
```

The SDK and dzbot test against the same [signature examples](https://github.com/siemendev/dzbot-sdk/blob/main/packages/sdk/test/vectors/webhook-signature.json) — use them to test your own implementation.

## Retries

A delivery that gets no 2xx is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, then given up. An endpoint that has failed for 24 hours is paused, and the organization's admins get a Discord message; switch it on again in the app once it works. The app shows every delivery of the last 7 days with its answer.

---

Web page: https://developers.dzbot.de/sdk/webhooks · All pages: https://developers.dzbot.de/llms.txt
