openapi: 3.1.0
info:
  title: Crown Intelligence API v1
  version: 1.0.0-rc.1
  summary: Partner API for sanctions, PEP, debarment and enforcement screening, with keys, metering, webhooks, monitoring and evidence packs.
  description: >
    Partner-facing contract for Crown Intelligence v1. It builds on the current data service (`service/data-api.mjs`),
    which exposes `/api/match/<collection>`, `/api/search`, `/api/entity`, `/api/reconcile`, `/api/sources`,
    `/api/programs`, `/api/export`, `/api/algorithms` and `/api/overview` without keys. v1 adds API keys with scopes,
    metering with `billing_ref` (decision 82), batch matching with bands and per-query status, webhooks, monitoring
    and evidence packs.

    STATUS: DRAFT, not live. Nothing in this document is deployed. The rules it encodes are listed in CONTRACT.md.

    Rules that apply to every operation:
    - A query whose status is `not_searched` or `error` is never reported as clear. The only negative outcome is
      `no_hit_searched`, which always lists the registers that were searched.
    - Crown Intelligence never stores a screened person's record (decision 83). Requests are not logged with bodies,
      and a stored match carries only a keyed digest of the query.
    - Money never appears in this API. Prices are composed by the portal and Payments (decision 82, C21). Usage
      responses report units only.
    - Every operation that writes data is append-only. Resources are retired, never deleted, so no DELETE method exists.
    - Human-readable error messages are returned in English and French (`messages.en`, `messages.fr`).
  termsOfService: https://crownintelligence.net/legal/terms
  contact:
    name: Crown Intelligence partner support
    email: support@crownintelligence.net
  license:
    name: Proprietary, see the partner agreement
    url: https://crownintelligence.net/legal/terms

servers:
  - url: https://api.crownintelligence.net
    description: Live (proposed, not yet provisioned)
  - url: https://sandbox.api.crownintelligence.net
    description: Sandbox with test keys (proposed, not yet provisioned). Same public registers, no billing.

security:
  - ApiKey: []

tags:
  - name: Service
  - name: Matching
  - name: Search and records
  - name: Sources and exports
  - name: Webhooks
  - name: Monitoring
  - name: Cases and evidence
  - name: Usage and keys

