openapi: 3.1.0
info:
  title: FirmaDB API
  version: 1.0.0
  summary: European company data API across 19 countries, sourced from official government registries.
  description: 'Use this when you need authoritative company records (legal name, status, address, NACE

    classification, registered date, dissolution date, employee count where collected) for

    one of 19 European countries. Does direct lookups by national registry id, fuzzy and

    structured search, and synchronous batch enrichment of up to 100 (country, registry_id)

    pairs in a single call. Does NOT replace sanctions screening, credit bureaus, beneficial

    ownership registers, or filings retrieval — FirmaDB exposes registry-derived facts only.


    Coverage: 36.2 million company records across FR, GB, SE, BE, SK, CZ, NO, PL, IE, BG,

    LT, FI, EE, MD, RS, CY, DK, LV, HR. Every record carries a `source_url` linking to the

    underlying government registry page and a `data_freshness` block explaining when it was

    last loaded.


    Dual audience from day one: human developers (compliance teams, fintech, sales tools)

    and AI agents (MCP clients, function-calling, autonomous workflows). Every operation

    description in this document follows the same pattern so an agent with zero prior

    context can correctly select and call it on the first attempt:

    "Use this when [context]. Does [specific action]. Does NOT [common confusion].

    Parameters: [example with realistic values]."


    Errors are RFC 9457 Problem Details with recovery extensions (`code`, `correction`,

    `suggested_request`, `retryable`, `retry_after_seconds`) so agents can self-correct

    without re-prompting a human. Every POST accepts an `Idempotency-Key` so retries are

    safe. See the Error responses on each operation for the exhaustive code catalog.


    Authentication is HTTP bearer with API keys formatted

    `fdb_{env}_{class}_{kid}_{secret}_{check}`. A small set of read-only endpoints

    (`GET /countries`, `GET /countries/{code}`, `GET /health`) are available anonymously

    with per-IP rate limits.

    '
  termsOfService: https://firmadb.com/legal/terms
  contact:
    name: FirmaDB Support
    url: https://firmadb.com/support
    email: support@firmadb.com
  license:
    name: FirmaDB API Terms
    url: https://firmadb.com/legal/api-terms
servers:
- url: https://api.firmadb.com/v1
  description: Production
