openapi: 3.1.0
info:
  title: Travelmode Visa Intelligence API
  version: 1.0.0
  summary: Deterministic visa requirements, official-source documents, and change detection.
  description: |
    The Visa Intelligence API exposes Travelmode's deterministic visa
    rules engine, official-source catalog (sources + documents), and a
    change-detection feed with customer-managed webhooks under a single
    versioned `/v1` surface.

    All endpoints are authenticated with a per-customer API key and
    share the same flat error envelope and rate-limit headers. See the
    accompanying markdown docs in `docs/visa-api/` for narrative
    guidance, the change/webhook workflow, and worked curl + JS
    examples.

    **Authentication.** Send your key as `X-Visa-Intel-Key: <key>` or
    `Authorization: Bearer <key>`. Keys look like `vsk_live_…` and are
    shown in plaintext exactly once at issuance.
  contact:
    name: Travelmode Developer Support
    url: https://travelmode.ai
  license:
    name: Proprietary
    url: https://travelmode.ai

servers:
  - url: https://api.travelmode.ai
    description: Production
  - url: https://{host}
    description: Self-hosted / Replit deployment. Replace `{host}` with your deployed domain; routes are served under `/api/v1/visa/...` on that host.
    variables:
      host:
        default: your-app-domain.replit.app

tags:
  - name: Check
    description: Run the deterministic rules engine for a traveler/destination pair.
  - name: Rules
    description: Browse the published visa rule set.
  - name: Changes
    description: Read the change-detection feed of official-source updates.
  - name: Sources
    description: Official authority sources and crawl snapshots per country.
  - name: Documents
    description: Current official documents per country (list + download).
  - name: Webhooks
    description: Customer-managed webhook endpoints for change events.
  - name: Health
    description: Module liveness and source counts.

security:
  - VisaApiKeyHeader: []
  - BearerApiKey: []

