{
  "openapi": "3.1.0",
  "info": {
    "title": "Whistle Partner API",
    "version": "1.0.0",
    "summary": "Server-to-server REST API for Whistle — post games, manage officials, track payouts.",
    "description": "Whistle's v1 partner API lets league management systems and partner automations create leagues and games, add and assign sports officials, read the payout ledger, and receive webhooks. All requests require a partner API key (`Authorization: Bearer whk_…`). Responses use a consistent envelope: success is `{\"data\": …}`, failure is `{\"error\": \"<code>\"}`.\n\n**Scoping:** partner keys are bound to a `source` namespace. A key reads and mutates only leagues whose `source` matches (plus leagues explicitly granted to it), and webhook endpoints it registered. Out-of-scope resources answer `404` identically to nonexistent ones — \"not yours\" and \"not there\" are deliberately indistinguishable. Write `source` is derived from the key: omit the field or echo your own value; any other value returns `400 source_mismatch`.\n\n**Contract stability:** v1 response field shapes, status strings, and webhook event names are additive-only. Breaking changes ship as `/api/v2` — v1 is never mutated in place.\n\n**Idempotency:** `source` + `externalId` form the upsert key on leagues, games, and officials. Re-posting the same `(source, externalId)` updates instead of duplicating. Re-using a game `externalId` under a different league returns `409 external_id_conflict`.\n\n**Money:** requests take dollars (`payDollars`); responses return both integer cents (`payCents`, `amountCents`) and dollars. **Time:** all timestamps are ISO 8601 UTC (`2026-07-09T18:00:00.000Z`).\n\nList endpoints are not paginated and take no filter parameters (except `?limit=` on webhook deliveries). The API has no CORS support — it is server-to-server only.",
    "contact": {
      "name": "HuddleUp Sports support",
      "email": "tommy@huddleup-sports.com",
      "url": "https://huddleup-sports.com/docs/developers"
    }
  },
  "servers": [
    {
      "url": "https://whistle.huddleup-sports.com/api/v1",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Index",
      "description": "Self-describing API index"
    },
    {
      "name": "Leagues",
      "description": "Create, read, and update leagues"
    },
    {
      "name": "Games",
      "description": "Batch-import games, read, update, cancel"
    },
    {
      "name": "Officials",
      "description": "Add, approve, list, and remove officials; assign them to games"
    },
    {
      "name": "Assignments",
      "description": "Who is covering which game"
    },
    {
      "name": "Payments",
      "description": "Read-only payout ledger"
    },
    {
      "name": "Webhooks",
      "description": "Register endpoints and inspect deliveries"
    }
  ],
  "paths": {
    "/": {
      "get": {
        "tags": [
          "Index"
        ],
        "operationId": "getIndex",
        "summary": "API index",
        "description": "Self-describing index: version, auth hint, endpoint list, MCP pointer, webhook event catalog, and contract notes. Useful as an authenticated smoke test — it is the recommended first call.",
        "responses": {
          "200": {
            "description": "Index document",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "version": {
                          "type": "string",
                          "const": "v1"
                        },
                        "auth": {
                          "type": "string"
                        },
                        "endpoints": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "mcp": {
                          "type": "string"
                        },
                        "webhookEvents": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/WebhookEventName"
                          }
                        },
                        "notes": {
                          "type": "object"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/leagues": {
      "get": {
        "tags": [
          "Leagues"
        ],
        "operationId": "listLeagues",
        "summary": "List leagues",
        "description": "All leagues in your key's scope (your `source` namespace plus explicit grants), newest first, each with `gameCount` and `memberCount`. Not paginated.",
        "responses": {
          "200": {
            "description": "Array of leagues with counts",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          {
                            "$ref": "#/components/schemas/League"
                          },
                          {
                            "type": "object",
                            "properties": {
                              "gameCount": {
                                "type": "integer"
                              },
                              "memberCount": {
                                "type": "integer"
                              }
                            },
                            "required": [
                              "gameCount",
                              "memberCount"
                            ]
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Leagues"
        ],
        "operationId": "createLeague",
        "summary": "Create a league (idempotent upsert)",
        "description": "With `externalId`, this is an upsert on `(source, externalId)`: on conflict, `name`, `paymentMode`, and `currency` are rewritten. Without `externalId`, it is a plain create. `source` is derived from your key — omit it or echo your own value (anything else → `400 source_mismatch`). An invalid `paymentMode` silently normalizes to `\"offline\"` (never errors). Returns 201 even when an upsert updated an existing league. No webhook is emitted for league creation.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Required, trimmed, non-empty."
                  },
                  "paymentMode": {
                    "type": "string",
                    "enum": [
                      "offline",
                      "platform"
                    ],
                    "description": "Anything other than \"platform\" normalizes to \"offline\"."
                  },
                  "currency": {
                    "type": "string",
                    "description": "ISO 4217, lowercased. Default \"usd\"."
                  },
                  "source": {
                    "type": "string",
                    "description": "Derived from your key — omit, or echo your key's source; any other value returns 400 source_mismatch."
                  },
                  "externalId": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Your stable id for this league — enables idempotent upsert."
                  }
                }
              },
              "example": {
                "name": "Springfield Little League",
                "externalId": "org_123",
                "currency": "usd"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created or upserted league",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/League"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/leagues/{leagueId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/leagueId"
        }
      ],
      "get": {
        "tags": [
          "Leagues"
        ],
        "operationId": "getLeague",
        "summary": "Get a league with games and officials",
        "description": "League detail: `games` (ordered by `startsAt` ascending, positions included) and `officials` (memberships with role `official` in **all** statuses — filter on `status` yourself; `pending` and `denied` are included).",
        "responses": {
          "200": {
            "description": "League detail",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/League"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "games": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Game"
                              }
                            },
                            "officials": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Official"
                              }
                            }
                          },
                          "required": [
                            "games",
                            "officials"
                          ]
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "Leagues"
        ],
        "operationId": "updateLeague",
        "summary": "Update a league",
        "description": "Only provided keys are applied. Emits no webhook.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "paymentMode": {
                    "type": "string",
                    "enum": [
                      "offline",
                      "platform"
                    ]
                  },
                  "currency": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated league",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/League"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/leagues/{leagueId}/games": {
      "parameters": [
        {
          "$ref": "#/components/parameters/leagueId"
        }
      ],
      "get": {
        "tags": [
          "Games"
        ],
        "operationId": "listLeagueGames",
        "summary": "List a league's games",
        "description": "Ordered by `startsAt` ascending, positions included. Not paginated.",
        "responses": {
          "200": {
            "description": "Array of games",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Game"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "post": {
        "tags": [
          "Games"
        ],
        "operationId": "createGames",
        "summary": "Batch create/upsert games (1–500)",
        "description": "Each game with an `externalId` is an idempotent upsert on `(source, externalId)`. **Create** makes the game plus exactly one position (label defaults to `\"Official\"`). **Upsert-update** rewrites `sport`, `startsAt`, `location`, `latitude`, `longitude` only — positions, pay, and existing claims are never touched, so re-posting a schedule cannot disturb an official who already claimed a slot. Re-using an `externalId` that belongs to a game in a **different** league returns `409 external_id_conflict` and leaves the existing game untouched (a game cannot switch leagues).\n\n⚠️ **Bulk mutation — confirm before sending.** One call can rewrite mutable fields on up to 500 existing games. Agents should summarize the target league and game count and get confirmation first.\n\n⚠️ **The batch is not transactional.** Games are inserted sequentially; if a mid-batch game fails (e.g. `invalid_startsAt:<value>`), the request returns 400 but earlier games are already persisted. Recovery: fix the input and re-post the whole batch — upserts make this safe when every game has an `externalId`.\n\nEmits one `game.created` webhook per persisted game (including upsert-updates).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "games"
                ],
                "properties": {
                  "source": {
                    "type": "string",
                    "maxLength": 40,
                    "description": "Derived from your key — omit, or echo your key's source; any other value returns 400 source_mismatch."
                  },
                  "games": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 500,
                    "items": {
                      "$ref": "#/components/schemas/NewGameInput"
                    }
                  }
                }
              },
              "example": {
                "games": [
                  {
                    "startsAt": "2026-08-15T18:00:00Z",
                    "sport": "Baseball",
                    "location": "Field 2, Springfield Park",
                    "payDollars": 65,
                    "label": "Umpire",
                    "externalId": "game_8801"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Persisted games (note the extra `created` wrapper)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "created": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Game"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/leagues/{leagueId}/officials": {
      "parameters": [
        {
          "$ref": "#/components/parameters/leagueId"
        }
      ],
      "get": {
        "tags": [
          "Officials"
        ],
        "operationId": "listOfficials",
        "summary": "List a league's officials",
        "description": "Memberships with role `official` in **any** status (`pending`, `approved`, `denied`), oldest first. Filter on `status` yourself.",
        "responses": {
          "200": {
            "description": "Array of officials",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Official"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "post": {
        "tags": [
          "Officials"
        ],
        "operationId": "addOfficial",
        "summary": "Add or re-approve an official (idempotent upsert)",
        "description": "Officials are referenced by **your** `(source, externalId)` pair, not by email. Upserts the user (email/name updated only when provided; a placeholder email is synthesized if none given) and the league membership. `status`: only `\"pending\"` is honored — any other value (or omitting it) becomes `\"approved\"`. If the referenced user is already the league's organizer → `409 user_is_organizer`.\n\nEmits `official.joined` (new membership) or `official.approved` (existing membership).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "externalId"
                ],
                "properties": {
                  "externalId": {
                    "type": "string",
                    "description": "Your stable id for this official. Required."
                  },
                  "source": {
                    "type": "string",
                    "description": "Derived from your key — omit, or echo your key's source; any other value returns 400 source_mismatch."
                  },
                  "email": {
                    "type": "string",
                    "description": "Optional bridge to the official's real identity. Trimmed and lowercased."
                  },
                  "name": {
                    "type": "string"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "approved",
                      "pending"
                    ],
                    "description": "Only \"pending\" is honored; everything else becomes \"approved\"."
                  }
                }
              },
              "example": {
                "externalId": "user_42",
                "email": "ref@example.com",
                "name": "Jordan Ref"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Official membership",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Official"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/leagues/{leagueId}/officials/{userId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/leagueId"
        },
        {
          "name": "userId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          },
          "description": "The **Whistle** user id (`userId` from officials responses), not your externalId."
        }
      ],
      "delete": {
        "tags": [
          "Officials"
        ],
        "operationId": "removeOfficial",
        "summary": "Remove an official from a league",
        "description": "⚠️ Destructive: every position this official has claimed in the league is reopened and the claims are deleted. Emits `official.removed`.",
        "responses": {
          "200": {
            "description": "Removed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "removed": {
                          "type": "boolean",
                          "const": true
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "removed": true
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/leagues/{leagueId}/assignments": {
      "parameters": [
        {
          "$ref": "#/components/parameters/leagueId"
        }
      ],
      "get": {
        "tags": [
          "Assignments"
        ],
        "operationId": "listAssignments",
        "summary": "List assignments (who's covering what)",
        "description": "Every claimed position in the league with its game, official, and payment, ordered by game start time ascending.",
        "responses": {
          "200": {
            "description": "Array of assignments",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Assignment"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/leagues/{leagueId}/payments": {
      "parameters": [
        {
          "$ref": "#/components/parameters/leagueId"
        }
      ],
      "get": {
        "tags": [
          "Payments"
        ],
        "operationId": "listPayments",
        "summary": "List a league's payout ledger (read-only)",
        "description": "Newest first. There is no v1 endpoint to create or settle payments — payments are produced by in-app organizer actions.",
        "responses": {
          "200": {
            "description": "Array of payments",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Payment"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/games/{gameId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/gameId"
        }
      ],
      "get": {
        "tags": [
          "Games"
        ],
        "operationId": "getGame",
        "summary": "Get a game with positions, officials, payments",
        "description": "Positions embed `official` and `payment`. ⚠️ A position's `official` is independent of its `status`: a `cancelled` position can retain a populated `official` (organizer marked a no-show and completed the game — the slot is cancelled unpaid but the claim is kept for the record). Read defensively.",
        "responses": {
          "200": {
            "description": "Game detail",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/GameDetail"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "Games"
        ],
        "operationId": "updateGame",
        "summary": "Update a game",
        "description": "Only provided keys applied. Setting `status` writes the string directly with **no** side effects on positions, claims, or payments — prefer `DELETE /games/{gameId}` to cancel; treat `status` patching as advanced. Emits `game.updated`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "sport": {
                    "type": "string"
                  },
                  "startsAt": {
                    "type": "string",
                    "format": "date-time",
                    "description": "ISO 8601."
                  },
                  "location": {
                    "type": "string"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "open",
                      "claimed",
                      "completed",
                      "cancelled"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated game (positions without official/payment)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Game"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Games"
        ],
        "operationId": "cancelGame",
        "summary": "Cancel a game",
        "description": "⚠️ Destructive: open positions become `cancelled`, then the game does. Claimed/completed positions and payments are preserved. Emits `game.cancelled`.",
        "responses": {
          "200": {
            "description": "Post-cancel game detail",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/GameDetail"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/games/{gameId}/assign": {
      "parameters": [
        {
          "$ref": "#/components/parameters/gameId"
        }
      ],
      "post": {
        "tags": [
          "Officials"
        ],
        "operationId": "assignOfficial",
        "summary": "Assign an official to a position",
        "description": "The official must already be an **approved** league member (add via `POST /leagues/{leagueId}/officials` first) — else `409 not_a_league_official`. Reference them by `userId` (wins if present) or `externalId` + `source`. Position: pass `positionId` explicitly, or omit it to claim the first `open` slot. Claims are race-safe — losing a concurrent claim returns `409 position_taken`, which is safe to handle by picking another slot. Returns **200** (not 201). Emits `position.claimed`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "userId": {
                    "type": "string",
                    "description": "Whistle user id. Takes precedence."
                  },
                  "externalId": {
                    "type": "string",
                    "description": "Your id for the official. Identifies who to assign — does NOT make this call idempotent."
                  },
                  "source": {
                    "type": "string",
                    "description": "Derived from your key — omit, or echo your key's source; any other value returns 400 source_mismatch."
                  },
                  "positionId": {
                    "type": "string",
                    "description": "Optional; defaults to the first open position."
                  }
                }
              },
              "example": {
                "externalId": "user_42"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Game detail after assignment",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/GameDetail"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "delete": {
        "tags": [
          "Officials"
        ],
        "operationId": "unassignPosition",
        "summary": "Unassign an official (reopen the slot)",
        "description": "⚠️ Destructive: deletes the claim and reopens the position. The position must currently be `claimed`. Emits `position.released`.",
        "parameters": [
          {
            "name": "positionId",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The position to reopen. Required — omitting it returns `400 positionId_required`."
          }
        ],
        "responses": {
          "200": {
            "description": "Game detail after unassignment",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/GameDetail"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhooks",
        "summary": "List webhook endpoints",
        "description": "Your `source`'s endpoints, newest first. `secret` is **never** returned here. Endpoints are owned by the registering key's `source` — you see and manage only your own.",
        "responses": {
          "200": {
            "description": "Array of endpoints",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookEndpoint"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "registerWebhook",
        "summary": "Register a webhook endpoint",
        "description": "`url` must be HTTPS. `events` empty or omitted = subscribe to **all** events; unknown names → `400 unknown_event:<name>`. The endpoint's `source` is derived from your key, and it receives events only for leagues in your scope. The signing `secret` (`whsec_…`) is returned **in this response only** — store it immediately; it cannot be retrieved again, and there is no rotation endpoint (delete and re-register to rotate).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "HTTPS only."
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/WebhookEventName"
                    },
                    "description": "Empty/omitted = all events."
                  },
                  "source": {
                    "type": "string",
                    "maxLength": 40,
                    "description": "Derived from your key — omit, or echo your key's source; any other value returns 400 source_mismatch."
                  }
                }
              },
              "example": {
                "url": "https://example.com/whistle/webhooks",
                "events": [
                  "game.created",
                  "position.claimed",
                  "payment.paid"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created endpoint including the once-only secret",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "secret": {
                              "type": "string",
                              "description": "whsec_ + 48 hex chars. Returned once, never again."
                            }
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/webhooks/{webhookId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/webhookId"
        }
      ],
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhook",
        "summary": "Delete a webhook endpoint",
        "description": "⚠️ Destructive: hard delete; delivery history is removed with it.",
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "deleted": {
                          "type": "boolean",
                          "const": true
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "deleted": true
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/webhooks/{webhookId}/deliveries": {
      "parameters": [
        {
          "$ref": "#/components/parameters/webhookId"
        }
      ],
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhookDeliveries",
        "summary": "Delivery history for an endpoint",
        "description": "Newest first.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 200
            },
            "description": "Default 50, max 200."
          }
        ],
        "responses": {
          "200": {
            "description": "Array of deliveries",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookDelivery"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    }
  },
  "webhooks": {
    "game.created": {
      "post": {
        "summary": "A game was created or upserted via the batch import",
        "description": "Fired once per persisted game on `POST /leagues/{leagueId}/games`, including idempotent upsert-updates.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "gameId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          },
                          "externalId": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "source": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "gameId",
                          "leagueId",
                          "externalId",
                          "source"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "game.updated": {
      "post": {
        "summary": "A game's fields were updated",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "gameId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "gameId",
                          "leagueId"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "game.cancelled": {
      "post": {
        "summary": "A game was cancelled",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "gameId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "gameId",
                          "leagueId"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "game.completed": {
      "post": {
        "summary": "An organizer marked a game complete (in-app action; not triggerable via the API)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "gameId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          },
                          "officialIds": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            }
                          }
                        },
                        "required": [
                          "gameId",
                          "leagueId",
                          "officialIds"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "position.claimed": {
      "post": {
        "summary": "An official claimed / was assigned a position",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "positionId": {
                            "type": "string"
                          },
                          "gameId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          },
                          "officialId": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "positionId",
                          "gameId",
                          "leagueId",
                          "officialId"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "position.released": {
      "post": {
        "summary": "A claimed position was released/unassigned",
        "description": "`officialId` is present only when the release originates in-app; API-driven unassigns omit it — treat it as optional.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "positionId": {
                            "type": "string"
                          },
                          "gameId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          },
                          "officialId": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "positionId",
                          "gameId",
                          "leagueId"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "payment.paid": {
      "post": {
        "summary": "A payment settled (organizer marked paid, or platform charge settled)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "paymentId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          },
                          "gameId": {
                            "type": "string"
                          },
                          "officialId": {
                            "type": "string"
                          },
                          "amountCents": {
                            "type": "integer"
                          },
                          "currency": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "paymentId",
                          "leagueId",
                          "gameId",
                          "officialId",
                          "amountCents",
                          "currency"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "payment.failed": {
      "post": {
        "summary": "A platform payment terminally failed",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "paymentId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          },
                          "gameId": {
                            "type": "string"
                          },
                          "officialId": {
                            "type": "string"
                          },
                          "amountCents": {
                            "type": "integer"
                          },
                          "currency": {
                            "type": "string"
                          },
                          "failureCode": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "failureMessage": {
                            "type": [
                              "string",
                              "null"
                            ]
                          }
                        },
                        "required": [
                          "paymentId",
                          "leagueId",
                          "gameId",
                          "officialId",
                          "amountCents",
                          "currency",
                          "failureCode",
                          "failureMessage"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "official.joined": {
      "post": {
        "summary": "An official joined a league (new membership)",
        "description": "API-originated payloads include `externalId` and `source`; in-app joins do not — treat those fields as optional.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "leagueId": {
                            "type": "string"
                          },
                          "userId": {
                            "type": "string"
                          },
                          "role": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string"
                          },
                          "externalId": {
                            "type": "string"
                          },
                          "source": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "leagueId",
                          "userId",
                          "role",
                          "status"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "official.approved": {
      "post": {
        "summary": "An official's membership was (re-)approved",
        "description": "Guaranteed fields: `leagueId`, `userId`. Other fields vary by origin (API adds `role`/`status`/`externalId`/`source`; in-app approval adds `membershipId`).",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "leagueId": {
                            "type": "string"
                          },
                          "userId": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "leagueId",
                          "userId"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "official.removed": {
      "post": {
        "summary": "An official was removed from a league",
        "description": "Guaranteed fields: `leagueId`, `userId`. In-app removals add `membershipId`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "leagueId": {
                            "type": "string"
                          },
                          "userId": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "leagueId",
                          "userId"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    },
    "official.no_show": {
      "post": {
        "summary": "An organizer marked an official as a no-show (in-app action; not triggerable via the API)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "data": {
                        "type": "object",
                        "properties": {
                          "claimId": {
                            "type": "string"
                          },
                          "gameId": {
                            "type": "string"
                          },
                          "leagueId": {
                            "type": "string"
                          },
                          "officialId": {
                            "type": "string"
                          },
                          "positionId": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "claimId",
                          "gameId",
                          "leagueId",
                          "officialId",
                          "positionId"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Partner API key: `Authorization: Bearer whk_…`. Keys are issued by the Whistle team (email tommy@huddleup-sports.com), shown once at issuance, and bound to your `source` namespace — they access only your leagues (plus explicit grants) and your webhook endpoints. Any auth failure returns `401 {\"error\":\"unauthorized\"}`; the API never returns 403 — out-of-scope resources answer 404 exactly like nonexistent ones."
      }
    },
    "parameters": {
      "leagueId": {
        "name": "leagueId",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Whistle league id (`id` from league responses)."
      },
      "gameId": {
        "name": "gameId",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Whistle game id (`id` from game responses)."
      },
      "webhookId": {
        "name": "webhookId",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Webhook endpoint id."
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid API key",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "unauthorized"
            }
          }
        }
      },
      "BadRequest": {
        "description": "Validation failure. The body is `{\"error\":\"<code>\"}` with a single snake_case code (no details object). Codes include: `invalid_json`, `name_required`, `source_reserved`, `source_mismatch` (body `source` differs from your key's — omit the field), `no_games`, `too_many_games`, `invalid_startsAt:<value>`, `invalid_startsAt`, `invalid_status`, `externalId_required`, `positionId_required`, `https_url_required`, `unknown_event:<name>`, `create_failed`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "invalid_json"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found **or outside your key's scope** — the two cases are deliberately indistinguishable (same status and code). Codes: `league_not_found`, `game_not_found`, `official_not_found`, `position_not_found`, `not_a_member`, `endpoint_not_found`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "league_not_found"
            }
          }
        }
      },
      "Conflict": {
        "description": "State conflict. Codes: `user_is_organizer`, `not_an_official`, `not_a_league_official`, `no_open_position`, `position_not_open`, `position_taken`, `position_not_claimed`, `external_id_conflict`. `position_taken` means a concurrent claim won the race — safe to retry with a different position. `external_id_conflict` means the game `externalId` already belongs to a different league; the existing game is untouched.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "position_taken"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "snake_case error code. Two codes carry a parameter suffix: `invalid_startsAt:<value>` and `unknown_event:<name>`."
          }
        }
      },
      "WebhookEventName": {
        "type": "string",
        "enum": [
          "game.created",
          "game.updated",
          "game.cancelled",
          "game.completed",
          "position.claimed",
          "position.released",
          "payment.paid",
          "payment.failed",
          "official.joined",
          "official.approved",
          "official.removed",
          "official.no_show"
        ]
      },
      "League": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "paymentMode": {
            "type": "string",
            "enum": [
              "offline",
              "platform"
            ],
            "description": "\"offline\" = organizer marks payments paid outside Whistle (fully supported). \"platform\" = Stripe payouts — accepted and stored, but platform payouts are not yet active for partners; see the integration status note in the docs."
          },
          "currency": {
            "type": "string",
            "description": "ISO 4217, lowercase. Default \"usd\"."
          },
          "source": {
            "type": "string",
            "description": "\"whistle\" for rows created in-app; your partner value otherwise."
          },
          "externalId": {
            "type": [
              "string",
              "null"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "name",
          "paymentMode",
          "currency",
          "source",
          "externalId",
          "createdAt"
        ]
      },
      "GamePosition": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "label": {
            "type": "string",
            "description": "e.g. \"Umpire\". Default \"Official\"."
          },
          "payCents": {
            "type": "integer"
          },
          "payDollars": {
            "type": "number"
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "claimed",
              "completed",
              "cancelled"
            ]
          }
        },
        "required": [
          "id",
          "label",
          "payCents",
          "payDollars",
          "status"
        ]
      },
      "Game": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "leagueId": {
            "type": "string"
          },
          "sport": {
            "type": "string",
            "description": "May be empty string."
          },
          "startsAt": {
            "type": "string",
            "format": "date-time"
          },
          "location": {
            "type": "string",
            "description": "May be empty string."
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "claimed",
              "completed",
              "cancelled"
            ],
            "description": "Rollup of position statuses."
          },
          "latitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "source": {
            "type": "string"
          },
          "externalId": {
            "type": [
              "string",
              "null"
            ]
          },
          "positions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/GamePosition"
            }
          }
        },
        "required": [
          "id",
          "leagueId",
          "sport",
          "startsAt",
          "location",
          "status",
          "latitude",
          "longitude",
          "source",
          "externalId",
          "positions"
        ]
      },
      "GameDetail": {
        "type": "object",
        "description": "Same as Game but each position embeds `official` and `payment`. A position's `official` is independent of its `status` — a cancelled slot can retain its official (no-show record).",
        "properties": {
          "id": {
            "type": "string"
          },
          "leagueId": {
            "type": "string"
          },
          "sport": {
            "type": "string"
          },
          "startsAt": {
            "type": "string",
            "format": "date-time"
          },
          "location": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "claimed",
              "completed",
              "cancelled"
            ]
          },
          "latitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "source": {
            "type": "string"
          },
          "externalId": {
            "type": [
              "string",
              "null"
            ]
          },
          "positions": {
            "type": "array",
            "items": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/GamePosition"
                },
                {
                  "type": "object",
                  "properties": {
                    "official": {
                      "oneOf": [
                        {
                          "type": "null"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "id": {
                              "type": "string"
                            },
                            "name": {
                              "type": "string"
                            },
                            "email": {
                              "type": "string"
                            },
                            "claimStatus": {
                              "type": "string",
                              "enum": [
                                "confirmed"
                              ]
                            }
                          }
                        }
                      ]
                    },
                    "payment": {
                      "oneOf": [
                        {
                          "type": "null"
                        },
                        {
                          "$ref": "#/components/schemas/Payment"
                        }
                      ]
                    }
                  },
                  "required": [
                    "official",
                    "payment"
                  ]
                }
              ]
            }
          }
        },
        "required": [
          "id",
          "leagueId",
          "sport",
          "startsAt",
          "location",
          "status",
          "latitude",
          "longitude",
          "source",
          "externalId",
          "positions"
        ]
      },
      "NewGameInput": {
        "type": "object",
        "required": [
          "startsAt"
        ],
        "properties": {
          "startsAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601, e.g. 2026-08-15T18:00:00Z. Required."
          },
          "sport": {
            "type": "string"
          },
          "location": {
            "type": "string"
          },
          "payDollars": {
            "type": [
              "number",
              "null"
            ],
            "description": "Dollars. Rounded to cents; negatives clamp to 0."
          },
          "label": {
            "type": "string",
            "description": "Position label. Default \"Official\"."
          },
          "latitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "externalId": {
            "type": [
              "string",
              "null"
            ],
            "description": "Your stable id — enables idempotent upsert."
          }
        }
      },
      "Official": {
        "type": "object",
        "properties": {
          "membershipId": {
            "type": "string"
          },
          "userId": {
            "type": "string",
            "description": "Whistle user id — use this for assign/remove calls."
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "description": "May be a synthesized placeholder (`<source>.<externalId>@partner.whistle`) when no real email was provided."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "approved",
              "denied"
            ]
          },
          "joinedAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "membershipId",
          "userId",
          "name",
          "email",
          "status",
          "joinedAt"
        ]
      },
      "Payment": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "positionId": {
            "type": "string"
          },
          "officialId": {
            "type": "string"
          },
          "amountCents": {
            "type": "integer"
          },
          "amountDollars": {
            "type": "number"
          },
          "currency": {
            "type": "string"
          },
          "mode": {
            "type": "string",
            "enum": [
              "platform",
              "offline"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "processing",
              "paid",
              "requires_card",
              "failed"
            ],
            "description": "pending = created, not yet charged (offline stays here until organizer marks paid); processing = platform charge created, awaiting settlement; paid = settled; requires_card / failed = recoverable platform failures."
          },
          "paidAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "failureCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "failureMessage": {
            "type": [
              "string",
              "null"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "positionId",
          "officialId",
          "amountCents",
          "amountDollars",
          "currency",
          "mode",
          "status",
          "paidAt",
          "failureCode",
          "failureMessage",
          "createdAt"
        ]
      },
      "Assignment": {
        "type": "object",
        "properties": {
          "positionId": {
            "type": "string"
          },
          "gameId": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "claimed",
              "completed",
              "cancelled"
            ]
          },
          "payCents": {
            "type": "integer"
          },
          "payDollars": {
            "type": "number"
          },
          "game": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "sport": {
                "type": "string"
              },
              "startsAt": {
                "type": "string",
                "format": "date-time"
              },
              "location": {
                "type": "string"
              },
              "status": {
                "type": "string",
                "enum": [
                  "open",
                  "claimed",
                  "completed",
                  "cancelled"
                ]
              }
            },
            "required": [
              "id",
              "sport",
              "startsAt",
              "location",
              "status"
            ]
          },
          "official": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string"
                  },
                  "claimStatus": {
                    "type": "string",
                    "enum": [
                      "confirmed"
                    ]
                  }
                }
              }
            ]
          },
          "payment": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Payment"
              }
            ]
          }
        },
        "required": [
          "positionId",
          "gameId",
          "label",
          "status",
          "payCents",
          "payDollars",
          "game",
          "official",
          "payment"
        ]
      },
      "WebhookEndpoint": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "url": {
            "type": "string"
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebhookEventName"
            },
            "description": "Empty array = subscribed to all events."
          },
          "active": {
            "type": "boolean"
          },
          "source": {
            "type": [
              "string",
              "null"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "url",
          "events",
          "active",
          "source",
          "createdAt"
        ]
      },
      "WebhookDelivery": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "event": {
            "$ref": "#/components/schemas/WebhookEventName"
          },
          "deliveryId": {
            "type": "string",
            "description": "The X-Whistle-Delivery header value — stable across retries; dedupe on this."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "delivered",
              "failed",
              "exhausted"
            ]
          },
          "attempts": {
            "type": "integer"
          },
          "lastStatus": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Last HTTP status from your receiver."
          },
          "lastError": {
            "type": [
              "string",
              "null"
            ]
          },
          "nextAttemptAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "deliveredAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "payload": {
            "type": "string",
            "description": "The exact JSON body that was/will be sent (and re-sent identically on retries)."
          }
        },
        "required": [
          "id",
          "event",
          "deliveryId",
          "status",
          "attempts",
          "lastStatus",
          "lastError",
          "nextAttemptAt",
          "deliveredAt",
          "createdAt",
          "payload"
        ]
      },
      "WebhookEnvelope": {
        "type": "object",
        "description": "Every webhook POST body. Headers: `X-Whistle-Event` (event name), `X-Whistle-Delivery` (stable across retries — dedupe on it), `X-Whistle-Signature` (`sha256=` + HMAC-SHA256 of the raw body using your endpoint secret).",
        "properties": {
          "id": {
            "type": "string",
            "description": "Delivery id."
          },
          "event": {
            "$ref": "#/components/schemas/WebhookEventName"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "data": {
            "type": "object",
            "description": "Event-specific payload."
          }
        },
        "required": [
          "id",
          "event",
          "createdAt",
          "data"
        ]
      }
    }
  }
}
