Skip to main content
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

EventFires whenNotable payload fields
game.createdA game is created or upserted via the batch import (one event per persisted game)gameId, leagueId, externalId, source
game.updatedA game’s fields are updated via the APIgameId, leagueId
game.cancelledA game is cancelledgameId, leagueId
game.completedAn organizer marks a game complete (in-app only)gameId, leagueId, officialIds
position.claimedAn official claims / is assigned a slotpositionId, gameId, leagueId, officialId
position.releasedA claimed slot is releasedpositionId, gameId, leagueId, officialId*
payment.paidA payment settlespaymentId, leagueId, gameId, officialId, amountCents, currency
payment.failedA platform payment terminally declinessame as paid + failureCode, failureMessage
official.joinedAn official joins a leagueleagueId, userId (+ externalId, source on API-originated joins)
official.approvedA membership is (re-)approvedleagueId, userId
official.removedAn official is removedleagueId, userId
official.no_showAn 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.

Delivery format

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:
HeaderValue
X-Whistle-EventThe event name
X-Whistle-DeliveryUnique delivery id — stable across retries; deduplicate on this
X-Whistle-Signaturesha256=<hex> — HMAC-SHA256 of the raw body bytes using your endpoint’s secret
Content-Typeapplication/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.
Last modified on July 10, 2026