paths:
  /v1/visa/check:
    post:
      tags:
        - Check
      summary: Resolve visa requirements for a traveler and destination.
      description: |
        Runs the deterministic rules engine for a `(nationality,
        destination)` pair and returns the structured requirement,
        required actions, documents, warnings, confidence, and the
        official sources behind the decision. The LLM is intentionally
        not in this path.

        **Required scope:** `visa:check`.
      operationId: postVisaCheck
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VisaCheckRequest"
            examples:
              us-to-france:
                $ref: "#/components/examples/VisaCheckRequestUsFr"
      responses:
        "200":
          description: Successful resolution.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VisaCheckResponse"
              examples:
                us-to-france:
                  $ref: "#/components/examples/VisaCheckResponseUsFr"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/rules:
    get:
      tags:
        - Rules
      summary: List published visa rules.
      description: |
        Read-only, paginated listing of the published visa rule set with
        optional filters. Authoring happens through admin tooling and is
        not part of the public surface.

        **Required scope:** `visa:check`.
      operationId: getVisaRules
      parameters:
        - name: destinationCountryCode
          in: query
          description: ISO 3166-1 alpha-2 destination country code.
          schema:
            $ref: "#/components/schemas/CountryCode"
        - name: nationalityCode
          in: query
          description: ISO 3166-1 alpha-2 nationality code.
          schema:
            $ref: "#/components/schemas/CountryCode"
        - name: purpose
          in: query
          schema:
            $ref: "#/components/schemas/TravelPurpose"
        - name: status
          in: query
          description: Lifecycle status of the rule.
          schema:
            type: string
            enum: [active, superseded, retired, draft]
        - name: verificationStatus
          in: query
          schema:
            $ref: "#/components/schemas/VerificationStatus"
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: A page of rules.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [rules, pagination]
                properties:
                  rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/VisaRule"
                  pagination:
                    $ref: "#/components/schemas/Pagination"
              examples:
                one-rule:
                  $ref: "#/components/examples/VisaRulesPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/changes:
    get:
      tags:
        - Changes
      summary: List visa change events (change-detection feed).
      description: |
        Cursor-paginated feed of detected changes to official sources.
        By default only `pending` and `reviewed` events are returned —
        dismissed events stay out of the public stream unless you pass
        `reviewStatus=dismissed` explicitly. Pass the returned
        `nextCursor` back via `?cursor=` to page.

        **Required scope:** `visa:changes`.
      operationId: getVisaChanges
      parameters:
        - name: countryCode
          in: query
          schema:
            $ref: "#/components/schemas/CountryCode"
        - name: severity
          in: query
          description: One or more severities (repeat the param or comma-separate).
          style: form
          explode: true
          schema:
            type: array
            items:
              $ref: "#/components/schemas/Severity"
        - name: changeType
          in: query
          description: One or more change types (repeat or comma-separate).
          schema:
            type: array
            items:
              type: string
        - name: reviewStatus
          in: query
          description: One or more review statuses. Defaults to `pending,reviewed`.
          schema:
            type: array
            items:
              type: string
              enum: [pending, reviewed, dismissed]
        - name: kind
          in: query
          schema:
            type: string
            enum: [added, updated, removed, reclassified]
        - name: sourceId
          in: query
          schema:
            type: integer
            minimum: 1
        - name: snapshotId
          in: query
          schema:
            type: integer
            minimum: 1
        - name: since
          in: query
          description: ISO-8601 lower bound on `occurredAt` (inclusive).
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          description: ISO-8601 upper bound on `occurredAt` (inclusive).
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
        - name: cursor
          in: query
          description: Opaque pagination cursor returned as `nextCursor`.
          schema:
            type: string
      responses:
        "200":
          description: A page of change events.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [items, nextCursor]
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/VisaChangeEvent"
                  nextCursor:
                    type: [string, "null"]
                    description: Pass back via `?cursor=`; `null` when there are no more pages.
              examples:
                one-change:
                  $ref: "#/components/examples/VisaChangesPage"
        "400":
          description: Invalid query parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                bad-query:
                  value:
                    error: "invalid severity: catastrophic"
                    code: visa_changes_bad_query
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/sources/{countryCode}:
    get:
      tags:
        - Sources
      summary: List active official sources for a country.
      description: |
        Returns the active authority sources currently registered for
        the destination country.

        **Required scope:** `visa:sources`.
      operationId: getVisaSources
      parameters:
        - $ref: "#/components/parameters/CountryCodePath"
      responses:
        "200":
          description: Active sources for the country.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [status, countryCode, count, sources, meta]
                properties:
                  status:
                    type: string
                    example: ok
                  countryCode:
                    $ref: "#/components/schemas/CountryCode"
                  count:
                    type: integer
                  sources:
                    type: array
                    items:
                      $ref: "#/components/schemas/VisaSource"
                  meta:
                    $ref: "#/components/schemas/Meta"
              examples:
                france:
                  $ref: "#/components/examples/VisaSourcesFr"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/sources/{countryCode}/snapshots:
    get:
      tags:
        - Sources
      summary: List recent crawl snapshots for a country (admin only).
      description: |
        Lists the most recent crawl snapshots for the country (metadata
        only — hashes, timestamps, storage paths; bytes are read
        separately). 

        **Authentication is different from the rest of this API.** This
        endpoint is gated by an admin session cookie (`requireVisaAdmin`),
        not by a Visa Intelligence API key — snapshots may contain
        authoritative-source fragments that are not exposed to public key
        holders. Calls made with an API key will not authenticate here.
      operationId: getVisaSourceSnapshots
      security:
        - AdminSession: []
      parameters:
        - $ref: "#/components/parameters/CountryCodePath"
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: A page of snapshots.
          content:
            application/json:
              schema:
                type: object
                required: [status, countryCode, total, count, limit, offset, snapshots, meta]
                properties:
                  status:
                    type: string
                    example: ok
                  countryCode:
                    $ref: "#/components/schemas/CountryCode"
                  total:
                    type: integer
                  count:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
                  snapshots:
                    type: array
                    items:
                      $ref: "#/components/schemas/VisaSnapshot"
                  meta:
                    $ref: "#/components/schemas/Meta"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: No valid admin session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          description: Authenticated but not an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /v1/visa/documents/{countryCode}:
    get:
      tags:
        - Documents
      summary: List current official documents for a country.
      description: |
        Returns the current (non-superseded) official documents held for
        the country. Optional `documentType`, `q` (full-text search), and
        `limit` narrow the result. Storage paths and snapshot ids are not
        exposed; use the `downloadUrl` field to fetch bytes.

        **Required scope:** `visa:sources`.
      operationId: getVisaDocuments
      parameters:
        - $ref: "#/components/parameters/CountryCodePath"
        - name: documentType
          in: query
          description: Narrow to a single document type (e.g. `form`, `guidance`).
          schema:
            type: string
        - name: q
          in: query
          description: Full-text query; matched fragments are returned in `snippet`.
          schema:
            type: string
        - name: limit
          in: query
          description: Maximum number of documents to return.
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: Current documents for the country.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [countryCode, count, documents]
                properties:
                  countryCode:
                    $ref: "#/components/schemas/CountryCode"
                  count:
                    type: integer
                  documents:
                    type: array
                    items:
                      $ref: "#/components/schemas/VisaDocument"
              examples:
                france:
                  $ref: "#/components/examples/VisaDocumentsFr"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/documents/{countryCode}/{id}/download:
    get:
      tags:
        - Documents
      summary: Download the original bytes of a document.
      description: |
        Streams the original document bytes from storage. The document is
        scoped by `countryCode` in addition to its `id`, so a URL that
        references a document from another country cannot be enumerated
        through this surface. The response carries a
        `Content-Disposition: attachment` header and an
        `X-Visa-Document-Sha256` integrity header.

        **Required scope:** `visa:sources`.
      operationId: downloadVisaDocument
      parameters:
        - $ref: "#/components/parameters/CountryCodePath"
        - name: id
          in: path
          required: true
          description: Positive integer document id.
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: Raw document bytes.
          headers:
            Content-Disposition:
              description: Always `attachment` with a sanitized filename.
              schema:
                type: string
            X-Visa-Document-Sha256:
              description: SHA-256 of the stored bytes (may be empty if unknown).
              schema:
                type: string
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
            application/pdf:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/webhooks:
    post:
      tags:
        - Webhooks
      summary: Register a webhook endpoint.
      description: |
        Registers a customer-managed webhook that receives
        `visa.change_detected` events filtered by country and minimum
        severity. The per-endpoint signing `secret` is returned exactly
        once in this response — store it; only its hash is persisted.

        **Required scope:** `visa:changes`. **Customer-scoped key only** —
        the dev master key cannot manage webhooks (`403`).
      operationId: createVisaWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookCreateRequest"
            examples:
              eu-high:
                value:
                  url: https://example.com/hooks/visa
                  description: EU high-severity changes
                  country_codes: [FR, DE, ES]
                  min_severity: high
      responses:
        "201":
          description: Webhook created. `secret` is shown once.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookCreated"
              examples:
                created:
                  $ref: "#/components/examples/WebhookCreated"
        "400":
          description: Invalid body, bad JSON, or a blocked/unsafe URL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                blocked-url:
                  value:
                    error: "URL resolves to a disallowed address"
                    code: visa_webhook_url_blocked
                    details:
                      reason: private_address
                      resolved_address: 10.0.0.5
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Missing scope, or a master key tried to manage webhooks.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                master-forbidden:
                  value:
                    error: Webhook registration requires a customer-scoped API key
                    code: visa_webhook_master_key_forbidden
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"
    get:
      tags:
        - Webhooks
      summary: List your webhook endpoints.
      description: |
        Lists the calling customer's webhook endpoints. Secrets are never
        returned — only the public `secret_prefix`.

        **Required scope:** `visa:changes`. **Customer-scoped key only.**
      operationId: listVisaWebhooks
      responses:
        "200":
          description: The customer's webhooks.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/webhooks/{webhookId}:
    get:
      tags:
        - Webhooks
      summary: Fetch one webhook endpoint.
      description: |
        Fetch a single webhook endpoint scoped to the calling customer.

        **Required scope:** `visa:changes`. **Customer-scoped key only.**
      operationId: getVisaWebhook
      parameters:
        - $ref: "#/components/parameters/WebhookIdPath"
      responses:
        "200":
          description: The webhook endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/WebhookNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"
    delete:
      tags:
        - Webhooks
      summary: Disable a webhook endpoint.
      description: |
        Soft-disables a webhook endpoint. The delivery worker stops
        picking it up; the row is retained so historical attempts queries
        still work.

        **Required scope:** `visa:changes`. **Customer-scoped key only.**
      operationId: deleteVisaWebhook
      parameters:
        - $ref: "#/components/parameters/WebhookIdPath"
      responses:
        "200":
          description: Webhook disabled.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_id, status]
                properties:
                  webhook_id:
                    type: string
                  status:
                    type: string
                    example: disabled
                  disabled_at:
                    type: [string, "null"]
                    format: date-time
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/WebhookNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/webhooks/{webhookId}/attempts:
    get:
      tags:
        - Webhooks
      summary: List recent delivery attempts for a webhook.
      description: |
        Returns the most-recent delivery attempts for a webhook endpoint
        owned by the calling customer — useful for debugging why an
        endpoint isn't receiving events.

        **Required scope:** `visa:changes`. **Customer-scoped key only.**
      operationId: listVisaWebhookAttempts
      parameters:
        - $ref: "#/components/parameters/WebhookIdPath"
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: Recent delivery attempts.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_id, attempts]
                properties:
                  webhook_id:
                    type: string
                  attempts:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookAttempt"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/WebhookNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unconfigured"

  /v1/visa/health:
    get:
      tags:
        - Health
      summary: Module health and source counts.
      description: |
        Reports whether the database is reachable, aggregate source
        counts, and the module version. Returns `200` when healthy and
        `503` when degraded. Authenticated so the endpoint can't be used
        as an oracle for whether the module is installed.

        **Required scope:** `visa:health`.
      operationId: getVisaHealth
      responses:
        "200":
          description: Module healthy.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
              examples:
                ok:
                  $ref: "#/components/examples/HealthOk"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          description: Module degraded (e.g. database unreachable). Same body shape as `200`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"