paths:
  /v1/status:
    get:
      tags: [Service]
      operationId: getStatus
      summary: Service status and register freshness (no key required)
      security: []
      responses:
        '200':
          description: Service status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Status'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v1/collections:
    get:
      tags: [Service]
      operationId: listCollections
      summary: List collections (for example sanctions, peps, crime, maritime, default)
      x-required-scope: search:read
      parameters:
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: Collections
          content:
            application/json:
              schema:
                type: object
                required: [collections]
                properties:
                  collections:
                    type: array
                    items:
                      $ref: '#/components/schemas/Collection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/match:
    post:
      tags: [Matching]
      operationId: matchBatch
      summary: Screen up to 100 queries in one request
      description: >
        Each query gets its own status, `searched_at`, the registers it was checked against, and any matches at or above
        `options.min_band`. Charged as one query unit per query. Repeat a request with the same `Idempotency-Key` within
        24 hours to receive the stored response without a second charge.
      x-required-scope: match:read
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/BillingRef'
        - $ref: '#/components/parameters/AcceptLanguage'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MatchRequest'
            examples:
              batch:
                summary: Three queries and one identifier that is not yet covered
                value:
                  queries:
                    - query_ref: case-7731:party-1
                      entity_type: Person
                      name: Aleksandr Petrov
                      dob: '1971-05-02'
                      country: [RU]
                    - query_ref: case-7731:party-2
                      entity_type: Company
                      name: Northwind Trading LLC
                      country: [AE]
                    - query_ref: case-7731:vessel-1
                      entity_type: Vessel
                      name: Sea Lark
                      identifiers:
                        imo: '9176187'
                    - query_ref: case-7731:wallet-1
                      entity_type: LegalEntity
                      name: Wallet holder unknown
                      identifiers:
                        wallet_address:
                          chain: ethereum
                          address: '0x0000000000000000000000000000000000000001'
                  options:
                    min_band: POSSIBLE
                    collections: [sanctions]
                    limit: 5
      responses:
        '200':
          description: Per-query results
          headers:
            X-Usage-Units:
              $ref: '#/components/headers/XUsageUnits'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MatchResponse'
              examples:
                mixed:
                  summary: One hit, one no-hit with registers listed, one identifier not covered, one error
                  value:
                    request_id: req_01J9Z3K4M6
                    match_request_id: mr_01J9Z3K4P2
                    searched_at: '2026-10-13T14:02:11Z'
                    min_band: POSSIBLE
                    collections: [sanctions]
                    billing_ref:
                      source_service: partner-api
                      source_account: acct_demo_bank
                      order_id: ord_2026_1041
                      type: ORDER
                      parent_call: null
                      invoice_line_id: null
                    results:
                      - query_ref: case-7731:party-1
                        status: hit
                        searched_at: '2026-10-13T14:02:11Z'
                        registers_searched:
                          - list_id: ofac_sdn
                            register_name: Consolidated sanctions (illustrative)
                            publisher: Example publisher
                            list_date: '2026-10-10'
                            loaded_at: '2026-10-11T03:14:00Z'
                            licence:
                              policy: Open licence with attribution
                              commercial_use: PERMITTED
                              internal_use: PERMITTED
                              url: https://crownintelligence.net/licensing
                        matches:
                          - record_id: crown-482913
                            list_id: ofac_sdn
                            band: STRONG
                            score: 1.0
                            schema: Person
                            caption: Aleksandr Petrov
                            matched_name: Alexander Petrov
                            features:
                              - field: name
                                effect: '+0.92'
                                rule: close spelling (Jaro-Winkler at or above 0.92)
                                explanation:
                                  en: "'Aleksandr' close to 'Alexander' (0.92)"
                                  fr: "« Aleksandr » proche de « Alexander » (0,92)"
                              - field: dob
                                effect: '+0.10'
                                rule: birth date match at the precision the record gives
                                explanation:
                                  en: 'Birth date 1971-05-02 matches'
                                  fr: 'Date de naissance 1971-05-02 concordante'
                              - field: country
                                effect: '+0.05'
                                rule: nationality match
                                explanation:
                                  en: 'Nationality RU matches'
                                  fr: 'Nationalité RU concordante'
                            record_url: https://crownintelligence.net/signal/record.html?id=482913
                      - query_ref: case-7731:party-2
                        status: no_hit_searched
                        searched_at: '2026-10-13T14:02:11Z'
                        registers_searched:
                          - list_id: ofac_sdn
                            register_name: Consolidated sanctions (illustrative)
                            publisher: Example publisher
                            list_date: '2026-10-10'
                            loaded_at: '2026-10-11T03:14:00Z'
                            licence:
                              policy: Open licence with attribution
                              commercial_use: PERMITTED
                              internal_use: PERMITTED
                              url: https://crownintelligence.net/licensing
                          - list_id: un_consolidated
                            register_name: UN Security Council consolidated list (illustrative)
                            publisher: United Nations
                            list_date: '2026-10-09'
                            loaded_at: '2026-10-11T03:20:00Z'
                            licence:
                              policy: Terms of use (UN)
                              commercial_use: RESTRICTED
                              internal_use: PERMITTED
                              url: https://crownintelligence.net/licensing
                        matches: []
                      - query_ref: case-7731:vessel-1
                        status: hit
                        searched_at: '2026-10-13T14:02:11Z'
                        registers_searched:
                          - list_id: maritime_watch
                            register_name: Maritime watch (illustrative)
                            publisher: Example publisher
                            list_date: '2026-10-08'
                            loaded_at: '2026-10-11T03:30:00Z'
                            licence:
                              policy: Open licence with attribution
                              commercial_use: PERMITTED
                              internal_use: PERMITTED
                              url: https://crownintelligence.net/licensing
                        matches:
                          - record_id: crown-771204
                            list_id: maritime_watch
                            band: STRONG
                            score: 1.0
                            schema: Vessel
                            caption: Sea Lark
                            matched_name: Sea Lark
                            features:
                              - field: identifier
                                effect: 'floor 0.98'
                                rule: IMO number exact match
                                explanation:
                                  en: 'IMO 9176187 matches'
                                  fr: 'Numéro OMI 9176187 concordant'
                            record_url: https://crownintelligence.net/signal/record.html?id=771204
                      - query_ref: case-7731:wallet-1
                        status: not_searched
                        searched_at: '2026-10-13T14:02:11Z'
                        registers_searched: []
                        matches: []
                        not_searched_reason: identifier_not_covered
                        identifiers_not_searched: [wallet_address]
                      - query_ref: case-7731:party-3
                        status: error
                        searched_at: '2026-10-13T14:02:11Z'
                        registers_searched: []
                        matches: []
                        error:
                          code: register_unavailable
                          message:
                            en: 'A register in this collection could not be read. Retry this query.'
                            fr: "Un registre de cette collection n'a pas pu être lu. Réessayez cette requête."
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v1/search:
    get:
      tags: [Search and records]
      operationId: searchRecords
      summary: Search records by name, with filters (same semantics as /api/search)
      x-required-scope: search:read
      parameters:
        - name: q
          in: query
          required: false
          description: Name to search for (two characters or more). Optional when a filter is set.
          schema:
            type: string
            maxLength: 300
        - name: collection
          in: query
          schema:
            $ref: '#/components/schemas/CollectionName'
        - name: entity_type
          in: query
          schema:
            type: string
            enum: [Person, Company, Organization, Vessel, Airplane, LegalEntity]
        - name: country
          in: query
          description: ISO 3166-1 alpha-2 code
          schema:
            type: string
            pattern: '^[A-Z]{2}$'
        - name: dob
          in: query
          description: Partial or full birth date (YYYY, YYYY-MM or YYYY-MM-DD)
          schema:
            type: string
            pattern: '^\d{4}(-\d{2}(-\d{2})?)?$'
        - name: changed_since
          in: query
          description: Only records added or changed on or after this ISO 8601 date or time. Use it for scheduled re-screening.
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 25
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            maximum: 500000
            default: 0
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/records/{record_id}:
    get:
      tags: [Search and records]
      operationId: getRecord
      summary: One list record, with its listings in other registers
      x-required-scope: search:read
      parameters:
        - name: record_id
          in: path
          required: true
          description: Record identifier, for example crown-482913
          schema:
            type: string
            pattern: '^crown-\d+$'
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: Record
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Record'
        '308':
          description: The record is no longer active; see Location (the merged entity), as data-api.mjs does today
          headers:
            Location:
              schema:
                type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/entities/{entity_id}:
    get:
      tags: [Search and records]
      operationId: getEntity
      summary: A merged entity (the same party across registers)
      x-required-scope: search:read
      parameters:
        - name: entity_id
          in: path
          required: true
          description: Merged entity identifier, crown-e-<uuid>
          schema:
            type: string
            pattern: '^crown-e-[0-9a-f-]{36}$'
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: Merged entity
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Entity'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/sources:
    get:
      tags: [Sources and exports]
      operationId: listSources
      summary: Registers (sources), with freshness and licence
      x-required-scope: sources:read
      parameters:
        - name: q
          in: query
          schema:
            type: string
            maxLength: 200
        - name: collection
          in: query
          schema:
            $ref: '#/components/schemas/CollectionName'
        - name: sort
          in: query
          description: 'name (default) or first_seen (newest first)'
          schema:
            type: string
            enum: [name, first_seen]
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: Registers
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SourceList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/programs:
    get:
      tags: [Sources and exports]
      operationId: listPrograms
      summary: Sanctions programmes and the number of records in each
      x-required-scope: sources:read
      parameters:
        - name: q
          in: query
          schema:
            type: string
            maxLength: 200
        - name: list_type
          in: query
          schema:
            type: string
            default: SANCTION
            enum: [SANCTION, PEP, DEBARMENT, TAX_WARRANT, INSOLVENCY, ALL]
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
      responses:
        '200':
          description: Programmes
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/exports/{register}:
    get:
      tags: [Sources and exports]
      operationId: exportRegister
      summary: Download a whole register, only where its licence permits republication
      description: >
        A register cleared for sale is not downloadable here (403 `register_not_downloadable`); it is available through
        the store. Exports are metered as one unit per 1,000 rows, rounded up.
      x-required-scope: exports:read
      parameters:
        - name: register
          in: path
          required: true
          schema:
            type: string
            maxLength: 160
        - name: format
          in: query
          schema:
            type: string
            enum: [csv, jsonl]
            default: csv
        - $ref: '#/components/parameters/BillingRef'
      responses:
        '200':
          description: Register file
          content:
            text/csv:
              schema:
                type: string
            application/x-ndjson:
              schema:
                type: string
          headers:
            Content-Disposition:
              schema:
                type: string
            Crown-Content-SHA256:
              description: SHA-256 of the file body, so partners can verify a download
              schema:
                type: string
                pattern: '^[0-9a-f]{64}$'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/reconcile/{collection}:
    get:
      tags: [Matching]
      operationId: reconcileManifest
      summary: Reconciliation service manifest (OpenRefine protocol 0.2)
      x-required-scope: match:read
      parameters:
        - name: collection
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/CollectionName'
      responses:
        '200':
          description: Manifest
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Matching]
      operationId: reconcileQueries
      summary: Reconciliation queries (OpenRefine protocol 0.2), up to 20 per call
      description: Metered as one query unit per query. Keeps the protocol shape of /api/reconcile.
      x-required-scope: match:read
      parameters:
        - $ref: '#/components/parameters/BillingRef'
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [queries]
              properties:
                queries:
                  type: string
                  description: JSON object of named reconciliation queries
      responses:
        '200':
          description: Candidates per query
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/collections/{collection}/statistics:
    get:
      tags: [Sources and exports]
      operationId: getCollectionStatistics
      summary: Records, entities and sources in a collection, with the last change
      x-required-scope: sources:read
      parameters:
        - name: collection
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/CollectionName'
      responses:
        '200':
          description: Statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Collection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook endpoints on the account
      x-required-scope: webhooks:manage
      parameters:
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: Endpoints (secrets are never shown after creation)
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items:
                      $ref: '#/components/schemas/Webhook'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Create a webhook endpoint (the signing secret is returned once)
      x-required-scope: webhooks:manage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/BillingRef'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCreate'
      responses:
        '201':
          description: Created; `secret` appears only in this response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCreated'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/webhooks/{webhook_id}:
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: One webhook endpoint
      x-required-scope: webhooks:manage
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: Endpoint
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Change the URL, events, or pause and resume (use /retire to stop for good)
      x-required-scope: webhooks:manage
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/BillingRef'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookUpdate'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/webhooks/{webhook_id}/retire:
    post:
      tags: [Webhooks]
      operationId: retireWebhook
      summary: Retire the endpoint (kept for the delivery log; no deletes)
      x-required-scope: webhooks:manage
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/BillingRef'
      responses:
        '200':
          description: Retired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/webhooks/{webhook_id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Issue a new signing secret; the old one stops working after 24 hours
      description: During the 24-hour overlap both secrets are sent in the Crown-Signature header, so the endpoint can switch without loss.
      x-required-scope: webhooks:manage
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/BillingRef'
      responses:
        '200':
          description: New secret (shown once)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCreated'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/webhooks/{webhook_id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhook
      summary: Send a signed `webhook.test` event to the endpoint
      x-required-scope: webhooks:manage
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/BillingRef'
      responses:
        '202':
          description: Queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDelivery'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/webhooks/{webhook_id}/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: 'Delivery log: every attempt, its response code, and the next retry time'
      x-required-scope: webhooks:manage
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, succeeded, retrying, failed]
        - name: since
          in: query
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: Deliveries, newest first
          content:
            application/json:
              schema:
                type: object
                required: [deliveries]
                properties:
                  deliveries:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookDelivery'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/monitors:
    get:
      tags: [Monitoring]
      operationId: listMonitors
      summary: List monitors on the account
      x-required-scope: monitors:manage
      parameters:
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: Monitors
          content:
            application/json:
              schema:
                type: object
                required: [monitors]
                properties:
                  monitors:
                    type: array
                    items:
                      $ref: '#/components/schemas/Monitor'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Monitoring]
      operationId: createMonitor
      summary: Watch entities, records or collections for list changes (no personal data is accepted)
      description: >
        A monitor watches public list entities, records and collections. It stores a customer reference and target
        identifiers only. Name, date of birth, country and identity numbers of a screened person are refused with
        `personal_data_not_accepted`. Partners who re-screen people use `changed_since` on /v1/search and /v1/match with
        their own subject data (see CONTRACT.md section 9).
      x-required-scope: monitors:manage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/BillingRef'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MonitorCreate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Monitor'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/monitors/{monitor_id}:
    get:
      tags: [Monitoring]
      operationId: getMonitor
      summary: One monitor
      x-required-scope: monitors:manage
      parameters:
        - $ref: '#/components/parameters/MonitorId'
      responses:
        '200':
          description: Monitor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Monitor'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/monitors/{monitor_id}/retire:
    post:
      tags: [Monitoring]
      operationId: retireMonitor
      summary: Stop the monitor (kept for the record; no deletes)
      x-required-scope: monitors:manage
      parameters:
        - $ref: '#/components/parameters/MonitorId'
        - $ref: '#/components/parameters/BillingRef'
      responses:
        '200':
          description: Retired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Monitor'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/cases:
    get:
      tags: [Cases and evidence]
      operationId: listCases
      summary: List cases on the account
      x-required-scope: cases:read
      parameters:
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: Cases
          content:
            application/json:
              schema:
                type: object
                required: [cases]
                properties:
                  cases:
                    type: array
                    items:
                      $ref: '#/components/schemas/Case'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Cases and evidence]
      operationId: createCase
      summary: Open a case from a stored match request
      x-required-scope: cases:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/BillingRef'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CaseCreate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Case'
        '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'

  /v1/cases/{case_id}:
    get:
      tags: [Cases and evidence]
      operationId: getCase
      summary: One case, with its dispositions
      x-required-scope: cases:read
      parameters:
        - $ref: '#/components/parameters/CaseId'
      responses:
        '200':
          description: Case
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Case'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/cases/{case_id}/dispositions:
    post:
      tags: [Cases and evidence]
      operationId: addDisposition
      summary: Record a reviewer decision on a match (append-only)
      x-required-scope: cases:write
      parameters:
        - $ref: '#/components/parameters/CaseId'
        - $ref: '#/components/parameters/BillingRef'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DispositionCreate'
      responses:
        '201':
          description: Recorded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Disposition'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/cases/{case_id}/evidence:
    get:
      tags: [Cases and evidence]
      operationId: getEvidencePack
      summary: Evidence pack for a case (JSON manifest, or PDF by content negotiation)
      description: >
        The pack holds, for each query: its keyed digest, status, `searched_at`, registers searched with `loaded_at` and
        licence, matches with features, and dispositions. It does not hold the screened person's name or other personal
        data. The partner joins the pack to its own subject data by `query_ref` (CONTRACT.md section 10).
      x-required-scope: cases:read
      parameters:
        - $ref: '#/components/parameters/CaseId'
        - name: Accept
          in: header
          required: false
          description: application/json (default) or application/pdf
          schema:
            type: string
            enum: [application/json, application/pdf]
            default: application/json
      responses:
        '200':
          description: Evidence pack
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EvidencePack'
            application/pdf:
              schema:
                type: string
                format: binary
          headers:
            Crown-Content-SHA256:
              schema:
                type: string
                pattern: '^[0-9a-f]{64}$'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '406':
          description: Accept header is neither application/json nor application/pdf
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

  /v1/usage:
    get:
      tags: [Usage and keys]
      operationId: getUsage
      summary: Usage for a billing period, in units (no money amounts)
      x-required-scope: usage:read
      parameters:
        - name: period
          in: query
          required: false
          description: Calendar month, YYYY-MM (default current month, UTC)
          schema:
            type: string
            pattern: '^\d{4}-(0[1-9]|1[0-2])$'
      responses:
        '200':
          description: Usage
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Usage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/keys:
    get:
      tags: [Usage and keys]
      operationId: listKeys
      summary: List API keys (prefix and scopes only, never the secret)
      x-required-scope: keys:manage
      responses:
        '200':
          description: Keys
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items:
                      $ref: '#/components/schemas/ApiKey'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Usage and keys]
      operationId: createKey
      summary: Create an API key (the secret is returned once)
      x-required-scope: keys:manage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/BillingRef'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApiKeyCreate'
      responses:
        '201':
          description: Created; `secret` appears only in this response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyCreated'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/keys/{key_id}/revoke:
    post:
      tags: [Usage and keys]
      operationId: revokeKey
      summary: Revoke a key immediately (the record is kept; no deletes)
      x-required-scope: keys:manage
      parameters:
        - name: key_id
          in: path
          required: true
          schema:
            type: string
            pattern: '^key_[A-Za-z0-9]{10,40}$'
        - $ref: '#/components/parameters/BillingRef'
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKey'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

