openapi: "3.1.0"
info:
  title: CHSAA API
  version: "1"
  description: |
    ## Overview

    The CHSAA API gives licensed partners programmatic access to Colorado High
    School Activities Association data — starting with active officials and a
    suggestion intake queue. Access is keyed per organization: you only receive
    the endpoints and sports your grant covers.

    Base URL: `https://api.chsaa.org/v1`

    ## Getting started

    1. Request a key from [ryan@chsaa.org](mailto:ryan@chsaa.org) or
       [mjensen@chsaa.org](mailto:mjensen@chsaa.org).
    2. Call an endpoint with your bearer token:

    ```bash
    curl -sS https://api.chsaa.org/v1/officials \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Accept: application/json"
    ```

    3. Optionally narrow to one sport. Results are always limited to the sports
       your grant covers, whether or not you pass `sport`:

    ```bash
    curl -sS "https://api.chsaa.org/v1/officials?sport=Basketball" \
      -H "Authorization: Bearer YOUR_API_KEY"
    ```

    ## Authentication

    Every `/v1` route requires:

    ```
    Authorization: Bearer <your-api-key>
    ```

    Keys are issued by CHSAA staff. Plaintext is shown **once** at creation —
    store it like a password. Failed auth returns `401` with a
    `WWW-Authenticate: Bearer` header.

    **Rotation:** request a new key, switch your client, then ask CHSAA to revoke
    the old one. CLI-issued keys default to a 365-day expiry.

    **Revocation:** staff revoke in the admin console (`revokedAt` / inactive
    consumer). The gateway caches auth for up to 60 seconds, so a revoke takes
    effect within that window.

    ## Keeping your copy in sync

    Every official carries an `updatedAt` timestamp: the newest change across
    the official's record and their current-year registrations in the sports
    your grant covers. Use it with `?updatedSince=` and `ETag` to avoid
    re-downloading the whole roster.

    **Recommended pattern (hybrid sync):**

    1. **Full pull nightly.** `GET /v1/officials` with `Accept-Encoding: gzip`
       (about 70 KB for the full roster). Replace your copy with this result.
       This is the only step that catches **removals**: officials who retire,
       let dues lapse, or fall out when a sport's dues year rolls over stop
       appearing in the list, and an incremental pull cannot tell you that.
    2. **Incremental pulls during the day.**
       `GET /v1/officials?updatedSince=<your last successful pull, ISO 8601>`
       returns only officials whose `updatedAt` is later than that instant.
       Upsert those into your copy. Store the server time of the request (from
       the `Date` header), not the newest `updatedAt` in the payload, as your
       next cursor.
    3. **Skip unchanged responses with `ETag`.** Every list response includes
       a weak `ETag`. Send it back as `If-None-Match`; if nothing in the roster
       data has changed you get `304 Not Modified` with no body and the server
       skips the main query entirely. The tag is conservative: any change to
       the underlying tables invalidates it, so a `304` is always safe.

    Notes:

    - `updatedAt` reflects in-scope sports only. An official's change in a
      sport outside your grant does not bump the value you see.
    - Timestamps are `DATETIME(3)` in UTC, serialized as ISO 8601.
    - Rows that predate timestamp tracking report `1970-01-01T00:00:00.000Z`
      until a one-time backfill runs; a nightly full pull makes this harmless.

    ```bash
    # Incremental pull with ETag short-circuit
    curl -sS "https://api.chsaa.org/v1/officials?updatedSince=2026-09-11T06:00:00Z" \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Accept-Encoding: gzip" \
      -H 'If-None-Match: W/"<etag from last response>"'
    ```

    ## Rate limits

    All routes are rate-limited at both the IP and per-grant level. Exceeding
    either returns `429` with a `Retry-After` header indicating when to retry.
    Your specific limits are set by CHSAA staff and noted in your onboarding.

    Prefer gzip: send `Accept-Encoding: gzip` (or `br`); large list payloads
    compress well.

    ## Errors

    All errors use a single envelope:

    ```json
    { "error": "Human-readable message" }
    ```

    | Status | Meaning |
    | --- | --- |
    | `400` | Invalid path param or request body |
    | `401` | Missing or invalid API key |
    | `403` | Key valid but no grant, or `?sport=` names a sport outside your scope |
    | `404` | Resource not found, not currently eligible, or not eligible in a sport you're scoped to |
    | `429` | Rate limit exceeded |
    | `500` | Unexpected server error |

    ## Versioning

    All routes live under `/v1`. Breaking changes ship as `/v2` (or new paths)
    with advance notice to active consumers. Additive fields and optional query
    params are non-breaking.

    ## Support

    Questions or access requests:
    [ryan@chsaa.org](mailto:ryan@chsaa.org) ·
    [mjensen@chsaa.org](mailto:mjensen@chsaa.org)

    Interactive reference: [https://api.chsaa.org/docs](https://api.chsaa.org/docs)
  contact:
    name: CHSAA Technology
    email: ryan@chsaa.org, mjensen@chsaa.org
  x-logo:
    url: /logos/combined.svg
    altText: CHSAA
    backgroundColor: "#f7f8fa"
    maxHeight: 30px

servers:
  - url: https://api.chsaa.org/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Officials
    description: Eligible officials roster and detail
  - name: Suggestions
    description: Vendor-submitted official suggestions for staff review

x-tagGroups:
  - name: Officials
    tags:
      - Officials
      - Suggestions

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key issued by CHSAA

  schemas:
    OfficialLite:
      type: object
      properties:
        id:
          type: integer
        firstName:
          type: string
        lastName:
          type: string
        email:
          type: string
          format: email
        area:
          type: string
          nullable: true
        updatedAt:
          type: string
          format: date-time
          description: |
            Newest change across the official's record and their current-year
            registrations in your authorized sports. Rows never stamped report
            `1970-01-01T00:00:00.000Z`.
          example: "2026-09-11T14:19:25.975Z"

    OfficialFull:
      allOf:
        - $ref: "#/components/schemas/OfficialLite"
        - type: object
          properties:
            address:
              type: string
              nullable: true
            city:
              type: string
              nullable: true
            state:
              type: string
              nullable: true
            zip:
              type: string
              nullable: true
            phone:
              type: string
              nullable: true
            gender:
              type: string
              nullable: true

    Suggestion:
      type: object
      required:
        - firstName
        - lastName
        - email
      properties:
        firstName:
          type: string
        lastName:
          type: string
        email:
          type: string
          format: email
        area:
          type: string
        address:
          type: string
        city:
          type: string
        state:
          type: string
          maxLength: 2
        zip:
          type: string
        phone:
          type: string
        gender:
          type: string
          maxLength: 1
        sport:
          type: string
        notes:
          type: string

    Error:
      type: object
      properties:
        error:
          type: string

paths:
  /officials:
    get:
      tags:
        - Officials
      summary: List active officials
      description: |
        Returns currently eligible officials (dues paid in the sport's current
        dues year). Response includes id, name, email, and area — suitable for
        matching against your records. For full contact details, use
        `GET /officials/{id}`.

        By default the full eligible set is returned (IAABO-compatible). Optional
        `limit` / `offset` paginate when you want smaller pages.
      operationId: listOfficials
      parameters:
        - name: sport
          in: query
          required: false
          schema:
            type: string
          description: |
            Narrow to one sport (e.g. `Basketball`, `Boys_Lacrosse`). Must be within
            your authorized sport scope or the request is rejected with `403`.
            When omitted, results include every sport your grant covers, and only those.
          example: Basketball
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 5000
          description: Optional page size. Omit for the full list.
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Rows to skip when `limit` is set.
        - name: updatedSince
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Return only officials whose `updatedAt` is strictly later than this
            ISO 8601 instant. Cannot report removals; pair with a nightly full pull.
            See *Keeping your copy in sync*.
          example: "2026-09-11T06:00:00Z"
        - name: If-None-Match
          in: header
          required: false
          schema:
            type: string
          description: |
            The `ETag` from a previous list response. If the roster data has not
            changed, the server responds `304 Not Modified` with no body.
      x-codeSamples:
        - lang: curl
          source: |
            curl -sS "https://api.chsaa.org/v1/officials?sport=Basketball" \
              -H "Authorization: Bearer YOUR_API_KEY" \
              -H "Accept: application/json" \
              -H "Accept-Encoding: gzip"
        - lang: JavaScript
          source: |
            const res = await fetch(
              "https://api.chsaa.org/v1/officials?sport=Basketball",
              {
                headers: {
                  Authorization: "Bearer YOUR_API_KEY",
                  Accept: "application/json",
                },
              },
            );
            if (!res.ok) throw new Error((await res.json()).error);
            const { officials } = await res.json();
      responses:
        "200":
          description: List of active officials
          headers:
            ETag:
              schema:
                type: string
              description: Weak validator for the roster data; send back as `If-None-Match`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  officials:
                    type: array
                    items:
                      $ref: "#/components/schemas/OfficialLite"
        "304":
          description: Not modified since the `If-None-Match` tag was issued. No body.
          headers:
            ETag:
              schema:
                type: string
        "400":
          description: Invalid `limit`, `offset`, or `updatedSince`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Your key does not have access to this endpoint or sport
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /officials/{id}:
    get:
      tags:
        - Officials
      summary: Get official by ID
      description: |
        Returns full contact details for a single active official.

        Sport scope applies here too: if your grant is limited to specific sports
        and the official has no current-year registration in any of them, the
        response is `404`. The `area` value reflects only in-scope sports.
      operationId: getOfficial
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: The official's ID from CHSAA records
      x-codeSamples:
        - lang: curl
          source: |
            curl -sS "https://api.chsaa.org/v1/officials/12345" \
              -H "Authorization: Bearer YOUR_API_KEY" \
              -H "Accept: application/json"
        - lang: JavaScript
          source: |
            const res = await fetch(
              "https://api.chsaa.org/v1/officials/12345",
              {
                headers: {
                  Authorization: "Bearer YOUR_API_KEY",
                  Accept: "application/json",
                },
              },
            );
            if (!res.ok) throw new Error((await res.json()).error);
            const { official } = await res.json();
      responses:
        "200":
          description: Official record
          content:
            application/json:
              schema:
                type: object
                properties:
                  official:
                    $ref: "#/components/schemas/OfficialFull"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Your key does not have access to this endpoint
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Official not found or not currently active
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /suggestions:
    post:
      tags:
        - Suggestions
      summary: Submit an official suggestion
      description: |
        Submit an official your records show that CHSAA may be missing.

        Suggestions go to a review queue and are manually approved by CHSAA staff
        before any record is created. A `202 Accepted` response means the suggestion
        was received, not that it was approved.
      operationId: createSuggestion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Suggestion"
            example:
              firstName: Jane
              lastName: Smith
              email: jane.smith@example.com
              sport: Basketball
              state: CO
              zip: "80202"
      x-codeSamples:
        - lang: curl
          source: |
            curl -sS -X POST "https://api.chsaa.org/v1/suggestions" \
              -H "Authorization: Bearer YOUR_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                "firstName": "Jane",
                "lastName": "Smith",
                "email": "jane.smith@example.com",
                "sport": "Basketball",
                "state": "CO",
                "zip": "80202"
              }'
        - lang: JavaScript
          source: |
            const res = await fetch("https://api.chsaa.org/v1/suggestions", {
              method: "POST",
              headers: {
                Authorization: "Bearer YOUR_API_KEY",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                firstName: "Jane",
                lastName: "Smith",
                email: "jane.smith@example.com",
                sport: "Basketball",
                state: "CO",
                zip: "80202",
              }),
            });
            if (!res.ok) throw new Error((await res.json()).error);
            // 202 — queued for staff review
            const body = await res.json();
      responses:
        "202":
          description: Suggestion received and pending review
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Your key does not have access to this endpoint
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
