> ## Documentation Index
> Fetch the complete documentation index at: https://huddleup-sports.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Whistle webhook events, HMAC-SHA256 signature verification with X-Whistle-Signature, retry and deduplication behavior, and a secure receiver example.

Webhooks push game, position, official, and payment changes to your HTTPS endpoint so you don't have to poll. Register endpoints via [`POST /webhooks`](/developers/recipes#register-and-verify-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.

## Delivery format

Every delivery is a `POST` with body:

```json theme={null}
{
  "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:

```js theme={null}
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);
}
```

<Warning>
  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.
</Warning>

## 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

```js theme={null}
// 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.
