openapi: 3.1.0
info:
  title: OpenCallbook API
  version: "0.1.0"
  summary: Public REST API for FCC-licensed call signs (Amateur Radio + GMRS)
  description: |
    OpenCallbook serves the FCC Universal Licensing System (ULS) database
    over a clean, documented REST API. Both Amateur Radio (`AM`) and
    General Mobile Radio Service (`GMRS`, exposed as `GM`) licenses are
    indexed and joined by FRN so a single FRN's holdings span services.

    **Free to use within rate limits**: 60 requests/minute per IP for
    anonymous traffic. Higher limits are available with an API key — write
    to `api@opencallbook.com` (placeholder; key issuance UI lands in a
    later phase).

    Data refreshes from the FCC weekly Sunday dump; check
    `/api/v1/health.data_age_hours` for freshness.
  contact:
    name: OpenCallbook
    url: https://opencallbook.com/
  license:
    name: Apache-2.0
    url: https://www.apache.org/licenses/LICENSE-2.0
servers:
  - url: https://opencallbook.com
    description: Production
tags:
  - name: lookup
    description: Look up an individual call sign or FRN
  - name: search
    description: Free-text search with filters
  - name: stats
    description: Aggregate statistics
  - name: meta
    description: Health and metadata
paths:
  /api/v1/health:
    get:
      tags: [meta]
      summary: Liveness + data freshness
      operationId: health
      responses:
        "200":
          description: Service is up. The body indicates DB status and the age of the most recent successful ingestion.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
        "503":
          description: Service is degraded (DB unreachable).

  /api/v1/licenses/{callsign}:
    get:
      tags: [lookup]
      summary: Look up one license by call sign
      description: |
        Returns the most relevant license record for the given call sign
        (preferring Active over Expired/Cancelled), enriched with the
        licensee's name and address (street suppressed for individuals)
        and amateur-specific details when applicable. The
        `also_held_by_frn` array surfaces other call signs the same FRN
        holds, including across services (e.g. an amateur op who also
        holds a GMRS license).
      operationId: getLicense
      parameters:
        - name: callsign
          in: path
          required: true
          schema: { type: string, example: "W1AW" }
      responses:
        "200":
          description: License found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LicenseDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/frn/{frn}:
    get:
      tags: [lookup]
      summary: Look up all licenses held by an FRN
      operationId: getFRN
      parameters:
        - name: frn
          in: path
          required: true
          schema: { type: string, pattern: "^[0-9]{10}$", example: "0002274249" }
      responses:
        "200":
          description: Licenses found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FRNResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/search:
    get:
      tags: [search]
      summary: Free-text search with filters and sort
      description: |
        Searches across callsign, licensee names, and city. The free-text
        match is fuzzy (trigram + ILIKE for callsign prefixes), so partial
        matches and minor typos work. Combine with filters and a sort mode
        for "Jay from Minnesota who just got his license"-style queries.
      operationId: search
      parameters:
        - in: query
          name: q
          schema: { type: string, example: "smith" }
          description: Free-text query — matches callsign / first / last / entity name / city
        - in: query
          name: service
          schema: { type: string, enum: [AM, GM, ALL], default: ALL }
        - in: query
          name: status
          schema: { type: string, default: A, example: "A,E" }
          description: Comma-separated FCC status codes (A=active, E=expired, C=cancelled, T=terminated). Defaults to active only.
        - in: query
          name: state
          schema: { type: string, pattern: "^[A-Z]{2}$", example: "MN" }
        - in: query
          name: city
          schema: { type: string }
        - in: query
          name: class
          schema: { type: string, example: "E,G" }
          description: Comma-separated amateur operator-class codes
        - in: query
          name: sort
          schema:
            type: string
            enum: [relevance, recent, newest, expiring, callsign]
          description: |
            Sort mode. Defaults to `relevance` when `q` is set, else `recent`.
              * `relevance` — best match first (trigram similarity)
              * `recent`    — most recently updated at the FCC
              * `newest`    — most recently issued (great for finding fresh licenses)
              * `expiring`  — soonest to expire
              * `callsign`  — alphabetical
        - in: query
          name: limit
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
        - in: query
          name: offset
          schema: { type: integer, default: 0, minimum: 0, maximum: 1000 }
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResponse" }

  /api/v1/stats:
    get:
      tags: [stats]
      summary: Aggregate statistics
      description: Cached for 5 minutes. Backed by materialized views refreshed weekly with each ingestion.
      operationId: stats
      responses:
        "200":
          description: Stats
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stats" }

