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

# Task Recipes

> End-to-end flows for the Whistle partner API: import leagues and games idempotently, list games and assignments, add and assign officials, and register webhooks — with expected outputs, idempotency, and retry guidance.

Safe, self-contained flows for the most common integration tasks. All examples use `$WHISTLE_API_KEY` and the production base URL:

```bash theme={null}
export WHISTLE_API_KEY="whk_your_key_here"
export WHISTLE_API="https://whistle.huddleup-sports.com/api/v1"
```

Throughout, `externalId` is *your* stable id for the record. Together with `source` — your system's namespace, e.g. `"huddleup"` — it forms the idempotency key: re-posting the same pair updates instead of duplicating.

<Note>
  **You don't set `source` — your key does.** Writes are stamped with the `source` your key is bound to. Omit the field (as these examples do) or echo your own value; any other value returns `400 {"error":"source_mismatch"}`. See [what your key can access](/developers/authentication#what-your-key-can-access).
</Note>

## Import or upsert leagues and games

**Inputs:** your league's name and id, plus each game's start time (ISO 8601), pay (dollars), and your game id.

<Steps>
  <Step title="Upsert the league">
    ```bash theme={null}
    curl -s -X POST "$WHISTLE_API/leagues" \
      -H "Authorization: Bearer $WHISTLE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Springfield Little League",
        "externalId": "org_123",
        "currency": "usd"
      }'
    ```

    Expected: `201 {"data": {"id": "…", "name": "Springfield Little League", …}}`. Save `data.id` — it's the `leagueId` for every later call.

    **Idempotency:** re-running updates `name`, `paymentMode`, and `currency` on the existing league (still `201`). No duplicate is created.
  </Step>

  <Step title="Batch-import games (1–500 per request)">
    <Warning>
      A batch import is a **bulk mutation**: with `externalId`s it upserts, so re-posting can rewrite `sport`, `startsAt`, and `location` on up to 500 *existing* games in one call — and it is not transactional. Agents and automations should summarize the target league, `source`, and game count and get confirmation before sending.
    </Warning>

    ```bash theme={null}
    curl -s -X POST "$WHISTLE_API/leagues/$LEAGUE_ID/games" \
      -H "Authorization: Bearer $WHISTLE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "games": [
          {
            "startsAt": "2026-08-15T18:00:00Z",
            "sport": "Baseball",
            "location": "Field 2, Springfield Park",
            "payDollars": 65,
            "label": "Umpire",
            "externalId": "game_8801"
          },
          {
            "startsAt": "2026-08-16T17:30:00Z",
            "sport": "Baseball",
            "location": "Field 1, Springfield Park",
            "payDollars": 65,
            "label": "Umpire",
            "externalId": "game_8802"
          }
        ]
      }'
    ```

    Expected: `201 {"data": {"created": [ …Game objects with positions… ]}}`. Each created game has one position (your `label`, your pay).

    **Idempotency:** re-posting a game with the same `(source, externalId)` updates its `sport`, `startsAt`, `location`, and coordinates — but **never touches positions, pay, or existing claims**, so a re-import can't disturb an official who already claimed a slot. Re-using a game's `externalId` under a **different league** is refused with `409 {"error":"external_id_conflict"}` and the existing game is left untouched — a game can't be moved between leagues by re-posting.
  </Step>
</Steps>

**Retry guidance:** the batch is inserted sequentially, **not** in a transaction. If one game fails validation (e.g. `400 invalid_startsAt:<value>`), earlier games in the array are already persisted. Recovery is simple *if every game has an `externalId`*: fix the bad input and re-post the entire batch — the upsert makes the retry safe. Always send `externalId`s on imports.

**MCP:** `whistle_create_league`, then `whistle_create_games`.

## List games and assignments

Read-only; safe to run anytime. Neither endpoint is paginated.

```bash theme={null}
# All games in a league (soonest first, with position statuses)
curl -s "$WHISTLE_API/leagues/$LEAGUE_ID/games" \
  -H "Authorization: Bearer $WHISTLE_API_KEY"

# Who's covering what (claimed positions with official + payment)
curl -s "$WHISTLE_API/leagues/$LEAGUE_ID/assignments" \
  -H "Authorization: Bearer $WHISTLE_API_KEY"
```

