Error format
Every error is{"error": "<code>"} — one snake_case code, no message or details object. Two codes carry a parameter after a colon: invalid_startsAt:<the bad value> and unknown_event:<the bad name>.
The API uses exactly four error statuses: 400, 401, 404, 409. It never returns 403. Unexpected server faults surface as plain 500s.
Classification and retry rules
| Class | Status | Codes | Retry? |
|---|---|---|---|
| Auth | 401 | unauthorized | No. The key is missing, wrong, or revoked. Fix the credential; see Authentication. |
| Validation | 400 | invalid_json, name_required, source_reserved, source_mismatch, no_games, too_many_games, invalid_startsAt(:<value>), invalid_status, externalId_required, positionId_required, https_url_required, unknown_event:<name>, create_failed | Not as-is. Fix the input, then re-send. source_mismatch means the body’s source differs from your key’s — omit the field. For batch game imports, remember earlier items may already be persisted — re-post the whole corrected batch with externalIds. |
| Not found | 404 | league_not_found, game_not_found, official_not_found, position_not_found, not_a_member, endpoint_not_found | No. The id is wrong, the resource is gone, or it’s outside your key’s scope — deliberately indistinguishable. Re-list to re-discover ids; don’t conclude a resource doesn’t exist platform-wide. |
| Conflict | 409 | user_is_organizer, not_an_official, not_a_league_official, no_open_position, position_not_open, position_taken, position_not_claimed, external_id_conflict | Not blindly. State disagrees with your intent. Re-fetch, then decide. position_taken means you lost a concurrent claim race — picking a different open position is a safe follow-up. external_id_conflict means that game externalId already belongs to a different league — a game can’t be moved by re-posting; fix the id or the target league. |
| Transient | 5xx / network / timeout | — | Yes, with care. Retry with backoff only requests that are safe to repeat: all GETs, and the three upsert writes (create league, create games, add official) with stable externalIds. Everything else — see below. |
Idempotency is your retry safety net
Exactly three writes are idempotent upserts on(source, externalId) and safe to repeat: POST /leagues, POST /leagues/{leagueId}/games, POST /leagues/{leagueId}/officials. Always send a stable externalId from an integration — it is what makes a timeout recoverable by simple re-send.
Every other write is not idempotent — never auto-retry after an ambiguous failure (timeout, dropped connection) without checking state first:
POST /games/{gameId}/assign— itsexternalId/sourcefields identify the official; they do not make the assignment idempotent. Re-fetch the game to see whether the claim landed before re-sending.POST /webhooks— registering twice creates two endpoints.GET /webhooksfirst.- Any
DELETE.
Rules for AI agents
If you are an agent operating this API (via REST or the MCP tools), follow these rules:Reads are free; writes need intent
List/get calls are always safe. Only call write tools when the user’s request actually requires the change.
Summarize and confirm before destructive actions
Before any of the following, present a plain-language summary of exactly what will change (which league/game/official, by name and id) and get explicit confirmation:
- Cancel a game (
DELETE /games/{gameId}/whistle_cancel_game) — cancels open slots. - Remove an official (
DELETE /leagues/{leagueId}/officials/{userId}/whistle_remove_official) — also reopens every position they’d claimed. - Unassign a position (
DELETE /games/{gameId}/assign/whistle_unassign_position) — deletes the claim. - Delete a webhook (
DELETE /webhooks/{webhookId}/whistle_delete_webhook) — removes delivery history; endpoint may belong to another integration. - Bulk mutations — any loop that writes to many records (mass game updates, batch official changes). Confirm the batch as a whole, with a count.
Never auto-repeat writes without stable identifiers
Repeating a write is safe only when it’s an upsert keyed on
(source, externalId). If you don’t have a stable externalId for a record, a repeated POST creates a duplicate — stop and verify state instead of retrying.Treat 409s as information, not failure
A conflict means the world changed (someone claimed the slot, the official was already removed). Re-fetch, report the actual state, and re-plan rather than forcing the original action.
Respect the scope boundary
Your key sees only leagues and webhook endpoints in your
source namespace (plus explicit grants) — out-of-scope resources answer 404 exactly like nonexistent ones. Don’t interpret a 404 as “gone” and re-create records on that basis alone; re-list what your key can see and reconcile from there.
