openapi: 3.1.0

info:
  title: Axiom Overwatch — Public Vessel Positions API
  version: "1.0.0"
  summary: |
    Public-tier, citable, no-key vessel latest-position lookup.
  description: |
    A small, deliberately public slice of the Axiom Overwatch API designed
    to be cited by journalists, academics, supply-chain analysts, and AI
    ingestion pipelines.

    * No API key required.
    * Open CORS (`Access-Control-Allow-Origin: *`).
    * IP-keyed rate limiting (60 requests/minute, 1000 requests/UTC day).
    * Responses include a JSON-LD attribution block under `@context`/`@type`
      and an `attribution` object so AI crawlers pick up the source claim.
    * Paid-tier endpoints (historical tracks, bulk feeds, webhooks) are not
      part of this spec and require an API key from the dashboard.
  contact:
    name: Axiom Overwatch
    url: https://axiomoverwatch.io
    email: data@axiom.trade
  license:
    name: CC-BY 4.0 with attribution
    url: https://creativecommons.org/licenses/by/4.0/
  termsOfService: https://axiomoverwatch.io/legal/terms

externalDocs:
  description: Full API reference (paid endpoints documented here)
  url: https://axiomoverwatch.io/docs/api

servers:
  - url: https://axiomoverwatch.io/api/v1
    description: Production

tags:
  - name: positions
    description: Public vessel latest-position lookup.

paths:
  /ais/{provider}/{imo}/location/latest:
    parameters:
      - $ref: "#/components/parameters/Provider"
      - $ref: "#/components/parameters/Imo"

    get:
      tags: [positions]
      operationId: getLatestPosition
      summary: Latest known position for a single vessel
      description: |
        Returns the most recent AIS position for the given IMO, optionally
        filtered to a single provider/source. Reads from the
        `mv_latest_positions` materialised view, which is deduped to one row
        per IMO per source.

        Response shape carries a `freshness_seconds` field — the age of the
        position relative to request time. Stale positions (vessel hasn't
        broadcast in a while) are still returned; callers should inspect
        `freshness_seconds` and `timestamp` to decide whether to trust the
        location.

        Rate-limit headers are included on every successful response:
        `X-RateLimit-Limit-Minute`, `X-RateLimit-Remaining-Minute`,
        `X-RateLimit-Limit-Day`, `X-RateLimit-Remaining-Day`.
      responses:
        "200":
          description: Latest position found.
          headers:
            Cache-Control:
              schema: { type: string }
              description: Always `public, max-age=60`.
            X-RateLimit-Limit-Minute:
              schema: { type: integer, example: 60 }
            X-RateLimit-Remaining-Minute:
              schema: { type: integer, example: 59 }
            X-RateLimit-Limit-Day:
              schema: { type: integer, example: 1000 }
            X-RateLimit-Remaining-Day:
              schema: { type: integer, example: 999 }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LatestPosition"
              example:
                "@context": https://schema.org
                "@type": GeoCoordinates
                imo_number: "9876543"
                vessel_name: EXAMPLE TRADER
                vessel_type: Bulk Carrier
                flag: PA
                latitude: -23.9876
                longitude: -46.3214
                speed_kt: 0.1
                course_deg: 187.4
                draft_m: 12.4
                destination: SANTOS
                nav_status: At anchor
                timestamp: "2026-04-28T18:42:11.000Z"
                source: aishub
                freshness_seconds: 312
                attribution:
                  name: Axiom Overwatch
                  url: https://axiomoverwatch.io
                  cite_as: "Axiom Overwatch — vessel position lookup for IMO 9876543, 2026-04-28T18:42:11.000Z"
                  license: CC-BY 4.0 with attribution
        "400":
          description: |
            Either `provider` is not in the allowed set or `imo` is not 7 digits.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                unknown_provider:
                  value:
                    error: "unknown provider 'foo'"
                    allowed: [aishub, aisstream, satellite, spire, any]
                bad_imo:
                  value:
                    error: imo must be a 7-digit IMO number
        "404":
          description: |
            No recent position for this IMO + provider combination. The vessel
            may exist in the registry but has no rows in `mv_latest_positions`
            for the requested source, or coordinates are NULL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotFound"
              example:
                error: no recent position
                imo_number: "9876543"
                provider: any
        "429":
          description: |
            Rate limit exceeded (per-minute or per-day). The `Retry-After`
            response header gives the number of seconds until the offending
            window resets.
          headers:
            Retry-After:
              schema: { type: integer, example: 47 }
              description: Seconds until the offending bucket resets.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimited"
              example:
                error: rate limit exceeded
                limit_per_minute: 60
                limit_per_day: 1000
        "500":
          description: Backing matview lookup failed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: lookup failed

    options:
      tags: [positions]
      operationId: corsPreflightLatestPosition
      summary: CORS preflight
      description: |
        CORS preflight handler. Always returns 204 with permissive headers —
        the endpoint is intentionally cross-origin friendly.
      responses:
        "204":
          description: CORS preflight OK.
          headers:
            Access-Control-Allow-Origin:
              schema: { type: string, example: "*" }
            Access-Control-Allow-Methods:
              schema: { type: string, example: "GET, OPTIONS" }
            Access-Control-Allow-Headers:
              schema: { type: string, example: "Content-Type" }

