Webhook Events
This page catalogues every webhook event the Race Platform API emits today, the JSON envelope shape every delivery wraps, and how to verify the HMAC signature in TypeScript and Python.
For configuration / management see Admin → Webhooks.
Source:
- Dispatcher:
apps/api/src/services/webhook_dispatcher.ts - Helper:
apps/api/src/lib/webhook_helpers.ts - Entity-route call sites:
apps/api/src/routes/runSheets.ts,apps/api/src/routes/boardCards.ts,apps/api/src/routes/laps.ts,apps/api/src/routes/setups.ts,apps/api/src/routes/issues.ts,apps/api/src/routes/cars.ts,apps/api/src/routes/events.ts(each callsfireWebhook(c, entityType, opType, payload)after a successful mutation)
Event catalogue
The API emits events of the form <entityType>.<opType>:
| Event | When it fires | Payload |
|---|---|---|
run_sheet.create | After a successful POST /run-sheets | The persisted run-sheet row |
board_card.update | After a successful PATCH /board-cards/:id | The updated board-card row |
lap.create | After a successful POST /laps | The persisted lap row |
lap.update | After a successful PATCH /laps/:id | The updated lap row |
lap.delete | After a successful DELETE /laps/:id | The lap row that was removed |
setup.create | After a successful POST /setups | The persisted setup row |
setup.update | After a successful PATCH /setups/:id | The updated setup row |
setup.delete | After a successful DELETE /setups/:id | The setup row that was removed |
issue.create | After a successful POST /issues | The persisted issue row |
issue.update | After a successful PATCH /issues/:id (incl. state transitions) | The updated issue row |
issue.delete | After a successful DELETE /issues/:id | The issue row that was removed |
car.create | After a successful POST /cars | The persisted car row |
car.update | After a successful PATCH /cars/:id | The updated car row |
car.delete | After a successful DELETE /cars/:id | The car row that was removed |
event.create | After a successful POST /events | The persisted event row |
event.update | After a successful PATCH /events/:id | The updated event row |
event.delete | After a successful DELETE /events/:id | The event row that was removed |
webhook.test.create | Synthetic — fired by the Test button on /admin/integrations | { message, testedByUserId } |
Subscribers using filters.entityTypes: [] (the default) automatically
receive new events as they're added — no subscriber-side changes
required. To narrow to a specific entity (e.g. only lap), set
filters.entityTypes = ["lap"]. Combine with filters.opTypes to
further narrow (e.g. ["create"] only).
Envelope shape
Every delivery POSTs a JSON body with this shape:
{
"deliveryId": "<uuid>",
"webhookId": "<uuid>",
"accountId": "<uuid>",
"entityType": "run_sheet",
"opType": "create | update | delete",
"occurredAt": "2026-06-05T13:42:11.034Z",
"payload": {
"...the entity row...": "...",
"_meta": {
"entityType": "run_sheet",
"opType": "create",
"accountId": "<uuid>",
"occurredAt": "2026-06-05T13:42:11.034Z"
}
}
}
Field semantics:
| Field | Meaning |
|---|---|
deliveryId | Unique per-attempt id. Replays carry a fresh id. Use as the idempotency key. |
webhookId | The subscription that fired this delivery |
accountId | Owning account — same on every delivery to a given subscription |
entityType | E.g. run_sheet, board_card, webhook.test |
opType | create / update / delete |
occurredAt | ISO-8601 timestamp of when the dispatcher saw the event |
payload | The entity row as returned by the originating route, plus a _meta block re-stating the event identity |
payload._meta duplicates the top-level event identity so
subscribers that flatten the payload before processing don't
lose context.
HTTP headers
Every POST carries:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | race-webhooks/1.0 |
X-Race-Event | <entityType>.<opType> (e.g. run_sheet.create) |
X-Race-Delivery | The deliveryId UUID (same as the body field) |
X-Race-Signature | sha256=<hex> — HMAC-SHA256(secret, rawBody) |
The dispatcher does not set custom retry headers — repeat
deliveries (manual replays) appear with a fresh X-Race-Delivery
because they go through a new delivery row.
HMAC verification
The signature is computed over the exact raw body bytes the API sent. Verify by recomputing the digest with the same secret you saved when you created the subscription, then compare with a constant-time function.
Node / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.RACE_WEBHOOK_SECRET!; // hex string
// Important: capture the raw body before JSON parsing so the
// HMAC is computed over the exact bytes the API signed.
app.use(
express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = buf;
},
}),
);
app.post("/race-webhook", (req, res) => {
const sig = req.header("X-Race-Signature") ?? "";
const event = req.header("X-Race-Event") ?? "";
const deliveryId = req.header("X-Race-Delivery") ?? "";
if (!sig.startsWith("sha256=")) return res.status(400).end();
const provided = Buffer.from(sig.slice("sha256=".length), "hex");
const expected = createHmac("sha256", SECRET)
.update((req as any).rawBody)
.digest();
// Constant-time compare; bail if lengths differ to avoid throw.
if (provided.length !== expected.length) return res.status(401).end();
if (!timingSafeEqual(provided, expected)) return res.status(401).end();
// ✓ Signature is valid. `req.body` is the parsed envelope.
console.log(`[webhook] ${event} delivery=${deliveryId}`, req.body);
// Always respond 2xx promptly — the dispatcher times out at 8s
// per attempt and retries on non-2xx.
res.status(204).end();
});
app.listen(3000);
Python (Flask)
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["RACE_WEBHOOK_SECRET"].encode()
@app.post("/race-webhook")
def handle_webhook():
sig = request.headers.get("X-Race-Signature", "")
event = request.headers.get("X-Race-Event", "")
delivery_id = request.headers.get("X-Race-Delivery", "")
if not sig.startswith("sha256="):
abort(400)
# Compute over the raw bytes the API signed. Using
# request.get_data(cache=True) lets a downstream get_json()
# call still work without a second read.
raw = request.get_data(cache=True)
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
provided = sig.split("=", 1)[1]
# hmac.compare_digest is constant-time
if not hmac.compare_digest(expected, provided):
abort(401)
payload = request.get_json()
print(f"[webhook] {event} delivery={delivery_id}", payload)
return "", 204
Common HMAC pitfalls
- Computing over the parsed JSON, not the raw body. Re-encoding the body invariably reorders or re-spaces it and the signature will mismatch. Always sign over the bytes you received.
- Splitting the secret incorrectly. The
X-Race-Signatureheader issha256=<hex>— strip thesha256=prefix before comparing. The Race API sends only the SHA-256 variant; future algorithms (if any) will get a different prefix. - Using
==instead of a constant-time compare.==returns early on the first mismatched byte and leaks timing info. Usecrypto.timingSafeEqual(Node) /hmac.compare_digest(Python) /subtle.ConstantTimeCompare(Go). - Logging the secret. Treat the webhook secret like an API key — never log it, never echo it in error messages.
Idempotency
The dispatcher gives every delivery a fresh deliveryId, even on
retries within a single dispatch. That's a footgun for
naive subscribers — if the API sees a 200 then network-times-out
on the next attempt, the subscriber may see two deliveries with
different deliveryIds but identical payload.
To deduplicate, use one of:
- Entity-row identity — for
<entity>.createevents thepayload.idis unique. Keep a recently-seen set keyed by(entityType, payload.id)for, say, the last 1000 events. - Manual replays — replays via the admin UI carry a fresh
deliveryIdand a freshoccurredAt, so you'll see them as distinct events. That's intentional — replays are an audit trail, not a "retry the same delivery" semantic.
Retry & failure semantics
| Behaviour | Detail |
|---|---|
| Retry trigger | Any non-2xx response, or a thrown fetch error (DNS, TLS, timeout) |
| Max attempts | 3 |
| Backoff | 300 ms → 600 ms → 1200 ms (exponential, factor 2) |
| Per-attempt timeout | 8 seconds (AbortController based) |
| Persistent state | webhook_deliveries.attempts, responseStatus, responseBody (truncated at 64 KB), succeededAt |
| Subscriber-side cap | The API does not throttle dispatch — a slow subscriber slows down its own deliveries, never any other subscriber's |
After three failed attempts the delivery is final. Use the
Replay button or
POST /webhooks/:id/deliveries/:deliveryId/replay to re-fire
a stored payload through a new delivery row.
Filtering at the subscription layer
Subscribers can narrow the events they receive via the
filters blob on the subscription:
{
"filters": {
"entityTypes": ["run_sheet"],
"opTypes": ["create"]
}
}
Empty arrays mean "match everything" on that axis. The two axes are conjunctive — a delivery fires only when both match. See Admin → Webhooks for the admin-UI walkthrough.
What's coming
- Remaining route coverage —
runSheets PATCH/DELETEandboardCards POST/DELETEare still single-op today. The pattern is identical to laps / setups / issues / cars / events; each route just needs afireWebhookcall after the successful mutation. - OpenAPI / event schema export — today the payload shape is the entity row's shape. A future pass will publish a per-event JSON Schema so subscribers can codegen typed handlers.
- Subscription-side selector queries — beyond entity / op
filters, subscribe to only-when-
severity ≥ criticalor only on issue state transitions intoresolved.