webhooks:
  list.updated:
    post:
      operationId: eventListUpdated
      summary: A register in a collection was refreshed
      description: Sent when a register is loaded with changes. Delivered at least once; deduplicate on `event_id`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventEnvelope'
      responses:
        '2XX':
          description: Acknowledged. Any other status is retried.
  monitor.hit:
    post:
      operationId: eventMonitorHit
      summary: A watched entity, record or collection changed
      description: Sent when a watched target is listed, delisted, amended or merged, subject to notify_on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventEnvelope'
      responses:
        '2XX':
          description: Acknowledged. Any other status is retried.
  record.changed:
    post:
      operationId: eventRecordChanged
      summary: A record on an account's watch list was amended, retired or merged
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventEnvelope'
      responses:
        '2XX':
          description: Acknowledged. Any other status is retried.

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: >
        A key is `ci_live_<random>` (live) or `ci_test_<random>` (sandbox). Keys are stored hashed. Each key has
        explicit scopes. Revoked keys return 401 `key_revoked`.

  headers:
    XUsageUnits:
      description: Query, export or call units charged for this response
      schema:
        type: integer
        minimum: 0
    RateLimitRemaining:
      description: Requests left in the current window (IETF RateLimit draft)
      schema:
        type: integer
        minimum: 0
    RateLimitLimit:
      description: Requests allowed in the current window
      schema:
        type: integer
        minimum: 0
    RateLimitReset:
      description: Seconds until the window resets
      schema:
        type: integer
        minimum: 0

  parameters:
    AcceptLanguage:
      name: Accept-Language
      in: header
      required: false
      description: 'en (default) or fr. Controls the `messages` and explanation text returned.'
      schema:
        type: string
        enum: [en, fr]
        default: en
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >
        Up to 255 printable ASCII characters. The same key with the same body returns the stored response for 24 hours
        and is not charged again. The same key with a different body returns 409 `idempotency_mismatch`.
      schema:
        type: string
        minLength: 1
        maxLength: 255
    BillingRef:
      name: X-Billing-Ref
      in: header
      required: false
      description: >
        Compact JSON of the BillingRef object (see schemas). Required on internal calls from another Crown service
        (decision 82; a call without it is refused as `billing_ref_missing`). For partner keys the gateway stamps
        `source_service` and `source_account` from the credential, so a partner cannot forge them, and the header is
        optional. Maximum 1024 bytes, ASCII only.
      schema:
        type: string
        maxLength: 1024
    WebhookId:
      name: webhook_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^wh_[A-Za-z0-9]{10,40}$'
    MonitorId:
      name: monitor_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^mon_[A-Za-z0-9]{10,40}$'
    CaseId:
      name: case_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^case_[A-Za-z0-9]{10,40}$'

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://api.crownintelligence.net/problems/invalid_request
            title: Invalid request
            status: 400
            code: invalid_request
            detail: 'queries[3].name must be at least 2 characters'
            messages:
              en: 'queries[3].name must be at least 2 characters'
              fr: "queries[3].name doit comporter au moins 2 caractères"
            request_id: req_01J9Z3K4M6
            errors:
              - pointer: /queries/3/name
                code: too_short
                message:
                  en: 'Use at least 2 characters.'
                  fr: "Utilisez au moins 2 caractères."
    Unauthorized:
      description: Missing, unknown or revoked key
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Forbidden:
      description: Key lacks the scope, or the environment or register is not allowed
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    NotFound:
      description: Not found (or not visible to this account)
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Conflict:
      description: Conflict, such as an idempotency mismatch or a duplicate
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    PayloadTooLarge:
      description: Body larger than 1 MB, or more than 100 queries
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    RateLimited:
      description: Rate or quota exceeded
      headers:
        Retry-After:
          schema:
            type: integer
            minimum: 0
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Unavailable:
      description: Temporarily unavailable; retry with backoff
      headers:
        Retry-After:
          schema:
            type: integer
            minimum: 0
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'

  schemas:
    Problem:
      description: RFC 9457 problem details, with English and French messages
      type: object
      required: [type, title, status, code, detail, messages, request_id]
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer
          minimum: 400
          maximum: 599
        code:
          $ref: '#/components/schemas/ErrorCode'
        detail:
          type: string
          description: English detail, same as messages.en
        messages:
          type: object
          required: [en, fr]
          properties:
            en:
              type: string
            fr:
              type: string
        request_id:
          type: string
        errors:
          type: array
          items:
            type: object
            required: [pointer, code, message]
            properties:
              pointer:
                type: string
              code:
                type: string
              message:
                type: object
                required: [en, fr]
                properties:
                  en:
                    type: string
                  fr:
                    type: string

    ErrorCode:
      type: string
      enum:
        - invalid_request
        - unauthenticated
        - key_revoked
        - forbidden_scope
        - forbidden_environment
        - not_found
        - conflict
        - idempotency_mismatch
        - billing_ref_missing
        - billing_ref_invalid
        - batch_too_large
        - payload_too_large
        - personal_data_not_accepted
        - collection_unknown
        - register_not_downloadable
        - rate_limited
        - quota_exceeded
        - register_unavailable
        - internal_error
        - unavailable

    BillingRef:
      description: >
        Decision 82. Passes unchanged down a chain (portal to Screening to Crown Intelligence). Each hop records the call
        that triggered it in `parent_call`.
      type: object
      additionalProperties: false
      required: [source_service, source_account, order_id, type, parent_call, invoice_line_id]
      properties:
        source_service:
          type: string
          pattern: '^[a-z][a-z0-9-]{1,62}$'
          description: An id registered in tools/ci/services.json. Partner keys are stamped as partner-api.
        source_account:
          type: string
          minLength: 1
          maxLength: 80
        order_id:
          type: [string, 'null']
          maxLength: 80
          description: Required when type is ORDER. Null for OVERHEAD.
        type:
          type: string
          enum: [ORDER, OVERHEAD]
        parent_call:
          type: [string, 'null']
          maxLength: 120
          description: The call id that triggered this one, or null at the top of a chain.
        invoice_line_id:
          type: [string, 'null']
          maxLength: 80
          description: Included when the invoice line is known.
      allOf:
        - if:
            properties:
              type:
                const: ORDER
          then:
            properties:
              order_id:
                type: string
                minLength: 1

    StatusOfQuery:
      type: string
      description: >
        hit: at least one match at or above min_band. no_hit_searched: every register in registers_searched was checked
        and none matched. not_searched: no register covered this query, so nothing was checked. error: the check failed.
        not_searched and error are never reported as clear, and no value means clear.
      enum: [hit, no_hit_searched, not_searched, error]

    Band:
      type: string
      description: >
        STRONG: score 0.90 or above, or an exact IMO, MMSI or LEI identifier. POSSIBLE: score 0.75 to 0.89 (the current
        default threshold). WEAK: score 0.60 to 0.74, returned only when min_band is WEAK. Bands are proposed and
        pending calibration on a labelled set (CONTRACT.md section 6.3).
      enum: [STRONG, POSSIBLE, WEAK]

    MinBand:
      type: string
      enum: [STRONG, POSSIBLE, WEAK]
      default: POSSIBLE

    CollectionName:
      type: string
      description: 'sanctions, peps, crime, maritime or default (all registers). Listed by GET /v1/collections.'
      enum: [default, sanctions, peps, crime, maritime]

    EntityType:
      type: string
      description: The FollowTheMoney schema the query is screened as. Company, Organization and Vessel match the same register types as the current API.
      enum: [Person, Company, Organization, Vessel, Airplane, LegalEntity]

    MatchQuery:
      type: object
      additionalProperties: false
      required: [query_ref, entity_type, name]
      description: >
        One subject to screen. `query_ref` is the partner's opaque reference and must not contain names, dates of birth
        or identity numbers. The partner keeps the link between its subject and `query_ref`. Crown Intelligence keeps
        only a keyed digest of the query (CONTRACT.md section 5.4).
      properties:
        query_ref:
          type: string
          pattern: '^[A-Za-z0-9._:-]{1,64}$'
        entity_type:
          $ref: '#/components/schemas/EntityType'
        name:
          type: string
          minLength: 2
          maxLength: 300
        aliases:
          type: array
          maxItems: 10
          items:
            type: string
            minLength: 2
            maxLength: 300
        dob:
          type: string
          description: Partial or full birth date (YYYY, YYYY-MM or YYYY-MM-DD). Use dob or dob_year, not both.
          pattern: '^\d{4}(-\d{2}(-\d{2})?)?$'
        dob_year:
          type: integer
          minimum: 1880
          maximum: 2100
        country:
          type: array
          maxItems: 5
          items:
            type: string
            pattern: '^[A-Z]{2}$'
            description: ISO 3166-1 alpha-2
        identifiers:
          type: object
          additionalProperties: false
          properties:
            imo:
              type: string
              pattern: '^\d{7}$'
            mmsi:
              type: string
              pattern: '^\d{9}$'
            lei:
              type: string
              pattern: '^[A-Z0-9]{18}[0-9]{2}$'
              description: ISO 17442. Accepted now; searched only once the LEI register is loaded (not_searched until then).
            wallet_address:
              type: object
              additionalProperties: false
              required: [chain, address]
              properties:
                chain:
                  type: string
                  enum: [bitcoin, ethereum, tron, solana, other]
                address:
                  type: string
                  minLength: 10
                  maxLength: 128
              description: Accepted now; searched only once the crypto-address register is loaded (not_searched until then).
      not:
        required: [dob, dob_year]

    MatchOptions:
      type: object
      additionalProperties: false
      properties:
        min_band:
          $ref: '#/components/schemas/MinBand'
        collections:
          type: array
          minItems: 1
          maxItems: 5
          uniqueItems: true
          default: [default]
          items:
            $ref: '#/components/schemas/CollectionName'
        limit:
          type: integer
          minimum: 1
          maximum: 25
          default: 5
        include_features:
          type: boolean
          default: true
        algorithm:
          type: string
          enum: [standard, name-only]
          default: standard
          description: name-only scores the name alone. Mirrors /api/algorithms.

    MatchRequest:
      type: object
      additionalProperties: false
      required: [queries]
      properties:
        queries:
          type: array
          minItems: 1
          maxItems: 100
          description: Up to 100 queries. query_ref must be unique within the request (checked at runtime).
          items:
            $ref: '#/components/schemas/MatchQuery'
        options:
          $ref: '#/components/schemas/MatchOptions'

    Feature:
      type: object
      additionalProperties: false
      required: [field, effect, rule, explanation]
      properties:
        field:
          type: string
          enum: [name, alias, transliteration, dob, country, identifier, type]
        effect:
          type: string
          description: 'Signed score change (+0.10, -0.25), or floor 0.98 for an exact identifier.'
          pattern: '^([+-]\d+\.\d{2}|floor \d\.\d{2})$'
        rule:
          type: string
          description: >
            The rule that fired, from a documented vocabulary (CONTRACT.md section 6.4). Examples: close spelling,
            fuzzy spelling, cyrillic transliteration, greek transliteration, arabic transliteration (planned), birth date
            match, nationality match, IMO exact match, type mismatch.
          maxLength: 120
        explanation:
          type: object
          required: [en, fr]
          properties:
            en:
              type: string
            fr:
              type: string

    RegisterLicence:
      type: object
      additionalProperties: false
      required: [policy, commercial_use, internal_use, url]
      properties:
        policy:
          type: string
        commercial_use:
          type: string
          enum: [PERMITTED, RESTRICTED, UNKNOWN]
        internal_use:
          type: string
          enum: [PERMITTED, RESTRICTED, UNKNOWN]
        url:
          type: string
          format: uri

    RegisterSearched:
      type: object
      additionalProperties: false
      required: [list_id, register_name, publisher, list_date, loaded_at, licence]
      properties:
        list_id:
          type: string
          description: The list name on the record (stable across loads).
        register_name:
          type: string
        publisher:
          type: string
        list_date:
          type: [string, 'null']
          format: date
          description: The register's own publication date, when published. Null when the publisher gives none.
        loaded_at:
          type: string
          format: date-time
          description: When Crown Intelligence last loaded this register. Freshness is judged on this.
        licence:
          $ref: '#/components/schemas/RegisterLicence'

    Match:
      type: object
      additionalProperties: false
      required: [record_id, list_id, band, score, schema, caption, matched_name, features, record_url]
      properties:
        record_id:
          type: string
          pattern: '^crown-\d+$'
        list_id:
          type: string
        band:
          $ref: '#/components/schemas/Band'
        score:
          type: number
          minimum: 0
          maximum: 1
        schema:
          $ref: '#/components/schemas/EntityType'
        caption:
          type: string
        matched_name:
          type: string
          description: The name on the record that scored best against the query.
        features:
          type: array
          items:
            $ref: '#/components/schemas/Feature'
        record_url:
          type: string
          format: uri

    QueryError:
      type: object
      additionalProperties: false
      required: [code, message]
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: object
          required: [en, fr]
          properties:
            en:
              type: string
            fr:
              type: string

    QueryResult:
      type: object
      description: >
        The result for one query. The conditional rules below are part of the contract. A `hit` has at least one match.
        A `no_hit_searched` has no matches and lists the registers it searched. A `not_searched` has no matches and gives a
        reason. An `error` has an error object and no matches.
      required: [query_ref, status, searched_at, registers_searched, matches]
      properties:
        query_ref:
          type: string
        status:
          $ref: '#/components/schemas/StatusOfQuery'
        searched_at:
          type: string
          format: date-time
        registers_searched:
          type: array
          items:
            $ref: '#/components/schemas/RegisterSearched'
        matches:
          type: array
          items:
            $ref: '#/components/schemas/Match'
        not_searched_reason:
          type: string
          enum: [identifier_not_covered, collection_unavailable, no_register_for_entity_type]
        identifiers_not_searched:
          type: array
          items:
            type: string
            enum: [imo, mmsi, lei, wallet_address]
        error:
          $ref: '#/components/schemas/QueryError'
      allOf:
        - if:
            properties:
              status:
                const: hit
          then:
            properties:
              matches:
                minItems: 1
        - if:
            properties:
              status:
                const: no_hit_searched
          then:
            properties:
              matches:
                maxItems: 0
              registers_searched:
                minItems: 1
        - if:
            properties:
              status:
                const: not_searched
          then:
            properties:
              matches:
                maxItems: 0
            required: [not_searched_reason]
        - if:
            properties:
              status:
                const: error
          then:
            properties:
              matches:
                maxItems: 0
            required: [error]

    MatchResponse:
      type: object
      required: [request_id, match_request_id, searched_at, min_band, collections, billing_ref, results]
      properties:
        request_id:
          type: string
        match_request_id:
          type: string
          pattern: '^mr_[A-Za-z0-9]{10,40}$'
          description: Use this id to open a case (POST /v1/cases) and to build an evidence pack.
        searched_at:
          type: string
          format: date-time
        min_band:
          $ref: '#/components/schemas/MinBand'
        collections:
          type: array
          items:
            $ref: '#/components/schemas/CollectionName'
        billing_ref:
          $ref: '#/components/schemas/BillingRef'
        results:
          type: array
          items:
            $ref: '#/components/schemas/QueryResult'

    SearchResponse:
      type: object
      required: [query, page, limit, offset, total, results]
      properties:
        query:
          type: string
        page:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        total:
          type: integer
        results:
          type: array
          items:
            $ref: '#/components/schemas/RecordSummary'

    RecordSummary:
      type: object
      required: [record_id, list_id, register_name, caption, entity_type]
      properties:
        record_id:
          type: string
        list_id:
          type: string
        register_name:
          type: string
        caption:
          type: string
        entity_type:
          type: string
        birth_date:
          type: [string, 'null']
        nationality:
          type: [string, 'null']
        programme:
          type: [string, 'null']
        entity_id:
          type: [string, 'null']
        first_seen:
          type: string
          format: date-time
        loaded_at:
          type: string
          format: date-time

    Record:
      type: object
      required: [record_id, list_id, register_name, caption, entity_type, status, last_change, last_change_basis, licence]
      properties:
        record_id:
          type: string
        list_id:
          type: string
        register_name:
          type: string
        caption:
          type: string
        aliases:
          type: array
          items:
            type: string
        entity_type:
          type: string
        birth_date:
          type: [string, 'null']
        nationality:
          type: [string, 'null']
        programme:
          type: [string, 'null']
        status:
          type: string
          enum: [active, retired]
        last_change:
          type: [string, 'null']
          format: date-time
        last_change_basis:
          type: string
        source_url:
          type: [string, 'null']
        merged_entity_id:
          type: [string, 'null']
        licence:
          $ref: '#/components/schemas/RegisterLicence'
        listings:
          type: array
          description: The same party in other registers
          items:
            $ref: '#/components/schemas/RecordSummary'

    Entity:
      type: object
      required: [entity_id, caption, listings, last_change]
      properties:
        entity_id:
          type: string
        caption:
          type: string
        aliases:
          type: array
          items:
            type: string
        listings:
          type: array
          items:
            $ref: '#/components/schemas/RecordSummary'
        last_change:
          type: [string, 'null']
          format: date-time

    Collection:
      type: object
      required: [name, title, records, distinct_entities, sources, last_change]
      properties:
        name:
          $ref: '#/components/schemas/CollectionName'
        title:
          type: string
        records:
          type: integer
          minimum: 0
        distinct_entities:
          type: integer
          minimum: 0
        sources:
          type: integer
          minimum: 0
        last_change:
          type: [string, 'null']
          format: date-time
        links:
          type: object
          additionalProperties: true

    SourceList:
      type: object
      required: [total, page, limit, sources]
      properties:
        total:
          type: integer
        page:
          type: integer
        limit:
          type: integer
        sources:
          type: array
          items:
            type: object
            required: [name, register_name, list_type, licensing_status]
            properties:
              name:
                type: string
              register_name:
                type: string
              publisher:
                type: [string, 'null']
              country:
                type: [string, 'null']
              list_type:
                type: string
              cadence_hours:
                type: [integer, 'null']
              last_row_count:
                type: [integer, 'null']
              last_status:
                type: [string, 'null']
              last_run_at:
                type: [string, 'null']
                format: date-time
              licensing_status:
                type: string
                enum: [CLEARED, RESTRICTED, REVIEW]
              commercial_use:
                type: [string, 'null']
              licence_url:
                type: [string, 'null']
              first_seen:
                type: [string, 'null']
                format: date-time

    Status:
      type: object
      required: [status, checked_at, collections]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        checked_at:
          type: string
          format: date-time
        collections:
          type: array
          items:
            type: object
            required: [name, loaded_at]
            properties:
              name:
                $ref: '#/components/schemas/CollectionName'
              loaded_at:
                type: [string, 'null']
                format: date-time

    WebhookEventType:
      type: string
      enum: [list.updated, monitor.hit, record.changed, webhook.test]

    WebhookCreate:
      type: object
      additionalProperties: false
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          pattern: '^https://'
          maxLength: 2048
          description: https only. Private, loopback and link-local addresses are refused.
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            enum: [list.updated, monitor.hit, record.changed]
        description:
          type: string
          maxLength: 120

    WebhookUpdate:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        url:
          type: string
          format: uri
          pattern: '^https://'
          maxLength: 2048
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            enum: [list.updated, monitor.hit, record.changed]
        status:
          type: string
          enum: [active, paused]

    Webhook:
      type: object
      required: [id, url, events, status, created_at, secret_prefix]
      properties:
        id:
          $ref: '#/components/schemas/WebhookIdValue'
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
        status:
          type: string
          enum: [active, paused, retired]
        secret_prefix:
          type: string
          description: First 8 characters of the signing secret, to identify it. The full secret is never returned here.
        created_at:
          type: string
          format: date-time
        retired_at:
          type: [string, 'null']
          format: date-time

    WebhookIdValue:
      type: string
      pattern: '^wh_[A-Za-z0-9]{10,40}$'

    WebhookCreated:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              pattern: '^whsec_[A-Za-z0-9]{32,64}$'
              description: Shown once. Store it in your secret manager.

    WebhookDelivery:
      type: object
      required: [delivery_id, event_id, event_type, attempt, status, created_at]
      properties:
        delivery_id:
          type: string
        event_id:
          type: string
          pattern: '^evt_[A-Za-z0-9]{10,40}$'
        event_type:
          $ref: '#/components/schemas/WebhookEventType'
        attempt:
          type: integer
          minimum: 1
          maximum: 8
        status:
          type: string
          enum: [pending, succeeded, retrying, failed]
        response_code:
          type: [integer, 'null']
        last_error:
          type: [string, 'null']
          maxLength: 300
        created_at:
          type: string
          format: date-time
        next_attempt_at:
          type: [string, 'null']
          format: date-time

    EventEnvelope:
      type: object
      description: >
        The body of every event. Verify the Crown-Signature header against the raw body before parsing (CONTRACT.md
        section 8.2). Event ids are stable across retries.
      required: [event_id, type, created_at, data]
      properties:
        event_id:
          type: string
          pattern: '^evt_[A-Za-z0-9]{10,40}$'
        type:
          $ref: '#/components/schemas/WebhookEventType'
        created_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true

    MonitorTargetType:
      type: string
      enum: [entity, record, collection]

    MonitorCreate:
      type: object
      additionalProperties: false
      required: [customer_ref, targets]
      properties:
        customer_ref:
          type: string
          pattern: '^[A-Za-z0-9._:-]{1,64}$'
          description: The partner's opaque reference. Must not contain a name, date of birth or identity number.
        targets:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: object
            additionalProperties: false
            required: [type, id]
            properties:
              type:
                $ref: '#/components/schemas/MonitorTargetType'
              id:
                type: string
                maxLength: 80
                description: 'entity: crown-e-<uuid>. record: crown-<n>. collection: a CollectionName.'
        notify_on:
          type: array
          uniqueItems: true
          items:
            type: string
            enum: [listed, delisted, amended, merged]
          default: [listed, delisted, amended, merged]

    Monitor:
      type: object
      required: [id, customer_ref, targets, status, created_at]
      properties:
        id:
          type: string
          pattern: '^mon_[A-Za-z0-9]{10,40}$'
        customer_ref:
          type: string
        targets:
          type: array
          items:
            type: object
            required: [type, id]
            properties:
              type:
                $ref: '#/components/schemas/MonitorTargetType'
              id:
                type: string
        notify_on:
          type: array
          items:
            type: string
        status:
          type: string
          enum: [active, retired]
        created_at:
          type: string
          format: date-time
        last_evaluated_at:
          type: [string, 'null']
          format: date-time
        retired_at:
          type: [string, 'null']
          format: date-time

    CaseCreate:
      type: object
      additionalProperties: false
      required: [match_request_id, customer_ref]
      properties:
        match_request_id:
          type: string
          pattern: '^mr_[A-Za-z0-9]{10,40}$'
        query_refs:
          type: array
          uniqueItems: true
          maxItems: 100
          description: Queries to include. Default is every query in the match request.
          items:
            type: string
        customer_ref:
          type: string
          pattern: '^[A-Za-z0-9._:-]{1,64}$'
        reference:
          type: string
          maxLength: 64
          pattern: '^[A-Za-z0-9._:/ -]{1,64}$'
          description: The partner's own case reference.

    Case:
      type: object
      required: [id, match_request_id, customer_ref, status, created_at]
      properties:
        id:
          type: string
          pattern: '^case_[A-Za-z0-9]{10,40}$'
        match_request_id:
          type: string
        customer_ref:
          type: string
        reference:
          type: [string, 'null']
        query_refs:
          type: array
          items:
            type: string
        status:
          type: string
          enum: [open, closed]
        created_at:
          type: string
          format: date-time
        dispositions:
          type: array
          items:
            $ref: '#/components/schemas/Disposition'

    DispositionCreate:
      type: object
      additionalProperties: false
      required: [query_ref, record_id, disposition]
      properties:
        query_ref:
          type: string
        record_id:
          type: string
          pattern: '^crown-\d+$'
        disposition:
          type: string
          enum: [true_match, false_positive, unresolved]
        note:
          type: string
          maxLength: 1000
          description: Do not write a name or date of birth here; the reviewer's reasoning only.

    Disposition:
      type: object
      required: [id, query_ref, record_id, disposition, recorded_at]
      properties:
        id:
          type: string
        query_ref:
          type: string
        record_id:
          type: string
        disposition:
          type: string
          enum: [true_match, false_positive, unresolved]
        note:
          type: [string, 'null']
        recorded_at:
          type: string
          format: date-time
        superseded_by:
          type: [string, 'null']
          description: Set when a later disposition replaces this one. The earlier row is kept.

    EvidencePack:
      type: object
      required: [case_id, generated_at, manifest_sha256, notice, match_requests, billing_ref]
      properties:
        case_id:
          type: string
        generated_at:
          type: string
          format: date-time
        manifest_sha256:
          type: string
          pattern: '^[0-9a-f]{64}$'
        notice:
          type: object
          required: [en, fr]
          properties:
            en:
              type: string
            fr:
              type: string
        billing_ref:
          $ref: '#/components/schemas/BillingRef'
        match_requests:
          type: array
          items:
            type: object
            required: [match_request_id, searched_at, queries]
            properties:
              match_request_id:
                type: string
              searched_at:
                type: string
                format: date-time
              queries:
                type: array
                items:
                  type: object
                  required: [query_ref, query_digest, status, searched_at, registers_searched, matches, dispositions]
                  properties:
                    query_ref:
                      type: string
                    query_digest:
                      type: string
                      pattern: '^hmac-sha256:[0-9a-f]{64}$'
                      description: Keyed digest of the normalised query. Not reversible without the account key.
                    status:
                      $ref: '#/components/schemas/StatusOfQuery'
                    searched_at:
                      type: string
                      format: date-time
                    registers_searched:
                      type: array
                      items:
                        $ref: '#/components/schemas/RegisterSearched'
                    matches:
                      type: array
                      items:
                        $ref: '#/components/schemas/Match'
                    dispositions:
                      type: array
                      items:
                        $ref: '#/components/schemas/Disposition'

    Usage:
      type: object
      required: [period, units, by_endpoint]
      properties:
        period:
          type: string
          pattern: '^\d{4}-\d{2}$'
        units:
          type: object
          required: [queries, exports, calls]
          properties:
            queries:
              type: integer
              minimum: 0
            exports:
              type: integer
              minimum: 0
            calls:
              type: integer
              minimum: 0
        by_endpoint:
          type: array
          items:
            type: object
            required: [endpoint, calls, units, errors]
            properties:
              endpoint:
                type: string
              calls:
                type: integer
              units:
                type: integer
              errors:
                type: integer
        note:
          type: string
          description: Usage is reported in units. Prices are on the invoice, not in the API.

    ApiKey:
      type: object
      required: [id, name, prefix, environment, scopes, status, created_at]
      properties:
        id:
          type: string
          pattern: '^key_[A-Za-z0-9]{10,40}$'
        name:
          type: string
        prefix:
          type: string
          description: The visible part of the key, for example ci_live_8f3a
        environment:
          type: string
          enum: [live, sandbox]
        scopes:
          type: array
          items:
            $ref: '#/components/schemas/Scope'
        status:
          type: string
          enum: [active, revoked]
        created_at:
          type: string
          format: date-time
        revoked_at:
          type: [string, 'null']
          format: date-time
        last_used_at:
          type: [string, 'null']
          format: date-time

    ApiKeyCreate:
      type: object
      additionalProperties: false
      required: [name, environment, scopes]
      properties:
        name:
          type: string
          maxLength: 80
        environment:
          type: string
          enum: [live, sandbox]
        scopes:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            $ref: '#/components/schemas/Scope'
        order_id:
          type: string
          maxLength: 80
          description: The subscription order the key is billed under (ORDER). Required for live keys.

    ApiKeyCreated:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              pattern: '^ci_(live|test)_[A-Za-z0-9]{32,64}$'
              description: Shown once.

    Scope:
      type: string
      description: >
        match:read (POST /v1/match, reconcile) · search:read (search, records, entities, collections) ·
        sources:read (sources, programs, statistics) · exports:read (register downloads) ·
        webhooks:manage · monitors:manage · cases:read · cases:write (cases, dispositions) · usage:read · keys:manage.
        Dataset and SaaS scopes never reach screening routes (decision 83): no scope grants /api/screen or any Screening route.
      enum:
        - match:read
        - search:read
        - sources:read
        - exports:read
        - webhooks:manage
        - monitors:manage
        - cases:read
        - cases:write
        - usage:read
        - keys:manage