components:
  responses:
    BadRequest:
      description: Invalid input
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Not found
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string, example: "not_found" }
        message: { type: string, example: "no license found for callsign" }
    Health:
      type: object
      properties:
        status: { type: string, example: "ok" }
        db: { type: string, example: "ok" }
        data_age_hours: { type: integer, example: 36 }
        last_ingest_run: { type: string, format: date-time }
        last_ingest_ok: { type: boolean }
        unix_time: { type: integer, format: int64 }
    Licensee:
      type: object
      properties:
        name: { type: string }
        first_name: { type: string }
        mi: { type: string }
        last_name: { type: string }
        suffix: { type: string }
        entity_type: { type: string }
        applicant_type_code:
          type: string
          description: I=individual, B=business, etc.
        street_address:
          type: string
          description: Suppressed (empty) for individual licensees, mirroring FCC's own privacy posture
        city: { type: string }
        state: { type: string }
        zip_code: { type: string }
    AmateurDetail:
      type: object
      properties:
        operator_class: { type: string, example: "E" }
        operator_class_name: { type: string, example: "Amateur Extra" }
        group_code: { type: string }
        region_code: { type: string }
        trustee_callsign: { type: string }
        trustee_name: { type: string }
        previous_callsign: { type: string }
        previous_operator_class: { type: string }
    LicenseDetail:
      type: object
      required: [callsign, service, status]
      properties:
        callsign: { type: string, example: "W1AW" }
        service: { type: string, enum: [AM, GM] }
        service_code: { type: string, example: "HA" }
        status: { type: string, example: "active" }
        status_code: { type: string, example: "A" }
        frn: { type: string }
        uls_file_num: { type: string }
        grant_date: { type: string, format: date }
        effective_date: { type: string, format: date }
        expired_date: { type: string, format: date }
        cancellation_date: { type: string, format: date }
        last_action_date: { type: string, format: date }
        licensee: { $ref: "#/components/schemas/Licensee" }
        amateur: { $ref: "#/components/schemas/AmateurDetail" }
        also_held_by_frn:
          type: array
          items: { $ref: "#/components/schemas/SearchHit" }
    SearchHit:
      type: object
      properties:
        callsign: { type: string }
        service: { type: string, enum: [AM, GM] }
        status: { type: string }
        name: { type: string }
        city: { type: string }
        state: { type: string }
        operator_class: { type: string }
        grant_date: { type: string, format: date }
        last_action_date: { type: string, format: date }
    SearchResponse:
      type: object
      properties:
        results:
          type: array
          items: { $ref: "#/components/schemas/SearchHit" }
        next_cursor: { type: string }
        total_estimated: { type: integer, format: int64 }
        took: { type: string, example: "12ms" }
    FRNResponse:
      type: object
      properties:
        frn: { type: string }
        licensee: { $ref: "#/components/schemas/Licensee" }
        licenses:
          type: array
          items: { $ref: "#/components/schemas/SearchHit" }
    Stats:
      type: object
      properties:
        totals:
          type: object
          additionalProperties: { type: integer, format: int64 }
        active_by_class:
          type: object
          additionalProperties: { type: integer, format: int64 }
        active_by_service:
          type: object
          additionalProperties: { type: integer, format: int64 }
        top_states:
          type: array
          items:
            type: object
            properties:
              state: { type: string }
              count: { type: integer, format: int64 }
        generated_at: { type: string, format: date-time }
