Webhooks
Receive signed dartpilot events when games and tournaments start or finish.
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.
Create a webhook
- Sign in, open the account menu in the top right, and choose Profile.
- Scroll to Webhooks and select Create webhook.
- Give it a name, paste an HTTPS endpoint URL, and choose at least one event.
- 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 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 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 totournament.match.*to follow those matches. tournament.*andtournament.match.*events are emitted to the tournament creator.
A ping event payload looks like this:
{
"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
{
"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:
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.
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:
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.