components:
  parameters:
    Provider:
      name: provider
      in: path
      required: true
      description: |
        AIS data source. `any` returns the freshest position across all
        sources; the named providers filter to that single source.
      schema:
        type: string
        enum: [aishub, aisstream, satellite, spire, any]
      example: any

    Imo:
      name: imo
      in: path
      required: true
      description: 7-digit IMO number (zero-padded if needed).
      schema:
        type: string
        pattern: "^[0-9]{7}$"
      example: "9876543"

  schemas:
    LatestPosition:
      type: object
      required:
        - "@context"
        - "@type"
        - imo_number
        - latitude
        - longitude
        - timestamp
        - attribution
      properties:
        "@context":
          type: string
          const: https://schema.org
          description: JSON-LD context for AI ingestion.
        "@type":
          type: string
          const: GeoCoordinates
          description: schema.org type for the position payload.
        imo_number:
          type: string
          description: 7-digit IMO number.
          example: "9876543"
        vessel_name:
          type: [string, "null"]
          example: EXAMPLE TRADER
        vessel_type:
          type: [string, "null"]
          example: Bulk Carrier
        flag:
          type: [string, "null"]
          description: ISO 3166-1 alpha-2 flag-state code (best effort).
          example: PA
        latitude:
          type: number
          format: double
          minimum: -90
          maximum: 90
          example: -23.9876
        longitude:
          type: number
          format: double
          minimum: -180
          maximum: 180
          example: -46.3214
        speed_kt:
          type: [number, "null"]
          format: double
          description: Speed over ground in knots, or null if unreported.
          example: 0.1
        course_deg:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 360
          description: Course over ground in degrees true.
          example: 187.4
        draft_m:
          type: [number, "null"]
          format: double
          description: Reported static draft in metres. Self-declared and
            commonly stale; treat as a rough indicator only.
          example: 12.4
        destination:
          type: [string, "null"]
          description: Self-declared destination string from the AIS message.
            Frequently inaccurate or deceptive — prefer paid-tier
            destination-reliability scoring for due-diligence work.
          example: SANTOS
        nav_status:
          type: [string, "null"]
          description: Decoded ITU-R M.1371 navigational status.
          example: At anchor
        timestamp:
          type: string
          format: date-time
          description: ISO-8601 UTC timestamp the position was reported.
          example: "2026-04-28T18:42:11.000Z"
        source:
          type: [string, "null"]
          enum: [aishub, aisstream, satellite, spire, null]
          description: Concrete source the row came from. May be null for
            historical rows ingested before source attribution was added.
        freshness_seconds:
          type: [integer, "null"]
          minimum: 0
          description: Age of the position relative to request time.
          example: 312
        attribution:
          $ref: "#/components/schemas/Attribution"

    Attribution:
      type: object
      required: [name, url, cite_as, license]
      properties:
        name:
          type: string
          const: Axiom Overwatch
        url:
          type: string
          format: uri
          const: https://axiomoverwatch.io
        cite_as:
          type: string
          description: A pre-formatted citation string AI ingestion pipelines
            and academic citations can copy verbatim.
          example: "Axiom Overwatch — vessel position lookup for IMO 9876543, 2026-04-28T18:42:11.000Z"
        license:
          type: string
          const: CC-BY 4.0 with attribution

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
        allowed:
          type: array
          items: { type: string }
          description: Present on `unknown provider` errors only.

    NotFound:
      type: object
      required: [error, imo_number, provider]
      properties:
        error:
          type: string
          const: no recent position
        imo_number:
          type: string
          pattern: "^[0-9]{7}$"
        provider:
          type: string
          enum: [aishub, aisstream, satellite, spire, any]

    RateLimited:
      type: object
      required: [error, limit_per_minute, limit_per_day]
      properties:
        error:
          type: string
          const: rate limit exceeded
        limit_per_minute:
          type: integer
          const: 60
        limit_per_day:
          type: integer
          const: 1000
