Webhooks
Webhooks deliver entity-change events from Race Platform to
your URL with an HMAC signature so receivers can verify
authenticity. Manage them in the Webhooks tab of
Admin → Integrations (/admin/integrations).
The full delivery payload reference (event names, envelope shape, HMAC verification examples in TypeScript and Python) lives at Reference → Webhook Events.
Source:
- API route:
apps/api/src/routes/webhooks.ts - Dispatcher:
apps/api/src/services/webhook_dispatcher.ts - Helper:
apps/api/src/lib/webhook_helpers.ts(thefireWebhook(c, entityType, opType, payload)wrapper that entity routes call after a successful mutation)
Authoring a subscription
Click + New webhook in the Webhooks tab. Fill in:
| Field | Notes |
|---|---|
| Name | Human-readable label shown in the admin grid |
| URL | Where the API POSTs deliveries — any HTTPS endpoint that can verify HMAC |
| Secret | The HMAC key. The UI generates a 48-char hex secret by default; you can paste your own if you have a secret manager |
| Enabled | Toggle without deleting (deliveries pause; existing history stays) |
| Filters → Entity types | Whitelist of entity types (run_sheet, board_card, …). Empty list = all entities |
| Filters → Op types | Whitelist of create / update / delete. Empty list = all ops |
When you save, the subscription is enabled immediately and starts matching the next mutation that satisfies its filters.
HMAC signing
Every delivery carries an X-Race-Signature header of the form:
X-Race-Signature: sha256=<hex>
…where <hex> is HMAC-SHA256(secret, rawBody). The dispatcher
also adds:
| Header | Value |
|---|---|
X-Race-Event | <entityType>.<opType> — e.g. run_sheet.create |
X-Race-Delivery | UUID of the delivery row in the database |
User-Agent | race-webhooks/1.0 |
Content-Type | application/json |
Verify the signature with constant-time comparison — see Reference → Webhook Events for worked examples in TypeScript and Python.
Retry policy
Each delivery attempts up to 3 times with exponential backoff:
| Attempt | Delay before |
|---|---|
| 1 | (immediate) |
| 2 | 300 ms |
| 3 | 600 ms |
Each attempt has an 8 second timeout. After three failed
attempts the delivery row carries succeededAt = null and the
parent webhook row's lastDeliveryStatus reflects the last
HTTP status code seen.
There's no durable queue in dev — fireWebhook is
fire-and-forget on the same Node event loop. The delivery row is
persisted before the first POST so a node restart mid-delivery
leaves a recoverable row that you can manually replay (see
below). In production on Cloudflare Workers + Queues, the same
dispatcher is wrapped in a Queue consumer so retries survive
deployments and worker restarts (see
Architecture → Plugin host for the
runtime split between Node dev and Workers prod).
Successful deliveries (any 2xx) stamp succeededAt = now() and
break out of the retry loop.
Entity-type filters
Filters are inclusive whitelists with empty-means-everything semantics:
filters.entityTypes: []— fire for any entityfilters.entityTypes: ["run_sheet"]— fire only on run-sheet eventsfilters.entityTypes: ["run_sheet", "board_card"]— fire on either
Same shape for filters.opTypes. The two lists are conjunctive:
a delivery only fires if both the entity-type and op-type
filters match.
The admin UI's chip pickers offer a fixed catalogue
(run_sheet, board_card, lap, setup, session, event,
issue, tyre, driver, car) but the schema accepts any
string — entity-route authors can introduce new
entityType strings without a migration.
What entities fire today
Webhook dispatch is wired into entity routes via the
fireWebhook(c, entityType, opType, payload) helper. As of
Phase 3 only two call sites are live:
| Trigger | Entity type | Op |
|---|---|---|
POST /run-sheets succeeds | run_sheet | create |
PATCH /board-cards/:id succeeds | board_card | update |
The helper pattern is established — adding runSheets PATCH/DELETE,
boardCards POST/DELETE, and the rest of the entity routes is a
one-liner per route. Subscribe today and you'll receive the two
events above; expect more entity-op coverage in subsequent
releases without any subscriber-side changes.
The webhook.test.create event is a synthetic event fired by
the Test Delivery button (next section).
Test delivery
POST /webhooks/:id/test ships a synthetic event:
{
"message": "Race webhooks delivery test",
"testedByUserId": "<auth.userId>",
"_meta": {
"entityType": "webhook.test",
"opType": "create",
"accountId": "<accountId>",
"occurredAt": "<iso8601>"
}
}
The delivery is always fired against the targeted webhook —
even when its filters.entityTypes would normally exclude
webhook.test. This means the Test button never silently
no-ops. Use it to confirm:
- Your URL is reachable from the API container.
- Your TLS / DNS / firewall is happy.
- Your HMAC verification accepts the signature.
The admin UI shows the resulting responseStatus /
responseBody inline so you can debug subscriber bugs without
crawling the deliveries log.
Delivery history + replay
The Webhooks tab's drill-down pane lists recent deliveries newest-first:
- HTTP status code (red ≥400, green 2xx, grey null)
entityType.opTypelabel- Attempt count + timestamp
- Pretty-printed payload + truncated response body on expand
- Replay button (refresh icon) — see below
Behind the scenes it calls
GET /webhooks/:id/deliveries?limit=200. The cap is 500 rows
per request.
Replay re-fires a stored payload via a fresh delivery row —
the original history stays intact, so you can confirm a fix
without losing the trace of the original failure. Endpoint:
POST /webhooks/:id/deliveries/:deliveryId/replay.
Pause / disable
Switching the Enabled toggle off in the admin UI sets
enabled: false on the webhook row. The dispatcher filters out
disabled webhooks at lookup time, so no new deliveries are
created while the toggle is off. Existing in-flight deliveries
finish their retry loop. Re-enabling resumes new dispatch
without losing any history.
What's coming
- Durable queue in dev — today
fireWebhookis fire-and-forget. A startup sweep that resends anywebhook_deliveriesrows withattempts < 3and nosucceededAtwould close the loss window on Node restarts. - Per-account dispatch concurrency limits — protect the API if a subscriber URL is slow.
- Subscription health summaries — last 50 success rate, p50 / p95 latency, surfaced on the admin grid.
- Inbound webhook receivers — accepting webhooks from external systems (timing partners, paddock services) into the ingestion pipeline.