security:
- bearerAuth: []
paths:
  /companies/{country}/{registry_id}:
    get:
      operationId: getCompany
      summary: Retrieve one company record by exact country + registry ID
      description: 'Use this when you already know a company''s official national registry identifier (FR SIREN, GB CRN, SE organisationsnummer, BE VAT, etc.) and need the canonical record. Does an exact lookup against the country''s partition and returns the full company object including legal status, address, NACE classification (where collected), employee count (where collected), website, source URL, and data_freshness. Does NOT do fuzzy or name-based matching — for that, call `searchCompanies` first to identify the registry_id, then call this. Does NOT accept registry IDs from the wrong country (passing FR/12345678 when 12345678 is a GB CRN returns 404, not a cross-country search).


        Parameters: `country=FR`, `registry_id=552120222` returns L''Oreal. Leading zeros in registry_id are significant — `00123` and `123` are different companies. Field coverage varies by country; if a field is missing the response explains via field_meta whether it is `not_published_by_registry`, `not_collected`, or `unknown`. Pass `?include=field_meta,provenance` to request expansions; pass `?include_nulls=true` to receive every canonical field even when empty.

        '
      tags:
      - Companies
      parameters:
      - $ref: '#/components/parameters/countryPath'
      - $ref: '#/components/parameters/registryIdPath'
      - $ref: '#/components/parameters/includeQuery'
      - $ref: '#/components/parameters/includeNullsQuery'
      - in: header
        name: If-None-Match
        required: false
        description: 'Use this when you have a previously cached ETag and want to avoid re-downloading unchanged data. Server returns 304 Not Modified when the record has not changed since the ETag was issued. Does NOT bypass authentication or rate limits — a 304 still consumes one rate-limit unit (but is not billed because no record is returned). Parameters: `If-None-Match: "W/\"a1b2c3d4\""`.

          '
        schema:
          type: string
          example: W/"a1b2c3d4e5f6"
      responses:
        '200':
          description: Company record found and returned. Always includes `source_url` and `data_freshness`. Cacheable for 24 hours per `Cache-Control` header.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-RateLimit-Resource:
              $ref: '#/components/headers/X-RateLimit-Resource'
            X-FirmaDB-Cost-Units:
              $ref: '#/components/headers/X-FirmaDB-Cost-Units'
            X-FirmaDB-Credits-Remaining:
              $ref: '#/components/headers/X-FirmaDB-Credits-Remaining'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Company'
              example:
                object: company
                country: FR
                registry_id: '552120222'
                name: L'Oreal
                legal_form: SA a conseil d'administration
                legal_form_code: '5710'
                status: active
                status_active: true
                registered_date: '1963-12-31'
                dissolution_date: null
                address:
                  street: 14 Rue Royale
                  city: Paris
                  postal_code: '75008'
                  country: FR
                nace_code: 20.42Z
                nace_description: Manufacture of perfumes and toilet preparations
                employee_count: 87400
                website: https://www.loreal.com
                source_url: https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
                data_freshness:
                  record_retrieved_at: '2026-05-04T02:15:00Z'
                  country_dataset_updated_at: '2026-05-04T02:00:00Z'
                  freshness_status: current
                  max_age_days: 7
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
        '304':
          description: Not Modified. Returned when `If-None-Match` matches the current ETag for this record. No body is returned. The request consumes one rate-limit unit but is not billed.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
        '400':
          $ref: '#/components/responses/Error400'
        '401':
          $ref: '#/components/responses/Error401'
        '403':
          $ref: '#/components/responses/Error403'
        '404':
          description: 'Company not found. The country was valid and the registry_id was well-formed, but no record exists. Includes `country_freshness` (when the country was last loaded) and a `correction` pointing at `searchCompanies`. Do NOT retry — re-issuing the same request will produce the same 404. To recover, call `searchCompanies` with the company name.

            '
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorCompanyNotFound'
              example:
                type: https://errors.firmadb.com/company-not-found
                title: Company not found
                status: 404
                detail: No company with registry_id '999999999' exists in FR. Format looks valid (9 digits, SIREN convention). FR was last loaded 2026-05-03.
                instance: /v1/companies/FR/999999999
                code: company_not_found
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4P
                retryable: false
                retry_after_seconds: null
                correction: Try searching by name with /v1/companies/search?country=FR&q=...
                suggested_request: null
                documentation_url: https://errors.firmadb.com/company-not-found
                country_freshness:
                  country: FR
                  last_loaded_at: '2026-05-03T02:00:00Z'
                  record_count: 16851670
        '429':
          $ref: '#/components/responses/Error429'
        '503':
          $ref: '#/components/responses/Error503'
  /companies/search:
    get:
      operationId: searchCompanies
      summary: Fuzzy + structured search across European company records
      description: 'Use this when you do NOT have a registry_id but you have a name, address fragment, NACE code, or industry term. Does fuzzy matching (typo tolerance, transliteration, abbreviations) backed by Meilisearch, returning ranked candidates with `match.score` and `match.matched_fields`. Does NOT return the same shape as `getCompany` for billing — search results themselves are free and unmetered for billing; only subsequent individual lookups via `getCompany` consume verified-result quota. Does NOT support cross-country structured filters (`nace`, `status`, `registered_date_*`) — those require the `country` parameter and return 400 `country_required` if omitted.


        Always pass `country` when known. Cross-country search (omitting `country`) scans all 19 partitions, is slower, returns at most 10 results, costs 10 rate-limit units, and includes a `_warnings` entry suggesting you narrow it. After this call, pick the best `match.score` result and call `getCompany` with its `country` and `registry_id` to obtain the full record.


        Parameters: `q=L''Oreal&country=FR&limit=5` returns up to 5 ranked candidates. `q=software&country=FR&nace=62` filters to NACE 62.xx (computer programming) companies in France. `q=Acme` (no country) does a cross-country fuzzy search capped at 10 results. Pagination is cursor-based — pass `cursor` from the previous response''s `next_cursor`. Cursors are bound to the original query parameters; using a cursor with different filters returns 400 `invalid_cursor`.

        '
      tags:
      - Companies
      parameters:
      - in: query
        name: q
        required: true
        description: 'Use this for the free-text search query (company name, address fragment, or industry term). Does case-insensitive fuzzy matching with typo tolerance. Does NOT accept SQL or NoSQL syntax (sanitized server-side). Minimum 3 characters; maximum 200. Parameters: `q=L''Oreal`, `q=Acme Holdings`, `q=software development`.

          '
        schema:
          type: string
          minLength: 3
          maxLength: 200
          example: L'Oreal
      - in: query
        name: country
        required: false
        description: 'Use this to scope the search to one or more countries. Strongly recommended — omitting it triggers a slow cross-country scan capped at 10 results and 10x rate-limit cost. Required when using any structured filter (`nace`, `status`, `registered_date_from`, `registered_date_to`); otherwise returns 400 `country_required`. Comma-separated for multi-country: `country=FR,BE`. Parameters: `country=FR`, `country=FR,BE,IE`.

          '
        schema:
          type: string
          pattern: ^[A-Z]{2}(,[A-Z]{2})*$
          example: FR
      - in: query
        name: nace
        required: false
        description: 'Use this to filter results by NACE Rev.2 code prefix. Does prefix matching — `62` matches all 62.xx codes. Does NOT work without `country` (returns 400 `country_required`). Parameters: `nace=62` (computer programming), `nace=20.42` (perfume manufacturing).

          '
        schema:
          type: string
          example: '62'
      - in: query
        name: status
        required: false
        description: 'Use this to filter by company status. Does NOT work without `country` (returns 400 `country_required`). Parameters: `status=active`, `status=dissolved`. Default `any`.

          '
        schema:
          type: string
          enum:
          - active
          - dissolved
          - suspended
          - any
          default: any
          example: active
      - in: query
        name: registered_date_from
        required: false
        description: 'Use this to filter to companies registered on or after this date. Does NOT work without `country` (returns 400 `country_required`). Parameters: `registered_date_from=2020-01-01`.

          '
        schema:
          type: string
          format: date
          example: '2020-01-01'
      - in: query
        name: registered_date_to
        required: false
        description: 'Use this to filter to companies registered on or before this date. Does NOT work without `country` (returns 400 `country_required`). Parameters: `registered_date_to=2024-12-31`.

          '
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - in: query
        name: sort
        required: false
        description: 'Use this to control result ordering. Default `relevance`. Cross-country search supports only `relevance`. Sorting by a field a country doesn''t collect has no effect. Parameters: `sort=name_asc`, `sort=registered_date_desc`.

          '
        schema:
          type: string
          enum:
          - relevance
          - name_asc
          - name_desc
          - registered_date_asc
          - registered_date_desc
          default: relevance
          example: relevance
      - $ref: '#/components/parameters/limitQuery'
      - $ref: '#/components/parameters/cursorQuery'
      - $ref: '#/components/parameters/includeQuery'
      responses:
        '200':
          description: 'Search results. Always returns 200 — zero matches return `data: []` with `total_count: 0`, which is NOT an error. `total_count_relation` is `exact` for < 1,000 results and `approximate` beyond that. Cross-country results include `_warnings` advising the caller to add `country=`. Use `has_more` (not cursor presence) as the canonical "more pages exist" signal.

            '
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-RateLimit-Resource:
              $ref: '#/components/headers/X-RateLimit-Resource'
            X-FirmaDB-Cost-Units:
              $ref: '#/components/headers/X-FirmaDB-Cost-Units'
            X-FirmaDB-Credits-Remaining:
              $ref: '#/components/headers/X-FirmaDB-Credits-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompanyList'
              example:
                object: list
                url: /v1/companies/search
                data:
                - object: company
                  country: FR
                  registry_id: '552120222'
                  name: L'Oreal
                  legal_form: SA a conseil d'administration
                  legal_form_code: '5710'
                  status: active
                  status_active: true
                  registered_date: '1963-12-31'
                  address:
                    street: 14 Rue Royale
                    city: Paris
                    postal_code: '75008'
                    country: FR
                  nace_code: 20.42Z
                  nace_description: Manufacture of perfumes and toilet preparations
                  employee_count: 87400
                  website: https://www.loreal.com
                  source_url: https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
                  data_freshness:
                    record_retrieved_at: '2026-05-04T02:15:00Z'
                    country_dataset_updated_at: '2026-05-04T02:00:00Z'
                    freshness_status: current
                    max_age_days: 7
                  match:
                    score: 0.99
                    matched_fields:
                    - name
                    explanation: Exact prefix match on company name
                total_count: 1
                total_count_relation: exact
                has_more: false
                next_cursor: null
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
        '400':
          $ref: '#/components/responses/Error400'
        '401':
          $ref: '#/components/responses/Error401'
        '403':
          $ref: '#/components/responses/Error403'
        '429':
          $ref: '#/components/responses/Error429'
        '503':
          description: 'Search backend (Meilisearch) is unavailable. PostgreSQL is still up, so `getCompany` and `lookupBatch` (exact lookups) will continue to work. Recovery: use exact lookup by registry_id while search recovers, then retry search after `Retry-After` seconds.

            '
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                example: 30
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorSearchUnavailable'
              example:
                type: https://errors.firmadb.com/search-unavailable
                title: Search temporarily unavailable
                status: 503
                detail: Meilisearch backend is unreachable. Exact lookups via /v1/companies/{country}/{registry_id} continue to work.
                instance: /v1/companies/search
                code: search_unavailable
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4Q
                retryable: true
                retry_after_seconds: 30
                correction: Use exact lookup by registry_id while search recovers.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/search-unavailable
                degraded_components:
                - meilisearch
                healthy_components:
                - postgresql
                - ingestion
  /companies/lookup-batch:
    post:
      operationId: lookupBatch
      summary: Look up 1-100 companies by exact (country, registry_id) pairs
      description: 'Use this when you need to enrich a CRM list, run a KYB batch, or resolve many references in one synchronous round-trip. Does up to 100 exact lookups in parallel and returns a positional array matching input order. Does NOT do fuzzy matching — every reference must include both `country` and `registry_id`. Does NOT reject the entire batch when individual items are invalid: invalid items return per-item RFC 9457 sub-problems at their position and the envelope still returns 200.


        HTTP status is ALWAYS 200 when the request envelope is valid — even if all 100 items 404 or have per-item errors. 400 is only returned for envelope-level validation failures (empty array, malformed JSON, missing `Idempotency-Key`, > 100 rows). Do NOT write retry-on-non-200 logic that misses per-item errors — branch on `results[i].status` instead.


        Pricing: only `found` results count against your verified-result quota. `not_found` results are free. Duplicates are allowed and each occurrence bills independently if found.


        Idempotency: pass `Idempotency-Key` (UUID v4 recommended). Same key + same body within 24 hours returns the cached response with header `Idempotent-Replayed: true`. Same key + different body returns 409 `idempotency_conflict` with the differing field names (no values).


        Parameters: `{"references": [{"country": "FR", "registry_id": "552120222"}, {"country": "GB", "registry_id": "12345678"}], "include": ["field_meta"]}` with header `Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000`.

        '
      tags:
      - Companies
      parameters:
      - in: header
        name: Idempotency-Key
        required: true
        description: 'Use this on every batch call to make retries safe. Does deduplicate identical requests within a 24-hour window: same key + same body returns the cached response with header `Idempotent-Replayed: true`. Does NOT collapse different requests — same key + different body returns 409 `idempotency_conflict`. UUID v4 recommended; any string up to 255 characters is accepted. Parameters: `Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000`.

          '
        schema:
          type: string
          maxLength: 255
          example: 550e8400-e29b-41d4-a716-446655440000
      requestBody:
        required: true
        description: 'Batch envelope. `references` is required (1-100 items). Each item must have `country` and `registry_id`. Empty array returns 400 `batch_validation_failed`. Optional `include` applies to every successful result.

          '
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchRequest'
            example:
              references:
              - country: FR
                registry_id: '552120222'
              - country: GB
                registry_id: '12345678'
              - country: IE
                registry_id: '999999'
              include:
              - field_meta
      responses:
        '200':
          description: 'Batch processed. Returns a positional `results` array matching input order. Per-item `status` is `found` (with `company`) or `not_found` (with embedded RFC 9457 `error`). Branch on `results[i].status` rather than HTTP status. `summary.billable_results` equals the count of `found` items. If the same `Idempotency-Key` was sent before with the same body, header `Idempotent-Replayed: true` is set and the original response is returned verbatim.

            '
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-RateLimit-Resource:
              $ref: '#/components/headers/X-RateLimit-Resource'
            X-FirmaDB-Cost-Units:
              $ref: '#/components/headers/X-FirmaDB-Cost-Units'
            X-FirmaDB-Credits-Remaining:
              $ref: '#/components/headers/X-FirmaDB-Credits-Remaining'
            Idempotent-Replayed:
              description: 'Set to `true` when the response is a cached replay of an earlier request with the same `Idempotency-Key` and body. Absent (or `false`) for fresh executions.

                '
              schema:
                type: boolean
                example: false
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchResponse'
              example:
                object: batch_result
                results:
                - status: found
                  company:
                    object: company
                    country: FR
                    registry_id: '552120222'
                    name: L'Oreal
                    legal_form: SA a conseil d'administration
                    legal_form_code: '5710'
                    status: active
                    status_active: true
                    registered_date: '1963-12-31'
                    address:
                      street: 14 Rue Royale
                      city: Paris
                      postal_code: '75008'
                      country: FR
                    nace_code: 20.42Z
                    nace_description: Manufacture of perfumes and toilet preparations
                    employee_count: 87400
                    website: https://www.loreal.com
                    source_url: https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
                    data_freshness:
                      record_retrieved_at: '2026-05-04T02:15:00Z'
                      country_dataset_updated_at: '2026-05-04T02:00:00Z'
                      freshness_status: current
                      max_age_days: 7
                - status: found
                  company:
                    object: company
                    country: GB
                    registry_id: '12345678'
                    name: Example Ltd
                    legal_form: Private limited Company
                    legal_form_code: ltd
                    status: active
                    status_active: true
                    registered_date: '1980-04-21'
                    address:
                      street: 1 High Street
                      city: London
                      postal_code: EC1A 1BB
                      country: GB
                    nace_code: 70.22
                    nace_description: Business and other management consultancy activities
                    employee_count: null
                    website: null
                    source_url: https://find-and-update.company-information.service.gov.uk/company/12345678
                    data_freshness:
                      record_retrieved_at: '2026-05-04T03:00:00Z'
                      country_dataset_updated_at: '2026-05-04T02:30:00Z'
                      freshness_status: current
                      max_age_days: 7
                - status: not_found
                  error:
                    type: https://errors.firmadb.com/company-not-found
                    title: Company not found
                    status: 404
                    detail: No company with registry_id '999999' exists in IE.
                    instance: /v1/companies/IE/999999
                    code: company_not_found
                    request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4P
                    retryable: false
                    retry_after_seconds: null
                    correction: Try searching by name with /v1/companies/search?country=IE&q=...
                    suggested_request: null
                    documentation_url: https://errors.firmadb.com/company-not-found
                summary:
                  total: 3
                  found: 2
                  not_found: 1
                  billable_results: 2
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC5R
        '400':
          description: 'Envelope-level validation failure. Triggered ONLY by: empty `references` array, malformed JSON, missing `Idempotency-Key`, or > 100 rows. Per-item invalid countries / IDs do NOT trigger this — they appear inside the 200 response. `errors[]` contains per-item RFC 9457 sub-problems with an `index` field pointing to the offending row.

            '
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorBatchValidationFailed'
              example:
                type: https://errors.firmadb.com/batch-validation-failed
                title: Batch validation failed
                status: 400
                detail: Batch contains 142 references; maximum is 100. Split client-side and submit multiple batches.
                instance: /v1/companies/lookup-batch
                code: batch_validation_failed
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4S
                retryable: false
                retry_after_seconds: null
                correction: Reduce references to 100 or fewer per batch.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/batch-validation-failed
                errors: []
        '401':
          $ref: '#/components/responses/Error401'
        '403':
          $ref: '#/components/responses/Error403'
        '409':
          description: 'Idempotency-Key conflict. The same key was used previously within 24 hours with a DIFFERENT request body (detected via SHA-256 hash of canonicalized JSON). `differing_fields` lists field names only — no values, for security. Recovery: use a new `Idempotency-Key` for the new request, or wait 24 hours for the original key to expire.

            '
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorIdempotencyConflict'
              example:
                type: https://errors.firmadb.com/idempotency-key-conflict
                title: Idempotency key conflict
                status: 409
                detail: The Idempotency-Key 550e8400-e29b-41d4-a716-446655440000 was used 14 minutes ago with a different request body.
                instance: /v1/companies/lookup-batch
                code: idempotency_conflict
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4T
                retryable: false
                retry_after_seconds: null
                correction: Use a new Idempotency-Key for this distinct request.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/idempotency-key-conflict
                differing_fields:
                - references
                - include
        '429':
          $ref: '#/components/responses/Error429'
        '503':
          $ref: '#/components/responses/Error503'
  /countries:
    get:
      operationId: listCountries
      summary: List all supported countries with record counts and freshness
      description: 'Use this when an agent or developer needs the FirmaDB country footprint before planning a workflow — supported countries, total record counts, and per-country freshness. Does a cheap, cacheable list call (1-hour HTTP cache) and is available without authentication (rate-limited 60/hr/IP for anonymous). Does NOT return per-country field coverage details — call `getCountryCapability` with a specific country code for that. Does NOT consume verified-result quota.


        Parameters: no parameters. Response includes 19 country entries with `code`, `name`, `record_count`, `last_loaded_at`, `registry_name`, and `freshness_status`.

        '
      tags:
      - Countries
      security: []
      responses:
        '200':
          description: Country list returned. Cached for 1 hour. Updated by the ingestion pipeline.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-RateLimit-Resource:
              $ref: '#/components/headers/X-RateLimit-Resource'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
          content:
            application/json:
              schema:
                type: object
                required:
                - object
                - data
                - total_count
                - request_id
                properties:
                  object:
                    type: string
                    enum:
                    - list
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Country'
                  total_count:
                    type: integer
                  request_id:
                    type: string
              example:
                object: list
                data:
                - code: FR
                  name: France
                  record_count: 16851670
                  last_loaded_at: '2026-05-04T02:00:00Z'
                  registry_name: INSEE Sirene
                  freshness_status: current
                - code: GB
                  name: United Kingdom
                  record_count: 5700000
                  last_loaded_at: '2026-05-04T02:30:00Z'
                  registry_name: Companies House
                  freshness_status: current
                - code: SE
                  name: Sweden
                  record_count: 1800000
                  last_loaded_at: '2026-05-04T02:45:00Z'
                  registry_name: Bolagsverket
                  freshness_status: current
                total_count: 19
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
        '429':
          $ref: '#/components/responses/Error429'
  /countries/{code}:
    get:
      operationId: getCountryCapability
      summary: Per-country capability manifest (field coverage, registry details, license)
      description: 'Use this BEFORE planning any lookup, search, or enrichment workflow against a specific country. Does return the full capability manifest — supported filters, supported sorts, registry_id format pattern, registry license, update frequency, and per-field `populated_pct` so an agent can decide whether `employee_count` or `website` is worth requesting for this country. Does NOT return company records — call `getCompany`, `searchCompanies`, or `lookupBatch` for data. Available without authentication (rate-limited 60/hr/IP for anonymous).


        Parameters: `code=FR` returns France''s manifest including the SIREN format pattern `^[0-9]{9}$`, INSEE Sirene as the registry, and field coverage like `employee_count: 12.40%` (only large filers report).

        '
      tags:
      - Countries
      security: []
      parameters:
      - in: path
        name: code
        required: true
        description: 'Use this for the ISO 3166-1 alpha-2 country code, uppercase. Must be in the supported set of 19 countries. Parameters: `FR`, `GB`, `IE`, `BE`, `SE`, `SK`, `CZ`, `NO`, `PL`, `BG`, `LT`, `FI`, `EE`, `MD`, `RS`, `CY`, `DK`, `LV`, `HR`.

          '
        schema:
          type: string
          pattern: ^[A-Z]{2}$
          example: FR
      responses:
        '200':
          description: Country capability manifest returned. Cached for 1 hour; supports `If-None-Match` via the `ETag` header.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-RateLimit-Resource:
              $ref: '#/components/headers/X-RateLimit-Resource'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CountryCapability'
              example:
                code: FR
                name: France
                record_count: 16851670
                last_loaded_at: '2026-05-04T02:11:00Z'
                registry:
                  name: INSEE Sirene
                  url: https://www.sirene.fr/
                  license: Open Licence 2.0
                  update_frequency: daily
                registry_id_format:
                  pattern: ^[0-9]{9}$
                  description: 9-digit SIREN number
                  example: '552120222'
                  leading_zeros_significant: true
                field_coverage:
                  name:
                    populated_pct: 100.0
                    collected: true
                  legal_form:
                    populated_pct: 99.97
                    collected: true
                  legal_form_code:
                    populated_pct: 99.97
                    collected: true
                  status:
                    populated_pct: 100.0
                    collected: true
                  registered_date:
                    populated_pct: 98.5
                    collected: true
                  address_street:
                    populated_pct: 95.2
                    collected: true
                  address_city:
                    populated_pct: 99.9
                    collected: true
                  address_postal:
                    populated_pct: 99.8
                    collected: true
                  nace_code:
                    populated_pct: 99.12
                    collected: true
                  employee_count:
                    populated_pct: 12.4
                    collected: true
                    note: Only large filers report
                  website:
                    populated_pct: 0.0
                    collected: false
                    note: Not published by INSEE Sirene
                  dissolution_date:
                    populated_pct: 8.3
                    collected: true
                    note: Only dissolved companies
                supported_filters:
                - q
                - nace
                - status
                - registered_date_from
                - registered_date_to
                supported_sorts:
                - relevance
                - name_asc
                - name_desc
                - registered_date_asc
                - registered_date_desc
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
        '400':
          description: Invalid country code (not ISO 3166-1 alpha-2 or not in supported set).
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorInvalidCountryCode'
              example:
                type: https://errors.firmadb.com/invalid-country-code
                title: Invalid country code
                status: 400
                detail: '''XX'' is not a supported country. FirmaDB supports 19 European countries.'
                instance: /v1/countries/XX
                code: invalid_country
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4U
                retryable: false
                retry_after_seconds: null
                correction: Use one of the supported_countries codes from the list endpoint.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/invalid-country-code
                supported_countries:
                - FR
                - GB
                - SE
                - BE
                - SK
                - CZ
                - 'NO'
                - PL
                - IE
                - BG
                - LT
                - FI
                - EE
                - MD
                - RS
                - CY
                - DK
                - LV
                - HR
        '404':
          $ref: '#/components/responses/Error404'
        '429':
          $ref: '#/components/responses/Error429'
  /account/usage:
    get:
      operationId: getUsage
      summary: Current billing period usage for the authenticated key
      description: 'Use this when you need to display billing state in a dashboard, decide whether to proceed with a large enrichment job, or build a cost-estimation tool. Does return current period start/end, results used / included / remaining / overage, and per-resource rate limits. Does NOT return usage for any key other than the one making the request — keys cannot inspect each other. Does NOT consume verified-result quota.


        Parameters: no parameters; usage is scoped to the bearer key. For pre-flight cost estimates of a planned job, the MCP tool `firmadb_estimate_cost` combines this endpoint with pricing rules client-side.

        '
      tags:
      - Account
      responses:
        '200':
          description: Current usage and rate-limit state for the authenticated key.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-RateLimit-Resource:
              $ref: '#/components/headers/X-RateLimit-Resource'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Usage'
              example:
                object: usage
                key_id: fdb_live_sk_aB12cD34eF56
                tier: solo
                period:
                  start: '2026-05-01T00:00:00Z'
                  end: '2026-06-01T00:00:00Z'
                  days_remaining: 26
                results:
                  used: 1234
                  included: 10000
                  remaining: 8766
                  overage: 0
                  overage_rate_eur: 0.005
                rate_limits:
                  lookup_per_minute:
                    limit: 120
                    used: 3
                    remaining: 117
                  search_per_minute:
                    limit: 30
                    used: 1
                    remaining: 29
                  batch_rows_per_request: 100
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
        '401':
          $ref: '#/components/responses/Error401'
        '403':
          $ref: '#/components/responses/Error403'
        '429':
          $ref: '#/components/responses/Error429'
  /health:
    get:
      operationId: getHealth
      summary: System health check (always returns 200; status field carries severity)
      description: 'Use this for liveness probes, status pages, and pre-flight checks before launching a long-running batch. Does ALWAYS return HTTP 200 — `status` field in the body carries the actual health (`healthy`, `degraded`, `unhealthy`). Does NOT return non-200 HTTP codes for component failures (use 503 only for the platform itself being unreachable). Does NOT require authentication; rate-limited 60/hr/IP for anonymous.


        Parameters: no parameters. Branch on the `status` field, not the HTTP status. `degraded` typically means search (Meilisearch) is slow or stale while exact lookups remain healthy.

        '
      tags:
      - System
      security: []
      responses:
        '200':
          description: 'Health check completed. Branch on the `status` field: `healthy` (all components nominal), `degraded` (one component slow or stale — typically search), or `unhealthy` (a component is down). Even `unhealthy` returns HTTP 200 — only platform-level failure returns non-200.

            '
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            Cache-Control:
              description: Always `no-store` — health responses are not cached.
              schema:
                type: string
                example: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
              example:
                status: healthy
                components:
                  postgresql:
                    status: healthy
                    latency_ms: 2
                  meilisearch:
                    status: healthy
                    latency_ms: 8
                    indexed_records: 36200000
                  ingestion:
                    status: healthy
                    last_run_at: '2026-05-05T02:00:00Z'
                    countries_updated: 19
                version: 1.0.0
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
components:
  schemas:
    Address:
      type: object
      description: 'Use this when rendering a company''s registered postal address from a Company record. Does represent the official registered address as published by the national registry, with locale-preserved street and city strings. Does NOT represent operational/trading addresses, billing addresses, or beneficial-owner addresses — registries publish only the legal seat. Parameters: `{"street": "14 Rue Royale", "city": "Paris", "postal_code": "75008", "country": "FR"}` (L''Oreal, FR SIREN 552120222).

        '
      properties:
        street:
          type: string
          description: Street line(s) as published by the registry. Locale-preserved (UTF-8). May contain accented characters or non-Latin scripts depending on country. Example for L'Oreal FR/552120222 — `14 Rue Royale`.
          examples:
          - 14 Rue Royale
        city:
          type: string
          description: City name as published by the registry. Locale-preserved. Example for L'Oreal FR/552120222 — `Paris`.
          examples:
          - Paris
        postal_code:
          type: string
          description: Postal code in the national format. Leading zeros preserved (significant in countries like FR, IT). Example for L'Oreal FR/552120222 — `75008`.
          examples:
          - '75008'
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code (uppercase) of the registered address. Always matches the parent Company.country. Example — `FR`.
          pattern: ^[A-Z]{2}$
          minLength: 2
          maxLength: 2
          examples:
          - FR
      required:
      - country
      examples:
      - street: 14 Rue Royale
        city: Paris
        postal_code: '75008'
        country: FR
    DataFreshness:
      type: object
      description: 'Use this when an agent or human consumer needs to decide whether to trust a company record without re-fetching, or to display a "last verified" timestamp. Does report when the specific record was last retrieved, when the country dataset was last loaded, the freshness class, and the country''s expected update cadence. Does NOT promise real-time data — registries publish on their own schedules (daily for FR Sirene, weekly for some, monthly for others). Parameters: `{"record_retrieved_at": "2026-05-04T02:15:00Z", "country_dataset_updated_at": "2026-05-04T02:00:00Z", "freshness_status": "current", "max_age_days": 7}` (FR L''Oreal record loaded daily).

        '
      properties:
        record_retrieved_at:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp when this specific record was last fetched from the registry. Use to display "last verified" in your UI. Example — `2026-05-04T02:15:00Z`.
          examples:
          - '2026-05-04T02:15:00Z'
        country_dataset_updated_at:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp when the country's full dataset was last loaded into FirmaDB. Different from record_retrieved_at when ingestion is incremental. Example — `2026-05-04T02:00:00Z`.
          examples:
          - '2026-05-04T02:00:00Z'
        freshness_status:
          type: string
          enum:
          - current
          - stale
          - unknown
          description: '`current` when the dataset was loaded within `max_age_days`; `stale` when it has exceeded that window; `unknown` when freshness cannot be determined (e.g., country onboarding in progress). Branch on this — never on a free-text field. Example — `current`.

            '
          examples:
          - current
        max_age_days:
          type: integer
          minimum: 1
          description: Expected update cadence for this country in days. FR Sirene = 7 (weekly cap on daily refresh), GB Companies House = 1, smaller registries up to 30. Example for FR — `7`.
          examples:
          - 7
      required:
      - record_retrieved_at
      - country_dataset_updated_at
      - freshness_status
      - max_age_days
      examples:
      - record_retrieved_at: '2026-05-04T02:15:00Z'
        country_dataset_updated_at: '2026-05-04T02:00:00Z'
        freshness_status: current
        max_age_days: 7
    FieldMeta:
      type: object
      description: 'Use this when an agent receives a Company record with `?include=field_meta` and needs to explain WHY a field is null (data gap vs. registry doesn''t publish vs. FirmaDB hasn''t ingested). Does map every canonical Company field name to a state + provenance triple (state, source_url, retrieved_at, confidence). Does NOT contain the field VALUES themselves — values live on the parent Company; FieldMeta only describes them. Parameters: `{"employee_count": {"state": "not_published_by_registry", "country": "BG", "source_url": null, "retrieved_at": null, "confidence": null}, "nace_code": {"state": "available", "source_url": "https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016", "retrieved_at": "2026-05-04T03:14:22Z", "confidence": "registry"}}`.

        '
      additionalProperties:
        type: object
        properties:
          state:
            type: string
            enum:
            - available
            - unknown
            - not_published_by_registry
            - not_collected
            - stale
            - estimated
            - derived
            description: 'Exhaustive state enum (no other values permitted). `available` = value present from registry. `unknown` = country collects this field but this record has no value. `not_published_by_registry` = the registry does not publish this field at all. `not_collected` = FirmaDB hasn''t ingested this field for this country (pipeline gap, not registry gap). `stale` = value exists but exceeds freshness threshold. `estimated`/`derived` reserved for future use. Example — `available`.

              '
            examples:
            - available
          country:
            type:
            - string
            - 'null'
            description: ISO alpha-2 country code attached to the meta entry when relevant (e.g., explaining `not_published_by_registry` for a specific country). Example — `BG`.
            examples:
            - BG
          source_url:
            type:
            - string
            - 'null'
            format: uri
            description: Direct link to the registry page that supplied this field's value. Null when state is not `available`. Example — `https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016`.
            examples:
            - https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
          retrieved_at:
            type:
            - string
            - 'null'
            format: date-time
            description: ISO 8601 UTC timestamp when this specific field was last verified at source. Null when state is not `available`. Example — `2026-05-04T03:14:22Z`.
            examples:
            - '2026-05-04T03:14:22Z'
          confidence:
            type:
            - string
            - 'null'
            enum:
            - registry
            - estimated
            - derived
            - null
            description: '`registry` = direct from the official government source (highest trust). `estimated` = computed by FirmaDB. `derived` = inferred from other fields. `null` when state is not `available`. Example — `registry`.

              '
            examples:
            - registry
        required:
        - state
      examples:
      - employee_count:
          state: not_published_by_registry
          country: BG
          source_url: null
          retrieved_at: null
          confidence: null
        nace_code:
          state: available
          source_url: https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
          retrieved_at: '2026-05-04T03:14:22Z'
          confidence: registry
    Provenance:
      type: object
      description: 'Use this when a customer''s audit log, KYB dossier, or compliance report needs full chain-of-custody metadata for a record. Returned only when `?include=provenance` is sent on a lookup. Does provide registry name + URL + license, ingestion method, dataset version, content hash, first-seen and last-changed timestamps, and a change counter. Does NOT replace the always-on `source_url` and `data_freshness` on the parent Company — Provenance is the deeper view, not a substitute. Parameters: `{"registry_name": "INSEE Sirene", "registry_url": "https://www.sirene.fr/", "registry_license": "Open Licence 2.0", "ingestion_method": "bulk_download", "dataset_version": "sirene_2026-05-04", "record_hash": "sha256:a1b2c3...", "first_seen_at": "2024-03-15T00:00:00Z", "last_changed_at": "2026-04-28T02:00:00Z", "change_count": 7}` (L''Oreal FR/552120222).

        '
      properties:
        registry_name:
          type: string
          description: Official name of the source registry. Example — `INSEE Sirene` (FR), `Companies House` (GB).
          examples:
          - INSEE Sirene
        registry_url:
          type: string
          format: uri
          description: Homepage URL of the source registry. Example — `https://www.sirene.fr/`.
          examples:
          - https://www.sirene.fr/
        registry_license:
          type: string
          description: License under which the source data is published. Example — `Open Licence 2.0`.
          examples:
          - Open Licence 2.0
        ingestion_method:
          type: string
          enum:
          - bulk_download
          - api
          - scrape
          description: 'How FirmaDB acquired the record. `bulk_download` = full dataset dumps (most common, cheapest). `api` = registry''s official API. `scrape` = HTML scrape of registry pages (last resort, only where no machine-readable feed exists). Example — `bulk_download`.

            '
          examples:
          - bulk_download
        dataset_version:
          type: string
          description: Version identifier of the source dataset that produced this record. Used for replay and audit. Example — `sirene_2026-05-04`.
          examples:
          - sirene_2026-05-04
        record_hash:
          type: string
          description: SHA-256 hex hash of the canonicalized record. Use to detect change between fetches without diffing every field. Example — `sha256:a1b2c3...`.
          examples:
          - sha256:a1b2c3d4e5f607890a1b2c3d4e5f6789
        first_seen_at:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp when FirmaDB first indexed this record. Useful for "company age in our data" displays. Example — `2024-03-15T00:00:00Z`.
          examples:
          - '2024-03-15T00:00:00Z'
        last_changed_at:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp when any field on this record last changed (registry update reflected in our data). Example — `2026-04-28T02:00:00Z`.
          examples:
          - '2026-04-28T02:00:00Z'
        change_count:
          type: integer
          minimum: 0
          description: Number of detected changes to this record since first_seen_at. Use to flag volatile companies (frequent address/director changes). Example for L'Oreal — `7`.
          examples:
          - 7
      required:
      - registry_name
      - registry_url
      - registry_license
      - ingestion_method
      - dataset_version
      - record_hash
      - first_seen_at
      - last_changed_at
      - change_count
      examples:
      - registry_name: INSEE Sirene
        registry_url: https://www.sirene.fr/
        registry_license: Open Licence 2.0
        ingestion_method: bulk_download
        dataset_version: sirene_2026-05-04
        record_hash: sha256:a1b2c3d4e5f607890a1b2c3d4e5f6789
        first_seen_at: '2024-03-15T00:00:00Z'
        last_changed_at: '2026-04-28T02:00:00Z'
        change_count: 7
    Company:
      type: object
      description: 'Use this when an agent or developer has resolved a target to a single (country, registry_id) and needs the canonical FirmaDB record. Does represent one company as published by an official European national registry, with normalized status, semantic NACE description, address, employee count where collected, plus always-on `source_url` and `data_freshness`. Does NOT represent beneficial owners, officers, financial filings, sanctions hits, or trading aliases — those are separate (deferred) resources. With `include_nulls=false` (default), fields the country does not collect are omitted entirely; with `include_nulls=true` all canonical fields appear (use `?include=field_meta` to learn WHY each null is null). Parameters: lookup of L''Oreal returns `{"object": "company", "country": "FR", "registry_id": "552120222", "name": "L''Oreal", "legal_form": "SA a conseil d''administration", "legal_form_code": "5710", "status": "active", "status_active": true, ...}`.

        '
      properties:
        object:
          type: string
          const: company
          description: Discriminator literal. Always `company`. Use to distinguish Company from BatchResultItem.error or list envelopes when records are nested.
          examples:
          - company
        country:
          type: string
          pattern: ^[A-Z]{2}$
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code (uppercase) of the registry that issued this record. Always present. Example — `FR`.
          examples:
          - FR
        registry_id:
          type: string
          maxLength: 50
          description: National registry identifier as a string. Leading zeros are significant — preserve exactly. Format varies by country (FR SIREN = 9 digits, GB CRN = 8 alphanumerics, BE VAT = `0203.201.340`). Example — `552120222` (L'Oreal FR SIREN).
          examples:
          - '552120222'
        name:
          type: string
          description: Official registered company name as published by the registry, locale-preserved. Example — `L'Oreal`.
          examples:
          - L'Oreal
        legal_form:
          type: string
          description: Full local-language name of the company's legal form. Present only when the country collects this field. Example — `SA a conseil d'administration` (FR), `Private Limited Company` (GB).
          examples:
          - SA a conseil d'administration
        legal_form_code:
          type: string
          description: National classification code for the legal form. Always paired with `legal_form` when collected. Example for L'Oreal — `5710`.
          examples:
          - '5710'
        status:
          type: string
          enum:
          - active
          - dissolved
          - suspended
          - liquidation
          - unknown
          description: 'Normalized status across all 19 registries. Branch on this — never on raw registry text. `active` = currently operating; `dissolved` = formally struck off; `suspended`/`liquidation` = transitional states; `unknown` = registry does not publish a clear activity indicator for this record. Example — `active`.

            '
          examples:
          - active
        status_active:
          type:
          - boolean
          - 'null'
          description: Convenience boolean. `true` when status is `active`; `false` when `dissolved`/`suspended`/`liquidation`; `null` when status is `unknown`. Use this for binary "is it operating?" decisions; treat `null` as "needs human review", not as `true`. Example — `true`.
          examples:
          - true
        registered_date:
          type: string
          format: date
          description: ISO 8601 date (YYYY-MM-DD) the company was first registered. Present when collected. Example for L'Oreal — `1963-12-31`.
          examples:
          - '1963-12-31'
        dissolution_date:
          type:
          - string
          - 'null'
          format: date
          description: ISO 8601 date the company was dissolved. `null` for active companies (only included when `include_nulls=true`). Always present with a value for dissolved companies. Example — `null` (L'Oreal is active).
          examples:
          - null
        address:
          $ref: '#/components/schemas/Address'
        nace_code:
          type: string
          description: European NACE Rev.2 code (4-digit + optional letter suffix in some countries). Always paired with `nace_description` when collected. Example for L'Oreal (perfume manufacturing) — `20.42Z`.
          examples:
          - 20.42Z
        nace_description:
          type: string
          description: Human-readable description of the NACE code (P3 — semantic name alongside the code). Example for `20.42Z` — `Manufacture of perfumes and toilet preparations`.
          examples:
          - Manufacture of perfumes and toilet preparations
        employee_count:
          type:
          - integer
          - 'null'
          minimum: 0
          description: Latest reported employee count. Coverage is sparse in most countries (FR ~12% — only large filers). Use field_meta to distinguish "not collected" from "zero". Example for L'Oreal — `87400`.
          examples:
          - 87400
        website:
          type:
          - string
          - 'null'
          format: uri
          description: Company website URL. Coverage varies sharply — most registries do not publish this (e.g., FR Sirene does not). Example — `https://www.loreal.com`.
          examples:
          - https://www.loreal.com
        source_url:
          type: string
          format: uri
          description: Direct link to the registry's public page for this record. ALWAYS present on every Company. This is the FirmaDB provenance moat — preserve it when displaying data downstream (DPA requirement). Example — `https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016`.
          examples:
          - https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
        data_freshness:
          $ref: '#/components/schemas/DataFreshness'
        field_meta:
          $ref: '#/components/schemas/FieldMeta'
        provenance:
          $ref: '#/components/schemas/Provenance'
        match:
          $ref: '#/components/schemas/MatchBlock'
        request_id:
          type: string
          pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
          description: ULID prefixed with `req_`. Mirrors the `x-request-id` response header. Pass back when reporting issues. Example — `req_01HZAB7MJX9V8GG6P3K5R2QC4N`.
          examples:
          - req_01HZAB7MJX9V8GG6P3K5R2QC4N
      required:
      - object
      - country
      - registry_id
      - name
      - status
      - status_active
      - source_url
      - data_freshness
      - request_id
      examples:
      - object: company
        country: FR
        registry_id: '552120222'
        name: L'Oreal
        legal_form: SA a conseil d'administration
        legal_form_code: '5710'
        status: active
        status_active: true
        registered_date: '1963-12-31'
        dissolution_date: null
        address:
          street: 14 Rue Royale
          city: Paris
          postal_code: '75008'
          country: FR
        nace_code: 20.42Z
        nace_description: Manufacture of perfumes and toilet preparations
        employee_count: 87400
        website: https://www.loreal.com
        source_url: https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
        data_freshness:
          record_retrieved_at: '2026-05-04T02:15:00Z'
          country_dataset_updated_at: '2026-05-04T02:00:00Z'
          freshness_status: current
          max_age_days: 7
        request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
    MatchBlock:
      type: object
      description: 'Use this when interpreting a search result and deciding whether the top hit is "good enough" to act on without human review. Does report a normalized score (0.0-1.0), the matched_fields list, and a one-line human-readable explanation of why this record matched. Does NOT guarantee correctness for ambiguous queries (e.g., "ABC Trading" matches dozens) — for low-confidence hits the agent must inspect addresses or escalate. Parameters: `{"score": 0.92, "matched_fields": ["name"], "explanation": "Exact prefix match on company name"}`.

        '
      properties:
        score:
          type: number
          minimum: 0
          maximum: 1
          description: 'Confidence score from Meilisearch ranking, normalized to [0, 1]. Rule of thumb — `>= 0.9` = act safely, `0.7–0.9` = inspect with the user, `< 0.7` = ask for disambiguation. Example — `0.92`.

            '
          examples:
          - 0.92
        matched_fields:
          type: array
          items:
            type: string
          description: List of Company fields where the query matched. Most distinctive matches come from `name`; `address_*` matches are weaker for common addresses. Example — `["name"]`.
          examples:
          - - name
        explanation:
          type: string
          description: One-line human-readable explanation of WHY this record matched. Surface this to humans for transparency; agents can branch on `score` instead. Example — `Exact prefix match on company name`.
          examples:
          - Exact prefix match on company name
      required:
      - score
      - matched_fields
      - explanation
      examples:
      - score: 0.92
        matched_fields:
        - name
        explanation: Exact prefix match on company name
    CompanyList:
      type: object
      description: 'Use this when paginating over search or list results. Does wrap a `data` array of Company records with cursor-based pagination metadata (`has_more`, `next_cursor`) plus an estimated `total_count` (exact below 1,000, approximate above). Does NOT use page numbers or offsets — pass `next_cursor` as the `cursor` query param on the next request. Cursors are bound to the original query — sending a cursor with different filters returns 400 `invalid_cursor`. Parameters: response from `GET /v1/companies/search?q=L%27Oreal&country=FR` returns `{"object": "list", "url": "/v1/companies/search", "data": [...], "total_count": 1234, "total_count_relation": "exact", "has_more": true, "next_cursor": "cur_eyJjIjoiRlIiLCJyIjoiNTUyMTIwMjIyIiwicyI6MC45Mn0=", "request_id": "req_..."}`.

        '
      properties:
        object:
          type: string
          const: list
          description: Discriminator literal. Always `list`. Distinguishes a list envelope from a single resource.
          examples:
          - list
        url:
          type: string
          description: Path of the request that produced this list (no query string). Useful for relative reconstruction. Example — `/v1/companies/search`.
          examples:
          - /v1/companies/search
        data:
          type: array
          items:
            $ref: '#/components/schemas/Company'
          description: Array of Company records for this page. Empty array `[]` is valid for zero-result searches (not an error). Maximum page size 50.
        total_count:
          type: integer
          minimum: 0
          description: Estimated total matches across all pages. Exact when below 1,000; approximate when above (Meilisearch limitation — see `total_count_relation`). Do NOT use approximate values for billing estimates — call `firmadb_estimate_cost` instead. Example — `1234`.
          examples:
          - 1234
        total_count_relation:
          type: string
          enum:
          - exact
          - approximate
          description: '`exact` for result sets below 1,000; `approximate` above. Approximate values may differ from truth by up to ±20%. Example — `exact`.

            '
          examples:
          - exact
        has_more:
          type: boolean
          description: Canonical "are there more pages" signal. Branch on this, NOT on cursor presence. Example — `true`.
          examples:
          - true
        next_cursor:
          type:
          - string
          - 'null'
          description: Opaque base64 token to pass as `cursor` on the next request. `null` when `has_more` is false. Cursors are query-bound — using one with different filters returns 400 `invalid_cursor`. Maximum result window 10,000 records per query — past that the response includes a `_warnings` entry suggesting narrower filters. Example — `cur_eyJjIjoiRlIiLCJyIjoiNTUyMTIwMjIyIiwicyI6MC45Mn0=`.
          examples:
          - cur_eyJjIjoiRlIiLCJyIjoiNTUyMTIwMjIyIiwicyI6MC45Mn0=
        _warnings:
          type: array
          items:
            type: string
          description: Non-fatal advisory messages (e.g., cross-country search slowdown, exceeded result window). Always optional — clients should display when present. Example — `["Cross-country search is slower and uses 10x rate budget. Add country= for faster results."]`.
          examples:
          - - Cross-country search is slower and uses 10x rate budget. Add country= for faster results.
        request_id:
          type: string
          pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
          description: ULID prefixed with `req_`. Mirrors the `x-request-id` response header. Example — `req_01HZAB7MJX9V8GG6P3K5R2QC4N`.
          examples:
          - req_01HZAB7MJX9V8GG6P3K5R2QC4N
      required:
      - object
      - data
      - total_count
      - total_count_relation
      - has_more
      - request_id
      examples:
      - object: list
        url: /v1/companies/search
        data: []
        total_count: 1234
        total_count_relation: exact
        has_more: true
        next_cursor: cur_eyJjIjoiRlIiLCJyIjoiNTUyMTIwMjIyIiwicyI6MC45Mn0=
        request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
    BatchRequest:
      type: object
      description: 'Use this when enriching a CRM list, running a KYB batch, or any workflow that needs 1-100 exact (country, registry_id) lookups in one synchronous call. Does accept a `references` array (1-100 items, each with `country` and `registry_id`) plus an optional `include` array for `field_meta` / `provenance` expansions; the `Idempotency-Key` HTTP header (NOT a body field) makes retries safe. Does NOT accept fuzzy names — use `firmadb_search_companies` first, then batch the resolved IDs. Does NOT support more than 100 rows per request — split client-side and call `firmadb_estimate_cost` first. Parameters: `{"references": [{"country": "FR", "registry_id": "552120222"}, {"country": "GB", "registry_id": "12345678"}, {"country": "IE", "registry_id": "999999"}], "include": ["field_meta"]}`.

        '
      properties:
        references:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            properties:
              country:
                type: string
                pattern: ^[A-Z]{2}$
                minLength: 2
                maxLength: 2
                description: ISO 3166-1 alpha-2 country code (uppercase). Invalid codes do NOT reject the whole batch — they return a per-item `invalid_country` error at the same array position. Example — `FR`.
                examples:
                - FR
              registry_id:
                type: string
                maxLength: 50
                description: National registry ID. Leading zeros significant. Example — `552120222` (FR SIREN), `12345678` (GB CRN).
                examples:
                - '552120222'
            required:
            - country
            - registry_id
          description: 'Array of (country, registry_id) lookup references. Duplicates allowed — each occurrence is processed independently and billed independently if found. Example — `[{"country": "FR", "registry_id": "552120222"}, {"country": "GB", "registry_id": "12345678"}]`.'
        include:
          type: array
          items:
            type: string
            enum:
            - field_meta
            - provenance
          description: Optional expansions applied to every Company in the response. Same semantics as the single-lookup `?include=` query param. Example — `["field_meta"]`.
          examples:
          - - field_meta
      required:
      - references
      examples:
      - references:
        - country: FR
          registry_id: '552120222'
        - country: GB
          registry_id: '12345678'
        - country: IE
          registry_id: '999999'
        include:
        - field_meta
    BatchResultItem:
      type: object
      description: 'Use this when iterating the per-row results of a batch lookup response. Does represent ONE row''s outcome — either `{status: "found", company: Company}` or `{status: "not_found", error: ProblemDetail}` (an embedded RFC 9457 sub-problem). Does NOT change array position relative to the request — index N in `references` maps to index N in `results`. Parameters: found row `{"status": "found", "company": {"object": "company", "country": "FR", "registry_id": "552120222", "name": "L''Oreal"}}` and not-found row `{"status": "not_found", "error": {"type": "https://errors.firmadb.com/company-not-found", "title": "Company not found", "status": 404, "code": "company_not_found", "detail": "No company with registry_id ''999999'' exists in IE."}}`.

        '
      properties:
        status:
          type: string
          enum:
          - found
          - not_found
          description: '`found` — the row resolved to a Company record (billable). `not_found` — registry ID is well-formed but no record exists, OR the row had a per-item validation error (e.g., invalid country). `not_found` rows are NOT billed. Example — `found`.

            '
          examples:
          - found
        company:
          $ref: '#/components/schemas/Company'
        error:
          $ref: '#/components/schemas/ProblemDetail'
      required:
      - status
      examples:
      - status: found
        company:
          object: company
          country: FR
          registry_id: '552120222'
          name: L'Oreal
          status: active
          status_active: true
          source_url: https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
          data_freshness:
            record_retrieved_at: '2026-05-04T02:15:00Z'
            country_dataset_updated_at: '2026-05-04T02:00:00Z'
            freshness_status: current
            max_age_days: 7
          request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
    BatchResponse:
      type: object
      description: 'Use this when consuming the response from `POST /v1/companies/lookup-batch`. Does return a positional `results` array (same order as request `references`) plus a `summary` block with totals and `billable_results`. Does NOT use HTTP status to communicate per-row outcomes — HTTP is always 200 for envelope-valid requests; check `summary.found` + per-row `status` instead. Parameters: response with 3 input rows (2 found, 1 not found) returns `{"object": "batch_result", "results": [{"status": "found", "company": ...}, {"status": "found", "company": ...}, {"status": "not_found", "error": {...}}], "summary": {"total": 3, "found": 2, "not_found": 1, "billable_results": 2}, "request_id": "req_..."}`.

        '
      properties:
        object:
          type: string
          const: batch_result
          description: Discriminator literal. Always `batch_result`.
          examples:
          - batch_result
        results:
          type: array
          items:
            $ref: '#/components/schemas/BatchResultItem'
          description: Per-row outcomes in input order. Length always equals `references.length`.
        summary:
          type: object
          properties:
            total:
              type: integer
              minimum: 0
              description: Total rows processed (equal to `results.length`). Example — `3`.
              examples:
              - 3
            found:
              type: integer
              minimum: 0
              description: Number of rows that returned a Company record. Example — `2`.
              examples:
              - 2
            not_found:
              type: integer
              minimum: 0
              description: Number of rows that did NOT resolve (404 or per-item validation). Example — `1`.
              examples:
              - 1
            billable_results:
              type: integer
              minimum: 0
              description: Equals `found`. Use this when reconciling client-side cost estimates against actual billing. Example — `2`.
              examples:
              - 2
          required:
          - total
          - found
          - not_found
          - billable_results
          description: Aggregate counts for the batch. `billable_results` is the only count that affects the monthly quota.
        request_id:
          type: string
          pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
          description: ULID prefixed with `req_`. Mirrors the `x-request-id` response header. Example — `req_01HZAB7MJX9V8GG6P3K5R2QC4N`.
          examples:
          - req_01HZAB7MJX9V8GG6P3K5R2QC4N
      required:
      - object
      - results
      - summary
      - request_id
      examples:
      - object: batch_result
        results:
        - status: found
          company:
            object: company
            country: FR
            registry_id: '552120222'
            name: L'Oreal
            status: active
            status_active: true
            source_url: https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200016
            data_freshness:
              record_retrieved_at: '2026-05-04T02:15:00Z'
              country_dataset_updated_at: '2026-05-04T02:00:00Z'
              freshness_status: current
              max_age_days: 7
            request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
        - status: found
          company:
            object: company
            country: GB
            registry_id: '12345678'
            name: Example Ltd
            status: active
            status_active: true
            source_url: https://find-and-update.company-information.service.gov.uk/company/12345678
            data_freshness:
              record_retrieved_at: '2026-05-04T02:15:00Z'
              country_dataset_updated_at: '2026-05-04T02:00:00Z'
              freshness_status: current
              max_age_days: 1
            request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
        - status: not_found
          error:
            type: https://errors.firmadb.com/company-not-found
            title: Company not found
            status: 404
            detail: No company with registry_id '999999' exists in IE.
            instance: /v1/companies/IE/999999
            code: company_not_found
            request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
            retryable: false
            retry_after_seconds: null
            correction: Try searching by name with /v1/companies/search?country=IE&q=...
            suggested_request: null
            documentation_url: https://errors.firmadb.com/company-not-found
        summary:
          total: 3
          found: 2
          not_found: 1
          billable_results: 2
        request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
    Country:
      type: object
      description: 'Use this when displaying or iterating the supported-countries list returned by `GET /v1/countries`. Does represent one country with code, name, total record count, last load timestamp, registry name, and freshness class. Does NOT include per-field coverage percentages, registry license, or supported filters — call `GET /v1/countries/{code}` for the full capability manifest. Parameters: `{"code": "FR", "name": "France", "record_count": 16851670, "last_loaded_at": "2026-05-04T02:00:00Z", "registry_name": "INSEE Sirene", "freshness_status": "current"}`.

        '
      properties:
        code:
          type: string
          pattern: ^[A-Z]{2}$
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code (uppercase). Example — `FR`.
          examples:
          - FR
        name:
          type: string
          description: Country name in English (P3 — semantic name alongside the code). Example — `France`.
          examples:
          - France
        record_count:
          type: integer
          minimum: 0
          description: Total number of company records FirmaDB holds for this country. Example for FR — `16851670`.
          examples:
          - 16851670
        last_loaded_at:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp when this country's full dataset was last loaded. Example — `2026-05-04T02:00:00Z`.
          examples:
          - '2026-05-04T02:00:00Z'
        registry_name:
          type: string
          description: Official name of the source registry. Example — `INSEE Sirene`.
          examples:
          - INSEE Sirene
        freshness_status:
          type: string
          enum:
          - current
          - stale
          - unknown
          description: 'Country-level freshness class. `current` = within country''s expected cadence; `stale` = exceeded; `unknown` = onboarding or outage. Use to flag countries you should not rely on for time-sensitive workflows. Example — `current`.

            '
          examples:
          - current
      required:
      - code
      - name
      - record_count
      - last_loaded_at
      - registry_name
      - freshness_status
      examples:
      - code: FR
        name: France
        record_count: 16851670
        last_loaded_at: '2026-05-04T02:00:00Z'
        registry_name: INSEE Sirene
        freshness_status: current
    CountryCapability:
      type: object
      description: 'Use this when an agent is planning a workflow and needs to know whether a country supports the fields/filters/sorts the workflow requires. Does return record count, registry details (name, URL, license, update frequency), the country''s `registry_id` regex + example, per-field `populated_pct` + `collected` flags, and the supported filter/sort lists. Does NOT return company records — call `/v1/companies/search` or `/v1/companies/{country}/{registry_id}` for that. Recommended FIRST call for any country-aware workflow (cheap, cacheable for 1 hour). Parameters: `GET /v1/countries/FR` returns `{"code": "FR", "name": "France", "record_count": 16851670, "registry": {"name": "INSEE Sirene", "url": "https://www.sirene.fr/", "license": "Open Licence 2.0", "update_frequency": "daily"}, "registry_id_format": {"pattern": "^[0-9]{9}$", "description": "9-digit SIREN number", "example": "552120222", "leading_zeros_significant": true}, "field_coverage": {...}, "supported_filters": [...], "supported_sorts": [...]}`.

        '
      properties:
        code:
          type: string
          pattern: ^[A-Z]{2}$
          examples:
          - FR
          description: ISO 3166-1 alpha-2 country code. Example — `FR`.
        name:
          type: string
          description: Country name in English. Example — `France`.
          examples:
          - France
        record_count:
          type: integer
          minimum: 0
          description: Total companies indexed for this country. Example — `16851670`.
          examples:
          - 16851670
        last_loaded_at:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp of the latest dataset load. Example — `2026-05-04T02:11:00Z`.
          examples:
          - '2026-05-04T02:11:00Z'
        registry:
          type: object
          properties:
            name:
              type: string
              description: Registry name. Example — `INSEE Sirene`.
              examples:
              - INSEE Sirene
            url:
              type: string
              format: uri
              description: Registry homepage. Example — `https://www.sirene.fr/`.
              examples:
              - https://www.sirene.fr/
            license:
              type: string
              description: License under which registry data is published. Customers must comply with this license downstream. Example — `Open Licence 2.0`.
              examples:
              - Open Licence 2.0
            update_frequency:
              type: string
              description: Human-readable cadence. Example — `daily`.
              examples:
              - daily
          required:
          - name
          - url
          - license
          - update_frequency
          description: Registry metadata. Use `license` to ensure downstream redistribution complies.
        registry_id_format:
          type: object
          properties:
            pattern:
              type: string
              description: Regex (PCRE-compatible) the registry_id must match for this country. Example for FR — `^[0-9]{9}$`.
              examples:
              - ^[0-9]{9}$
            description:
              type: string
              description: Human-readable description of the format. Example — `9-digit SIREN number`.
              examples:
              - 9-digit SIREN number
            example:
              type: string
              description: A canonical valid registry_id. Example — `552120222` (L'Oreal).
              examples:
              - '552120222'
            leading_zeros_significant:
              type: boolean
              description: When `true`, leading zeros in registry_id MUST be preserved (e.g., GB CRN `01234567` ≠ `1234567`). Example — `true`.
              examples:
              - true
          required:
          - pattern
          - description
          - example
          - leading_zeros_significant
          description: Format rules for this country's national registry identifier. Validate client-side before calling exact lookup.
        field_coverage:
          type: object
          additionalProperties:
            type: object
            properties:
              populated_pct:
                type: number
                minimum: 0
                maximum: 100
                description: Percentage of records in this country with a non-null value for this field. Example — `99.97`.
                examples:
                - 99.97
              collected:
                type: boolean
                description: Whether FirmaDB collects this field for this country at all. `false` = registry doesn't publish OR FirmaDB hasn't ingested yet. Example — `true`.
                examples:
                - true
              note:
                type: string
                description: Optional human-readable explanation for low coverage or non-collection. Example — `Only large filers report` (FR employee_count).
                examples:
                - Only large filers report
            required:
            - populated_pct
            - collected
          description: Per-field coverage map. Keys are canonical Company field names (`name`, `legal_form`, `nace_code`, `employee_count`, `website`, etc.). Use to decide whether a workflow can rely on a given field for this country.
        supported_filters:
          type: array
          items:
            type: string
          description: Search filters this country supports (subset of `q`, `nace`, `status`, `registered_date_from`, `registered_date_to`). Example — `["q", "nace", "status", "registered_date_from", "registered_date_to"]`.
          examples:
          - - q
            - nace
            - status
            - registered_date_from
            - registered_date_to
        supported_sorts:
          type: array
          items:
            type: string
            enum:
            - relevance
            - name_asc
            - name_desc
            - registered_date_asc
            - registered_date_desc
          description: Sort orders this country supports. Sorting by a field the country does not collect has no effect. Example — `["relevance", "name_asc", "name_desc", "registered_date_asc", "registered_date_desc"]`.
          examples:
          - - relevance
            - name_asc
            - name_desc
            - registered_date_asc
            - registered_date_desc
        request_id:
          type: string
          pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
          description: ULID prefixed with `req_`. Example — `req_01HZAB7MJX9V8GG6P3K5R2QC4N`.
          examples:
          - req_01HZAB7MJX9V8GG6P3K5R2QC4N
      required:
      - code
      - name
      - record_count
      - last_loaded_at
      - registry
      - registry_id_format
      - field_coverage
      - supported_filters
      - supported_sorts
      - request_id
      examples:
      - code: FR
        name: France
        record_count: 16851670
        last_loaded_at: '2026-05-04T02:11:00Z'
        registry:
          name: INSEE Sirene
          url: https://www.sirene.fr/
          license: Open Licence 2.0
          update_frequency: daily
        registry_id_format:
          pattern: ^[0-9]{9}$
          description: 9-digit SIREN number
          example: '552120222'
          leading_zeros_significant: true
        field_coverage:
          name:
            populated_pct: 100.0
            collected: true
          legal_form:
            populated_pct: 99.97
            collected: true
          legal_form_code:
            populated_pct: 99.97
            collected: true
          status:
            populated_pct: 100.0
            collected: true
          registered_date:
            populated_pct: 98.5
            collected: true
          address_street:
            populated_pct: 95.2
            collected: true
          address_city:
            populated_pct: 99.9
            collected: true
          address_postal:
            populated_pct: 99.8
            collected: true
          nace_code:
            populated_pct: 99.12
            collected: true
          employee_count:
            populated_pct: 12.4
            collected: true
            note: Only large filers report
          website:
            populated_pct: 0.0
            collected: false
            note: Not published by INSEE Sirene
          dissolution_date:
            populated_pct: 8.3
            collected: true
            note: Only dissolved companies
        supported_filters:
        - q
        - nace
        - status
        - registered_date_from
        - registered_date_to
        supported_sorts:
        - relevance
        - name_asc
        - name_desc
        - registered_date_asc
        - registered_date_desc
        request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
    Usage:
      type: object
      description: 'Use this when an agent or human needs to check the authenticated key''s current billing-period results, remaining quota, and per-minute rate-limit budgets BEFORE running a costly batch. Does return tier, period window with `days_remaining`, results used/included/remaining/overage, and per-resource rate-limit state. Does NOT return historical periods, invoice details, or per-key audit logs — those belong in the (deferred) billing API. Parameters: `GET /v1/account/usage` returns `{"object": "usage", "key_id": "fdb_live_sk_aB12cD34eF56", "tier": "solo", "period": {...}, "results": {"used": 1234, "included": 10000, "remaining": 8766, "overage": 0, "overage_rate_eur": 0.005}, "rate_limits": {...}, "request_id": "req_..."}`.

        '
      properties:
        object:
          type: string
          const: usage
          description: Discriminator literal. Always `usage`.
          examples:
          - usage
        key_id:
          type: string
          description: The FirmaDB key prefix making the request (NOT the full secret). Example — `fdb_live_sk_aB12cD34eF56`.
          examples:
          - fdb_live_sk_aB12cD34eF56
        tier:
          type: string
          enum:
          - free
          - solo
          - team
          - scale
          - custom
          description: Plan tier of the calling key. Example — `solo`.
          examples:
          - solo
        period:
          type: object
          properties:
            start:
              type: string
              format: date-time
              description: ISO 8601 UTC start of the current billing period (inclusive). Example — `2026-05-01T00:00:00Z`.
              examples:
              - '2026-05-01T00:00:00Z'
            end:
              type: string
              format: date-time
              description: ISO 8601 UTC end of the current billing period (exclusive). Example — `2026-06-01T00:00:00Z`.
              examples:
              - '2026-06-01T00:00:00Z'
            days_remaining:
              type: integer
              minimum: 0
              description: Whole days until the period ends. Example — `26`.
              examples:
              - 26
          required:
          - start
          - end
          - days_remaining
        results:
          type: object
          properties:
            used:
              type: integer
              minimum: 0
              description: Billable results consumed this period. Example — `1234`.
              examples:
              - 1234
            included:
              type: integer
              minimum: 0
              description: Results included in the tier (free quota). Example for Solo tier — `10000`.
              examples:
              - 10000
            remaining:
              type: integer
              description: '`included - used` (clamped to 0 once overage starts). Example — `8766`.'
              examples:
              - 8766
            overage:
              type: integer
              minimum: 0
              description: Billable results consumed past the included quota. Example — `0`.
              examples:
              - 0
            overage_rate_eur:
              type: number
              minimum: 0
              description: EUR per overage result for this tier. Example for Solo — `0.005`.
              examples:
              - 0.005
          required:
          - used
          - included
          - remaining
          - overage
          - overage_rate_eur
        rate_limits:
          type: object
          properties:
            lookup_per_minute:
              type: object
              properties:
                limit:
                  type: integer
                  examples:
                  - 120
                used:
                  type: integer
                  examples:
                  - 3
                remaining:
                  type: integer
                  examples:
                  - 117
              required:
              - limit
              - used
              - remaining
              description: 'Per-minute single-lookup budget. Example for Solo — `{"limit": 120, "used": 3, "remaining": 117}`.'
            search_per_minute:
              type: object
              properties:
                limit:
                  type: integer
                  examples:
                  - 30
                used:
                  type: integer
                  examples:
                  - 1
                remaining:
                  type: integer
                  examples:
                  - 29
              required:
              - limit
              - used
              - remaining
              description: 'Per-minute search budget. Cross-country searches consume 10 units. Example for Solo — `{"limit": 30, "used": 1, "remaining": 29}`.'
            batch_rows_per_request:
              type: integer
              description: Maximum rows per `lookup-batch` request (always 100 in v1.0). Example — `100`.
              examples:
              - 100
          required:
          - lookup_per_minute
          - search_per_minute
          - batch_rows_per_request
        request_id:
          type: string
          pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
          description: ULID prefixed with `req_`. Example — `req_01HZAB7MJX9V8GG6P3K5R2QC4N`.
          examples:
          - req_01HZAB7MJX9V8GG6P3K5R2QC4N
      required:
      - object
      - key_id
      - tier
      - period
      - results
      - rate_limits
      - request_id
      examples:
      - object: usage
        key_id: fdb_live_sk_aB12cD34eF56
        tier: solo
        period:
          start: '2026-05-01T00:00:00Z'
          end: '2026-06-01T00:00:00Z'
          days_remaining: 26
        results:
          used: 1234
          included: 10000
          remaining: 8766
          overage: 0
          overage_rate_eur: 0.005
        rate_limits:
          lookup_per_minute:
            limit: 120
            used: 3
            remaining: 117
          search_per_minute:
            limit: 30
            used: 1
            remaining: 29
          batch_rows_per_request: 100
        request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
    Health:
      type: object
      description: 'Use this when implementing a status page, an uptime check, or an agent''s pre-flight readiness probe. Does always return HTTP 200 — severity is carried in the body''s `status` field (`healthy` / `degraded` / `unhealthy`) per component (`postgresql`, `meilisearch`, `ingestion`). Does NOT page on HTTP errors — clients MUST inspect the `status` field. Anonymous-accessible (no API key required), rate-limited 60/hr/IP. Parameters: `GET /v1/health` returns `{"status": "healthy", "components": {"postgresql": {"status": "healthy", "latency_ms": 2}, "meilisearch": {"status": "healthy", "latency_ms": 8, "indexed_records": 36200000}, "ingestion": {"status": "healthy", "last_run_at": "2026-05-05T02:00:00Z", "countries_updated": 19}}, "version": "1.0.0", "request_id": "req_..."}`.

        '
      properties:
        status:
          type: string
          enum:
          - healthy
          - degraded
          - unhealthy
          description: 'Overall service status. `healthy` = all components nominal; `degraded` = at least one component slow or stale (search may be unavailable but lookups work); `unhealthy` = a critical component is down. Example — `healthy`.

            '
          examples:
          - healthy
        components:
          type: object
          properties:
            postgresql:
              type: object
              properties:
                status:
                  type: string
                  enum:
                  - healthy
                  - degraded
                  - unhealthy
                  examples:
                  - healthy
                latency_ms:
                  type: integer
                  examples:
                  - 2
              required:
              - status
              description: 'PostgreSQL liveness + p50 query latency. Example — `{"status": "healthy", "latency_ms": 2}`.'
            meilisearch:
              type: object
              properties:
                status:
                  type: string
                  enum:
                  - healthy
                  - degraded
                  - unhealthy
                  examples:
                  - healthy
                latency_ms:
                  type: integer
                  examples:
                  - 8
                indexed_records:
                  type: integer
                  examples:
                  - 36200000
              required:
              - status
              description: 'Meilisearch liveness, p50 search latency, and total indexed records. Example — `{"status": "healthy", "latency_ms": 8, "indexed_records": 36200000}`.'
            ingestion:
              type: object
              properties:
                status:
                  type: string
                  enum:
                  - healthy
                  - degraded
                  - unhealthy
                  examples:
                  - healthy
                last_run_at:
                  type: string
                  format: date-time
                  examples:
                  - '2026-05-05T02:00:00Z'
                countries_updated:
                  type: integer
                  examples:
                  - 19
              required:
              - status
              description: 'Daily ingestion pipeline status. Example — `{"status": "healthy", "last_run_at": "2026-05-05T02:00:00Z", "countries_updated": 19}`.'
          required:
          - postgresql
          - meilisearch
          - ingestion
        version:
          type: string
          description: Semantic API version. Example — `1.0.0`.
          examples:
          - 1.0.0
        request_id:
          type: string
          pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
          description: ULID prefixed with `req_`. Example — `req_01HZAB7MJX9V8GG6P3K5R2QC4N`.
          examples:
          - req_01HZAB7MJX9V8GG6P3K5R2QC4N
      required:
      - status
      - components
      - version
      - request_id
      examples:
      - status: healthy
        components:
          postgresql:
            status: healthy
            latency_ms: 2
          meilisearch:
            status: healthy
            latency_ms: 8
            indexed_records: 36200000
          ingestion:
            status: healthy
            last_run_at: '2026-05-05T02:00:00Z'
            countries_updated: 19
        version: 1.0.0
        request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4N
    ProblemDetail:
      type: object
      description: 'Use this when handling any 4xx or 5xx response — every FirmaDB error uses RFC 9457 Problem Details with FirmaDB extensions for agent self-correction. Does provide `type` URI, `title`, HTTP `status`, occurrence-specific `detail`, request `instance`, stable machine-readable `code` (BRANCH ON THIS, not on title/detail), `request_id`, `retryable` boolean, optional `retry_after_seconds`, actionable `correction` string, optional ready-to-send `suggested_request`, and a `documentation_url`. Does NOT use RFC 7807 (obsoleted in 2023). Specific error subtypes extend this base via `allOf` to add fields like `supported_countries[]`, `expected_format`, `limit{}`, `price{}`. Parameters: `{"type": "https://errors.firmadb.com/company-not-found", "title": "Company not found", "status": 404, "detail": "No company with registry_id ''999999999'' exists in FR. Format looks valid (9 digits, SIREN convention). FR was last loaded 2026-05-03.", "instance": "/v1/companies/FR/999999999", "code": "company_not_found", "request_id": "req_01HZAB7MJX9V8GG6P3K5R2QC4P", "retryable": false, "retry_after_seconds": null, "correction": "Try searching by name with /v1/companies/search?country=FR&q=...", "suggested_request": null, "documentation_url": "https://errors.firmadb.com/company-not-found"}`.

        '
      properties:
        type:
          type: string
          format: uri
          description: Stable URI that resolves to a real docs page (not a placeholder). Example — `https://errors.firmadb.com/company-not-found`.
          examples:
          - https://errors.firmadb.com/company-not-found
        title:
          type: string
          description: Short human-readable title. Display, but do NOT branch on it. Example — `Company not found`.
          examples:
          - Company not found
        status:
          type: integer
          minimum: 400
          maximum: 599
          description: HTTP status code mirrored in the body. Example — `404`.
          examples:
          - 404
        detail:
          type: string
          description: Occurrence-specific human-readable detail. Includes the actual value, country, or quota numbers — never generic. Example — `No company with registry_id '999999999' exists in FR. Format looks valid (9 digits, SIREN convention). FR was last loaded 2026-05-03.`.
          examples:
          - No company with registry_id '999999999' exists in FR. Format looks valid (9 digits, SIREN convention). FR was last loaded 2026-05-03.
        instance:
          type: string
          description: Request URI or URN identifying this specific occurrence. Example — `/v1/companies/FR/999999999`.
          examples:
          - /v1/companies/FR/999999999
        code:
          type: string
          description: 'Stable machine-readable error code. CLIENTS BRANCH ON THIS, never on `title` or `detail`. One of the 16 codes from spec section 6.2 (`invalid_country`, `country_required`, `invalid_registry_id`, `query_too_short`, `invalid_parameter`, `invalid_cursor`, `batch_validation_failed`, `unauthenticated`, `insufficient_scope`, `company_not_found`, `idempotency_conflict`, `rate_limit_exceeded`, `quota_exhausted`, `payment_required`, `search_unavailable`, `source_unavailable`). Example — `company_not_found`.

            '
          examples:
          - company_not_found
        request_id:
          type: string
          pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
          description: Same ULID as `x-request-id` response header. Pass back when reporting issues. Example — `req_01HZAB7MJX9V8GG6P3K5R2QC4P`.
          examples:
          - req_01HZAB7MJX9V8GG6P3K5R2QC4P
        retryable:
          type: boolean
          description: Whether the same request might succeed on retry (true for 429, 503; false for 4xx validation errors). Example — `false`.
          examples:
          - false
        retry_after_seconds:
          type:
          - integer
          - 'null'
          minimum: 0
          description: Seconds to wait before retrying when `retryable` is true. `null` when not retryable or no specific delay. Example — `null` for `company_not_found`, `42` for `rate_limit_exceeded`.
          examples:
          - null
        correction:
          type: string
          description: 'One-line ACTIONABLE remediation hint (not "check the docs" — must reference a specific endpoint/parameter). Example — `Try searching by name with /v1/companies/search?country=FR&q=...`.

            '
          examples:
          - Try searching by name with /v1/companies/search?country=FR&q=...
        suggested_request:
          type:
          - object
          - 'null'
          additionalProperties: true
          description: 'Ready-to-send request body or query parameters that would fix the problem. `null` when not applicable. When present, agents can self-correct without re-planning. Example — `null` for `company_not_found`; for `invalid_registry_id` could be `{"path": "/v1/companies/FR/552120222"}`.

            '
          examples:
          - null
        documentation_url:
          type: string
          format: uri
          description: Direct link to the error type's documentation page. Same host as `type` — separate field for clients that prefer not to dereference the type URI. Example — `https://errors.firmadb.com/company-not-found`.
          examples:
          - https://errors.firmadb.com/company-not-found
      required:
      - type
      - title
      - status
      - detail
      - instance
      - code
      - request_id
      - retryable
      - correction
      - documentation_url
      examples:
      - type: https://errors.firmadb.com/company-not-found
        title: Company not found
        status: 404
        detail: No company with registry_id '999999999' exists in FR. Format looks valid (9 digits, SIREN convention). FR was last loaded 2026-05-03.
        instance: /v1/companies/FR/999999999
        code: company_not_found
        request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4P
        retryable: false
        retry_after_seconds: null
        correction: Try searching by name with /v1/companies/search?country=FR&q=...
        suggested_request: null
        documentation_url: https://errors.firmadb.com/company-not-found
    ErrorInvalidCountryCode:
      description: 'Use this when handling 400 responses with `code: invalid_country`. Does extend ProblemDetail with `supported_countries[]` so the agent can pick a valid alternative without re-fetching `/v1/countries`. Does NOT trigger on case mismatch alone — the API normalizes to uppercase and only rejects unsupported codes. Parameters: `{"type": "https://errors.firmadb.com/invalid-country-code", "title": "Invalid country code", "status": 400, "code": "invalid_country", "detail": "Country ''US'' is not supported. FirmaDB covers 19 European countries.", "supported_countries": ["FR", "GB", "SE", "BE", "SK", "CZ", "NO", "PL", "IE", "BG", "LT", "FI", "EE", "MD", "RS", "CY", "DK", "LV", "HR"], "correction": "Use one of the supported_countries codes.", "request_id": "req_..."}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          supported_countries:
            type: array
            items:
              type: string
              pattern: ^[A-Z]{2}$
            description: Exhaustive list of supported ISO alpha-2 codes (19 entries in v1.0). Example — `["FR", "GB", "SE", ...]`.
            examples:
            - - FR
              - GB
              - SE
              - BE
              - SK
              - CZ
              - 'NO'
              - PL
              - IE
              - BG
              - LT
              - FI
              - EE
              - MD
              - RS
              - CY
              - DK
              - LV
              - HR
        required:
        - supported_countries
    ErrorCountryRequired:
      description: 'Use this when handling 400 responses with `code: country_required` — emitted when a structured filter (`nace`, `status`, `registered_date_from/to`) is sent without `country`. Does extend ProblemDetail with `supported_filters_without_country` listing the filters that ARE permitted cross-country (only `q` in v1.0). Does NOT trigger when `q` is sent alone — that''s valid cross-country search (with 10x rate cost). Parameters: `{"code": "country_required", "detail": "Filter ''nace'' requires a country. Cross-country structured filters are not supported.", "supported_filters_without_country": ["q"], "correction": "Add country=FR (or another supported code) to your query.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          supported_filters_without_country:
            type: array
            items:
              type: string
            description: Filters that may be used WITHOUT a `country` parameter. Only `q` in v1.0. Example — `["q"]`.
            examples:
            - - q
        required:
        - supported_filters_without_country
    ErrorInvalidRegistryId:
      description: 'Use this when handling 400 responses with `code: invalid_registry_id` — the supplied registry_id does not match the country''s pattern. Does extend ProblemDetail with `expected_format` (the same format object returned by `/v1/countries/{code}`) so the agent can retry with a corrected ID. Does NOT trigger on a well-formed ID that simply has no record — that returns 404 `company_not_found`. Parameters: `{"code": "invalid_registry_id", "detail": "Registry ID ''ABC123'' does not match FR SIREN format.", "expected_format": {"pattern": "^[0-9]{9}$", "description": "9-digit SIREN number", "example": "552120222", "leading_zeros_significant": true}, "correction": "Strip non-digit characters and ensure 9 digits.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          expected_format:
            type: object
            properties:
              pattern:
                type: string
                examples:
                - ^[0-9]{9}$
              description:
                type: string
                examples:
                - 9-digit SIREN number
              example:
                type: string
                examples:
                - '552120222'
              leading_zeros_significant:
                type: boolean
                examples:
                - true
            required:
            - pattern
            - description
            - example
            - leading_zeros_significant
            description: Country-specific registry_id format rules. Same shape as `CountryCapability.registry_id_format`.
        required:
        - expected_format
    ErrorQueryTooShort:
      description: 'Use this when handling 400 responses with `code: query_too_short` — `q` parameter has fewer than 3 characters. Does extend ProblemDetail with `minimum_length: 3` so the agent can validate before retry. Does NOT trigger on 0-length q (that returns `invalid_parameter` because q is required when search has no other filters). Parameters: `{"code": "query_too_short", "detail": "Query ''L'' is too short. Minimum 3 characters.", "minimum_length": 3, "correction": "Provide at least 3 characters in q.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          minimum_length:
            type: integer
            description: Minimum character length for `q`. Always 3 in v1.0. Example — `3`.
            examples:
            - 3
        required:
        - minimum_length
    ErrorInvalidParameter:
      description: 'Use this when handling 400 responses with `code: invalid_parameter` — generic parameter validation failure (wrong type, out of range, malformed enum). Does extend ProblemDetail with `parameter` (which one), `value` (what was sent), and `constraint` (what was required). Does NOT replace specific error codes for known cases (use `query_too_short`, `invalid_country`, `invalid_cursor` when applicable). Parameters: `{"code": "invalid_parameter", "detail": "Parameter ''limit'' value 100 exceeds maximum 50.", "parameter": "limit", "value": "100", "constraint": "integer 1-50", "correction": "Set limit to 50 or smaller.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          parameter:
            type: string
            description: Name of the offending parameter. Example — `limit`.
            examples:
            - limit
          value:
            description: The rejected value (string-encoded for safety; do not log raw user data). Example — `100`.
            examples:
            - '100'
          constraint:
            type: string
            description: Human-readable description of the constraint. Example — `integer 1-50`.
            examples:
            - integer 1-50
        required:
        - parameter
        - value
        - constraint
    ErrorInvalidCursor:
      description: 'Use this when handling 400 responses with `code: invalid_cursor` — cursor is malformed, expired, or does not match the current query parameters. Does extend ProblemDetail (no extra recovery fields beyond `correction`). Does NOT trigger on the FIRST page request (no cursor required there); cursors are only required to advance pages. Parameters: `{"code": "invalid_cursor", "detail": "Cursor was issued for a different query. Cursors are bound to (q, country, filters, sort).", "correction": "Drop the cursor and re-issue the original search to start from page 1.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
    ErrorBatchValidationFailed:
      description: 'Use this when handling 400 responses with `code: batch_validation_failed` — the batch envelope itself is invalid (empty `references`, missing `Idempotency-Key`, more than 100 rows, malformed JSON). Does extend ProblemDetail with `errors[]`, an array of embedded RFC 9457 sub-problems each carrying an `index` pointing at the offending row. Does NOT trigger on a batch where SOME rows have invalid countries — those return per-row `not_found` results inside a 200 response (per spec section 3.3). Parameters: `{"code": "batch_validation_failed", "detail": "Batch envelope invalid: references array is empty.", "errors": [{"index": null, "code": "empty_references", "detail": "references must contain 1-100 items."}], "correction": "Provide between 1 and 100 references.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          errors:
            type: array
            items:
              allOf:
              - $ref: '#/components/schemas/ProblemDetail'
              - type: object
                properties:
                  index:
                    type:
                    - integer
                    - 'null'
                    minimum: 0
                    description: Zero-based index of the offending row in `references`, or `null` for envelope-level errors. Example — `2`.
                    examples:
                    - 2
            description: Per-item sub-problems explaining each validation failure with its `index`. Each sub-problem is itself a full RFC 9457 record.
        required:
        - errors
    ErrorUnauthenticated:
      description: 'Use this when handling 401 responses with `code: unauthenticated` — missing, malformed, or expired API key. Does extend ProblemDetail (the recovery signal is the `WWW-Authenticate` HTTP header, not a body field). Does NOT trigger on insufficient scope (that''s 403 `insufficient_scope`). Parameters: `{"code": "unauthenticated", "detail": "Missing Authorization header. Use ''Authorization: Bearer fdb_*''.", "correction": "Send ''Authorization: Bearer <your-fdb-key>'' on every request.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
    ErrorInsufficientScope:
      description: 'Use this when handling 403 responses with `code: insufficient_scope` — the restricted key authenticated successfully but lacks the scope required for the requested endpoint. Does extend ProblemDetail with `required_scopes`, `granted_scopes`, and `missing_scopes` so the agent can either escalate to a key with broader scope or report precisely what to grant. Does NOT trigger for secret keys (sk) — those have all scopes implicitly. Parameters: `{"code": "insufficient_scope", "detail": "Restricted key lacks scope ''companies:enrich'' required for /v1/companies/lookup-batch.", "required_scopes": ["companies:enrich"], "granted_scopes": ["companies:read", "companies:search"], "missing_scopes": ["companies:enrich"], "correction": "Create a restricted key with companies:enrich, or use a secret key.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          required_scopes:
            type: array
            items:
              type: string
            description: Scopes the endpoint requires. Example — `["companies:enrich"]`.
            examples:
            - - companies:enrich
          granted_scopes:
            type: array
            items:
              type: string
            description: Scopes the calling key currently has. Example — `["companies:read", "companies:search"]`.
            examples:
            - - companies:read
              - companies:search
          missing_scopes:
            type: array
            items:
              type: string
            description: Set difference (`required` minus `granted`). Example — `["companies:enrich"]`.
            examples:
            - - companies:enrich
        required:
        - required_scopes
        - granted_scopes
        - missing_scopes
    ErrorCompanyNotFound:
      description: 'Use this when handling 404 responses with `code: company_not_found` — the registry_id is well-formed for the country but no record exists. Does extend ProblemDetail with `country_freshness` (when the country was last loaded and how many records it has) so the agent can decide whether to retry later or pivot to search. Does NOT trigger on malformed IDs (use `invalid_registry_id`). Parameters: `{"code": "company_not_found", "detail": "No company with registry_id ''999999999'' exists in FR. Format looks valid (9 digits, SIREN convention). FR was last loaded 2026-05-03.", "country_freshness": {"country": "FR", "last_loaded_at": "2026-05-03T02:00:00Z", "record_count": 16851670}, "correction": "Try searching by name with /v1/companies/search?country=FR&q=...", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          country_freshness:
            type: object
            properties:
              country:
                type: string
                pattern: ^[A-Z]{2}$
                examples:
                - FR
                description: ISO alpha-2 country whose dataset was searched.
              last_loaded_at:
                type: string
                format: date-time
                description: ISO 8601 UTC timestamp the country dataset was last loaded. Example — `2026-05-03T02:00:00Z`.
                examples:
                - '2026-05-03T02:00:00Z'
              record_count:
                type: integer
                minimum: 0
                description: Total records FirmaDB holds for this country. Example — `16851670`.
                examples:
                - 16851670
            required:
            - country
            - last_loaded_at
            - record_count
            description: Country-level context to help the agent decide retry vs. pivot.
        required:
        - country_freshness
    ErrorIdempotencyConflict:
      description: 'Use this when handling 409 responses with `code: idempotency_conflict` — the same `Idempotency-Key` was sent within 24 hours but with a DIFFERENT request body. Does extend ProblemDetail with `differing_fields` (field NAMES only, no values, for security). Does NOT trigger on a true replay of the same body — that returns the cached response with `Idempotent-Replayed: true`. Parameters: `{"code": "idempotency_conflict", "detail": "Idempotency-Key ''550e8400-...'' was previously used with a different body. Differing fields: references, include.", "differing_fields": ["references", "include"], "correction": "Generate a fresh Idempotency-Key (UUID v4) for this distinct request.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          differing_fields:
            type: array
            items:
              type: string
            description: Names of fields that differ between the previous body and this one. NEVER includes values (security). Example — `["references", "include"]`.
            examples:
            - - references
              - include
        required:
        - differing_fields
    ErrorRateLimitExceeded:
      description: 'Use this when handling 429 responses with `code: rate_limit_exceeded` — per-minute rate limit hit on a specific resource. Does extend ProblemDetail with a `limit` block (resource, limit, remaining, reset_seconds) and includes `Retry-After` HTTP header. `retryable: true`. Does NOT trigger on monthly quota exhaustion — that''s `quota_exhausted` (also 429). Parameters: `{"code": "rate_limit_exceeded", "detail": "Rate limit exceeded for resource ''companies-search''. 30/min on Solo tier.", "limit": {"resource": "companies-search", "limit": 30, "remaining": 0, "reset_seconds": 42}, "retryable": true, "retry_after_seconds": 42, "correction": "Wait 42 seconds, then retry.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          limit:
            type: object
            properties:
              resource:
                type: string
                description: Resource bucket name (e.g., `companies-read`, `companies-search`, `companies-search-global`, `companies-enrich`). Example — `companies-search`.
                examples:
                - companies-search
              limit:
                type: integer
                minimum: 0
                description: Per-minute cap for this resource on the calling tier. Example — `30`.
                examples:
                - 30
              remaining:
                type: integer
                minimum: 0
                description: Remaining calls in this minute window. Always `0` when this error fires. Example — `0`.
                examples:
                - 0
              reset_seconds:
                type: integer
                minimum: 0
                description: Seconds until the window resets. Example — `42`.
                examples:
                - 42
            required:
            - resource
            - limit
            - remaining
            - reset_seconds
            description: Per-minute rate-limit state. Mirrors the `ratelimit-*` response headers.
        required:
        - limit
    ErrorQuotaExhausted:
      description: 'Use this when handling 429 responses with `code: quota_exhausted` — monthly result quota used up (or test-mode daily cap on `fdb_test_*` keys). Does extend ProblemDetail with `limit` (bucket, scope, limit, remaining, reset_iso) AND `price` (next_tier, monthly_fee_eur, included_results, upgrade_url) so the agent or human can decide to upgrade. Does NOT trigger on per-minute rate limit — that''s `rate_limit_exceeded`. Parameters: `{"code": "quota_exhausted", "detail": "Solo tier 10000 result quota exhausted. Resets 2026-06-01T00:00:00Z.", "limit": {"bucket": "monthly_results", "scope": "account", "limit": 10000, "remaining": 0, "reset_iso": "2026-06-01T00:00:00Z"}, "price": {"next_tier": "team", "monthly_fee_eur": 99, "included_results": 50000, "upgrade_url": "https://firmadb.com/upgrade?from=solo"}, "correction": "Upgrade to Team tier or wait until 2026-06-01.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          limit:
            type: object
            properties:
              bucket:
                type: string
                description: Quota bucket (e.g., `monthly_results`, `test_daily`). Example — `monthly_results`.
                examples:
                - monthly_results
              scope:
                type: string
                description: Scope of the bucket (`account`, `key`). Example — `account`.
                examples:
                - account
              limit:
                type: integer
                minimum: 0
                examples:
                - 10000
              remaining:
                type: integer
                minimum: 0
                examples:
                - 0
              reset_iso:
                type: string
                format: date-time
                description: ISO 8601 UTC timestamp when the quota resets (start of next billing period). Example — `2026-06-01T00:00:00Z`.
                examples:
                - '2026-06-01T00:00:00Z'
            required:
            - bucket
            - scope
            - limit
            - remaining
            - reset_iso
          price:
            type: object
            properties:
              next_tier:
                type: string
                enum:
                - solo
                - team
                - scale
                - custom
                description: Suggested upgrade tier. Example — `team`.
                examples:
                - team
              monthly_fee_eur:
                type: number
                description: Monthly fee for `next_tier` in EUR. Example — `99`.
                examples:
                - 99
              included_results:
                type: integer
                description: Results included in `next_tier`. Example — `50000`.
                examples:
                - 50000
              upgrade_url:
                type: string
                format: uri
                description: Direct link to the upgrade flow. Example — `https://firmadb.com/upgrade?from=solo`.
                examples:
                - https://firmadb.com/upgrade?from=solo
            required:
            - next_tier
            - monthly_fee_eur
            - included_results
            - upgrade_url
            description: Upgrade pricing block. Use to render an in-product upgrade CTA.
        required:
        - limit
        - price
    ErrorPaymentRequired:
      description: 'Use this when handling 402 responses with `code: payment_required` — the account is on a paid tier but has no valid payment method (card declined, expired, missing). Does extend ProblemDetail with `price` (current_tier, balance_eur, upgrade_url) so the customer can resolve billing. Does NOT trigger on the Free tier — Free is hard-capped via `quota_exhausted` instead. Parameters: `{"code": "payment_required", "detail": "Payment method required for Solo tier. Last invoice failed.", "price": {"current_tier": "solo", "balance_eur": 29.00, "upgrade_url": "https://firmadb.com/billing"}, "correction": "Update your payment method at https://firmadb.com/billing.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          price:
            type: object
            properties:
              current_tier:
                type: string
                enum:
                - free
                - solo
                - team
                - scale
                - custom
                examples:
                - solo
                description: Current tier of the account.
              balance_eur:
                type: number
                description: Outstanding balance in EUR. Example — `29.00`.
                examples:
                - 29.0
              upgrade_url:
                type: string
                format: uri
                description: Direct link to billing portal. Example — `https://firmadb.com/billing`.
                examples:
                - https://firmadb.com/billing
            required:
            - current_tier
            - balance_eur
            - upgrade_url
        required:
        - price
    ErrorSearchUnavailable:
      description: 'Use this when handling 503 responses with `code: search_unavailable` — Meilisearch is down but PostgreSQL exact lookups still work. Does extend ProblemDetail with `degraded_components[]` and `healthy_components[]` arrays so the agent can fall back to exact lookups. `correction` always points to "use exact lookup by registry_id while search recovers." Does NOT trigger when both PostgreSQL and Meilisearch are down — that surfaces as a generic 503 without this specific code. Parameters: `{"code": "search_unavailable", "detail": "Search backend is degraded. Exact lookup by registry_id still works.", "degraded_components": ["meilisearch"], "healthy_components": ["postgresql", "ingestion"], "retryable": true, "retry_after_seconds": 30, "correction": "Use exact lookup by registry_id while search recovers.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          degraded_components:
            type: array
            items:
              type: string
            description: Component names that are unhealthy. Example — `["meilisearch"]`.
            examples:
            - - meilisearch
          healthy_components:
            type: array
            items:
              type: string
            description: Component names still serving requests. Example — `["postgresql", "ingestion"]`.
            examples:
            - - postgresql
              - ingestion
        required:
        - degraded_components
        - healthy_components
    ErrorSourceUnavailable:
      description: 'Use this when handling 503 responses with `code: source_unavailable` — a country''s upstream registry feed has been unreachable beyond the staleness threshold, so FirmaDB cannot guarantee data is current for that country. Does extend ProblemDetail with `source_status` (country, last_success_at, current_status) so the agent can decide whether stale data is acceptable. Does NOT block exact lookups — older cached data may still serve; the warning is about freshness, not availability. Parameters: `{"code": "source_unavailable", "detail": "FR Sirene feed has been unreachable for 48 hours. Cached data may be stale beyond max_age_days.", "source_status": {"country": "FR", "last_success_at": "2026-05-03T02:00:00Z", "current_status": "unreachable"}, "retryable": true, "retry_after_seconds": 3600, "correction": "Treat FR data freshness as ''stale''. Re-check in 1 hour.", ...}`.

        '
      allOf:
      - $ref: '#/components/schemas/ProblemDetail'
      - type: object
        properties:
          source_status:
            type: object
            properties:
              country:
                type: string
                pattern: ^[A-Z]{2}$
                examples:
                - FR
                description: ISO alpha-2 country whose source feed is degraded.
              last_success_at:
                type: string
                format: date-time
                description: ISO 8601 UTC timestamp of the last successful upstream fetch. Example — `2026-05-03T02:00:00Z`.
                examples:
                - '2026-05-03T02:00:00Z'
              current_status:
                type: string
                enum:
                - unreachable
                - degraded
                - recovering
                description: Current status of the upstream feed. Example — `unreachable`.
                examples:
                - unreachable
            required:
            - country
            - last_success_at
            - current_status
        required:
        - source_status
  parameters:
    countryPath:
      name: country
      in: path
      required: true
      description: 'Use this when you already know which country''s registry to query. Specifies the

        ISO 3166-1 alpha-2 country code, uppercase, restricted to FirmaDB''s 19 supported

        countries. Does NOT accept lowercase ("fr"), three-letter codes ("FRA"), or

        country names ("France") — those return `400 invalid_country` with a

        `supported_countries[]` array showing valid values. Does NOT accept comma-separated

        lists in the path (use `country=FR,BE` on `/companies/search` for that).


        Parameters: `FR` (France), `GB` (United Kingdom), `SE` (Sweden), `BE` (Belgium),

        `SK` (Slovakia), `CZ` (Czech Republic), `NO` (Norway), `PL` (Poland), `IE` (Ireland),

        `BG` (Bulgaria), `LT` (Lithuania), `FI` (Finland), `EE` (Estonia), `MD` (Moldova),

        `RS` (Serbia), `CY` (Cyprus), `DK` (Denmark), `LV` (Latvia), `HR` (Croatia).

        '
      schema:
        type: string
        pattern: ^[A-Z]{2}$
        minLength: 2
        maxLength: 2
        enum:
        - FR
        - GB
        - SE
        - BE
        - SK
        - CZ
        - 'NO'
        - PL
        - IE
        - BG
        - LT
        - FI
        - EE
        - MD
        - RS
        - CY
        - DK
        - LV
        - HR
        example: FR
    registryIdPath:
      name: registry_id
      in: path
      required: true
      description: "Use this when you already have the official national registry identifier for a\ncompany in the target country. Identifies one company exactly. The identifier is\ntreated as an opaque string: leading zeros are significant, and country-specific\npunctuation (dots, hyphens, slashes) is preserved verbatim. Maximum length 50\ncharacters.\n\nDoes NOT accept VAT numbers when the country uses a separate registry id (e.g.,\nfor FR pass the 9-digit SIREN, not the 13-digit VAT). Does NOT accept name fragments\n— for fuzzy lookup call `GET /v1/companies/search` first, then call this endpoint\nwith the resolved id. Does NOT strip leading zeros — `00012345` is a different\ncompany from `12345`.\n\nParameters per country (see `GET /v1/countries/{code}` for the authoritative\nper-country regex and example):\n  - FR SIREN: 9 digits, e.g. `552120222`\n  - GB CRN:   8 alphanumeric, e.g. `12345678`\n  - BE VAT:   `0000.000.000`, e.g. `0203.201.340`\n  - IE CRO:   up to 7 digits, e.g. `999999`\n  - SE Org#:  10 digits with optional hyphen, e.g. `5560360793`\n"
      schema:
        type: string
        minLength: 1
        maxLength: 50
        example: '552120222'
    includeQuery:
      name: include
      in: query
      required: false
      description: "Use this when you need optional expansions on the company record beyond the default\ncanonical fields. Comma-separated list. Each value adds one extra block to every\ncompany in the response. Does NOT control which canonical fields appear (use\n`include_nulls` for that). Does NOT accept arbitrary field names — only the listed\nexpansion names are valid.\n\nValues:\n  - `field_meta` — adds `field_meta.{field}.{state, source_url, retrieved_at,\n    confidence}` so the agent can tell whether a missing value is\n    `not_published_by_registry`, `not_collected`, `unknown`, or `stale`. Most\n    useful in combination with `include_nulls=true`.\n  - `provenance` — adds a `provenance` block with `registry_name`, `registry_url`,\n    `registry_license`, `ingestion_method`, `dataset_version`, `record_hash`,\n    `first_seen_at`, `last_changed_at`, `change_count`.\n\nDefault: omitted (no expansions). Parameters: `include=field_meta`,\n`include=provenance`, `include=field_meta,provenance`.\n"
      schema:
        type: array
        items:
          type: string
          enum:
          - field_meta
          - provenance
        uniqueItems: true
        default: []
        example:
        - field_meta
      style: form
      explode: false
    includeNullsQuery:
      name: include_nulls
      in: query
      required: false
      description: 'Use this when you need a stable response shape across countries with different

        field coverage — for example when feeding data to a downstream system that expects

        every column to be present. When `true`, every canonical field appears in the

        response, with `null` for fields the country does not collect. When `false`

        (default), fields with state `not_published_by_registry` or `not_collected` are

        omitted entirely so the response is smaller.


        Does NOT control which optional expansions appear (use `include` for that). Does

        NOT change which fields are billable — billing is per matched record, not per

        field. Pair with `include=field_meta` to see WHY each null is null.


        Parameters: `include_nulls=true`, `include_nulls=false` (default).

        '
      schema:
        type: boolean
        default: false
        example: false
    limitQuery:
      name: limit
      in: query
      required: false
      description: 'Use this when you want fewer than the default 10 search results, or up to 50 in

        one page. Integer 1-50 inclusive. Does NOT control batch size for

        `POST /v1/companies/lookup-batch` (that is bounded by the request body''s

        `references` array, max 100). Does NOT accept floats, strings, or values outside

        1-50 — those return `400 invalid_parameter` with `parameter: "limit"` and

        `constraint: "integer 1-50"`. Default: 10.


        For result sets larger than the page size use the `next_cursor` returned in the

        response and pass it back as `cursor=` on the next call. The maximum retrievable

        result window per query is 10,000 records — beyond that narrow the filters.


        Parameters: `limit=10` (default), `limit=25`, `limit=50` (maximum).

        '
      schema:
        type: integer
        minimum: 1
        maximum: 50
        default: 10
        example: 10
    cursorQuery:
      name: cursor
      in: query
      required: false
      description: 'Use this when paging through search results larger than `limit`. Pass the

        `next_cursor` value returned by the previous response. The cursor is opaque —

        treat it as a string and do not parse, decode, or modify it (the server may change

        the encoding without notice). Cursors are bound to the original query parameters

        (`q`, `country`, filters, `sort`); using a cursor from a different query returns

        `400 invalid_cursor`. Cursors expire after 1 hour.


        Does NOT support page numbers — FirmaDB is cursor-only. Does NOT support reverse

        pagination at launch (forward only). Does NOT survive arbitrary filter changes —

        if the user refines their search, start a new query without a cursor.


        Parameters: `cursor=cur_eyJjIjoiRlIiLCJyIjoiNTUyMTIwMjIyIiwicyI6MC45Mn0=`.

        '
      schema:
        type: string
        minLength: 1
        maxLength: 2048
        example: cur_eyJjIjoiRlIiLCJyIjoiNTUyMTIwMjIyIiwicyI6MC45Mn0=
  headers:
    X-Request-Id:
      description: 'Use this when correlating a client log entry with a server log entry, support

        ticket, or distributed trace. ULID prefixed with `req_`, returned on every

        response (success and error). The same value also appears in the response body''s

        `request_id` field and in error envelopes. Does NOT need to be sent by the client —

        FirmaDB always generates the id server-side. Does NOT change across an idempotent

        replay (the original request_id is returned along with `Idempotent-Replayed: true`).


        Example: `req_01HZAB7MJX9V8GG6P3K5R2QC4N`.

        '
      schema:
        type: string
        pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
        example: req_01HZAB7MJX9V8GG6P3K5R2QC4N
    RateLimit-Limit:
      description: 'Use this when you want to know the per-minute rate-limit ceiling for the resource

        being called by this request. Integer. Returned on every response. Pair with

        `RateLimit-Remaining` and `RateLimit-Reset` to back off proactively before hitting

        a 429. Does NOT reflect the monthly result quota (use the response body of

        `GET /v1/account/usage` for that). Does NOT change inside a window — only at the

        start of the next window or when an account changes tier.


        Example: `120` (Solo tier, lookup endpoint).

        '
      schema:
        type: integer
        minimum: 0
        example: 120
    RateLimit-Remaining:
      description: 'Use this when deciding whether to send the next request now or wait. Integer

        number of requests remaining in the current per-minute window for this resource.

        Returned on every response. When this drops to 0 the next call returns `429

        rate_limit_exceeded` with a `Retry-After` header. Does NOT reflect remaining

        monthly quota — for that, read the response body of `GET /v1/account/usage` or

        the `X-FirmaDB-Credits-Remaining` header. Does NOT decrement for cached responses

        served from local storage (only round-trips to FirmaDB count).


        Example: `117`.

        '
      schema:
        type: integer
        minimum: 0
        example: 117
    RateLimit-Reset:
      description: 'Use this when computing how long to back off before retrying. Integer seconds

        until the current per-minute rate-limit window resets and `RateLimit-Remaining`

        returns to `RateLimit-Limit`. Returned on every response. On 429 responses prefer

        the explicit `Retry-After` header — `RateLimit-Reset` is for proactive pacing.

        Does NOT reset partially as individual requests age out — FirmaDB uses fixed

        per-minute windows, not sliding windows.


        Example: `42` (42 seconds until the window resets).

        '
      schema:
        type: integer
        minimum: 0
        example: 42
    X-RateLimit-Resource:
      description: "Use this when you need to know which rate-limit bucket the current request was\ncounted against, especially when one client calls multiple endpoints. Returned on\nevery response. Does NOT control routing — it is informational. Does NOT include\nper-key constraint buckets (those are reflected directly in the limit values).\n\nValues:\n  - `companies-read`           — single lookup `GET /v1/companies/{country}/{registry_id}`\n  - `companies-search`         — country-scoped search\n  - `companies-search-global`  — cross-country search (consumes 10 units per call)\n  - `companies-enrich`         — batch lookup `POST /v1/companies/lookup-batch`\n  - `coverage-read`            — `GET /v1/countries`, `GET /v1/countries/{code}`\n  - `account-read`             — `GET /v1/account/usage`\n"
      schema:
        type: string
        enum:
        - companies-read
        - companies-search
        - companies-search-global
        - companies-enrich
        - coverage-read
        - account-read
        example: companies-read
    X-FirmaDB-Cost-Units:
      description: 'Use this when reconciling client-side billing telemetry with FirmaDB''s own

        accounting. Integer cost units consumed by THIS request against the monthly

        verified-result quota. Single found lookup = 1. 404 = 0. Search request itself = 0.

        Batch row found = 1, batch row not found = 0. Coverage and health = 0.


        Does NOT bill non-2xx responses (FirmaDB''s marketing line: "If we can''t find it,

        we don''t charge for it."). Does NOT include rate-limit cost (cross-country search

        consumes 10 rate-limit units but typically 0 billing units because search results

        are free).


        Example: `1` for a successful single lookup, `12` for a batch with 12 matches.

        '
      schema:
        type: integer
        minimum: 0
        example: 1
    X-FirmaDB-Credits-Remaining:
      description: 'Use this when an agent or worker needs to decide whether the next planned request

        will fit inside the monthly quota without round-tripping to `GET /v1/account/usage`.

        Integer verified results remaining in the current billing period for the

        authenticated key''s account. Returned on every authenticated response. Decrements

        with each billable result.


        Does NOT include rate-limit allowance — that is conveyed in the `RateLimit-*`

        headers. Does NOT predict overage cost — call `firmadb_estimate_cost` (MCP) or

        `GET /v1/account/usage` for plan-aware projections.


        Example: `8766` (Solo tier with 1234 of 10000 results used).

        '
      schema:
        type: integer
        minimum: 0
        example: 8766
    ETag:
      description: 'Use this when you plan to cache a company record or coverage manifest locally and

        revalidate cheaply. Strong validator. Send the value back on a follow-up request

        in `If-None-Match` to receive `304 Not Modified` when the underlying record has

        not changed (zero billing impact, zero quota impact, near-zero latency). Does NOT

        appear on search responses (search results are not stable identifiers). Does NOT

        appear on error responses.


        Example: `"a1b2c3d4e5f60718"`.

        '
      schema:
        type: string
        example: '"a1b2c3d4e5f60718"'
    Cache-Control:
      description: "Use this when configuring a caching proxy or client. Indicates how long the\nresponse may be cached and whether it may be served stale while a revalidation\nruns in the background. Defaults vary by endpoint:\n  - Single company lookup: `public, max-age=86400, stale-while-revalidate=604800`\n  - Countries list and per-country capability: `public, max-age=3600`\n  - Health: `no-store`\n  - Account usage: `private, no-store`\n\nDoes NOT override the per-tier data-licensing cache window in section 9.2 of the\nspec — that is a contractual limit independent of HTTP caching headers.\n"
      schema:
        type: string
        example: public, max-age=86400, stale-while-revalidate=604800
  responses:
    Error400:
      description: "Use this when a 4xx code in the 400 family is returned. The request was malformed\nor the parameters failed validation. The response is RFC 9457 Problem Details\n(Content-Type `application/problem+json`) with a stable `code` field that the\nclient should branch on. Does NOT cover authentication failures (see 401) or\npermission failures (see 403).\n\nPossible `code` values for 400:\n  - `invalid_country`            — country not in the supported set; extension\n    includes `supported_countries[]`\n  - `country_required`           — structured filter sent without `country`;\n    extension includes `supported_filters_without_country`\n  - `invalid_registry_id`        — registry id failed the per-country regex;\n    extension includes `expected_format`\n  - `query_too_short`            — `q` shorter than 3 characters; extension\n    includes `minimum_length`\n  - `invalid_parameter`          — generic validation failure; extension includes\n    `parameter`, `value`, `constraint`\n  - `invalid_cursor`             — cursor malformed, expired, or query-mismatched\n  - `batch_validation_failed`    — one or more batch items invalid; extension\n    includes `errors[]` with per-item RFC 9457 sub-problems carrying `index`\n"
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          examples:
            invalid_country:
              summary: Country code not in the supported set
              value:
                type: https://errors.firmadb.com/invalid-country-code
                title: Invalid country code
                status: 400
                detail: '''XX'' is not a supported country. FirmaDB currently supports 19 countries — see supported_countries.'
                instance: /v1/companies/XX/123456
                code: invalid_country
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4P
                retryable: false
                retry_after_seconds: null
                correction: Use one of the ISO 3166-1 alpha-2 codes in supported_countries.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/invalid-country-code
                supported_countries:
                - FR
                - GB
                - SE
                - BE
                - SK
                - CZ
                - false
                - PL
                - IE
                - BG
                - LT
                - FI
                - EE
                - MD
                - RS
                - CY
                - DK
                - LV
                - HR
            country_required:
              summary: Structured filter sent without country
              value:
                type: https://errors.firmadb.com/country-required-for-filter
                title: Country required for structured filter
                status: 400
                detail: Filter `nace=62` requires a `country` parameter. Cross-country structured filters are not supported.
                instance: /v1/companies/search
                code: country_required
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4Q
                retryable: false
                retry_after_seconds: null
                correction: Add `country=FR` (or another supported country) to the query.
                suggested_request:
                  method: GET
                  url: /v1/companies/search?q=acme&country=FR&nace=62
                documentation_url: https://errors.firmadb.com/country-required-for-filter
                supported_filters_without_country:
                - q
            invalid_registry_id:
              summary: Registry id format invalid for the country
              value:
                type: https://errors.firmadb.com/invalid-registry-id-format
                title: Invalid registry id format
                status: 400
                detail: '''abc'' does not match the FR SIREN format (9 digits).'
                instance: /v1/companies/FR/abc
                code: invalid_registry_id
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4R
                retryable: false
                retry_after_seconds: null
                correction: Pass a 9-digit SIREN, e.g. 552120222.
                suggested_request:
                  method: GET
                  url: /v1/companies/FR/552120222
                documentation_url: https://errors.firmadb.com/invalid-registry-id-format
                expected_format:
                  pattern: ^[0-9]{9}$
                  description: 9-digit SIREN number
                  example: '552120222'
                  leading_zeros_significant: true
            query_too_short:
              summary: Search q parameter too short
              value:
                type: https://errors.firmadb.com/query-too-short
                title: Search query too short
                status: 400
                detail: '`q` must be at least 3 characters. Received 2.'
                instance: /v1/companies/search
                code: query_too_short
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4S
                retryable: false
                retry_after_seconds: null
                correction: Provide at least 3 characters in `q`, e.g. q=loreal.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/query-too-short
                minimum_length: 3
            invalid_parameter:
              summary: Generic parameter validation failure
              value:
                type: https://errors.firmadb.com/invalid-parameter
                title: Invalid parameter
                status: 400
                detail: '`limit` must be an integer between 1 and 50. Received 999.'
                instance: /v1/companies/search
                code: invalid_parameter
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4T
                retryable: false
                retry_after_seconds: null
                correction: Use a `limit` between 1 and 50, e.g. 50.
                suggested_request:
                  method: GET
                  url: /v1/companies/search?q=loreal&country=FR&limit=50
                documentation_url: https://errors.firmadb.com/invalid-parameter
                parameter: limit
                value: 999
                constraint: integer 1-50
            invalid_cursor:
              summary: Cursor malformed, expired, or query-mismatched
              value:
                type: https://errors.firmadb.com/invalid-cursor
                title: Invalid cursor
                status: 400
                detail: The supplied `cursor` does not match the original query (filter set differs).
                instance: /v1/companies/search
                code: invalid_cursor
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4U
                retryable: false
                retry_after_seconds: null
                correction: Restart the query without a `cursor` and use the new `next_cursor` for subsequent pages.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/invalid-cursor
            batch_validation_failed:
              summary: One or more batch items invalid at envelope level
              value:
                type: https://errors.firmadb.com/batch-validation-failed
                title: Batch validation failed
                status: 400
                detail: 'Batch envelope is invalid: `references` is empty.'
                instance: /v1/companies/lookup-batch
                code: batch_validation_failed
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4V
                retryable: false
                retry_after_seconds: null
                correction: Send between 1 and 100 references, each with a `country` and `registry_id`.
                suggested_request:
                  method: POST
                  url: /v1/companies/lookup-batch
                  body:
                    references:
                    - country: FR
                      registry_id: '552120222'
                documentation_url: https://errors.firmadb.com/batch-validation-failed
                errors:
                - index: 0
                  code: invalid_country
                  detail: '''XX'' is not a supported country.'
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
    Error401:
      description: 'Use this when the request is missing, malformed, or expired credentials. The

        response is RFC 9457 Problem Details with `code: "unauthenticated"` and a

        `WWW-Authenticate: Bearer` header. Does NOT cover scope or permission failures

        (see 403). Does NOT distinguish missing vs. invalid keys in `detail` (security:

        FirmaDB does not leak which keys exist).


        The fix is always: provide a valid `Authorization: Bearer fdb_*` header. If the

        key was recently revoked or has expired, mint a new one in the dashboard.

        '
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          examples:
            unauthenticated:
              summary: Missing or invalid bearer key
              value:
                type: https://errors.firmadb.com/unauthenticated
                title: Authentication required
                status: 401
                detail: 'Missing or invalid `Authorization: Bearer` header.'
                instance: /v1/companies/FR/552120222
                code: unauthenticated
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4W
                retryable: false
                retry_after_seconds: null
                correction: 'Send `Authorization: Bearer fdb_live_sk_...` with every request.'
                suggested_request: null
                documentation_url: https://errors.firmadb.com/unauthenticated
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        WWW-Authenticate:
          description: Bearer challenge per RFC 6750.
          schema:
            type: string
            example: Bearer realm="api.firmadb.com", error="invalid_token"
    Error403:
      description: 'Use this when the key authenticated successfully but is not permitted to perform

        the requested operation. The response is RFC 9457 Problem Details with

        `code: "insufficient_scope"`, plus extension fields `required_scopes`,

        `granted_scopes`, and `missing_scopes` so the agent or operator can fix the key

        configuration. Does NOT cover unauthenticated requests (see 401) or quota

        problems (see 429).


        Common causes: a restricted key (rk) was minted with `companies:read` only and

        the call hit `/companies/lookup-batch` (which needs `companies:enrich`); or the

        key has a `country_scope` constraint that excludes the requested country.

        '
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          examples:
            insufficient_scope:
              summary: Restricted key lacks the scope for this endpoint
              value:
                type: https://errors.firmadb.com/insufficient-scope
                title: Insufficient scope
                status: 403
                detail: Key `fdb_live_rk_xY99zZ00aA11_…` is missing scope `companies:enrich` required by POST /v1/companies/lookup-batch.
                instance: /v1/companies/lookup-batch
                code: insufficient_scope
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4X
                retryable: false
                retry_after_seconds: null
                correction: Mint a new restricted key with the `companies:enrich` scope, or use a secret key (sk) which has all scopes.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/insufficient-scope
                required_scopes:
                - companies:enrich
                granted_scopes:
                - companies:read
                - companies:search
                missing_scopes:
                - companies:enrich
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
    Error404:
      description: 'Use this when the request was well-formed and authorized but no matching record

        exists. The response is RFC 9457 Problem Details with `code: "company_not_found"`

        and a `country_freshness` extension giving the country''s last load time and total

        record count so the agent can decide whether the absence reflects a stale dataset

        or a non-existent company. The `correction` field always points to

        `/v1/companies/search` for fuzzy lookup.


        Does NOT bill — 404 responses are free per FirmaDB''s "If we can''t find it, we

        don''t charge for it." policy. Does NOT mean the company never existed — only that

        the registry feed FirmaDB ingested does not contain it at the requested id.

        '
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          examples:
            company_not_found:
              summary: Well-formed registry id, no matching record
              value:
                type: https://errors.firmadb.com/company-not-found
                title: Company not found
                status: 404
                detail: No company with registry_id '999999999' exists in FR. Format looks valid (9 digits, SIREN convention). FR was last loaded 2026-05-03.
                instance: /v1/companies/FR/999999999
                code: company_not_found
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4Y
                retryable: false
                retry_after_seconds: null
                correction: Try searching by name with /v1/companies/search?country=FR&q=...
                suggested_request:
                  method: GET
                  url: /v1/companies/search?country=FR&q=acme
                documentation_url: https://errors.firmadb.com/company-not-found
                country_freshness:
                  country: FR
                  last_loaded_at: '2026-05-03T02:00:00Z'
                  record_count: 16851670
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
    Error409:
      description: 'Use this when an idempotency conflict is detected on `POST /v1/companies/lookup-batch`.

        The same `Idempotency-Key` was previously used with a different request body

        (detected via SHA-256 hash of the canonicalized JSON). The response is RFC 9457

        Problem Details with `code: "idempotency_conflict"` and a `differing_fields[]`

        extension listing field NAMES only (no values, for security).


        Does NOT mean the original request failed — the original response is still

        retrievable by replaying with the original body. Does NOT delete or modify the

        original cache entry; the key remains bound to the first body for its 24-hour

        retention.


        The fix is to either (a) replay with the original body and read the result, or

        (b) generate a fresh `Idempotency-Key` (UUID v4 recommended) for the new body.

        '
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          examples:
            idempotency_conflict:
              summary: Same key reused with a different body
              value:
                type: https://errors.firmadb.com/idempotency-key-conflict
                title: Idempotency key conflict
                status: 409
                detail: Idempotency-Key '550e8400-e29b-41d4-a716-446655440000' was previously used with a different request body.
                instance: /v1/companies/lookup-batch
                code: idempotency_conflict
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC4Z
                retryable: false
                retry_after_seconds: null
                correction: Either replay with the original body, or generate a new Idempotency-Key (UUID v4) for this new body.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/idempotency-key-conflict
                differing_fields:
                - references
                - include
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
    Error429:
      description: 'Use this when the request was throttled. Two distinct `code` values share this

        status: `rate_limit_exceeded` (per-minute window for a resource exceeded; transient,

        retry after `Retry-After` seconds) and `quota_exhausted` (monthly verified-result

        budget for the account or test-mode daily budget exceeded; not transient — upgrade

        the tier or wait for the next billing period).


        For `rate_limit_exceeded` the `limit` extension carries `{resource, limit,

        remaining, reset_seconds}`. For `quota_exhausted` it carries `{bucket, scope,

        limit, remaining, reset_iso}` plus a `price` block with upgrade information.


        Does NOT consume monthly result quota itself — 429 responses are free. Does NOT

        affect search-only workflows on the free tier when the search budget renews each

        minute (only billable lookups deplete monthly results).

        '
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          examples:
            rate_limit_exceeded:
              summary: Per-minute rate limit hit; transient
              value:
                type: https://errors.firmadb.com/rate-limit-exceeded
                title: Rate limit exceeded
                status: 429
                detail: Rate limit exceeded for resource `companies-read` (120/minute on Solo tier).
                instance: /v1/companies/FR/552120222
                code: rate_limit_exceeded
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC50
                retryable: true
                retry_after_seconds: 23
                correction: Wait 23 seconds and retry. Spread your traffic across the minute or upgrade to a higher tier for more throughput.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/rate-limit-exceeded
                limit:
                  resource: companies-read
                  limit: 120
                  remaining: 0
                  reset_seconds: 23
            quota_exhausted:
              summary: Monthly verified-result quota exhausted
              value:
                type: https://errors.firmadb.com/quota-exhausted
                title: Monthly quota exhausted
                status: 429
                detail: Monthly verified-result quota of 10,000 exhausted for tier `solo`. Quota resets on 2026-06-01T00:00:00Z.
                instance: /v1/companies/FR/552120222
                code: quota_exhausted
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC51
                retryable: false
                retry_after_seconds: null
                correction: Upgrade to Team (50,000 results/month) or wait until 2026-06-01T00:00:00Z for the next billing period.
                suggested_request: null
                documentation_url: https://errors.firmadb.com/quota-exhausted
                limit:
                  bucket: monthly_results
                  scope: account
                  limit: 10000
                  remaining: 0
                  reset_iso: '2026-06-01T00:00:00Z'
                price:
                  next_tier: team
                  monthly_fee_eur: 99
                  included_results: 50000
                  upgrade_url: https://firmadb.com/billing/upgrade
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
        Retry-After:
          description: Seconds to wait before retrying. Present on `rate_limit_exceeded` only.
          schema:
            type: integer
            minimum: 0
            example: 23
    Error503:
      description: 'Use this when a downstream component required for the request is unavailable. Two

        distinct `code` values share this status: `search_unavailable` (Meilisearch is

        down or unreachable while PostgreSQL is healthy — fall back to exact lookup by

        registry id) and `source_unavailable` (a country''s registry feed is stale beyond

        threshold — the country''s records are returned but flagged as stale via

        `data_freshness.freshness_status`).


        Does NOT mean the API is down — `GET /v1/health` will return 200 with the

        affected component flagged. Does NOT bill. Does NOT consume rate-limit budget on

        the failing resource.

        '
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          examples:
            search_unavailable:
              summary: Meilisearch unavailable; exact lookup still works
              value:
                type: https://errors.firmadb.com/search-unavailable
                title: Search temporarily unavailable
                status: 503
                detail: Meilisearch is temporarily unavailable. Exact lookup by registry id is unaffected.
                instance: /v1/companies/search
                code: search_unavailable
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC52
                retryable: true
                retry_after_seconds: 60
                correction: Use exact lookup by registry_id while search recovers. Retry search after 60 seconds.
                suggested_request:
                  method: GET
                  url: /v1/companies/FR/552120222
                documentation_url: https://errors.firmadb.com/search-unavailable
                degraded_components:
                - meilisearch
                healthy_components:
                - postgresql
                - ingestion
            source_unavailable:
              summary: Country registry feed stale beyond threshold
              value:
                type: https://errors.firmadb.com/source-unavailable
                title: Source registry unavailable
                status: 503
                detail: Country FR's registry feed has not refreshed within the maximum freshness window of 7 days.
                instance: /v1/companies/FR/552120222
                code: source_unavailable
                request_id: req_01HZAB7MJX9V8GG6P3K5R2QC53
                retryable: true
                retry_after_seconds: 3600
                correction: Retry in 1 hour. Check /v1/health for the current ingestion status.
                suggested_request:
                  method: GET
                  url: /v1/health
                documentation_url: https://errors.firmadb.com/source-unavailable
                source_status:
                  country: FR
                  last_success_at: '2026-04-25T02:00:00Z'
                  current_status: stale
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            minimum: 0
            example: 60
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: fdb_{env}_{class}_{kid}_{secret}_{check}
      description: "Use this when calling any non-anonymous endpoint. Authenticate with an HTTP\n`Authorization: Bearer <key>` header where `<key>` is a FirmaDB API key in the\nformat `fdb_{env}_{class}_{kid}_{secret}_{check}`.\n\n- `env`   — `live` or `test`. Test keys hit live data, are capped at 1,000\n  calls/day, and never bill. When the daily cap is hit, requests return\n  `429 quota_exhausted` with `limit.bucket: \"test_daily\"`.\n- `class` — `sk` (secret key, full account scope, one per account, server-to-server)\n  or `rk` (restricted key, scoped + constrained, multiple per account, used for\n  specific agents or services).\n- `kid`   — 12 character base62 key identifier, non-secret, used for audit logs.\n- `secret`— 32 random bytes base62-encoded (>=190 bits entropy).\n- `check` — 6 character truncated HMAC-SHA256 checksum used to detect typos before\n  a database lookup.\n\nDoes NOT accept the API key as a query parameter, in a cookie, or in the request\nbody — the `Authorization` header is the only supported transport. Does NOT permit\nsharing keys across agents; provision one restricted key per agent or workload so\nkeys can be scoped, IP-allowlisted, and revoked independently.\n\nExamples:\n  Authorization: Bearer fdb_live_sk_aB12cD34eF56_7Hk2QmRn8wXz4PvY3LjB6Tab5mDf9xKc_a1B2c3\n  Authorization: Bearer fdb_test_rk_xY99zZ00aA11_9Kp3RsUo7yWb2QxZ1NjA8VfC4mEh6tLg_d4E5f6\n"
tags:
- name: Companies
  description: Lookup, search, and batch enrichment of company records.
- name: Countries
  description: Coverage manifest and per-country capability metadata.
- name: Account
  description: Authenticated key usage and quota state.
- name: System
  description: Service health and status checks.