components:
  securitySchemes:
    VisaApiKeyHeader:
      type: apiKey
      in: header
      name: X-Visa-Intel-Key
      description: Per-customer Visa Intelligence API key (`vsk_live_…`).
    BearerApiKey:
      type: http
      scheme: bearer
      description: "The same `vsk_live_…` key sent as `Authorization: Bearer <key>`."
    AdminSession:
      type: apiKey
      in: cookie
      name: session_token
      description: Admin session cookie. Used only by the snapshots endpoint.

  parameters:
    CountryCodePath:
      name: countryCode
      in: path
      required: true
      description: ISO 3166-1 alpha-2 country code (case-insensitive; upper-cased server-side).
      schema:
        $ref: "#/components/schemas/CountryCode"
    WebhookIdPath:
      name: webhookId
      in: path
      required: true
      description: Webhook endpoint id (as returned in `webhook_id`).
      schema:
        type: string

  headers:
    XRateLimitLimit:
      description: Per-key hourly request cap.
      schema:
        type: integer
    XRateLimitRemaining:
      description: Requests remaining in the current hourly window (clamped to ≥ 0).
      schema:
        type: integer
    XRateLimitReset:
      description: Unix-seconds timestamp when the hourly window resets.
      schema:
        type: integer
    RetryAfter:
      description: Seconds to wait before retrying (present on 429).
      schema:
        type: integer

  responses:
    BadRequest:
      description: Malformed request — bad JSON, invalid parameters, or schema mismatch.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            invalid-request:
              value:
                error: Request body did not match the expected shape
                code: invalid_request
    Unauthorized:
      description: Missing or invalid API key (or expired/revoked key).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            invalid-key:
              value:
                error: Missing or invalid X-Visa-Intel-Key header
                code: visa_api_key_invalid
    Forbidden:
      description: Key valid but missing the required scope (or customer inactive).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            insufficient-scope:
              value:
                error: "API key is missing required scope: visa:check"
                code: visa_api_key_insufficient_scope
                requiredScope: visa:check
    NotFound:
      description: Resource not found, or not visible to the calling key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            not-found:
              value:
                error: Document not found
                code: not_found
    WebhookNotFound:
      description: No webhook with that id belongs to the calling customer.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            not-found:
              value:
                error: "Webhook 'wh_123' not found"
                code: visa_webhook_not_found
    RateLimited:
      description: Per-key hourly rate limit exceeded.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitReset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            rate-limited:
              value:
                error: Rate limit exceeded
                code: visa_api_rate_limited
                retryAfter: 1800
    InternalError:
      description: Unhandled server-side error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            internal:
              value:
                error: Failed to resolve visa rules
                code: internal_error
    Unconfigured:
      description: No API key was presented and no key is configured (dev/test signal).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            unconfigured:
              value:
                error: Visa Intelligence API key not configured
                code: visa_api_key_unconfigured

  schemas:
    CountryCode:
      type: string
      description: ISO 3166-1 alpha-2 country code.
      pattern: "^[A-Za-z]{2}$"
      example: FR

    TravelPurpose:
      type: string
      enum: [tourism, business, transit, study, work]

    Severity:
      type: string
      description: Change severity. `low|medium|high|critical` are current; `info|minor|major` are legacy synonyms still present on older rows.
      enum: [low, medium, high, critical, info, minor, major]

    VerificationStatus:
      type: string
      enum:
        - human_verified
        - cross_source_verified
        - official_source_verified
        - provider_verified
        - machine_extracted

    Pagination:
      type: object
      required: [total, limit, offset]
      properties:
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    Meta:
      type: object
      required: [apiVersion, moduleVersion, generatedAt]
      properties:
        apiVersion:
          type: string
          example: v1
        moduleVersion:
          type: string
        generatedAt:
          type: string
          format: date-time

    ErrorEnvelope:
      type: object
      required: [error, code]
      description: |
        Flat error envelope shared by every Visa endpoint. `error` is a
        human-readable message and `code` is a stable machine token.
        Some errors add extra fields: `details` (structured context),
        `requiredScope` (on insufficient-scope 403s), and `retryAfter`
        (seconds, on 429s).
      properties:
        error:
          type: string
        code:
          type: string
        details: {}
        requiredScope:
          type: string
        retryAfter:
          type: integer
      additionalProperties: true

    VisaCheckRequest:
      type: object
      required: [destinationCountryCode, nationalityCode]
      properties:
        destinationCountryCode:
          $ref: "#/components/schemas/CountryCode"
        nationalityCode:
          $ref: "#/components/schemas/CountryCode"
        additionalNationalityCodes:
          type: array
          maxItems: 4
          items:
            $ref: "#/components/schemas/CountryCode"
        residencyCode:
          $ref: "#/components/schemas/CountryCode"
        passportType:
          type: string
          enum: [ordinary, diplomatic, service, official]
        purpose:
          allOf:
            - $ref: "#/components/schemas/TravelPurpose"
          default: tourism
        arrivalDate:
          type: string
          format: date
        departureDate:
          type: string
          format: date
        passportIssueDate:
          type: string
          format: date
        passportExpiryDate:
          type: string
          format: date
        lengthOfStayDays:
          type: integer
          minimum: 0
          maximum: 3650
        age:
          type: integer
          minimum: 0
          maximum: 150
        heldVisasOrPermits:
          type: array
          maxItems: 20
          items:
            type: object
            required: [countryCode, type]
            properties:
              countryCode:
                $ref: "#/components/schemas/CountryCode"
              type:
                type: string
                minLength: 1
                maxLength: 64
              validUntil:
                type: string
                format: date
        transitCountryCodes:
          type: array
          maxItems: 10
          items:
            $ref: "#/components/schemas/CountryCode"
        transitDetails:
          type: array
          maxItems: 10
          items:
            type: object
            required: [countryCode]
            properties:
              countryCode:
                $ref: "#/components/schemas/CountryCode"
              airside:
                type: boolean
              arrivalDate:
                type: string
                format: date
              departureDate:
                type: string
                format: date
        transportMode:
          type: string
          enum: [air, land, sea]
        ticketType:
          type: string
          enum: [one_way, round_trip, onward]
        context:
          type: object
          additionalProperties: true

    VisaCheckResponse:
      type: object
      required: [status, request, result, meta]
      properties:
        status:
          type: string
          enum: [ok]
        request:
          type: object
          description: Echo of the normalized request the engine evaluated.
          additionalProperties: true
        result:
          $ref: "#/components/schemas/VisaCheckResult"
        transit_checks:
          type: array
          items:
            $ref: "#/components/schemas/VisaCheckResult"
        meta:
          $ref: "#/components/schemas/Meta"

    VisaCheckResult:
      type: object
      required:
        - requirement_type
        - summary
        - required_actions
        - documents
        - warnings
        - confidence
        - sources
        - manual_review_required
      properties:
        requirement_type:
          type: string
          enum:
            - visa_free
            - visa_required
            - visa_on_arrival
            - eta_required
            - evisa_required
            - transit_visa_required
            - banned
            - unknown
        summary:
          type: string
        required_actions:
          type: array
          items:
            type: object
            required: [action]
            properties:
              action:
                type: string
              description:
                type: string
              deadline_days_before:
                type: integer
        documents:
          type: array
          items:
            type: object
            required: [name, required]
            properties:
              name:
                type: string
              description:
                type: string
              required:
                type: boolean
        warnings:
          type: array
          items:
            type: string
        confidence:
          type: object
          required: [level, reason]
          properties:
            level:
              type: string
              enum: [high, medium, low]
            reason:
              type: string
        sources:
          type: array
          items:
            type: object
            required: [id, authority_name, url, source_type]
            properties:
              id:
                type: integer
              authority_name:
                type: string
              url:
                type: string
                format: uri
              source_type:
                type: string
              last_checked_at:
                type: [string, "null"]
                format: date-time
              excerpt:
                type: [string, "null"]
        last_verified_at:
          type: [string, "null"]
          format: date-time
        rule_version:
          type: [object, "null"]
          properties:
            rule_id:
              type: integer
            version:
              type: integer
            verification_status:
              type: string
        manual_review_required:
          type: boolean
        fee:
          type: [object, "null"]
          properties:
            amount:
              type: number
            currency:
              type: string
        processing_time_days:
          type: [integer, "null"]
        max_stay_days:
          type: [integer, "null"]

    VisaRule:
      type: object
      description: A published visa rule. Date fields are ISO-8601 strings.
      properties:
        id:
          type: integer
        destinationCountryCode:
          type: string
        nationalityCode:
          type: [string, "null"]
        residencyCode:
          type: [string, "null"]
        passportType:
          type: [string, "null"]
        purpose:
          type: string
        requirementType:
          type: string
        decision:
          type: string
        summary:
          type: [string, "null"]
        requiredActions:
          type: [array, "null"]
          items:
            type: object
            additionalProperties: true
        documentsRequired:
          type: [array, "null"]
          items:
            type: object
            additionalProperties: true
        maxStayDays:
          type: [integer, "null"]
        maxStayWindowDays:
          type: [integer, "null"]
        lengthOfStayMinDays:
          type: [integer, "null"]
        lengthOfStayMaxDays:
          type: [integer, "null"]
        passportMinValidityMonths:
          type: [integer, "null"]
        feeAmount:
          type: [number, "null"]
        feeCurrency:
          type: [string, "null"]
        processingTimeDays:
          type: [integer, "null"]
        effectiveFrom:
          type: [string, "null"]
          format: date-time
        effectiveTo:
          type: [string, "null"]
          format: date-time
        confidence:
          type: number
        verificationStatus:
          type: string
        status:
          type: string
        version:
          type: integer
        updatedAt:
          type: string
          format: date-time

    VisaChangeEvent:
      type: object
      description: A single detected change to an official source. `payload` is free-form.
      properties:
        id:
          type: integer
        countryCode:
          type: [string, "null"]
        countryName:
          type: [string, "null"]
        sourceId:
          type: [integer, "null"]
        sourceAuthorityName:
          type: [string, "null"]
        changeType:
          type: [string, "null"]
        kind:
          type: string
          enum: [added, updated, removed, reclassified]
        severity:
          $ref: "#/components/schemas/Severity"
        summary:
          type: string
        llmSummary:
          type: [string, "null"]
        detectedBy:
          type: string
        payload: {}
        affectedDocumentIds:
          type: array
          items:
            type: integer
        reviewStatus:
          type: string
          enum: [pending, reviewed, dismissed]
        occurredAt:
          type: string
          format: date-time

    VisaSource:
      type: object
      properties:
        id:
          type: integer
        authorityName:
          type: string
        sourceType:
          type: string
        url:
          type: string
          format: uri
        language:
          type: [string, "null"]
        crawlFrequency:
          type: [string, "null"]
        reliabilityScore:
          type: [number, "null"]
        lastCheckedAt:
          type: [string, "null"]
          format: date-time
        lastSuccessAt:
          type: [string, "null"]
          format: date-time

    VisaSnapshot:
      type: object
      properties:
        id:
          type: integer
        sourceId:
          type: integer
        authorityName:
          type: [string, "null"]
        sourceUrl:
          type: [string, "null"]
        finalUrl:
          type: [string, "null"]
        fetchedAt:
          type: [string, "null"]
          format: date-time
        httpStatus:
          type: [integer, "null"]
        contentHash:
          type: [string, "null"]
        mainContentHash:
          type: [string, "null"]
        changed:
          type: [boolean, "null"]
        byteSize:
          type: [integer, "null"]
        fetchDurationMs:
          type: [integer, "null"]
        rawStoragePath:
          type: [string, "null"]
        textStoragePath:
          type: [string, "null"]
        renderedWith:
          type: [string, "null"]
        extractionStatus:
          type: [string, "null"]
        errorMessage:
          type: [string, "null"]

    VisaDocument:
      type: object
      properties:
        id:
          type: integer
        countryCode:
          type: string
        title:
          type: string
        documentType:
          type: string
        language:
          type: string
        sourceUrl:
          type: [string, "null"]
        downloadUrl:
          type: [string, "null"]
          description: Server-relative download path, e.g. `/api/v1/visa/documents/FR/123/download`.
        mimeType:
          type: [string, "null"]
        byteSize:
          type: [integer, "null"]
        contentHash:
          type: [string, "null"]
        retrievedAt:
          type: [string, "null"]
          format: date-time
        effectiveFrom:
          type: [string, "null"]
          format: date-time
        effectiveTo:
          type: [string, "null"]
          format: date-time
        supersededByDocumentId:
          type: [integer, "null"]
        excerpt:
          type: [string, "null"]
        snippet:
          type: [string, "null"]
          description: HTML fragment with `<mark>` tags around `q` matches (present only when `q` is supplied).
        source:
          type: object
          properties:
            authorityName:
              type: [string, "null"]
            url:
              type: [string, "null"]

    WebhookCreateRequest:
      type: object
      required: [url]
      description: Snake_case body. `countryCodes`/`minSeverity` camelCase aliases are also accepted.
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: HTTPS endpoint we POST events to. Must be `https://` and resolve to a public address.
        description:
          type: [string, "null"]
          minLength: 1
          maxLength: 500
        country_codes:
          type: [array, "null"]
          minItems: 1
          maxItems: 250
          description: ISO-2 codes to filter on. Omit or `null` for all countries.
          items:
            $ref: "#/components/schemas/CountryCode"
        min_severity:
          allOf:
            - $ref: "#/components/schemas/Severity"
          default: low
        metadata:
          type: [object, "null"]
          additionalProperties: true

    WebhookCreated:
      type: object
      required: [webhook_id, url, status, secret, secret_prefix, created_at]
      properties:
        webhook_id:
          type: string
        url:
          type: string
          format: uri
        description:
          type: [string, "null"]
        country_codes:
          type: [array, "null"]
          items:
            type: string
        min_severity:
          type: string
        status:
          type: string
          example: active
        secret:
          type: string
          description: The signing secret, shown exactly once. Store it now.
        secret_prefix:
          type: string
        created_at:
          type: string
          format: date-time

    WebhookEndpoint:
      type: object
      description: A webhook endpoint as returned by list/get. No secret — only `secret_prefix`.
      properties:
        webhook_id:
          type: string
        url:
          type: string
          format: uri
        description:
          type: [string, "null"]
        country_codes:
          type: [array, "null"]
          items:
            type: string
        min_severity:
          type: string
        status:
          type: string
        secret_prefix:
          type: string
        failure_count:
          type: integer
        last_delivery_at:
          type: [string, "null"]
          format: date-time
        last_success_at:
          type: [string, "null"]
          format: date-time
        last_failure_at:
          type: [string, "null"]
          format: date-time
        disabled_at:
          type: [string, "null"]
          format: date-time
        disabled_reason:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookAttempt:
      type: object
      properties:
        attempt_id:
          type: string
        event_id:
          type: string
        event_type:
          type: string
          enum:
            - visa.change_detected
            - visa.webhook_delivery_failed
            - visa.webhook_endpoint_disabled
        attempt_number:
          type: integer
        status:
          type: string
        response_status:
          type: [integer, "null"]
        response_time_ms:
          type: [integer, "null"]
        response_body_excerpt:
          type: [string, "null"]
        error_message:
          type: [string, "null"]
        request_signature:
          type: [string, "null"]
        request_timestamp:
          type: [string, "null"]
          format: date-time
        scheduled_at:
          type: string
          format: date-time
        attempted_at:
          type: [string, "null"]
          format: date-time
        completed_at:
          type: [string, "null"]
          format: date-time
        next_retry_at:
          type: [string, "null"]
          format: date-time

    HealthResponse:
      type: object
      required: [status, module, version, apiVersion, checks, generatedAt]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        module:
          type: string
          example: visa-intel
        version:
          type: string
        apiVersion:
          type: string
          example: v1
        checks:
          type: object
          properties:
            database:
              type: object
              properties:
                status:
                  type: string
                  enum: [ok, down]
                error:
                  type: string
            sources:
              type: object
              additionalProperties: true
        generatedAt:
          type: string
          format: date-time

  examples:
    VisaCheckRequestUsFr:
      summary: US national, tourism, to France
      value:
        nationalityCode: US
        destinationCountryCode: FR
        purpose: tourism
        arrivalDate: "2026-07-01"
        departureDate: "2026-07-12"
    VisaCheckResponseUsFr:
      summary: Visa-free result
      value:
        status: ok
        request:
          nationalityCode: US
          destinationCountryCode: FR
          purpose: tourism
        result:
          requirement_type: visa_free
          summary: US passport holders can enter France visa-free for stays up to 90 days in any 180-day period.
          required_actions: []
          documents:
            - name: Passport
              description: Valid for at least 3 months beyond departure.
              required: true
          warnings: []
          confidence:
            level: high
            reason: Backed by an official-source-verified rule.
          sources:
            - id: 42
              authority_name: France-Visas (Ministry for Europe and Foreign Affairs)
              url: https://france-visas.gouv.fr/
              source_type: government
              last_checked_at: "2026-06-18T09:00:00.000Z"
              excerpt: "Short-stay (Schengen) visa is not required for US nationals."
          last_verified_at: "2026-06-18T09:00:00.000Z"
          rule_version:
            rule_id: 1001
            version: 4
            verification_status: official_source_verified
          manual_review_required: false
          fee: null
          processing_time_days: null
          max_stay_days: 90
        meta:
          apiVersion: v1
          moduleVersion: "1.0.0"
          generatedAt: "2026-06-20T12:00:00.000Z"
    VisaRulesPage:
      summary: One rule
      value:
        rules:
          - id: 1001
            destinationCountryCode: FR
            nationalityCode: US
            residencyCode: null
            passportType: null
            purpose: tourism
            requirementType: visa_free
            decision: allow
            summary: Visa-free up to 90/180 days.
            requiredActions: []
            documentsRequired: []
            maxStayDays: 90
            maxStayWindowDays: 180
            lengthOfStayMinDays: null
            lengthOfStayMaxDays: null
            passportMinValidityMonths: 3
            feeAmount: null
            feeCurrency: null
            processingTimeDays: null
            effectiveFrom: null
            effectiveTo: null
            confidence: 0.98
            verificationStatus: official_source_verified
            status: active
            version: 4
            updatedAt: "2026-06-18T09:00:00.000Z"
        pagination:
          total: 1
          limit: 50
          offset: 0
    VisaChangesPage:
      summary: One change event
      value:
        items:
          - id: 9001
            countryCode: FR
            countryName: France
            sourceId: 42
            sourceAuthorityName: France-Visas
            changeType: content_changed
            kind: updated
            severity: medium
            summary: Short-stay fee schedule page updated.
            llmSummary: The published fee table changed for several visa categories.
            detectedBy: crawler
            payload:
              diffRatio: 0.12
            affectedDocumentIds: [555]
            reviewStatus: reviewed
            occurredAt: "2026-06-19T22:14:00.000Z"
        nextCursor: "eyJpZCI6OTAwMX0="
    VisaSourcesFr:
      summary: France sources
      value:
        status: ok
        countryCode: FR
        count: 1
        sources:
          - id: 42
            authorityName: France-Visas
            sourceType: government
            url: https://france-visas.gouv.fr/
            language: fr
            crawlFrequency: daily
            reliabilityScore: 0.95
            lastCheckedAt: "2026-06-20T06:00:00.000Z"
            lastSuccessAt: "2026-06-20T06:00:00.000Z"
        meta:
          apiVersion: v1
          moduleVersion: "1.0.0"
          generatedAt: "2026-06-20T12:00:00.000Z"
    VisaDocumentsFr:
      summary: One document
      value:
        countryCode: FR
        count: 1
        documents:
          - id: 555
            countryCode: FR
            title: Short-stay visa application guide
            documentType: guidance
            language: en
            sourceUrl: https://france-visas.gouv.fr/guide.pdf
            downloadUrl: /api/v1/visa/documents/FR/555/download
            mimeType: application/pdf
            byteSize: 184320
            contentHash: 9f2c...e1
            retrievedAt: "2026-06-18T09:00:00.000Z"
            effectiveFrom: null
            effectiveTo: null
            supersededByDocumentId: null
            excerpt: "This guide explains how to apply for a short-stay Schengen visa."
            snippet: null
            source:
              authorityName: France-Visas
              url: https://france-visas.gouv.fr/
    WebhookCreated:
      summary: Created webhook (secret shown once)
      value:
        webhook_id: wh_8c0f3c2c
        url: https://example.com/hooks/visa
        description: EU high-severity changes
        country_codes: [FR, DE, ES]
        min_severity: high
        status: active
        secret: whsec_a1b2c3d4e5f6...
        secret_prefix: whsec_a1b2
        created_at: "2026-06-20T12:00:00.000Z"
    HealthOk:
      summary: Healthy
      value:
        status: ok
        module: visa-intel
        version: "1.0.0"
        apiVersion: v1
        checks:
          database:
            status: ok
          sources:
            total: 312
            active: 280
            archived: 32
        generatedAt: "2026-06-20T12:00:00.000Z"
