Webhooks push game, position, official, and payment changes to your HTTPS endpoint so you don’t have to poll. Register endpoints via POST /webhooks.
Event catalog
| Event | Fires when | Notable payload fields |
|---|
game.created | A game is created or upserted via the batch import (one event per persisted game) | gameId, leagueId, externalId, source |
game.updated | A game’s fields are updated via the API | gameId, leagueId |
game.cancelled | A game is cancelled | gameId, leagueId |
game.completed | An organizer marks a game complete (in-app only) | gameId, leagueId, officialIds |
position.claimed | An official claims / is assigned a slot | positionId, gameId, leagueId, officialId |
position.released | A claimed slot is released | positionId, gameId, leagueId, officialId* |
payment.paid | A payment settles | paymentId, leagueId, gameId, officialId, amountCents, currency |
payment.failed | A platform payment terminally declines | same as paid + failureCode, failureMessage |
official.joined | An official joins a league | leagueId, userId (+ externalId, source on API-originated joins) |
official.approved | A membership is (re-)approved | leagueId, userId |
official.removed | An official is removed | leagueId, userId |
official.no_show | An organizer marks a no-show (in-app only) | claimId, gameId, leagueId, officialId, positionId |
* officialId on position.released is present only for in-app releases; API-driven unassigns omit it. Several official.* payloads likewise vary by origin — treat fields beyond the ones guaranteed above as optional.
Some events (game.completed, official.no_show, payment.*) are produced only by in-app actions — webhooks are the only way an integration observes them.
Every delivery is a POST with body:
{
"id": "d4f9…",
"event": "position.claimed",
"createdAt": "2026-08-15T18:04:11.201Z",
"data": { "positionId": "…", "gameId": "…", "leagueId": "…", "officialId": "…" }
}
Headers:
| Header | Value |
|---|
X-Whistle-Event | The event name |
X-Whistle-Delivery | Unique delivery id — stable across retries; deduplicate on this |
X-Whistle-Signature | sha256=<hex> — HMAC-SHA256 of the raw body bytes using your endpoint’s secret |
Content-Type | application/json |
Signature verification
Your endpoint’s secret (whsec_…) is returned once, in the POST /webhooks response. Verify every delivery by recomputing the HMAC over the exact raw body (before any JSON parsing) and comparing in constant time:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWhistleSignature(rawBody, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
The signature covers the body only — there is no timestamp component or built-in replay window. A captured delivery could be replayed later and would still verify. Mitigate on your side: deduplicate on X-Whistle-Delivery (which also handles retry duplicates) and treat webhook data as a trigger to re-fetch authoritative state via the REST API rather than as the state itself.
Retries and deduplication
- The first attempt is immediate (8-second timeout). Any
2xx from you counts as delivered.
- On failure, retries follow this backoff: 1m, 5m, 30m, 2h, 6h, 12h — 7 attempts total over roughly 20 hours, then the delivery is marked
exhausted and dropped.
- Retries re-send the byte-identical payload with the same
X-Whistle-Delivery id — idempotent processing keyed on that header is safe.
- Respond
2xx fast (enqueue, then process async). Slow handlers risk the 8-second timeout, which counts as a failure.
- Inspect history anytime:
GET /webhooks/{webhookId}/deliveries shows status, attempts, your last HTTP status, and the exact payload.
Secure receiver example
// Express — note: raw body capture is required for signature verification
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SECRET = process.env.WHISTLE_WEBHOOK_SECRET; // the whsec_… from registration
const seen = new Set(); // use a persistent store in production
app.post("/whistle/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.get("X-Whistle-Signature");
const expected = "sha256=" + createHmac("sha256", SECRET).update(req.body).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(sig ?? "");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).end(); // reject unsigned/forged deliveries
}
const deliveryId = req.get("X-Whistle-Delivery");
if (seen.has(deliveryId)) return res.status(200).end(); // retry duplicate — already processed
seen.add(deliveryId);
res.status(200).end(); // acknowledge fast…
const { event, data } = JSON.parse(req.body);
queueForProcessing(event, data); // …process async
});
Managing endpoints
- Register:
POST /webhooks (HTTPS URLs only; empty events = all events). The endpoint’s source is derived from your key.
- List:
GET /webhooks (secrets never shown).
- Delete:
DELETE /webhooks/{webhookId} — destructive; delivery history goes with it.
- There is no update, pause, or secret-rotation endpoint — to rotate a secret or change the URL/events, register a new endpoint, cut over, then delete the old one.
Endpoints are owned by your source: you see and manage only your own endpoints (anyone else’s answer 404 endpoint_not_found, same as nonexistent ones), and fan-out is scoped — your endpoints receive events only for leagues your key can read.