Webhooks

Webhooks

A webhook is a registered endpoint URL with one or more feed subscriptions. RaceHooks POSTs a JSON payload to that URL for every matching event during a live session. The webhook infrastructure is sport-agnostic: the same endpoint, signature verification, and filter system works across every sport RaceHooks supports. One endpoint can subscribe to many feeds (all sharing a single signing secret), each subscription can carry its own filters to narrow which events trigger a delivery, and the X-RaceHooks-Feed header tells you which feed produced each payload.

Lifecycle

Webhooks have three operational states:

activeDeliveries are sent for every matching event. This is the default state after creation.
pausedNo deliveries are sent (active: false). The webhook remains registered and can be resumed at any time with PATCH { "active": true } without losing configuration. An endpoint auto-disabled after 10 consecutive failed deliveries lands here too.
deletedPermanently removed. Its delivery logs are no longer readable through the API (GET /v1/webhooks/:id/logs returns 404).

Create a webhook

One webhook endpoint (URL + signing secret) can subscribe to many feeds. Provide exactly one feed selection: feedId for a single feed (the simplest form), feedIds to subscribe one endpoint to several feeds at once, or subscriptions when different feeds need different filters. Omitting all three returns 400. Every feed on the endpoint shares one signing secret, and the X-RaceHooks-Feed header on each delivery tells you which feed produced it. The webhookSecret is returned at creation time and retrievable anytime via GET /v1/webhooks/:id/secret.

# Subscribe one endpoint to several feeds (all share one signing secret).
# Use "feedId" for a single feed, or "subscriptions" for per-feed filters.
curl -X POST https://api.racehooks.io/v1/webhooks \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://your-app.com/f1",
    "feedIds": ["timing.data", "session.status"]
  }'

# Response (feedIds / subscriptions form) — one endpoint carrying its per-feed
# subscriptions. The id is at data.webhookId.
{
  "data": {
    "webhookId": "V1StGXR8_Z5jdHi6B-myT",
    "webhookUrl": "https://your-app.com/f1",
    "webhookMethod": "post",
    "subscriptions": [
      { "feedId": "session.status", "seriesId": "f1", "filters": {}, "active": true, ... },
      { "feedId": "timing.data",    "seriesId": "f1", "filters": {}, "active": true, ... }
    ],
    "webhookSecret": "3f8a9c1d5e7b2a4f6c8e0d1b3a5f7c9e2d4b6a8f0c1e3d5b7a9f2c4e6d8b0a1c",
    "tier": "developer"
  }
}
# webhookSecret is also retrievable anytime via GET /v1/webhooks/:id/secret.

# The single-feedId form returns a different shape — the id is at
# data.webhook.webhookId:
# { "data": { "webhook": { "webhookId": "...", "feedId": "...", ... },
#             "webhookSecret": "...", "tier": "..." } }
⚠
Retrieve your webhookSecret anytime via GET /v1/webhooks/:id/secret. To issue a new secret, rotate it from the webhook detail page or via POST /v1/webhooks/:id/rotate-secret. Rotation takes effect immediately.

Request fields

FieldTypeDescription
webhookUrlstringYour HTTPS endpoint URL. Must be publicly reachable: localhost, loopback, and private-network addresses are rejected with 400, including for endpoints you use with Simulate replays. While developing, expose your local server through a public HTTPS tunnel or point the webhook at a request-capture URL.
feedIdstring (one of)Subscribe to a single feed (the simplest form). See Feed catalog for all available IDs.
feedIdsstring[] (one of)Subscribe this one endpoint to many feeds at once. Supports "*" (all feeds in your tier) and group globs like "analytics.*". All feeds share one signing secret.
subscriptionsobject[] (one of)Per-feed subscriptions with their own filters: [{ feedId, filters }]. Use instead of feedIds when different feeds need different filters.
webhookMethodstring?HTTP method for deliveries: "post" (default) or "put".
seriesIdstring?Series the endpoint belongs to. Defaults to "f1".
filtersobject?Optional endpoint-wide delivery filter applied to every feed. See Filters below.

