Skip to main content

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 calls fireWebhook(c, entityType, opType, payload) after a successful mutation)

Event catalogue

The API emits events of the form <entityType>.<opType>:

EventWhen it firesPayload
run_sheet.createAfter a successful POST /run-sheetsThe persisted run-sheet row
board_card.updateAfter a successful PATCH /board-cards/:idThe updated board-card row
lap.createAfter a successful POST /lapsThe persisted lap row
lap.updateAfter a successful PATCH /laps/:idThe updated lap row
lap.deleteAfter a successful DELETE /laps/:idThe lap row that was removed
setup.createAfter a successful POST /setupsThe persisted setup row
setup.updateAfter a successful PATCH /setups/:idThe updated setup row
setup.deleteAfter a successful DELETE /setups/:idThe setup row that was removed
issue.createAfter a successful POST /issuesThe persisted issue row
issue.updateAfter a successful PATCH /issues/:id (incl. state transitions)The updated issue row
issue.deleteAfter a successful DELETE /issues/:idThe issue row that was removed
car.createAfter a successful POST /carsThe persisted car row
car.updateAfter a successful PATCH /cars/:idThe updated car row
car.deleteAfter a successful DELETE /cars/:idThe car row that was removed
event.createAfter a successful POST /eventsThe persisted event row
event.updateAfter a successful PATCH /events/:idThe updated event row
event.deleteAfter a successful DELETE /events/:idThe event row that was removed
webhook.test.createSynthetic — 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:

FieldMeaning
deliveryIdUnique per-attempt id. Replays carry a fresh id. Use as the idempotency key.
webhookIdThe subscription that fired this delivery
accountIdOwning account — same on every delivery to a given subscription
entityTypeE.g. run_sheet, board_card, webhook.test
opTypecreate / update / delete
occurredAtISO-8601 timestamp of when the dispatcher saw the event
payloadThe 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:

HeaderValue
Content-Typeapplication/json
User-Agentrace-webhooks/1.0
X-Race-Event<entityType>.<opType> (e.g. run_sheet.create)
X-Race-DeliveryThe deliveryId UUID (same as the body field)
X-Race-Signaturesha256=<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-Signature header is sha256=<hex> — strip the sha256= 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. Use crypto.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:

  1. Entity-row identity — for <entity>.create events the payload.id is unique. Keep a recently-seen set keyed by (entityType, payload.id) for, say, the last 1000 events.
  2. Manual replays — replays via the admin UI carry a fresh deliveryId and a fresh occurredAt, 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

BehaviourDetail
Retry triggerAny non-2xx response, or a thrown fetch error (DNS, TLS, timeout)
Max attempts3
Backoff300 ms → 600 ms → 1200 ms (exponential, factor 2)
Per-attempt timeout8 seconds (AbortController based)
Persistent statewebhook_deliveries.attempts, responseStatus, responseBody (truncated at 64 KB), succeededAt
Subscriber-side capThe 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 coveragerunSheets PATCH/DELETE and boardCards POST/DELETE are still single-op today. The pattern is identical to laps / setups / issues / cars / events; each route just needs a fireWebhook call 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 ≥ critical or only on issue state transitions into resolved.