Skip to content
dartpilot
Esc
↑↓navigate↵open⌘Jpreview
On this page

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

  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 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 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:

{
  "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.

Was this page helpful?