Provide exactly one of feedId, feedIds, or subscriptions. Omitting all three returns 400.

Filters

Filters let you narrow which events trigger a delivery. They are evaluated per payload before each delivery attempt; only matching events are sent. Filters are supported on per-driver feeds (timing.data, tire.stints, timing.driver-info, etc.) and on the events.race feed. Filters are validated strictly: an unrecognized key (e.g. a typo like driverNumber) or a wrong value type is rejected with a 400 invalid_filters error naming every violation, rather than silently delivering unfiltered.

Available filters

FieldTypeDescription
driversstring[]Three-letter driver codes (TLAs). e.g. ["VER", "NOR"]. Resolved to driver numbers at delivery time.
driverNumbersstring[]Raw driver racing numbers. e.g. ["1", "4"]. Use drivers (TLAs) for readability.
constructorsstring[]Team name keywords (case-insensitive partial match). e.g. ["ferrari", "mclaren"]. Resolved to all drivers on those teams using the current session's DriverList.
positions{ min?: number; max?: number }Race position range. Only delivers when at least one matching driver is within the specified position window.
eventTypesstring[]events.race feed only: deliver only these event types, e.g. ["overtake", "pit_entry", "fastest_lap"].
# Filter by driver TLA codes
curl -X POST https://api.racehooks.io/v1/webhooks \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://your-app.com/verstappen",
    "feedId": "timing.data",
    "filters": {
      "drivers": ["VER", "NOR"]
    }
  }'

# Filter by constructor keyword + position range
curl -X POST https://api.racehooks.io/v1/webhooks \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://your-app.com/ferrari-top5",
    "feedId": "timing.data",
    "filters": {
      "constructors": ["ferrari"],
      "positions": { "min": 1, "max": 5 }
    }
  }'
ℹ
Filters are combined with AND logic: if you specify both constructors and positions, a payload is delivered only when a constructor driver is within the position range.
✦
Constructor filters use fuzzy matching: "red bull" and "redbull" both match Red Bull Racing. The driver list is resolved live from the current session's DriverList feed, so mid-season team swaps are handled automatically.

HMAC signatures

Every delivery, on every tier (Free included), is signed twice with HMAC-SHA256 and your webhook secret. Verifying a signature proves the delivery originated from RaceHooks and the body was not tampered with in transit.

X-RaceHooks-Signature-V1: t=1759400000123,v1=5d1c0e7f9a2b4c6d8e0f1a3b5c7d9e1f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d X-RaceHooks-Signature: sha256=a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1

The two signatures are computed as:

X-RaceHooks-Signature-V1  t=<sentAtMs>,v1=HMAC-SHA256(webhookSecret, "<sentAtMs>." + rawRequestBody)
X-RaceHooks-Signature     sha256=HMAC-SHA256(webhookSecret, rawRequestBody)

Verify X-RaceHooks-Signature-V1. Its t is the send time of this attempt in epoch milliseconds, the same value as X-RaceHooks-Sent-At, and it is inside the signed material, so after the signature checks out you can reject anything whose t is more than a few minutes from your clock (300 seconds is a sensible window). Each retry is re-signed with its own t. The legacy X-RaceHooks-Signature covers the body only; it is still sent unchanged, so existing verifiers keep working, but it cannot tell a fresh delivery from a captured one re-sent later.

Always verify against the raw request body bytes, not the parsed JSON object. Use a timing-safe comparison function (timingSafeEqual in Node.js, hmac.compare_digest in Python, subtle.ConstantTimeCompare in Go) to prevent timing attacks.

import crypto from "crypto";
import express from "express";

const app = express();
const TOLERANCE_SECONDS = 300;

// Verify X-RaceHooks-Signature-V1: "t=<sentAtMs>,v1=<hex>", where
// hex = HMAC-SHA256(secret, `${t}.${rawBody}`). t is signed, so the age
// check below is real replay protection.
function verifyV1(rawBody: Buffer, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=", 2)));
  const t = parts.t, sig = parts.v1;
  if (!t || !/^\d+$/.test(t) || !sig) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  if (sig.length !== expected.length) return false;
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return false;
  return Math.abs(Date.now() - Number(t)) <= TOLERANCE_SECONDS * 1000;
}