Expected: `200 {"data": [ … ]}`. Game `status` is a rollup of its positions: `open` | `claimed` | `completed` | `cancelled`. Each assignment includes the game summary, the official (`id`, `name`, `email`), and the payment record if one exists.

<Note>
  Prefer [webhooks](/developers/webhooks) over polling these endpoints — `position.claimed`, `position.released`, and `game.completed` tell you the moment coverage changes.
</Note>

**MCP:** `whistle_list_games`, `whistle_list_assignments`.

## Add and approve an official

Officials are referenced by **your** `(source, externalId)` — not by email. Email is an optional bridge that links them to their real Whistle account.

```bash theme={null}
curl -s -X POST "$WHISTLE_API/leagues/$LEAGUE_ID/officials" \
  -H "Authorization: Bearer $WHISTLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "user_42",
    "email": "jordan.ref@example.com",
    "name": "Jordan Ref"
  }'
```

Expected: `201 {"data": {"membershipId": "…", "userId": "…", "name": "Jordan Ref", "status": "approved", …}}`. Save `userId` — assign and remove calls take it.

* Omitting `status` (or sending anything except `"pending"`) approves the official immediately. Send `"status": "pending"` to stage them for organizer approval instead.
* **Idempotency:** re-posting the same `(source, externalId)` re-approves/updates the same person (updates `email`/`name` only when provided) — no duplicate memberships. A repeat emits `official.approved` instead of `official.joined`.
* `409 user_is_organizer` means that person runs the league — they can't also be added as an official.

**MCP:** `whistle_add_official`.

## Assign and unassign an official

The official must already be an **approved** league member (previous recipe), or you'll get `409 not_a_league_official`.

```bash theme={null}
# Assign: claims the first open position (or pass "positionId" to pick one)
curl -s -X POST "$WHISTLE_API/games/$GAME_ID/assign" \
  -H "Authorization: Bearer $WHISTLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"externalId": "user_42"}'
```

Expected: `200` with the full game detail — the position now shows your official and `"status": "claimed"`. Emits `position.claimed`.

**Retry guidance:** claims are race-safe. `409 position_taken` means someone claimed the slot first — re-fetch the game and pick another open position (or stop). `409 no_open_position` / `position_not_open` are terminal for that slot; don't blind-retry a `409`.

```bash theme={null}
# Unassign: reopens the slot and deletes the claim — destructive, confirm first
curl -s -X DELETE "$WHISTLE_API/games/$GAME_ID/assign?positionId=$POSITION_ID" \
  -H "Authorization: Bearer $WHISTLE_API_KEY"
```

Expected: `200` with the game detail; the position is `open` again. Emits `position.released`. The `positionId` query parameter is required.

**MCP:** `whistle_assign_official`, `whistle_unassign_position` (destructive — confirm).

## Register and verify webhooks

```bash theme={null}
curl -s -X POST "$WHISTLE_API/webhooks" \
  -H "Authorization: Bearer $WHISTLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/whistle/webhooks",
    "events": ["game.created", "position.claimed", "position.released", "payment.paid"]
  }'
```

Expected: `201` including `"secret": "whsec_…"` — **shown in this response only, never retrievable again.** Store it immediately; you need it to verify signatures. An empty/omitted `events` array subscribes to all events. The endpoint is owned by your key's `source` and receives events only for leagues in your scope.

Verify it's receiving:

```bash theme={null}
curl -s "$WHISTLE_API/webhooks/$WEBHOOK_ID/deliveries?limit=10" \
  -H "Authorization: Bearer $WHISTLE_API_KEY"
```

Each delivery shows `status` (`pending` | `delivered` | `failed` | `exhausted`), `attempts`, `lastStatus`, and the exact `payload` sent. Failed deliveries retry automatically — see [Webhooks](/developers/webhooks) for the schedule, signature verification code, and a secure receiver example.

**Retry guidance:** registering the same URL twice creates two endpoints (registration is **not** idempotent — it's the one write here without an `externalId` upsert). Before registering, `GET /webhooks` and check whether your URL is already present.

**MCP:** `whistle_register_webhook`, `whistle_list_webhook_deliveries`, `whistle_delete_webhook` (destructive — confirm).
