---
title: Webhooks
description: Receive signed dartpilot events when games and tournaments start or finish.
sidebar:
  badge: Beta
---

Webhooks let your own service react when something happens in dartpilot — a game
starts or finishes, or a tournament advances. dartpilot sends an HTTPS `POST`
with a signed JSON body to your endpoint.

:::caution[Beta]
Webhooks are in beta. Event names, payload fields, and retry behavior may change
before general availability. Pin the `apiVersion` field and ignore fields you do
not recognize.
:::

## Create a webhook

1. Sign in, open the account menu in the top right, and choose **Profile**.
2. Scroll to **Webhooks** and select **Create webhook**.
3. Give it a name, paste an HTTPS endpoint URL, and choose at least one event.
4. Select **Create**, then copy the signing secret from the dialog. The full
   secret is shown only once.

Use **Send test** from the webhook menu to send a synthetic `ping` event before
real games start. A webhook can be edited later to add or remove events.

## Event types

The `type` field is one of these keys:

| Event key | Sent when | `data` shape |
| --- | --- | --- |
| `game.started` | A game starts. | `Game` |
| `game.completed` | A game finishes normally. | `GameDetail` |
| `game.abandoned` | A game is abandoned. | `GameDetail` |
| `tournament.started` | A tournament starts. | `TournamentDetail` |
| `tournament.completed` | A tournament finishes. | `TournamentDetail` |
| `tournament.cancelled` | A tournament is cancelled. | `TournamentDetail` |
| `tournament.match.started` | A bracket match starts. | `TournamentMatch` |
| `tournament.match.completed` | A bracket match is decided. | `TournamentMatch` |
| `ping` | **Send test** is used. | `{ webhookId, message }` |

The `data` object for game and tournament events is the same object returned by
the matching public API endpoint. See the [API Reference](/api-reference) for the
exact fields. `tournament.match.*` events contain the bracket match only; fetch
`GET /v1/tournaments/:id/matches/:matchId` when you need its game state.

The same contract is published in the machine-readable OpenAPI document at
`https://api.dartpilot.app/v1/openapi.json`: one envelope per event, a
`WebhookEvent` union discriminated on `type`, and the signing headers. See
[Types and SDKs](/sdks) to generate typed handlers or a client.

### Which events you receive

Webhooks are owned by the account that created them, and events are scoped to
that account:

- `game.*` events are emitted for games owned by the account, including local
  tournament matches the account hosts.
- Online tournament games are ownerless, so they do not emit `game.*`. Subscribe
  to `tournament.match.*` to follow those matches.
- `tournament.*` and `tournament.match.*` events are emitted to the tournament
  creator.

A `ping` event payload looks like this:

```json
{
  "id": "evt_01J8Z3K4T9Q7M2V6R1X0C5B8NA",
  "type": "ping",
  "apiVersion": "2026-10-20",
  "createdAt": "2026-10-20T19:42:11.000Z",
  "data": {
    "webhookId": "01J8Z3K4T9Q7M2V6R1X0C5B8NA",
    "message": "dartpilot test delivery"
  }
}
```

`ping` goes through the same signing and retry path as real events, so a
successful test means the endpoint URL, secret and response code all work.

## Payload

```json
{
  "id": "game.completed:01J8Z3K4T9Q7M2V6R1X0C5B8NA",
  "type": "game.completed",
  "apiVersion": "2026-10-20",
  "createdAt": "2026-10-20T19:42:11.000Z",
  "data": {
    "id": "01J8Z3K4T9Q7M2V6R1X0C5B8NA",
    "type": "x01",
    "status": "completed",
    "...": "the same GameDetail object as GET /v1/games/:id"
  }
}
```

Every request also carries these headers:

```text
webhook-id: game.completed:01J8Z3K4T9Q7M2V6R1X0C5B8NA
webhook-timestamp: 1760000000
webhook-signature: v1,base64(HMAC-SHA256(secret, id.timestamp.body))
```

`webhook-id` is stable across retries and manual redeliveries. Use it to
deduplicate: delivery is at-least-once, and events can arrive out of order.
`createdAt` is when the event happened, so order by it rather than by arrival.
`webhook-timestamp` is Unix seconds and is refreshed for each attempt, so a
retry is signed with the current time rather than the original delivery time.

`apiVersion` is currently `2026-10-20`. Compare it before relying on a field
introduced after the version you built against.

## Verify the signature

The signature covers the exact raw request body, so verify it **before** parsing
JSON. The secret starts with `whsec_`; the rest is base64-encoded key material.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = process.env.DARTPILOT_WEBHOOK_SECRET;
const key = Buffer.from(
  secret.replace(/^whsec_/, ""),
  "base64",
);
const id = req.headers["webhook-id"];
const timestamp = req.headers["webhook-timestamp"];
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return res.status(401).end();
const signed = `${id}.${timestamp}.${rawBody}`;
const digest = createHmac("sha256", key)
  .update(signed)
  .digest("base64");
const expected = `v1,${digest}`;

const signatures = String(
  req.headers["webhook-signature"],
);
const valid = signatures.split(" ").some((signature) => {
  const actual = Buffer.from(signature);
  const wanted = Buffer.from(expected);
  return (
    actual.length === wanted.length &&
    timingSafeEqual(actual, wanted)
  );
});

if (!valid) return res.status(401).end();
```

Reject timestamps more than five minutes away from your clock to limit replay.
Rotate the secret from the webhook menu if it leaks; the old secret stops working
immediately.

## Receiving on a local machine

Endpoints must use public HTTPS, so a service running on your own computer needs
a tunnel. For example, with a receiver listening on port `8791`:

```bash
cloudflared tunnel --url http://127.0.0.1:8791
# or: ngrok http 8791
```

Then use the tunnel's public URL plus your route as the webhook URL, for example
`https://your-tunnel.example.com/webhooks/dartpilot`.

Expose **only** the webhook receiver. Tunneling a whole local application can
publish unrelated admin routes, API keys or device-control endpoints along with
it. Quick tunnels also get a new hostname when restarted; update the webhook URL
in **Profile → Webhooks** when that happens.

## Responses and retries

Return any `2xx` status. `204 No Content` is recommended. Respond within 10
seconds; redirects are not followed. A slow or non-2xx endpoint is retried:

| Attempt | When |
| --- | --- |
| 1 | Immediately |
| 2 | After 60 seconds |
| 3 | After 5 minutes |
| 4 | After 15 minutes |
| 5 | After 1 hour |
| 6 | After 6 hours |

After the sixth failed attempt the delivery is marked failed. Five deliveries in a
row that exhaust their retries disable the webhook; the owner gets a notification and
can re-enable it from **Profile → Webhooks**.

## Delivery log and redelivery

**Profile → Webhooks → a webhook** shows the last 30 days of deliveries with
status, attempts, HTTP result, and error. Any finished delivery — successful or
failed — can be redelivered from its row, one at a time. Redelivery reuses the
stored payload and the same `id`; your endpoint should deduplicate by that id.
You can also disable a webhook from its detail view to pause deliveries without
deleting it.

## Limits and security

- Up to 3 webhooks per account.
- Endpoints must use HTTPS and a public host. Localhost, private, and link-local
  addresses are rejected.
- Treat the signing secret like a password. It is shown only once and can be
  rotated at any time.
- Do not return secrets or internal diagnostics in your response body; dartpilot
  stores a short error snippet for the delivery log.
- Webhooks are separate from API keys. You do not need an API key to receive
  them.