// Raw body middleware — verify the exact bytes, not re-serialized JSON
app.post("/f1-hook", express.raw({ type: "application/json" }), (req, res) => {
  const v1 = req.headers["x-racehooks-signature-v1"] as string | undefined;
  if (!v1 || !verifyV1(req.body, v1, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send("Invalid or stale signature");
  }
  // Dedupe: X-RaceHooks-Delivery-Id is stable across the automatic retries.
  const payload = JSON.parse(req.body.toString());
  // handle payload...
  res.sendStatus(200);
});

// Legacy body-only check (still sent, still valid — proves origin, not freshness):
//   "sha256=" + HMAC-SHA256(secret, rawBody) === req.headers["x-racehooks-signature"]
// racehooks <= 0.4.0's verifySignatureBoolean(req.body, sig, secret) checks this one.

Duplicates and stale deliveries

Replay protection comes from the signed t in X-RaceHooks-Signature-V1: verify the signature, then enforce your tolerance window on t. A captured request re-sent with a fresh X-RaceHooks-Sent-At fails, because changing t breaks the signature. If you only check the legacy body-only X-RaceHooks-Signature, X-RaceHooks-Sent-At is unsigned and is a staleness hint at best. The published racehooks Node SDK (0.4.0) checks that header for its toleranceSeconds option, so treat it the same way until you upgrade to an SDK release that verifies the v1 header.

Duplicates are a separate matter: deliveries are at-least-once. Dedupe on X-RaceHooks-Delivery-Id. It stays the same across the automatic retries of a delivery (see retries), so you can record the ids you've processed and skip repeats. A redelivery from the hourly sweep carries a new id (and a new signed t), so make handlers idempotent on the payload's own keys too (feed, session, lap, driver).

Delivery headers

FieldTypeDescription
X-RaceHooks-Signature-V1stringt=<sentAtMs>,v1=<hex> — HMAC-SHA256 of "<t>.<raw body>" with your webhook secret. t equals this attempt's X-RaceHooks-Sent-At and is signed, so check your tolerance window against it. Re-signed on every attempt. Sent on every tier.
X-RaceHooks-SignaturestringLegacy: sha256=<hex> HMAC-SHA256 of the raw body only. Still sent on every tier, unchanged; proves origin, not freshness.
X-RaceHooks-Delivery-IdstringUnique id for this delivery (UUID). The same across the automatic retries of a delivery, so it works as a dedup key.
X-RaceHooks-Sent-AtstringSend time of this attempt, epoch milliseconds. Not itself signed; the signed copy is the t= in X-RaceHooks-Signature-V1.
X-RaceHooks-FeedstringThe feed that produced this payload, e.g. timing.data.
X-RaceHooks-Webhook-IdstringThe id of the webhook endpoint this delivery is for.
X-RaceHooks-SeriesstringThe endpoint's series, e.g. f1.
X-RaceHooks-Modestringlive, replay, or test (see below).
X-RaceHooks-Replay-SessionstringReplay deliveries only: the original session id being replayed.
X-RaceHooks-Replay-Run-IdstringReplay deliveries only: one id shared by every event of that replay run.
X-RaceHooks-Replay-SpeedstringReplay deliveries only: the replay speed multiplier.

A delivery, end to end

A replay of the Canadian Grand Prix (2026-canada_r) at 10x, as your endpoint receives it. The body is the feed payload itself, with no wrapper; the headers say which feed, which webhook and which mode it belongs to. Browse every feed's payload in the feed catalog, or start with the race events reference.

http
POST /f1 HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-RaceHooks-Feed: events.race
X-RaceHooks-Series: f1
X-RaceHooks-Mode: replay
X-RaceHooks-Replay-Session: 2026-canada_r
X-RaceHooks-Replay-Speed: 10
X-RaceHooks-Replay-Run-Id: <run id>
X-RaceHooks-Webhook-Id: <your webhook id>
X-RaceHooks-Delivery-Id: <uuid>
X-RaceHooks-Sent-At: <epoch ms>
X-RaceHooks-Signature-V1: t=<epoch ms>,v1=<hex>
X-RaceHooks-Signature: sha256=<hex>

{
  "feed": "events.race",
  "sessionId": "2026-canada_r",
  "event": "lead.change",
  "lap": 30,
  "utc": "2026-10-05T18:49:20.412Z",
  "data": {
    "newLeader": {
      "driverId": "antonelli-kimi",
      "constructorId": "mercedes",
      "number": "12",
      "tla": "ANT",
      "name": "Kimi Antonelli",
      "team": "Mercedes"
    },
    "previousLeader": {
      "driverId": "russell-george",
      "constructorId": "mercedes",
      "number": "63",
      "tla": "RUS",
      "name": "George Russell",
      "team": "Mercedes"
    },
    "viaOvertake": true
  }
}

A handler that applies the rules above: verify the signature with a constant-time compare, dedupe on the delivery id, then route on the feed and event. Replay deliveries are the test path, so the handler accepts and processes them like live ones, tagging them [replay] so a replay never writes to production state.

f1-handler.ts
import crypto from "crypto";
import express from "express";

const app = express();
const TOLERANCE_MS = 300_000;
const seen = new Set<string>(); // use a shared store with a TTL in production

// X-RaceHooks-Signature-V1 is "t=<sentAtMs>,v1=<hex>", where hex = HMAC-SHA256(secret, "<t>.<raw body>").
function verifyV1(rawBody: Buffer, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=", 2)));
  const t = parts.t, sig = parts.v1;
  if (!t || !/^\d+$/.test(t) || !sig) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false; // constant-time compare
  return Math.abs(Date.now() - Number(t)) <= TOLERANCE_MS;
}

app.post("/f1", express.raw({ type: "application/json" }), (req, res) => {
  // 1. Verify the signature against the raw bytes before parsing anything.
  const sig = req.header("X-RaceHooks-Signature-V1");
  if (!sig || !verifyV1(req.body, sig, process.env.WEBHOOK_SECRET!)) return res.sendStatus(401);

  // 2. Dedupe on the delivery id: deliveries are at-least-once, and the id is the same across retries.
  const deliveryId = req.header("X-RaceHooks-Delivery-Id")!;
  if (seen.has(deliveryId)) return res.sendStatus(200);
  seen.add(deliveryId);

  const body = JSON.parse(req.body.toString());

  // 3. Replay deliveries are your test path: handle them, but keep them out of production state.
  const isReplay = req.header("X-RaceHooks-Mode") === "replay";

  if (body.feed === "events.race" && body.event === "lead.change") {
    console.log(`${isReplay ? "[replay] " : ""}lap ${body.lap}: ${body.data.newLeader.tla} takes the lead from ${body.data.previousLeader.tla}`);
    // [replay] lap 30: ANT takes the lead from RUS
  }
  res.sendStatus(200); // any 2xx within 10 seconds is a success
});

New here? The quickstart registers an endpoint and replays this race in four steps; the SDK verifies signatures for you; the model reference covers the probability feeds you can subscribe to the same way.

Rotate a secret

If a secret is compromised or you want to rotate it for security hygiene, call the rotation endpoint. The new secret takes effect immediately.

bash
curl -X POST https://api.racehooks.io/v1/webhooks/V1StGXR8_Z5jdHi6B-myT/rotate-secret \
  -H "Authorization: Bearer ${TOKEN}"

# Response — new secret (also retrievable via GET /v1/webhooks/:id/secret)
{
  "data": {
    "webhookId": "V1StGXR8_Z5jdHi6B-myT",
    "webhookSecret": "a7c2e9f4b1d6083e5c7a9b2d4f6e8a0c1b3d5f7e9a2c4b6d8f0e1a3c5b7d9f2e",
    "note": "New secret is active for every feed on this endpoint."
  }
}
⚠
After rotation, the new secret is returned in the response and retrievable anytime via GET /v1/webhooks/:id/secret. Update your verification code before sending the next delivery.

Live, replay, and test deliveries

Every delivery carries an X-RaceHooks-Mode header so you can tell real session data apart from a replay or a test. It has three values:

  • live: real session data from a live session.
  • replay: a Simulate replay of a past session (you started it from the Simulate page). Carries X-RaceHooks-Replay-Session (the original session id), X-RaceHooks-Replay-Run-Id (one id shared by every event of that replay run), and X-RaceHooks-Replay-Speed.
  • test: a manual connectivity ping from the “Send test payload” button or POST /v1/webhooks/:id/test.
✦
A replay can run at the same time as a live session. If your production pipeline should only act on real data, branch on the header: drop or route anything where X-RaceHooks-Mode is notlive. Group a replay’s events by X-RaceHooks-Replay-Run-Id to handle or discard a whole run at once.

Test pings

POST /v1/webhooks/:id/test sends one sample payload to the endpoint right away: a single attempt with a 5-second timeout, no retries, and no filters applied. It doesn't count toward auto-disable, but it does count as a delivery against your monthly allowance.

bash
curl -X POST https://api.racehooks.io/v1/webhooks/V1StGXR8_Z5jdHi6B-myT/test \
  -H "Authorization: Bearer ${TOKEN}"

# Body your endpoint receives (headers include X-RaceHooks-Mode: test).
# "feed" (and the legacy "type") is the webhook's own feedId, matching the
# X-RaceHooks-Feed header — detect a ping by X-RaceHooks-Mode: test or data._test.
{
  "feed": "driver.list",
  "type": "driver.list",
  "sessionId": "test",
  "utc": "2026-10-02T12:00:00.000Z",
  "data": { "_test": true, "feedId": "driver.list", "timestamp": "2026-10-02T12:00:00.000Z" }
}

# API response
{ "data": { "delivered": true, "statusCode": 200, "errorMessage": null } }

Delivery SLA and retries

A delivery succeeds when your endpoint returns a 2xx status within 10 seconds. If an attempt times out, fails at the network level, or gets a 5xx or 429, RaceHooks retries it up to three more times. Each delay has ±25% random jitter so retries from many deliveries don't arrive at once:

AttemptDelay
1 (initial)Immediate
2~1 second after attempt 1
3~5 seconds after attempt 2
4~30 seconds after attempt 3

Any other 4xx (400, 401, 404, 410, …) counts as a permanent rejection and is not retried. Return 429 or a 5xx if you want RaceHooks to try again. Every retry of a delivery carries the same X-RaceHooks-Delivery-Id.

If a delivery never got an HTTP response (timeout or connection failure), an hourly sweep tries it again for up to 24 hours while the endpoint is active. An endpoint that racks up 5 such failures within that window is dead-lettered and not swept again. Swept redeliveries get a new X-RaceHooks-Delivery-Id.

After 10 consecutive failed deliveries, RaceHooks disables the endpoint and emails the account owner. A failed delivery is one whose last attempt didn't return a 2xx, so a rejecting 4xx counts. The endpoint is set to active: false and autoDisabledAt is stamped. Any 2xx resets the count. Fix the endpoint, then re-enable it from the console or with PATCH /v1/webhooks/:id { "active": true }.

Billing follows what was transmitted: a delivery that got any HTTP response (including 4xx/5xx) counts once against your monthly allowance, however many retries it took. A delivery that never connected is not counted.

Simulate replay deliveries and test pings are single attempts with a 5-second timeout. They are never retried and never count toward auto-disable. During a replay, an endpoint that keeps failing is paused for that run only and resumes once it responds again. Your standing webhook isn't affected.

Free and Developer are best-effort; there's no contractual delivery SLA. Custom includes a 99.5% delivery SLA, measured per calendar month, covering delivery infrastructure and excluding upstream feed availability; remedies are service credits, with full terms negotiated per contract.

← QuickstartRace Events →