Skip to main content
Safe, self-contained flows for the most common integration tasks. All examples use $WHISTLE_API_KEY and the production base URL:
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.
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.

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

Upsert the league

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

Batch-import games (1–500 per request)

A batch import is a bulk mutation: with externalIds 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.
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.
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 externalIds on imports. MCP: whistle_create_league, then whistle_create_games.

List games and assignments

Read-only; safe to run anytime. Neither endpoint is paginated.
# 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.
Prefer webhooks over polling these endpoints — position.claimed, position.released, and game.completed tell you the moment coverage changes.
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.
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.
# 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.
# 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

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:
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 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).
Last modified on July 10, 2026