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

# Add or re-approve an official (idempotent upsert)

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

Emits `official.joined` (new membership) or `official.approved` (existing membership).



## OpenAPI

````yaml /api-reference/openapi.json post /leagues/{leagueId}/officials
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>"}`.


    **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`.


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


    **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`.


    **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`).


    List 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:
  /leagues/{leagueId}/officials:
    parameters:
      - $ref: '#/components/parameters/leagueId'
    post:
      tags:
        - Officials
      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`.


        Emits `official.joined` (new membership) or `official.approved`
        (existing membership).
      operationId: addOfficial
      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'
components:
  parameters:
    leagueId:
      name: leagueId
      in: path
      required: true
      schema:
        type: string
      description: Whistle league id (`id` from league responses).
  schemas:
    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
    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>`.
  responses:
    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
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unauthorized
    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
  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.

````