openapi: 3.1.0
info:
  title: getpeppr API
  version: 1.0.0
  description: |
    Developer-first API gateway for Peppol e-invoices.

    Send JSON, receive Peppol-compliant UBL — getpeppr handles the XML translation,
    validation, and delivery to the Peppol network via Storecove.

    ## Authentication

    All endpoints require a Bearer token in the `Authorization` header.
    API keys follow the format `sk_sandbox_*` (sandbox) or `sk_live_*` (production).

    ```
    Authorization: Bearer sk_sandbox_abc123...
    ```

    ## Rate Limits

    Rate limits are enforced per API key and per account, based on your subscription tier:

    | Tier       | Per Key (req/min) | Per Account (req/min) |
    |------------|-------------------|-----------------------|
    | Sandbox    | 10                | 50                    |
    | Starter    | 60                | 300                   |
    | Pro        | 120               | 600                   |
    | Business   | 300               | 1500                  |

    Validation endpoints (`/validate` and `/validate/server`) use a separate per-key bucket:
    Sandbox 20 req/min, Starter 120 req/min, Pro 240 req/min, Business 600 req/min.
    They remain subject to the account-level safety limit above.

    When rate-limited, the response includes a `Retry-After` header with the number of seconds to wait.

    ## Idempotency

    POST endpoints support an optional `Idempotency-Key` header. If provided, the response
    is cached for 24 hours (scoped to your API key). Replaying the same key returns the
    cached response without re-executing the operation.

    ## Multi-Tenant Isolation

    All resources are scoped to your account. Requesting a resource owned by another account
    returns `404 Not Found` (not `403 Forbidden`) to prevent resource enumeration.

    Within an account, what a key can read follows its type. A **standard key** covers one
    company: your account's own legal entity — read it back at any time with
    `GET /identity`. It never lists or retrieves a sub-tenant's
    invoices, received documents or events — those answer `404 Not Found`, the same as an
    unknown id. A **master key** reads across every sub-tenant it owns.

    Platform endpoints require a master key with the relevant `legal_entities:*` scope.
    Use `POST /legal-entities` to provision sub-tenants, then pass `sender.legalEntityId`
    or `sender.externalSubTenantId` to `POST /invoices` to send on their behalf.

    ## Environments

    Your API key determines the environment:
    - `sk_sandbox_*` keys hit the sandbox environment
    - `sk_live_*` keys hit the production environment
  contact:
    name: getpeppr Support
    url: https://getpeppr.dev
    email: support@getpeppr.dev
  license:
    name: Proprietary

servers:
  - url: https://api.getpeppr.dev/v1
    description: Production

tags:
  - name: Invoices
    description: Create, send, list, and manage Peppol e-invoices.
  - name: Contacts
    description: Manage your address book of clients and providers.
  - name: Bank Accounts
    description: Manage bank accounts for payment information on invoices.
  - name: Directory
    description: Look up participants on the Peppol network.
  - name: Transports
    description: View transport documents and available transport types.
  - name: Validation
    description: Validate invoice data before sending.
  - name: Events
    description: View the event log for your invoices.
  - name: Onboarding
    description: Register your legal entity and Peppol identity before sending invoices.
  - name: Identity
    description: Read your own account's Peppol identity — works with any API key, including standard keys.
  - name: Legal Entities
    description: Provision, list, authorise, and off-board platform sub-tenants.
  - name: Received Documents
    description: |
      Read the Peppol documents received on behalf of your Legal Entities — list,
      detail, and the original UBL exactly as it arrived from the network.

      Reception is also pushed to you in real time as `inbound.*` webhook events.
      These endpoints are the pull counterpart: they let you re-read anything you
      received, including documents whose webhook delivery you did not persist.
  - name: Capabilities
    description: Discover which operations the active provider supports.
  - name: Health
    description: Public health check endpoint for uptime monitoring.
  - name: Newsletter
    description: Public newsletter signup, confirmation, and unsubscribe (no auth)
  - name: Webhooks
    description: |
      Receive real-time notifications about invoice lifecycle and platform sub-tenant lifecycle events.

      ## Setup

      Configure webhook endpoints via the getpeppr dashboard (Settings → Webhooks).
      Each endpoint receives a `whsec_*` secret shown once at creation.

      ## Event Types

      | Event | Description |
      |-------|-------------|
      | `invoice.sent` | Invoice successfully delivered to recipient's access point |
      | `invoice.accepted` | Recipient accepted the invoice |
      | `invoice.refused` | Recipient rejected the invoice |
      | `invoice.error` | Delivery failed (final state) |
      | `invoice.registered` | Cleared by tax authority (e.g., KSA, PT) |
      | `invoice.received` | Receipt acknowledged by recipient (outbound — a document you sent was acknowledged) |
      | `invoice.paid` | Payment confirmed by recipient |
      | `invoice.undeliverable` | Not deliverable — no receiving capability found for the recipient on the Peppol network (final state for the send; payload carries `status: "no_action"`). Also emitted when no delivery evidence has appeared after 7 days: that window is our own policy, not a verdict from the network. |
      | `invoice.delivery_unconfirmed` | No delivery evidence yet for a document we accepted. **Not a failure** — if delivery is confirmed later, `invoice.sent` follows normally and supersedes this. Sent so that silence reaches you from us rather than from your own timer. |
      | `invoice.partially_paid` | Recipient confirmed a partial payment |
      | `invoice.under_query` | Recipient raised a question about the invoice |
      | `invoice.conditionally_accepted` | Recipient accepted the invoice subject to conditions |
      | `invoice.status_changed` | Generic status notification carrying the full per-axis state (`axes`, optional `detail`) — opt-in: never matched by `*`, subscribe explicitly |
      | `legal_entity.registered` | Platform sub-tenant reached a verified or active state |
      | `legal_entity.verification_failed` | Platform sub-tenant registry verification failed |
      | `legal_entity.awaiting_authz` | Platform sub-tenant authorisation email is awaiting customer action |
      | `legal_entity.registration_failed` | Platform sub-tenant identity verified but network (SMP) registration failed |
      | `peppol_identifier.verified` | A Peppol identifier completed registry verification |
      | `peppol_identifier.verification_failed` | A Peppol identifier failed registry verification |
      | `test.ping` | Test event sent during endpoint setup |
      | `inbound.invoice.received` | An invoice addressed to your Legal Entity was received from the Peppol network (pilot) |
      | `inbound.creditnote.received` | A credit note addressed to your Legal Entity was received from the Peppol network (pilot) |
      | `*` | Wildcard — subscribes to all events except `invoice.status_changed` (opt-in only) |

      > **Stable event ids:** status events for sent documents (`invoice.accepted`, `invoice.paid`,
      > `invoice.status_changed`, …) carry a deterministic `id` derived from the underlying provider
      > occurrence — a provider redelivery reproduces the same `id`. Deliveries are at-least-once:
      > deduplicate by event `id` on your side.

      > **Note:** `inbound.invoice.received` and `inbound.creditnote.received` are inbound reception
      > events (a document was sent _to_ your Legal Entity by a third party). They are distinct from
      > the outbound `invoice.received` event (a document _you sent_ was acknowledged by the recipient).
      > Inbound events are a pilot feature — contact support@getpeppr.dev to enable.

      ## Payload Format

      ```json
      {
        "id": "evt_abc123def456",
        "type": "invoice.sent",
        "data": {
          "invoiceId": "8b3f2c1d-4a5e-4f6b-9c8d-7e6f5a4b3c2d",
          "submissionId": "8b3f2c1d-4a5e-4f6b-9c8d-7e6f5a4b3c2d",
          "invoiceNumber": "INV-2026-001",
          "providerDocumentId": "prov_789",
          "status": "delivered",
          "environment": "sandbox"
        },
        "createdAt": "2026-02-26T10:30:00.000Z"
      }
      ```

      ## Signature Verification

      Every request includes a `Getpeppr-Signature` header:

      ```
      Getpeppr-Signature: t=1709000000,s=a1b2c3d4e5...
      ```

      To verify:
      1. Extract `t` (Unix timestamp) and `s` (HMAC hex)
      2. Compute `HMAC-SHA256(secret, "${t}.${rawBody}")` where `rawBody` is the **raw request body string** (do NOT parse and re-serialize — use the bytes as received)
      3. Compare with `s` using constant-time comparison
      4. Reject if timestamp is older than 5 minutes (replay protection)

      ## Retry Policy

      Failed deliveries (non-2xx or timeout after 5s) are retried with exponential backoff:
      - Initial delivery: immediate (synchronous, 5s timeout)
      - Retry 1: after 1 minute
      - Retry 2: after 5 minutes
      - Retry 3: after 30 minutes
      - After 4 total attempts: marked as `failed`

security:
  - bearerAuth: []

paths:
  # ─── Invoices ───────────────────────────────────────────────

  /invoices:
    post:
      operationId: createInvoice
      summary: Create or send an invoice
      description: |
        Creates and optionally sends a Peppol e-invoice. The invoice data is translated
        to UBL XML and forwarded to the Peppol network.

        Platform accounts can send on behalf of a sub-tenant by including `sender`
        with exactly one of `legalEntityId` or `externalSubTenantId`. The sender's
        supplier identity is derived from the verified legal entity; any `from`
        object in the payload is ignored on the send-as path.

        Set `_draft: true` to create a draft without sending. Drafts can be sent later
        via `POST /invoices/send/{id}`.

        Supports idempotency via the `Idempotency-Key` header.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: x-validate-recipient
          in: header
          description: |
            Validate recipient exists on the Peppol network before sending.
            Omit this header to skip validation (default).
            - `warn`: proceeds with sending even if recipient not found (logs for monitoring)
            - `strict`: rejects with 422 if recipient not found
          schema:
            type: string
            enum: [warn, strict]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceInput'
            example:
              number: "INV-2026-001"
              date: "2026-02-26"
              dueDate: "2026-03-28"
              currency: "EUR"
              sender:
                externalSubTenantId: "customer_8412"
              to:
                name: "ACMEDIA"
                peppolId: "0208:0685660237"
                country: "BE"
              lines:
                - description: "API Integration Setup"
                  quantity: 1
                  unitPrice: 500.00
                  vatRate: 21
              buyerReference: "PO-2026-042"
      responses:
        '201':
          description: Invoice created (and sent, unless `_draft` was true)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResult'
        '400':
          description: Invalid request body or missing required fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Missing required fields: number (string), to (object), lines (array)"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Master key is missing the legal_entities:send_as scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Your API key does not have permission to perform this action"
        '404':
          description: Sub-tenant sender was not found or is not owned by this platform
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "not_found"
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: >-
            Unprocessable invoice — recipient not found in the Peppol Directory,
            payload semantically invalid (missing buyer address or recipient
            Peppol ID), or a production send gate failed (identity not
            verified/incomplete, platform billing not active, commercial
            production access expired).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                recipientNotFound:
                  summary: Strict recipient validation failed
                  value:
                    error: "Recipient 0208:0685660237 not found in Peppol Directory. The participant may not be registered on the Peppol network."
                senderNotReady:
                  summary: Sub-tenant send gate failed
                  value:
                    error: "peppol_identity_not_verified"
                    code: "attestation_required"
                    message: "Sub-tenant attestation is required before sending in production."
                    docs: "https://getpeppr.dev/docs/platform/sending-and-webhooks/"
                identityIncomplete:
                  summary: Production identity gate failed
                  value:
                    error: "peppol_identity_incomplete"
                billingNotActive:
                  summary: Platform billing gate failed
                  value:
                    error: "platform_billing_not_active"
                    code: "billing_state_invalid"
                accessExpired:
                  summary: Commercial production access expired
                  value:
                    error: "production_access_expired"
                missingBuyerAddress:
                  summary: Semantic validation failed
                  value:
                    error: "Buyer address is required: street, city, and postalCode must be provided in the 'to' object"
                missingRecipientPeppolId:
                  summary: Missing or malformed recipient Peppol ID
                  value:
                    error: 'Recipient Peppol ID is required: to.peppolId must be provided (format "scheme:id", e.g. "0208:0123456789"). Documents are delivered via the Peppol network only — the contact email is not a delivery channel.'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      operationId: listInvoices
      summary: List invoices
      description: |
        Returns a paginated list of invoices owned by your account. Supports filtering
        by type (issued or received).

        Results are also scoped to the **type** of your API key: a standard key returns
        only the invoices of your own legal entity, while a master key also returns those
        sent on behalf of the sub-tenants it owns.
      tags: [Invoices]
      parameters:
        - name: type
          in: query
          description: Filter by invoice type
          schema:
            type: string
            enum: [issued, received]
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Paginated list of invoices
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoices:
                    type: array
                    items:
                      $ref: '#/components/schemas/InvoiceSummary'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /invoices/{id}:
    get:
      operationId: getInvoice
      summary: Get invoice status
      description: |
        Returns the current status and details of an invoice. The invoice must be
        owned by your account.

        **Both identifiers resolve to the same document.** You can pass the
        provider document GUID (returned in the `id` field of `POST /invoices`)
        or the getpeppr submission ID (the `id` field of `GET /invoices`, and the
        `invoiceId` field of webhook payloads); they are looked up in that order.
        An id you do not own answers `404 Not Found` either way — as does a
        document whose registration did not complete, whichever identifier you
        use for it.

        A standard key can only retrieve documents belonging to your own legal entity;
        an invoice sent on behalf of a sub-tenant answers `404 Not Found`,
        indistinguishable from an unknown id. Use a master key to retrieve it.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/InvoiceId'
        - name: include
          in: query
          required: false
          description: >-
            Comma-separated list of optional enrichments. `evidence` adds
            `peppolMessageId` by reading the sending evidence from the Peppol
            network. It is opt-in because it costs a provider round trip, and it
            degrades silently: if the document has not gone out yet, or the read
            fails, the field is simply absent and the rest of the response is
            unaffected. Unknown values are ignored.
          schema:
            type: string
            example: evidence
      responses:
        '200':
          description: Invoice status and details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvoiceStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Invoice not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invoice not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

    put:
      operationId: updateInvoice
      summary: Update a draft invoice
      description: |
        Updates an existing invoice. The invoice must be owned by your account.
        Typically used to modify draft invoices before sending.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/InvoiceId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceInput'
      responses:
        '200':
          description: Invoice updated
          content:
            application/json:
              schema:
                type: object
                description: Updated invoice data from the provider
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Invoice not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invoice not found"
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      operationId: deleteInvoice
      summary: Delete an invoice
      description: |
        Permanently deletes an invoice. The invoice must be owned by your account.
        The resource ownership record is also removed.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/InvoiceId'
      responses:
        '200':
          description: Invoice deleted
          content:
            application/json:
              schema:
                type: object
                description: Deletion result from the provider
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Invoice not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invoice not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /invoices/{id}/ack:
    post:
      operationId: acknowledgeInvoice
      summary: Acknowledge receipt of an invoice
      description: |
        Acknowledges receipt of a received invoice. This updates the invoice status
        on the Peppol network.

        Supports idempotency via the `Idempotency-Key` header.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/InvoiceId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Invoice acknowledged successfully
          content:
            application/json:
              schema:
                type: object
                description: Acknowledgement result from the provider
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Invoice not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invoice not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /invoices/{id}/as/{format}:
    get:
      operationId: getInvoiceAs
      summary: Download invoice in a specific format
      description: |
        Downloads the invoice in the specified format. Returns the file content
        with appropriate `Content-Type` and `Content-Disposition` headers.

        A standard key can only download documents belonging to your own legal entity;
        an invoice sent on behalf of a sub-tenant answers `404 Not Found`,
        indistinguishable from an unknown id. Use a master key to download it.

        **Timing.** For a short window after `POST /invoices` returns, this
        endpoint answers `404 Not Found` on a document that is perfectly healthy:
        the stored copy is still being registered. Retry rather than treating the
        first 404 as a failure — no bound on that window is guaranteed, so poll
        rather than assuming a single retry is enough.

        ⚠️ A `200` here does **not** mean the document was delivered. The stored
        copy is available well before the receiving access point has signed for
        it, so this endpoint says nothing about delivery. Read `GET
        /invoices/{id}` for the status, or subscribe to `invoice.sent`.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/InvoiceId'
        - name: format
          in: path
          required: true
          description: >-
            Output format for the invoice. `payload` returns the business
            document with the Peppol SBDH envelope removed, for callers who
            supplied their own UBL and want to inspect what was transmitted.
            It is the document **as transmitted**, not the bytes you sent: the
            network re-serialises in transit, so byte equality with your own
            file is not guaranteed. Seal a canonical form (C14N) if you need to
            compare across the boundary.
          schema:
            type: string
            enum: [pdf, xml.ubl.invoice.bis3, xml.facturae.3.2, original, payload]
      responses:
        '200':
          description: Invoice file content
          headers:
            Content-Disposition:
              description: Suggested filename for the download
              schema:
                type: string
              example: 'inline; filename="invoice-12345.pdf"'
          content:
            application/pdf:
              schema:
                type: string
                format: binary
            application/xml:
              schema:
                type: string
                format: binary
        '400':
          description: Invalid format specified
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: 'Invalid format "docx". Valid formats: pdf, xml.ubl.invoice.bis3, xml.facturae.3.2, original'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Invoice not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invoice not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /invoices/{id}/mark-as:
    post:
      operationId: markInvoiceAs
      summary: Change invoice status
      description: |
        Manually changes the status of an invoice on the provider side.
        Useful for marking invoices as paid, accepted, or cancelled outside
        of the normal Peppol delivery flow.

        When the seller operates under a jurisdiction e-reporting mandate
        (e.g. the French private pilot), marking an invoice as `paid` also
        files the regulatory payment notification through the provider.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/InvoiceId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state]
              properties:
                state:
                  type: string
                  enum: [draft, sending, sent, received, accepted, paid, refused, cancelled, corrected]
                  description: Target state for the invoice
                commit:
                  type: boolean
                  description: Whether to commit the state change on the provider
                reason:
                  type: string
                  description: Reason for the state change (optional)
            example:
              state: "paid"
      responses:
        '200':
          description: Invoice status updated
          content:
            application/json:
              schema:
                type: object
                description: State change result from the provider
        '400':
          description: Invalid state or missing required field
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing:
                  summary: Missing state field
                  value:
                    error: "Missing required field: state (string)"
                invalid:
                  summary: Invalid state value
                  value:
                    error: 'Invalid state "archived". Must be one of: draft, sending, sent, received, accepted, paid, refused, cancelled, corrected'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Invoice not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invoice not found"
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: >-
            State change rejected by the provider or by a jurisdiction
            mandate gate (error passed through).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /invoices/import:
    post:
      operationId: importInvoice
      summary: Import an invoice from a file
      description: |
        Imports a UBL Invoice or CreditNote from a base64-encoded file and sends it
        on the Peppol network. **getpeppr does not regenerate, normalise, or repair
        your document** — we forward the bytes you supplied, unchanged, to the
        network.

        **Byte-for-byte equality is not guaranteed**, because the network
        re-serialises the document in transit. Measured 2026-08-18 on a test
        document: namespace declarations came back reordered, numeric character
        references were resolved (`&#65;` became `A`), whitespace inside tags was
        dropped, and no element was added, removed or altered. That is one
        document and four kinds of difference, so treat it as indicative rather
        than as a warranty of what is preserved.

        If you seal your documents, hash a canonical form (C14N) rather than the
        raw bytes.

        Before transmission the document is validated against the complete official
        OpenPeppol rulebooks. A document violating any `fatal` rule is refused with a
        terminal `422` naming the rule, and is **not sent** — retrying the same
        document will fail identically. Send `x-skip-validation: true` to bypass
        validation for one call.

        Two size caps apply, and the smaller one decides. The request body is
        capped at 4 MiB, checked against the `Content-Length` you declare; the
        decoded document is capped at 4 MiB as well, checked on the bytes
        themselves. Because the document travels base64-encoded inside JSON,
        which inflates it by about a third, the body cap is what you hit first:
        the largest document that fits is roughly 3 MB.

        Supports idempotency via the `Idempotency-Key` header.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: x-skip-validation
          in: header
          required: false
          description: |
            Set to `true` to send the document without validating it. The escape
            hatch against a false positive of ours: being stricter than the
            network is the symmetric error of a silent death, and the worse of
            the two. Any other value, and the absence of the header, validate
            normally.

            Each use is logged, at the level of the account and nothing finer:
            when the opt-out is set no validation runs at all, so no rule fires
            and none can be named. The signal is coarse on purpose — an account
            opting out repeatedly is one worth a conversation, not a rule we can
            point at.
          schema:
            type: string
            enum: ['true']
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ImportInvoiceRequest'
            example:
              file: "PD94bWwgdmVyc2lvbj0iMS4wIj8+..."
              filename: "invoice-2026-001.xml"
              to:
                peppolId: "0208:0685660237"
      responses:
        '201':
          description: |
            Invoice imported and sent.

            The body is the standard send result plus two keys. `rulebook` names
            the rulebook release the document was judged against; it is present
            whenever validation actually ran, and absent when it did not — that
            is, when the call carried `x-skip-validation: true`. Nothing was
            judged in that case, so naming a rulebook would claim a verdict that
            was never reached.

            `transmission` states what getpeppr does **not** guarantee about
            your bytes, and this endpoint sends it on every `201` it produces —
            not on a body replayed from an `Idempotency-Key` cached before the
            field existed, which is why it is not declared `required`. getpeppr
            forwards the document you supplied unchanged, but the Peppol network
            re-serialises it in transit, so byte-for-byte equality is not
            something to rely on — seal a canonical form (C14N) rather than raw
            bytes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportInvoiceResult'
        '400':
          description: |
            Invalid request body or missing required fields.

            Two machine codes are returned here: `invalid_base64` (the `file`
            field does not decode to exactly the bytes it announces — getpeppr
            never repairs your document, so a lossy decode is refused rather than
            patched) and `missing_recipient` (`to.peppolId` is absent; it is
            never read from the document, because routing is what decides
            delivery and the document is payload).

            The remaining `400`s on this route are older refusals that carry a
            human sentence in `error` rather than a code — a missing `file` or
            `filename`, or a body that is not JSON. Match on the code when there
            is one; do not parse the sentences.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "invalid_base64"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: |
            The document was NOT sent. Two unrelated families answer with this
            status, and **they do not retry the same way** — do not treat them
            alike.

            **Document rejected** (`validation_failed`, `not_ubl_document`,
            `document_too_complex`, `undecodable_document`,
            `unsupported_encoding`) is **terminal**: the same bytes fail
            identically forever, and a retry is pure waste. Fix the document —
            `validation_failed` names the rule that refused it.

            **Account may not send right now** (`peppol_identity_incomplete`,
            `peppol_identity_not_verified`, `platform_billing_not_active`,
            `production_access_expired`) is **not terminal**: it describes
            account state, and account state changes — a verification completes,
            a contract is activated. The identical document goes through once it
            does.

            They are fixed in
            different places. **The account is asked first**: a document is never
            judged on behalf of an account that may not send at all.

            *The account may not send* — `peppol_identity_incomplete` (no
            production Peppol identifier registered),
            `peppol_identity_not_verified` (an identifier exists but is not in a
            send-allowed state, or carries an active flag),
            `platform_billing_not_active` (the platform contract is suspended or
            over its sub-tenant cap), `production_access_expired` (a
            complimentary grant has lapsed). Production only — sandbox is exempt
            from all four. These carry a `code` and often a `docs` link, never a
            `rulebook`.

            *The document is refused* — `undecodable_document` (the decoded
            bytes are not valid UTF-8), `unsupported_encoding` (the XML
            declaration announces an encoding other than UTF-8, contradicting
            its own bytes), `unreadable_document` (number, issue date, currency,
            customer name or country cannot be read from it),
            `malformed_xml` (not accepted as XML, or it carries a document type
            declaration), `not_ubl_document` (well-formed XML, but not a UBL
            Invoice or CreditNote — a PDF renamed `.xml` lands here),
            `document_too_complex` (the structure is too large to validate at
            bounded cost; split it, or send it with `x-skip-validation: true` if
            you have validated it yourself), `validation_failed` (at least one fatal Peppol rule is
            violated; fix the document, or set `x-skip-validation: true` to
            bypass validation for this call), `supplier_identity_not_owned` (the
            document is issued by a Peppol identifier this account has not
            registered), `supplier_identity_ambiguous` (the document names no
            supplier endpoint and the account holds none, or holds several).
            Only `validation_failed` carries `rulebook` and `violations`.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                    enum:
                      - peppol_identity_incomplete
                      - peppol_identity_not_verified
                      - platform_billing_not_active
                      - production_access_expired
                      - undecodable_document
                      - unsupported_encoding
                      - unreadable_document
                      - malformed_xml
                      - not_ubl_document
                      - document_too_complex
                      - validation_failed
                      - supplier_identity_not_owned
                      - supplier_identity_ambiguous
                  code:
                    type: string
                    description: |
                      Narrower machine reason, on the account-gate family only.
                  message: { type: string }
                  docs:
                    type: string
                    format: uri
                    description: |
                      Where to go to clear the gate, on the account-gate family only.
                  rulebook:
                    $ref: '#/components/schemas/UblValidationVerdict/properties/rulebook'
                  violations:
                    $ref: '#/components/schemas/UblValidationVerdict/properties/violations'
        '413':
          description: |
            Request body exceeds the 4 MB cap. Because the document is
            base64-encoded inside JSON, this is reached at roughly 3 MB of
            document.

            The cap is enforced twice, and the two answers differ. A declared
            `Content-Length` over the cap is refused up front with a human
            sentence in `error`. A request that under-declares or omits it is
            refused on the DECODED bytes with the machine code
            `payload_too_large` — the only cap that actually holds, since
            nothing obliges a caller to declare its size honestly.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "payload_too_large"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: |
            The document was NOT sent.

            `validation_unavailable` is a deliberate fail-closed refusal, not a
            crash: the validator could not run, so getpeppr declines to send a
            document it was unable to check rather than letting it through
            unverified. It is safe to retry. Any other `500` carries a human
            sentence and is an ordinary internal error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "validation_unavailable"

  /invoices/send/{id}:
    post:
      operationId: sendInvoice
      summary: Send a draft invoice
      description: |
        Sends a previously created draft invoice to the Peppol network.
        The invoice must have been created with `_draft: true`.

        Returns `204 No Content` on success.

        Supports idempotency via the `Idempotency-Key` header.
      tags: [Invoices]
      parameters:
        - $ref: '#/components/parameters/InvoiceId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: Invoice sent successfully (no content)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Invoice not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invoice not found"
        '422':
          description: >-
            Production send gate failed — Peppol identity not verified,
            platform billing not active, or commercial production access
            expired. Same gates as POST /invoices.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                identityNotVerified:
                  value:
                    error: "peppol_identity_not_verified"
                    code: "verification_pending"
                billingNotActive:
                  value:
                    error: "platform_billing_not_active"
                    code: "billing_state_invalid"
                accessExpired:
                  value:
                    error: "production_access_expired"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Contacts ───────────────────────────────────────────────

  /contacts:
    get:
      operationId: listContacts
      summary: List contacts
      description: |
        Returns a paginated list of contacts (clients and providers) in your address book.
        Supports filtering by name, client status, and provider status.
      tags: [Contacts]
      parameters:
        - name: name
          in: query
          description: Filter contacts by name (case-insensitive partial match)
          schema:
            type: string
        - name: isClient
          in: query
          description: Filter by client status
          schema:
            type: boolean
        - name: isProvider
          in: query
          description: Filter by provider/supplier status
          schema:
            type: boolean
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Paginated list of contacts
          content:
            application/json:
              schema:
                type: object
                properties:
                  contacts:
                    type: array
                    items:
                      $ref: '#/components/schemas/Contact'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      operationId: createContact
      summary: Create a contact
      description: |
        Creates a new contact in your address book. The `name` field is required.

        Supports idempotency via the `Idempotency-Key` header.
      tags: [Contacts]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactInput'
            example:
              name: "ACMEDIA"
              peppolId: "0208:0685660237"
              vatNumber: "BE0685660237"
              country: "BE"
              isClient: true
      responses:
        '201':
          description: Contact created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          description: Invalid request body or missing required fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Missing required field: name (string)"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /contacts/{id}:
    get:
      operationId: getContact
      summary: Get a contact
      description: Returns the details of a specific contact owned by your account.
      tags: [Contacts]
      parameters:
        - $ref: '#/components/parameters/ContactId'
      responses:
        '200':
          description: Contact details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Contact not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Contact not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    put:
      operationId: updateContact
      summary: Update a contact
      description: >-
        Partially updates an existing contact: only the fields present in the
        body are overwritten, omitted fields keep their current value.
      tags: [Contacts]
      parameters:
        - $ref: '#/components/parameters/ContactId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactUpdate'
      responses:
        '200':
          description: Contact updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Contact not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Contact not found"
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      operationId: deleteContact
      summary: Delete a contact
      description: Permanently deletes a contact from your address book.
      tags: [Contacts]
      parameters:
        - $ref: '#/components/parameters/ContactId'
      responses:
        '204':
          description: Contact deleted (no content)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Contact not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Contact not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Bank Accounts ─────────────────────────────────────────

  /bank-accounts:
    get:
      operationId: listBankAccounts
      summary: List bank accounts
      description: Returns a paginated list of bank accounts owned by your account.
      tags: [Bank Accounts]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Paginated list of bank accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  bankAccounts:
                    type: array
                    items:
                      $ref: '#/components/schemas/BankAccount'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      operationId: createBankAccount
      summary: Create a bank account
      description: |
        Creates a new bank account. The `name` field is required.

        Supports idempotency via the `Idempotency-Key` header.
      tags: [Bank Accounts]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BankAccountInput'
            example:
              name: "Main EUR Account"
              type: "iban"
              iban: "BE68539007547034"
              bic: "BBRUBEBB"
              country: "BE"
      responses:
        '201':
          description: Bank account created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BankAccount'
        '400':
          description: Invalid request body or missing required fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Missing required field: name (string)"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /bank-accounts/{id}:
    get:
      operationId: getBankAccount
      summary: Get a bank account
      description: Returns the details of a specific bank account owned by your account.
      tags: [Bank Accounts]
      parameters:
        - $ref: '#/components/parameters/BankAccountId'
      responses:
        '200':
          description: Bank account details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BankAccount'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Bank account not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Bank account not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    put:
      operationId: updateBankAccount
      summary: Update a bank account
      description: >-
        Partially updates an existing bank account: only the fields present in
        the body are overwritten, omitted fields keep their current value.
      tags: [Bank Accounts]
      parameters:
        - $ref: '#/components/parameters/BankAccountId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BankAccountUpdate'
      responses:
        '200':
          description: Bank account updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BankAccount'
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Bank account not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Bank account not found"
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      operationId: deleteBankAccount
      summary: Delete a bank account
      description: Permanently deletes a bank account.
      tags: [Bank Accounts]
      parameters:
        - $ref: '#/components/parameters/BankAccountId'
      responses:
        '204':
          description: Bank account deleted (no content)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Bank account not found or not owned by your account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Bank account not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Directory ──────────────────────────────────────────────

  /directory/search:
    get:
      tags:
        - Directory
      summary: Search the Peppol Directory
      description: |
        Search for participants in the Peppol Directory by name, country, or VAT number.
        At least one search criterion is required. Name searches require a minimum of 3 characters.
        Results are cached for 1 hour.

        **Rate limit:** 30 requests per minute per API key (shared with the participant lookup),
        in addition to your plan's global rate limit.
      operationId: searchDirectory
      parameters:
        - name: name
          in: query
          description: Business name to search (min 3 characters)
          schema:
            type: string
            minLength: 3
          example: "Acme"
        - name: country
          in: query
          description: Country filter (ISO 3166-1 alpha-2, uppercase)
          schema:
            type: string
            pattern: "^[A-Z]{2}$"
          example: "BE"
        - name: vatNumber
          in: query
          description: VAT number to search (country prefix is auto-stripped)
          schema:
            type: string
          example: "BE0685660237"
        - name: limit
          in: query
          description: Max results per page (default 20, max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          description: Offset for pagination (default 0)
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DirectoryEntry'
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      offset:
                        type: integer
                      limit:
                        type: integer
                      has_more:
                        type: boolean
        '400':
          description: Missing search criteria, name too short, or invalid params
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
      security:
        - bearerAuth: []

  /directory/{scheme}/{participantId}:
    get:
      operationId: lookupDirectory
      summary: Look up a Peppol participant
      description: |
        Look up a Peppol participant by their scheme and identifier.
        Returns enriched data from the Peppol Directory including business name, country,
        capabilities, registration date, VAT number, and contact info.
        Falls back to basic registration check if the Peppol Directory is unavailable.
        Results are cached for 24 hours.

        Two URL formats are supported:
        - `/directory/{scheme}/{id}` (e.g., `/directory/0208/0685660237`)
        - `/directory/{scheme}:{id}` (e.g., `/directory/0208:0685660237`)

        Directory lookups have an additional rate limit of 30 requests per minute per API key.
      tags: [Directory]
      parameters:
        - name: scheme
          in: path
          required: true
          description: Peppol participant scheme (e.g., "0208" for Belgian BCE)
          schema:
            type: string
          example: "0208"
        - name: participantId
          in: path
          required: true
          description: Participant identifier within the scheme
          schema:
            type: string
          example: "0685660237"
      responses:
        '200':
          description: Participant found in the Peppol directory
          content:
            application/json:
              schema:
                type: object
                properties:
                  participant:
                    type: object
                    properties:
                      registered:
                        type: boolean
                        description: Whether the participant is registered on Peppol
                      scheme:
                        type: string
                        description: Peppol scheme code
                        example: "0208"
                      id:
                        type: string
                        description: Participant identifier within the scheme
                        example: "0685660237"
                      name:
                        type: string
                      country:
                        type: string
                      capabilities:
                        type: array
                        items:
                          type: string
                      registrationDate:
                        type: string
                      vatNumber:
                        type: string
                      additionalIds:
                        type: array
                        items:
                          type: object
                          properties:
                            scheme:
                              type: string
                            value:
                              type: string
                      contactInfo:
                        type: object
                        properties:
                          name:
                            type: string
                          email:
                            type: string
                          phone:
                            type: string
                      website:
                        type: string
              example:
                participant:
                  registered: true
                  scheme: "0208"
                  id: "0685660237"
                  name: "ACMEDIA"
                  country: "BE"
                  capabilities:
                    - "invoice"
                    - "credit_note"
                  registrationDate: "2020-11-20"
        '404':
          description: Participant not found in the Peppol network
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Peppol participant 0208:0755752134 not found in the directory"
        '400':
          description: Invalid Peppol ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invalid Peppol ID format. Expected: /directory/{scheme}/{id} or /directory/{scheme}:{id}"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Transports ─────────────────────────────────────────────

  /transports:
    get:
      operationId: listTransports
      summary: List transports
      description: |
        Returns the list of active transports. Currently, only the Peppol AS4 transport
        (via Storecove) is available. Transport routing is managed automatically —
        write operations (POST, PUT, DELETE) return `405 Method Not Allowed`.
      tags: [Transports]
      responses:
        '200':
          description: List of transports
          content:
            application/json:
              schema:
                type: object
                properties:
                  transports:
                    type: array
                    items:
                      $ref: '#/components/schemas/Transport'
              example:
                transports:
                  - id: "peppol"
                    transportTypeCode: "peppol"
                    name: "Peppol AS4 (Storecove)"
                    status: "active"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /transports/{code}:
    get:
      operationId: getTransport
      summary: Get a transport by code
      description: |
        Returns details of a specific transport. Currently only `peppol` is a valid code.
      tags: [Transports]
      parameters:
        - name: code
          in: path
          required: true
          description: Transport code (e.g., "peppol")
          schema:
            type: string
          example: "peppol"
      responses:
        '200':
          description: Transport details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transport'
              example:
                id: "peppol"
                transportTypeCode: "peppol"
                name: "Peppol AS4 (Storecove)"
                status: "active"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Transport not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Transport not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /transports/types:
    get:
      operationId: listTransportTypes
      summary: List transport types
      description: |
        Returns the list of available transport types. Currently a single
        static entry: Peppol BIS 3.0 (routing is handled automatically by the
        network provider).
      tags: [Transports]
      responses:
        '200':
          description: List of transport types
          content:
            application/json:
              schema:
                type: object
                properties:
                  transportTypes:
                    type: array
                    items:
                      $ref: '#/components/schemas/TransportType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Validation ─────────────────────────────────────────────

  /validate:
    post:
      operationId: validateInvoice
      summary: Validate invoice data (client-side)
      description: |
        Performs client-side validation of invoice data without sending it.
        Checks required fields, line item completeness, and basic format rules.

        This is a fast, local validation. For gateway-side UBL generation checks
        and offline Peppol business-rule validation, use `POST /validate/server`.
      tags: [Validation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceInput'
      responses:
        '200':
          description: Validation result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClientValidationResult'
              example:
                valid: false
                errors:
                  - "Customer country (to.country) is required"
                  - "Line 1: unitPrice is required"
        '400':
          description: Invalid JSON body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /validate/server:
    post:
      operationId: validateInvoiceServer
      summary: Validate invoice data (server-side)
      description: |
        Performs server-side pre-flight validation in the getpeppr gateway without sending
        the invoice to Storecove. The invoice data is checked with the same SDK validation
        engine used by the CLI, converted to UBL XML as a format sanity check, and evaluated
        against getpeppr's offline Peppol business-rule validator.

        This is slower than client-side validation but catches Peppol-specific compliance issues.
        Validation failures return `200` with `valid: false` so SDK clients can inspect all
        errors without exception handling; transport, auth, rate-limit, and malformed-request
        failures still use non-2xx HTTP statuses.

        Embedded attachment content is rejected before UBL generation when the decoded content
        exceeds 2 MB for a single attachment.
      tags: [Validation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceInput'
      responses:
        '200':
          description: Server validation result with SDK and Peppol business-rule details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerValidationResult'
              example:
                valid: false
                errors:
                  - field: "to.street"
                    message: "Street address is required for the buyer"
                    ruleId: "BR-50"
                    suggestion: "Add to.street, to.city, and to.postalCode for Peppol BG-8 compliance."
                warnings: []
                ubl:
                  valid: true
                  errors: []
                xsd:
                  valid: true
                  errors: []
                  note: "Deprecated compatibility field. This endpoint verifies UBL XML generation but does not run a standalone XSD validator."
                schematron:
                  valid: false
                  coverage:
                    rulesChecked: 18
                    ofNetworkFatalRules: "partial"
                  errors:
                    - severity: "error"
                      message: "Endpoint ID must use a valid Peppol participant identifier."
                      location: "to.peppolId"
                      ruleId: "PEPPOL-EN16931-R020"
                  warnings: []
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Missing required fields: number, to, and lines are required"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /validate/ubl:
    post:
      operationId: validateUbl
      summary: Validate a UBL document you produced yourself
      description: |
        Runs the complete official OpenPeppol rulebooks against a UBL document you
        produced yourself. Unlike `/validate/server`, which judges getpeppr's own JSON
        invoice model, this endpoint judges **the XML itself**.

        The document is never modified, never repaired, and never sent. Every
        verdict — that is, every `200`, conformant or not — names the rulebook it
        was judged against, so you can tell when getpeppr has fallen behind the
        network. The refusals do not: a `400`, `413`, `429` or `500` means no
        document was judged, so there is no rulebook to name.

        Both official layers are applied — the OpenPeppol rules and the CEN EN 16931
        rules — plus whichever national family your document falls into. Only rules
        flagged `fatal` set `conformant` to false; `warning` findings are reported for
        information and never block a send.
      tags: [Validation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: byte
                  description: |
                    The UBL document, base64-encoded. The request body is capped
                    at 4 MB; base64 inflates the document by about a third, so
                    the largest document that fits is roughly 3 MB.
      responses:
        '200':
          description: |
            Validation verdict. A `200` does NOT mean the document is conformant —
            read the `conformant` field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UblValidationVerdict'
        '400':
          description: |
            Nothing was judged. Every refusal here describes your request or your
            document, never a fault on our side, and each carries a machine code
            in `error`:

            `invalid_json` (the request body is not JSON) · `missing_file` (no
            `file` field, or it is not a string) · `invalid_base64` (the `file`
            field does not decode to exactly the bytes it announces) ·
            `undecodable_document` (the decoded bytes are not valid UTF-8) ·
            `unsupported_encoding` (the XML declaration announces an encoding
            other than UTF-8, contradicting its own bytes) ·
            `doctype_not_allowed` (the document carries a document type
            declaration — the only door to a custom entity, refused before any
            parser sees it) · `malformed_xml` (not well-formed XML) ·
            `not_ubl_document` (well-formed, but not a UBL Invoice or CreditNote
            — a PDF renamed `.xml` lands here) · `document_too_complex` (the
            structure is too large to validate at bounded cost; split it into
            smaller documents).

            The three decoding refusals are **not repairs declined, they are
            repairs never attempted**: a base64 decode silently drops characters
            outside its alphabet and a UTF-8 decode replaces invalid octets with
            U+FFFD, so a lenient reader would judge a document that differs from
            the one you sent and report a verdict about neither. We refuse
            instead. The same discipline, and the same three codes, apply on
            `POST /invoices/import`, where the stakes are higher still: there the
            bytes we check are the bytes we transmit.

            The same codes appear on `POST /invoices/import` as `422`, because
            there they refuse a **send**. Here nothing is being sent, so a
            document we cannot judge is an ordinary bad request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "not_ubl_document"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          description: |
            The decoded document exceeds the 4 MB cap, answered with the machine
            code `payload_too_large`. The cap is measured on the DECODED bytes,
            not on a declared `Content-Length` — nothing obliges a caller to
            declare its size honestly.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "payload_too_large"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: |
            `validation_unavailable` — validation is temporarily unavailable. The
            document was NOT validated. getpeppr fails closed rather than
            reporting a verdict it could not compute, so this is the one answer
            here that is about us and not about your document. It is safe to
            retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "validation_unavailable"
  # ─── Events ─────────────────────────────────────────────────

  /events:
    get:
      operationId: listEvents
      summary: List events
      description: |
        Returns a paginated event log for your account. Events include state changes,
        delivery confirmations, inbound reception, and other lifecycle events.

        Events are scoped to your account **and to the environment of the API key**
        used to make the request — a sandbox key returns only sandbox events, a
        production key only production events.

        They are also scoped to the **type** of the key: with a standard key, events
        attached to a sub-tenant's document are omitted, while a master key sees the
        activity of every sub-tenant it owns. Events that refer to no document —
        validation calls, directory lookups — are account activity and are always
        returned.
      tags: [Events]
      parameters:
        - name: invoiceId
          in: query
          description: >-
            Filter events by invoice ID. Accepts either the provider document
            GUID or the getpeppr submission ID (the `id` field of GET /invoices,
            and the `invoiceId` field of webhook payloads). An unknown or unowned
            ID simply yields an empty list (no 404).
          schema:
            type: string
        - name: dateFrom
          in: query
          description: Filter events from this date (ISO 8601)
          schema:
            type: string
            format: date
          example: "2026-02-01"
        - name: dateTo
          in: query
          description: Filter events until this date (ISO 8601)
          schema:
            type: string
            format: date
          example: "2026-02-28"
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Paginated list of events
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/EventEntry'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '400':
          description: Malformed date filter
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "dateFrom must be in YYYY-MM-DD format"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Received Documents ─────────────────────────────────────

  /received-documents:
    get:
      operationId: listReceivedDocuments
      summary: List received documents
      description: |
        Returns a paginated list of the Peppol documents received for your
        account, newest first.

        Results are scoped to your account **and to the environment of the API
        key** used to make the request — a sandbox key returns only sandbox
        documents, a production key only production ones. The environment is
        never taken from a query parameter.

        They are also scoped to the **type** of the key: a standard key returns only
        the documents addressed to your own legal entity, while a master key returns
        those of every sub-tenant it owns.
      tags: [Received Documents]
      parameters:
        - name: legalEntityId
          in: query
          description: >-
            Return only the documents addressed to this Legal Entity. An unknown
            or unowned id simply yields an empty list (no 404); a value that is
            not a UUID is rejected with 400.
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Paginated list of received documents
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReceivedDocument'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '400':
          description: Malformed `legalEntityId`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "legalEntityId must be a UUID"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /received-documents/{id}:
    get:
      operationId: getReceivedDocument
      summary: Get a received document
      description: |
        Returns one received document.

        An unknown id, a document belonging to another account, and a document
        from the other environment are indistinguishable: all three answer
        `404`. This is deliberate — a `403` would confirm that the document
        exists. A sub-tenant's document read with a standard key answers `404`
        for the same reason — use a master key to retrieve it.
      tags: [Received Documents]
      parameters:
        - name: id
          in: path
          required: true
          description: The received document id.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The received document
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReceivedDocument'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No such document is visible to this API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /received-documents/{id}/as/xml:
    get:
      operationId: getReceivedDocumentXml
      summary: Download the original UBL
      description: |
        Returns the document exactly as it arrived from the Peppol network,
        byte for byte. The stored bytes are the archive — getpeppr applies no
        transformation and no re-rendering.

        The guarantee is about our custody, not about the whole journey: what
        arrives may already have been re-serialised upstream, as documents sent
        through getpeppr are. What you download is byte-identical to what reached
        us, never a re-rendering of it.

        This is the artifact with evidential value: the JSON representation
        returned by the other endpoints is our reading of the document, not the
        document itself.

        A standard key can only download documents addressed to your own legal
        entity; a sub-tenant's document answers `404 Not Found`, indistinguishable
        from an unknown id. Use a master key to download it.
      tags: [Received Documents]
      parameters:
        - name: id
          in: path
          required: true
          description: The received document id.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: |
            The original UBL. `Content-Disposition` names the file after the
            sender's document number, reduced to `[A-Za-z0-9_.-]`, falling back
            to the document id.
          content:
            application/xml:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No such document is visible to this API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Onboarding ─────────────────────────────────────────────

  /onboarding/legal-entity:
    post:
      operationId: registerLegalEntity
      summary: Register a legal entity for Peppol
      description: |
        Registers (or retrieves) a Storecove Legal Entity linked to your account,
        and optionally registers a Peppol Identifier on it.

        This is the first step before sending invoices — your account must have a
        Legal Entity with at least one Peppol ID to send documents on the Peppol network.

        If a Legal Entity already exists for this account and environment, it is returned
        (idempotent — `200` instead of `201`).

        **Production keys:** Peppol Identifier registration is dashboard-only —
        passing `peppolScheme`/`peppolIdentifier` with a production key is
        rejected with `400 production_peppol_identifier_requires_dashboard`
        before any side effect.
      tags: [Onboarding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partyName, line1, city, zip, country]
              properties:
                partyName:
                  type: string
                  description: Company name (2-64 characters)
                  example: "ACMEDIA"
                line1:
                  type: string
                  description: Street address (2-192 characters)
                  example: "Rue de la Loi 42"
                city:
                  type: string
                  description: City (2-64 characters)
                  example: "Brussels"
                zip:
                  type: string
                  description: Postal code (2-32 characters)
                  example: "1000"
                country:
                  type: string
                  description: ISO 3166-1 alpha-2 country code (2 characters)
                  example: "BE"
                peppolScheme:
                  type: string
                  description: 'Peppol scheme code (e.g., "0208" for Belgian BCE). Max 10 characters.'
                  example: "0208"
                peppolIdentifier:
                  type: string
                  description: 'Peppol participant identifier within the scheme. Max 64 characters.'
                  example: "0685660237"
            example:
              partyName: "ACMEDIA"
              line1: "Rue de la Loi 42"
              city: "Brussels"
              zip: "1000"
              country: "BE"
              peppolScheme: "0208"
              peppolIdentifier: "0685660237"
      responses:
        '200':
          description: Legal entity already existed for this account/environment (idempotent)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingResult'
        '201':
          description: Legal entity created and registered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingResult'
        '400':
          description: Missing or invalid fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missingField:
                  summary: Missing required field
                  value:
                    error: "partyName is required and must be a string"
                validation:
                  summary: Length validation failure
                  value:
                    error: "Validation failed"
                    details: ["partyName must be at least 2 characters"]
                productionIdentifier:
                  summary: Production Peppol identifier requires the dashboard
                  value:
                    error: "production_peppol_identifier_requires_dashboard"
                    code: "production_peppol_identifier_requires_dashboard"
                    message: "Production Peppol Identifier registration requires dashboard attestation. Add the identifier from the dashboard Peppol identity page."
                    docs: "https://getpeppr.dev/docs/onboarding/verification"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Identity ───────────────────────────────────────────────

  /identity:
    get:
      operationId: getIdentity
      summary: Read your own Peppol identity
      description: |
        Returns the legal entity and Peppol identifiers registered for your own
        account, in the environment your API key targets.

        Works with any API key, including standard keys. This is how you read
        YOUR OWN account's Peppol identity — `/legal-entities` is the platform
        sub-tenant surface and requires a master key. If all you need is your
        own company's registration and identifiers, call this endpoint; there
        is nothing to provision first.

        An account with no identity registered in this environment answers
        `200` with `legalEntity: null` (never `404`), and `identifiers` may
        be an empty array — absence is an answer, not an error. Address fields not
        captured at registration come back as `null`.
      tags: [Identity]
      responses:
        '200':
          description: The account's own Peppol identity in this environment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IdentityResponse'
              examples:
                registered:
                  summary: Account with a registered identity
                  value:
                    environment: "sandbox"
                    legalEntity:
                      companyName: "Bright Health Ltd"
                      country: "GB"
                      address:
                        line1: "10 King Street"
                        city: "London"
                        zip: "EC2V 8EA"
                      createdAt: "2026-06-01T10:00:00.000Z"
                    identifiers:
                      - scheme: "GB:VAT"
                        value: "gb123456789"
                        status: "verified"
                        createdAt: "2026-06-01T10:05:00.000Z"
                empty:
                  summary: Account with no identity in this environment yet
                  value:
                    environment: "production"
                    legalEntity: null
                    identifiers: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Platform Legal Entities ───────────────────────────────

  /legal-entities:
    post:
      operationId: createLegalEntity
      summary: Create a platform sub-tenant legal entity
      description: |
        Creates a customer as a platform-managed Peppol legal entity. Requires a
        master key with the `legal_entities:create` scope.

        The call is idempotent by `externalId`: replaying the same `externalId`
        returns the existing legal entity with `200`.

        Registry verification runs asynchronously. Listen for `legal_entity.*`
        webhooks or poll `GET /legal-entities/{id}`.
      tags: [Legal Entities]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LegalEntityCreateRequest'
            example:
              externalId: "customer_8412"
              companyName: "Bright Health Ltd"
              country: "GB"
              address:
                line1: "10 King Street"
                city: "London"
                zip: "EC2V 8EA"
              identifier:
                scheme: "GB:VAT"
                value: "gb123456789"
      responses:
        '200':
          description: Legal entity already existed for this externalId
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LegalEntity'
        '202':
          description: Legal entity accepted; verification runs asynchronously
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LegalEntity'
        '400':
          description: Missing or invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalidExternalId:
                  value:
                    error: "externalId is required and must be a string of 1-64 characters"
                validation:
                  value:
                    error: "Validation failed"
                    details: ["companyName must be at least 2 characters"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: API key is not a master key, or lacks legal_entities:create
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Your API key does not have permission to perform this action"
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: >-
            Business rule rejection — sandbox legal-entity cap reached, or the
            targeted legal entity's identifier is immutable / the entity is
            locked (archive & recreate instead).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                sandboxCap:
                  value:
                    error: "Sandbox Legal Entity limit reached for this account."
                    code: "sandbox_le_cap_exceeded"
                identifierImmutable:
                  value:
                    error: "The identifier of an existing Legal Entity cannot be changed. Archive it and create a new one."
                    code: "identifier_immutable"
                legalEntityLocked:
                  value:
                    error: "This Legal Entity is already verified and cannot be renamed. Archive it and create a new one."
                    code: "legal_entity_locked"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      operationId: listLegalEntities
      summary: List platform sub-tenant legal entities
      description: |
        Lists the sub-tenants owned by your platform account, newest first.
        Requires a master key with the `legal_entities:list` scope.
      tags: [Legal Entities]
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Maximum number of legal entities to return
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            maximum: 100000
            default: 0
          description: Number of legal entities to skip
      responses:
        '200':
          description: Paginated legal entity list
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/LegalEntity'
                  pagination:
                    $ref: '#/components/schemas/LegalEntityPagination'
        '400':
          description: Offset exceeds the maximum allowed value
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "offset must not exceed 100000"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: API key is not a master key, or lacks legal_entities:list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Your API key does not have permission to perform this action"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /legal-entities/{id}:
    get:
      operationId: getLegalEntity
      summary: Retrieve a platform sub-tenant legal entity
      description: |
        Retrieves one sub-tenant by getpeppr legal entity id. Unknown, disabled,
        or cross-platform ids return `404` to preserve tenant isolation.
      tags: [Legal Entities]
      parameters:
        - $ref: '#/components/parameters/LegalEntityId'
      responses:
        '200':
          description: Legal entity details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LegalEntity'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: API key is not a master key, or lacks legal_entities:list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Your API key does not have permission to perform this action"
        '404':
          description: Legal entity not found, disabled, or not owned by this platform
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Legal entity not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      operationId: archiveLegalEntity
      summary: Archive a platform sub-tenant legal entity
      description: |
        Archives a sub-tenant legal entity. Archived sub-tenants can no longer send.
        Requires a master key with the `legal_entities:archive` scope.
      tags: [Legal Entities]
      parameters:
        - $ref: '#/components/parameters/LegalEntityId'
      responses:
        '200':
          description: Legal entity archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LegalEntityArchiveResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: API key is not a master key, or lacks legal_entities:archive
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Your API key does not have permission to perform this action"
        '404':
          description: Legal entity not found, disabled, or not owned by this platform
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Legal entity not found"
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /legal-entities/{id}/attestation:
    post:
      operationId: requestLegalEntityAttestation
      summary: Request sub-tenant attestation
      description: |
        Sends or refreshes the customer authorisation email for a production
        sub-tenant. Requires a master key with the `legal_entities:attest` scope.

        Sandbox sub-tenants are auto-activated and do not use attestation.
      tags: [Legal Entities]
      parameters:
        - $ref: '#/components/parameters/LegalEntityId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LegalEntityAttestationRequest'
            example:
              contactEmail: "owner@brighthealth.co.uk"
              contactName: "Dr Jane Okafor"
      responses:
        '202':
          description: Authorisation email queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LegalEntityAttestationResult'
        '400':
          description: Invalid request body, or attestation attempted in sandbox
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                sandbox:
                  value:
                    error: "Attestation applies to production sub-tenants only; sandbox entities are auto-activated"
                invalidEmail:
                  value:
                    error: "contactEmail is required and must be a valid email address"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: API key is not a master key, or lacks legal_entities:attest
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Your API key does not have permission to perform this action"
        '404':
          description: Legal entity not found, disabled, or not owned by this platform
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Legal entity not found"
        '409':
          description: Sub-tenant is not verified yet, has no identifier, or already attested
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                notVerified:
                  value:
                    error: "Sub-tenant identity is not verified yet"
                    reason: "not_verified"
                alreadyAttested:
                  value:
                    error: "Sub-tenant has already attested"
                    reason: "already_attested"
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          description: Authorisation email could not be sent; retry may mint a fresh link
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Capabilities ──────────────────────────────────────────

  /capabilities:
    get:
      operationId: getCapabilities
      summary: Get provider capabilities
      description: |
        Returns the active provider's capability matrix so SDK consumers can
        discover which operations are supported before calling them.

        The matrix includes support status for invoicing, contacts, bank accounts,
        directory lookups, validation, webhooks, and more.
      tags: [Capabilities]
      responses:
        '200':
          description: Provider capability matrix
          content:
            application/json:
              schema:
                type: object
                properties:
                  provider:
                    type: string
                    description: Active provider name
                    example: "storecove"
                  capabilities:
                    type: object
                    description: Capability matrix (operation → support status and notes)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  # ─── Health ────────────────────────────────────────────────

  /health:
    get:
      operationId: healthCheck
      summary: Health check
      description: |
        Public endpoint (no authentication required). Returns the health status
        of the API gateway, including database and Redis connectivity.

        Used by external uptime monitors (BetterStack, UptimeRobot).
        Readiness semantics: returns `200` only when both the database and
        Redis are reachable, `503` as soon as either is down.
      tags: [Health]
      security: []
      responses:
        '200':
          description: Service is healthy (database and Redis both reachable)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'
              examples:
                healthy:
                  summary: All systems operational
                  value:
                    status: "healthy"
                    checks:
                      db: true
                      redis: true
        '503':
          description: Database or Redis unreachable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'
              examples:
                databaseDown:
                  summary: Database unreachable
                  value:
                    status: "unhealthy"
                    checks:
                      db: false
                      redis: true
                redisDown:
                  summary: Redis unreachable
                  value:
                    status: "unhealthy"
                    checks:
                      db: true
                      redis: false

  /version:
    get:
      operationId: getVersion
      summary: API version
      description: |
        Public endpoint (no authentication required). Returns build metadata
        of the deployed gateway — useful when reporting an issue.
      tags: [Health]
      security: []
      responses:
        '200':
          description: Build metadata
          content:
            application/json:
              schema:
                type: object
                required: [version, commit, environment]
                properties:
                  version:
                    type: string
                    description: Gateway package version
                    example: "1.42.0"
                  commit:
                    type: [string, 'null']
                    description: Deployed git commit SHA, truncated to 7 characters (null outside Vercel)
                    example: "7fd153b"
                  environment:
                    type: string
                    description: Deployment environment
                    example: "production"

  # ─── Newsletter ─────────────────────────────────────────────

  /newsletter/subscribe:
    post:
      summary: Subscribe to the newsletter
      description: |
        Public endpoint. Creates a pending subscriber row and triggers a double opt-in
        confirmation email. Rate-limited to 5 attempts per IP per hour. CORS open
        (Access-Control-Allow-Origin: *) but the form on getpeppr.dev/newsletter is
        the intended caller.
      operationId: newsletterSubscribe
      tags:
        - Newsletter
      security: []  # Public, no auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, consent]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 254
                  example: marie@example.com
                consent:
                  type: boolean
                  enum: [true]
                  description: Must be exactly true (UK GDPR explicit consent)
                _hp:
                  type: string
                  description: Honeypot field (leave empty — bot trap)
      responses:
        '200':
          description: Subscription accepted (or honeypot triggered, or already-confirmed silent)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [pending]
                  message:
                    type: string
                    example: Check your inbox
        '400':
          description: Invalid email, missing consent, or malformed body
        '429':
          description: Rate limit exceeded (5 attempts per IP per hour)
        '500':
          description: Internal error
    options:
      summary: CORS preflight
      operationId: newsletterSubscribeOptions
      tags:
        - Newsletter
      security: []
      responses:
        '204':
          description: Preflight OK

  /newsletter/confirm:
    get:
      summary: Confirm a newsletter subscription
      description: |
        Called when subscriber clicks the confirmation link in their email.
        Verifies HMAC token, transitions to confirmed (idempotent), sends welcome
        email if not already confirmed, redirects to /newsletter/confirmed on
        success or /newsletter/error on failure.
      operationId: newsletterConfirm
      tags:
        - Newsletter
      security: []
      parameters:
        - name: token
          in: query
          required: true
          schema:
            type: string
          description: HMAC-signed token from the confirmation email
      responses:
        '302':
          description: Redirect to success or error page
          headers:
            Location:
              schema:
                type: string

  /newsletter/unsubscribe:
    get:
      summary: Render the unsubscribe HTML confirmation page
      description: |
        Returns an HTML confirmation page (RFC 8058 + anti-prefetcher pattern).
        Does NOT mutate state — email scanners (Gmail, Outlook) auto-click links,
        so the actual unsubscribe happens via POST after user clicks the form button.
      operationId: newsletterUnsubscribePage
      tags:
        - Newsletter
      security: []
      parameters:
        - name: token
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          description: HTML confirmation page
          content:
            text/html:
              schema:
                type: string
        '400':
          description: Missing or invalid token
    post:
      summary: Perform the newsletter unsubscribe
      description: |
        Performs the unsubscribe mutation. Accepts token via form-encoded body OR
        query param (RFC 8058 List-Unsubscribe-Post one-click). Idempotent.
      operationId: newsletterUnsubscribe
      tags:
        - Newsletter
      security: []
      parameters:
        - name: token
          in: query
          required: false
          schema:
            type: string
          description: Token via query param (RFC 8058 one-click)
      requestBody:
        required: false
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                token:
                  type: string
              description: Token via form body (HTML confirmation page submit)
      responses:
        '302':
          description: Redirect to /newsletter/unsubscribed
        '400':
          description: Missing or invalid token
        '500':
          description: Internal error

webhooks:
  event:
    post:
      operationId: webhookEventDelivery
      summary: Webhook event delivery
      tags: [Webhooks]
      description: |
        Delivered as a `POST` to every endpoint subscribed to the event's type.
        The body is the standard event envelope; the shape of `data` depends on
        the event family (see the schema). Every request is signed with the
        `Getpeppr-Signature` header (`t=<unix>,s=<hmac-sha256>` over
        `<t>.<rawBody>` with your endpoint's `whsec_*` secret).

        Respond with any `2xx` quickly to acknowledge receipt. Anything else
        (or a timeout) triggers retries at 1 min, 5 min and 30 min — 4 attempts
        total, then the delivery is marked failed. Deliveries are at-least-once:
        dedupe on the event `id`, which is stable across provider redeliveries
        for document status events.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: Acknowledged — any 2xx status is accepted

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API key authentication. Pass your API key as a Bearer token.

        Keys follow the format:
        - `sk_sandbox_*` for sandbox/staging environment
        - `sk_live_*` for production environment

  parameters:
    InvoiceId:
      name: id
      in: path
      required: true
      description: Invoice ID
      schema:
        type: string
      example: "12345"

    ContactId:
      name: id
      in: path
      required: true
      description: Contact ID (UUID — any non-UUID value yields a 404)
      schema:
        type: string
        format: uuid
      example: "6f1c2b3a-9d4e-4c5f-8a7b-1e2d3c4b5a69"

    BankAccountId:
      name: id
      in: path
      required: true
      description: Bank account ID (UUID — any non-UUID value yields a 404)
      schema:
        type: string
        format: uuid
      example: "2a7b4c1d-5e6f-4a8b-9c0d-3f2e1d4c5b6a"

    LegalEntityId:
      name: id
      in: path
      required: true
      description: getpeppr legal entity id
      schema:
        type: string
        format: uuid
      example: "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b"

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-generated unique key to ensure idempotent operation. If a request
        with the same key was already processed, the cached response is returned.
        Keys are scoped to your API key and expire after 24 hours.
      schema:
        type: string
        maxLength: 256
      example: "inv-2026-001-create"

    Limit:
      name: limit
      in: query
      required: false
      description: Maximum number of items to return (1-100, default 20)
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

    Offset:
      name: offset
      in: query
      required: false
      description: Number of items to skip for pagination (default 0)
      schema:
        type: integer
        minimum: 0
        default: 0

  schemas:
    # ─── UBL validation (GPR-1069) ──────────────────────────────

    UblValidationVerdict:
      type: object
      required: [conformant, rulebook, violations, rulesFired]
      properties:
        conformant:
          type: boolean
          description: |
            False as soon as one FATAL rule is violated. Warnings never affect this
            field and never block a send.
        rulebook:
          type: object
          required: [peppol, verifiedAt]
          properties:
            peppol:
              type: string
              description: |
                The OpenPeppol release these rules come from. Named `peppol`
                rather than `release` because the rulebook is not a single
                thing: the CEN EN 16931 layer and any national family ride
                alongside it, and a bare `release` would leave a second version
                nowhere to go.
              example: v3.0.20
            verifiedAt:
              type: string
              format: date
              description: |
                The date getpeppr last verified this release byte-identical against the
                upstream OpenPeppol repository.
              example: '2026-08-17'
        violations:
          type: array
          description: |
            Every rule that fired against your document, fatal and warning alike. A
            fatal violation on `/v1/invoices/import` produces a terminal 422 and the
            document is not sent.
          items:
            type: object
            required: [ruleId, flag, text, location]
            properties:
              ruleId:
                type: string
                description: The official rule identifier.
                example: PEPPOL-EN16931-R003
              flag:
                type: string
                enum: [fatal, warning]
                description: |
                  Only `fatal` prevents a send. `warning` is reported for information.
                  Two German IBAN checksum rules (DE-R-019, DE-R-020) are known false
                  positives of the XPath engine and are always reported as warnings.
              text:
                type: string
                description: The rule text, verbatim from the official rulebook.
              location:
                type: string
                description: XPath of the offending node in your document.
        rulesFired:
          type: integer
          description: |
            How many rule **activations** occurred — one per rule per matching
            node, not one per rule. A rule whose context matches twenty invoice
            lines counts twenty times, so this number grows with the size of
            your document and is normally far larger than the number of distinct
            rules involved. Duplicating a single `InvoiceLine` in one corpus
            document moves it from 165 to 174 while the count of distinct rules
            stays at 19.

            Read it as a sign of life from the validator, never as a measure of
            how much of the rulebook was covered. Zero would mean the validator
            itself failed; getpeppr reports that as an error rather than as a
            clean result.
          example: 165

    # ─── Error Responses ────────────────────────────────────────

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message
        code:
          type: string
          description: Machine-readable error code when available
        message:
          type: string
          description: Additional developer-facing detail when available
        reason:
          type: string
          description: Lifecycle or conflict reason when available
        docs:
          type: string
          format: uri
          description: Documentation link when available
        details:
          type: array
          items:
            type: string
          description: Field-level validation details when available
      example:
        error: "Internal server error"

    # ─── Pagination ─────────────────────────────────────────────

    PaginationMeta:
      type: object
      description: >-
        Pagination envelope. Field names are snake_case on the wire; the
        official SDK re-maps them to camelCase client-side.
      required: [total_count, offset, limit, has_more]
      properties:
        total_count:
          type: integer
          description: Total number of matching items
          example: 42
        offset:
          type: integer
          description: Current offset
          example: 0
        limit:
          type: integer
          description: Items per page
          example: 20
        has_more:
          type: boolean
          description: Whether more items exist beyond this page
          example: true

    # ─── Party ──────────────────────────────────────────────────

    Party:
      type: object
      required: [name, peppolId, country]
      properties:
        name:
          type: string
          description: Business name
          example: "ACMEDIA"
        peppolId:
          type: string
          description: 'Peppol participant ID in scheme:id format'
          example: "0208:0685660237"
        vatNumber:
          type: string
          description: VAT number
          example: "BE0685660237"
        street:
          type: string
          description: Street address
        city:
          type: string
          description: City
        postalCode:
          type: string
          description: Postal/zip code
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code
          example: "BE"
        companyId:
          type: string
          description: Company registration number (BT-30/BT-47)
        companyIdScheme:
          type: string
          description: 'Scheme ID for company registration (e.g., "0208" for Belgian BCE)'
        contactName:
          type: string
          description: Contact person name
        phone:
          type: string
          description: Contact telephone
        email:
          type: string
          format: email
          description: Contact email

    InvoiceSender:
      type: object
      description: |
        Platform-only sender selector. Provide exactly one field. Standard API keys
        ignore this object; master keys use it to send as a sub-tenant.
      oneOf:
        - required: [legalEntityId]
        - required: [externalSubTenantId]
      properties:
        legalEntityId:
          type: string
          format: uuid
          description: getpeppr legal entity id for the sub-tenant sender
          example: "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b"
        externalSubTenantId:
          type: string
          description: Your stable external id for the sub-tenant sender
          example: "customer_8412"

    # ─── Invoice Types ──────────────────────────────────────────

    InvoiceLine:
      type: object
      required: [description, quantity, unitPrice, vatRate]
      properties:
        description:
          type: string
          description: Line item description
          example: "API Integration Setup"
        quantity:
          type: number
          description: Quantity
          example: 1
        unit:
          type: string
          description: 'Unit of measure (UN/ECE code or human-readable name like "hour", "day", "kg")'
          default: "EA"
          example: "hours"
        unitPrice:
          type: number
          description: Unit price (exclusive of tax)
          example: 500.00
        vatRate:
          type: number
          description: VAT rate in percent
          example: 21
        vatCategory:
          type: string
          enum: [S, Z, E, AE, K, G, O, L, M]
          default: "S"
          description: VAT category code
        itemId:
          type: string
          description: Seller's item number
        allowances:
          type: array
          items:
            $ref: '#/components/schemas/LineAllowanceCharge'
          description: Line-level allowances/discounts
        charges:
          type: array
          items:
            $ref: '#/components/schemas/LineAllowanceCharge'
          description: Line-level charges/surcharges
        standardItemId:
          type: string
          description: Standard item identifier (e.g., GTIN/EAN)
        standardItemScheme:
          type: string
          description: 'Scheme for standard item ID (default: "0160" for GTIN)'
        commodityCode:
          type: string
          description: Commodity classification code (e.g., UNSPSC)
        commodityScheme:
          type: string
          description: 'List ID for commodity classification (e.g., "STI", "CPV")'
        properties:
          type: array
          items:
            $ref: '#/components/schemas/ItemProperty'
          description: Additional key-value item properties
        baseQuantity:
          type: number
          description: Base quantity for price calculation (e.g., 100 for "price per 100 units")
          default: 1
        baseQuantityUnit:
          type: string
          description: Unit code for base quantity
        accountingCost:
          type: string
          description: Buyer accounting reference for this line

    ItemProperty:
      type: object
      required: [name, value]
      properties:
        name:
          type: string
          description: Property name
          example: "Color"
        value:
          type: string
          description: Property value
          example: "Blue"

    LineAllowanceCharge:
      type: object
      required: [reason, amount]
      properties:
        reason:
          type: string
          description: Reason for the allowance/charge
        amount:
          type: number
          description: Amount (positive number)

    AllowanceCharge:
      type: object
      required: [reason, amount, vatRate]
      properties:
        reason:
          type: string
          description: Reason for the allowance/charge
          example: "Early payment discount"
        amount:
          type: number
          description: Amount (positive, exclusive of tax)
          example: 50.00
        vatRate:
          type: number
          description: VAT rate in percent
          example: 21
        vatCategory:
          type: string
          enum: [S, Z, E, AE, K, G, O, L, M]
          default: "S"

    Attachment:
      type: object
      required: [id]
      properties:
        id:
          type: string
          description: Document reference identifier
        description:
          type: string
          description: Human-readable description
        filename:
          type: string
          description: Filename (required when content is provided)
        mimeType:
          type: string
          description: 'MIME type (e.g., "application/pdf")'
        content:
          type: string
          description: Raw base64-encoded file content (for embedded attachments), without a `data:*;base64,` URI prefix. The `POST /validate/server` endpoint rejects content whose decoded size exceeds 2 MB per attachment; other endpoints apply their own payload limits.
        url:
          type: string
          format: uri
          description: External URL reference (alternative to embedded content)

    InvoicePeriod:
      type: object
      properties:
        startDate:
          type: string
          format: date
          description: Period start date (ISO 8601)
        endDate:
          type: string
          format: date
          description: Period end date (ISO 8601)

    DeliveryAddress:
      type: object
      required: [country]
      properties:
        street:
          type: string
        city:
          type: string
        postalCode:
          type: string
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code

    Delivery:
      type: object
      properties:
        date:
          type: string
          format: date
          description: Actual delivery date (ISO 8601)
        locationId:
          type: string
          description: Delivery location identifier
        address:
          $ref: '#/components/schemas/DeliveryAddress'

    InvoiceInput:
      type: object
      required: [number, to, lines]
      properties:
        sender:
          $ref: '#/components/schemas/InvoiceSender'
        number:
          type: string
          description: Invoice number (must be unique per supplier)
          example: "INV-2026-001"
        date:
          type: string
          format: date
          description: Invoice date (ISO 8601, defaults to today)
          example: "2026-02-26"
        dueDate:
          type: string
          format: date
          description: Due date (ISO 8601)
          example: "2026-03-28"
        currency:
          type: string
          description: ISO 4217 currency code
          default: "EUR"
          example: "EUR"
        taxCurrency:
          type: string
          description: Tax reporting currency if different from document currency
        taxCurrencyRate:
          type: number
          description: Exchange rate from document currency to tax currency
        to:
          $ref: '#/components/schemas/Party'
        payeeParty:
          $ref: '#/components/schemas/Party'
        taxRepresentative:
          $ref: '#/components/schemas/Party'
        lines:
          type: array
          items:
            $ref: '#/components/schemas/InvoiceLine'
          minItems: 1
          maxItems: 500
          description: Line items (at least one required)
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/Attachment'
          maxItems: 10
          description: Supporting documents/attachments. Server-side validation caps embedded content at 2 MB decoded per attachment.
        allowances:
          type: array
          items:
            $ref: '#/components/schemas/AllowanceCharge'
          description: Document-level allowances/discounts
        charges:
          type: array
          items:
            $ref: '#/components/schemas/AllowanceCharge'
          description: Document-level charges/surcharges
        invoicePeriod:
          $ref: '#/components/schemas/InvoicePeriod'
        delivery:
          $ref: '#/components/schemas/Delivery'
        note:
          type: string
          description: Optional note/memo
        paymentReference:
          type: string
          description: Payment reference (e.g., structured communication)
        buyerReference:
          type: string
          description: Buyer reference (required by Peppol BIS 3.0 if no orderReference)
        orderReference:
          type: string
          description: Buyer's purchase order number
        salesOrderReference:
          type: string
          description: Seller's sales order reference
        contractReference:
          type: string
          description: Contract reference number
        projectReference:
          type: string
          description: Project reference identifier
        despatchReference:
          type: string
          description: Despatch advice / delivery note reference
        receiptReference:
          type: string
          description: Receiving advice reference
        paymentTerms:
          type: string
          description: 'Free-text payment terms (e.g., "Net 30 days")'
        paymentMeans:
          type: integer
          description: >-
            UNCL 4461 payment means code. Sendable values: 10 (cash), 30 (credit
            transfer), 31 (debit transfer, requires paymentIban and paymentBic),
            48 (bank card), 57 (standing agreement), 58 (SEPA credit transfer).
            Any other value returns 422 unsupported_payment_means. Direct debit
            (49 and 59) returns 422 payment_mandate_required: the Peppol network
            fatally requires a mandate reference this API cannot yet carry.
            Omit the field and provide paymentIban to default to a credit transfer —
            for domestic Danish invoices the gateway upgrades that default to 58,
            which is what Danish national rules require.
        paymentIban:
          type: string
          description: IBAN for payment
        paymentBic:
          type: string
          description: BIC/SWIFT code
        taxPointDate:
          type: string
          format: date
          description: Tax point date (when VAT becomes accountable)
        prepaidAmount:
          type: number
          description: Amount already paid before this invoice
        roundingAmount:
          type: number
          description: Rounding applied to the payable amount (-0.99 to 0.99)
        accountingCost:
          type: string
          description: Buyer accounting reference at document level
        invoiceTypeCode:
          type: integer
          enum: [380, 381, 383, 384, 386, 389, 751]
          description: |
            Invoice type code (UNCL1001):
            - 380: Commercial invoice (default)
            - 381: Credit note
            - 383: Debit note
            - 384: Corrective invoice
            - 386: Prepayment invoice
            - 389: Self-billed invoice
            - 751: Invoice for accounting purposes
        isCreditNote:
          type: boolean
          description: Set to true for credit notes (sets type code to 381)
        invoiceReference:
          type: string
          description: Reference to original invoice (required when isCreditNote is true)
        _draft:
          type: boolean
          description: Set to true to create a draft without sending
          default: false

    ImportInvoiceRequest:
      type: object
      required: [file, filename, to]
      properties:
        file:
          type: string
          description: >-
            Base64-encoded UBL Invoice or CreditNote. It must decode to exactly
            the bytes you intend to send — getpeppr never repairs your document,
            so a lossy decode is refused (`invalid_base64`) rather than patched.

            Its content reaches your recipient as you wrote it, but byte equality
            is not guaranteed: the network re-serialises the document in transit.
            Seal a canonical form (C14N) rather than the raw bytes.
        filename:
          type: string
          description: Original filename (e.g., "invoice.xml")
          example: "invoice-2026-001.xml"
        mimeType:
          type: string
          description: >-
            MIME type of the document. Optional — derived from the filename
            extension when omitted.
          example: "application/xml"
        to:
          type: object
          required: [peppolId]
          description: >-
            The recipient, declared explicitly. **Required**, and deliberately
            never read from the document: routing is what decides delivery, and
            the document travels as payload. A request without it is refused
            with `missing_recipient`.
          properties:
            peppolId:
              type: string
              description: Recipient Peppol participant identifier, `scheme:identifier`.
              example: "0208:0685660237"

    ImportInvoiceResult:
      description: >-
        The `201` body of `POST /invoices/import` — a `SendResult` carrying, in
        addition, the provenance of the rulebook the document was judged
        against and what getpeppr does not guarantee about your bytes.
      allOf:
        - $ref: '#/components/schemas/SendResult'
        - type: object
          properties:
            rulebook:
              allOf:
                - $ref: '#/components/schemas/UblValidationVerdict/properties/rulebook'
              description: >-
                The rulebook this document was validated against before it was
                sent. **Absent when validation did not run** — that is, when the
                call carried `x-skip-validation: true`. It is never invented: a
                document nobody judged names no rulebook.
            transmission:
              type: object
              required: [mode, bytePreservation]
              description: >-
                How the bytes that left getpeppr relate to the bytes you
                supplied. Present on every `201` this endpoint produces,
                including when `x-skip-validation: true` removed `rulebook` —
                validation and byte handling are independent.


                ⚠️ Deliberately NOT `required`, and the reason is worth knowing
                before you rely on it. Replaying an `Idempotency-Key` returns
                the body cached for it, verbatim and untouched; an entry cached
                before this field existed therefore has none, and the cache
                holds entries for 24 hours. Declaring it `required` would have
                been a promise a real replay can break — the same reason
                `rulebook` never claimed unconditional presence.


                **Absent is not `false`.** It means this response did not say,
                never that your bytes are safe.


                This is a **guarantee, not a measurement of your document**.
                `not_guaranteed` does not mean "we changed it" — getpeppr
                forwards your bytes verbatim and takes no decision from their
                content. It means the Peppol network re-serialises in transit,
                so byte equality is never something to rely on. Measured
                2026-08-18: namespace declarations reordered, numeric character
                references resolved, whitespace inside tags dropped, no element
                or value altered.


                ⛔ Do not read this as "canonicalise first and your bytes will
                survive". A document already in canonical form has been observed
                to come back unchanged **once**, on a document the provider had
                itself generated — which is exactly the measurement that misled
                us before. Nothing guarantees it, and a normaliser that happens
                to be idempotent today is not a contract. Seal a canonical form
                because the canonical form is what survives, never because the
                bytes might.


                In one line: byte-for-byte equality is not guaranteed, and the
                remedy is to seal a canonical form (C14N) rather than raw bytes.


                **Seal a canonical form (C14N), not raw bytes.** A digest over
                the bytes you sent attests to your record, not to what was
                transmitted.
              properties:
                mode:
                  type: string
                  example: enveloped
                  description: >-
                    Whose rendering went out. `enveloped` means getpeppr
                    forwarded the document you supplied, wrapped only in the
                    Peppol SBDH, rather than generating UBL from JSON.


                    Deliberately NOT an `enum`. An enum is a closed set by
                    specification — code generated from one cannot represent a
                    value added later, and a strict validator rejects it. Since
                    a second import mode may be named in future, an enum here
                    would promise exhaustiveness we do not intend. `enveloped`
                    is the only value this version sends; compare against the
                    values you know and treat an unfamiliar one as "not a JSON
                    send". `@getpeppr/sdk` types it `string` for the same reason.
                bytePreservation:
                  type: string
                  example: not_guaranteed
                  description: >-
                    Whether byte equality between what you supplied and what
                    reaches the recipient is guaranteed. It is not, and the
                    reason is downstream of getpeppr. `not_guaranteed` is the
                    only value this version sends; same open-set reasoning as
                    `mode`.

    # ─── Response Schemas ───────────────────────────────────────

    SendResult:
      type: object
      # `createdAt` is NOT required here (GPR-1067). The value crosses a
      # conditional mapper — `if (camel.createdAt) result.createdAt = …` in
      # lib/api/storecove-mapper — so the send response omits it whenever the
      # provider did not supply one. Same shape of defect as the `status` field
      # that GPR-1061 fixed, and the reason @getpeppr/sdk 4.0.0 types it
      # `createdAt?: string` rather than inventing a value.
      # ⚠️ The InvoiceStatus schema below DOES require it, correctly: its value
      # comes from `invoice_submissions.created_at`, a NOT NULL column.
      required: [id, status]
      properties:
        id:
          type: string
          deprecated: true
          description: >-
            The provider (Storecove) document GUID. **Deprecated:** `id` means
            the submission ID on `GET /invoices` and the provider GUID here, so
            code that carries it between surfaces has to know which one it
            holds. Read `providerDocumentId` or `submissionId` instead. Both are
            accepted as input everywhere; this field is unchanged and stays.
          example: "12345"
        providerDocumentId:
          type: string
          description: Provider (Storecove) document GUID — the same value as `id`.
        submissionId:
          type: string
          format: uuid
          description: >-
            getpeppr submission ID — the local record of this document, and the
            `submissionId` your webhooks carry. **Absent** when the local record
            could not be written: the document is on the Peppol network either
            way, and an invented id would resolve to nothing.
        status:
          $ref: '#/components/schemas/DocumentStatus'
          description: >-
            Raw provider status at submission time — may occasionally carry a
            provider value outside the DocumentStatus vocabulary.
        peppolMessageId:
          type: string
          description: >-
            Peppol AS4 message ID. **Never present on this response**: it only
            exists once the transmission has actually gone out, seconds after
            this call returns. Read it from `GET /invoices/{id}?include=evidence`.
            The field is declared here because the SDK shares one result type
            across both surfaces.
        ublXml:
          type: string
          description: Generated UBL XML (for debugging)
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/ValidationWarning'
          description: Non-blocking validation warnings
        createdAt:
          type: string
          format: date-time
          description: >-
            Creation timestamp, when the provider supplied one. **Not
            guaranteed** on this response — read it defensively rather than
            substituting the moment of your call, which is a different
            measurement wearing the same name.
          example: "2026-02-26T10:30:00Z"

    DocumentStatus:
      type: string
      enum:
        - submitted
        - delivered
        - accepted
        - rejected
        - paid
        - failed
        - cleared
        - acknowledged
        - in_process
        - under_query
        - conditionally_accepted
        - partially_paid
        - no_action
        - unknown
      description: |
        Document lifecycle status (mapped from Storecove webhook events):
        - `submitted`: Document submitted for Peppol delivery
        - `delivered`: Received by the recipient's access point (corner 3)
        - `accepted`: Accepted by the recipient (corner 4)
        - `rejected`: Rejected by the recipient
        - `paid`: Payment confirmed by the recipient
        - `failed`: Delivery failed (final state)
        - `cleared`: Cleared by tax authority (e.g. KSA, PT)
        - `acknowledged`: Receipt acknowledged by corner 4
        - `in_process`: Processing started by corner 4
        - `under_query`: Under query by corner 4
        - `conditionally_accepted`: Conditionally accepted
        - `partially_paid`: Partially paid
        - `no_action`: No recipients found for delivery
        - `unknown`: Unrecognized status from gateway

    InvoiceSummary:
      type: object
      description: >-
        Row from the invoice-submissions ledger, as returned by GET /invoices
        (camelCase keys, returned as stored).
      properties:
        id:
          type: string
          format: uuid
          deprecated: true
          description: >-
            getpeppr submission ID. **Deprecated:** `id` names a different thing
            depending on the surface — the submission ID here, the provider GUID
            on `POST /invoices` and `GET /invoices/{id}`. Read `submissionId` or
            `providerDocumentId` instead; both are accepted as input on every
            endpoint that takes an invoice id, so switching breaks nothing. The
            field itself is not going away without a decided removal date.
        submissionId:
          type: string
          format: uuid
          description: >-
            getpeppr submission ID — the same value as `id` on this surface,
            under a name that cannot be confused with the provider GUID.
        accountId:
          type: string
          format: uuid
          description: Internal account identifier (surrogate key, not the organization ID)
        providerDocumentId:
          type: string
          description: >-
            Provider (Storecove) document GUID. Pass this to `GET
            /invoices/{id}` or `GET /invoices/{id}/as/{format}` to move from a
            list row to the document itself.
        environment:
          type: string
          enum: [sandbox, production]
        invoiceNumber:
          type: string
          description: Invoice number as submitted
        issueDate:
          type: string
          format: date
        currency:
          type: string
          description: ISO 4217 currency code
          example: EUR
        recipientName:
          type: [string, 'null']
        recipientPeppolId:
          type: [string, 'null']
        recipientCountry:
          type: [string, 'null']
        isCreditNote:
          type: boolean
        totalAmount:
          type: [integer, 'null']
          description: >-
            Grand total including tax, in minor currency units (naive line sum,
            display only — not the legal payable amount)
        hasAttachment:
          type: boolean
        status:
          $ref: '#/components/schemas/DocumentStatus'
        legalEntityId:
          type: [string, 'null']
        legalEntityName:
          type: [string, 'null']
        detail:
          $ref: '#/components/schemas/StatusDetail'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    StatusDetail:
      type: object
      description: >
        Structured national status detail, one entry per axis. Present on list items when the
        jurisdiction provides structured lifecycle codes (e.g. the French DGFiP CTC lifecycle);
        omitted otherwise. The native code is preserved, never over-translated.
      properties:
        platformFiscal:
          $ref: '#/components/schemas/StatusDetailEntry'
        delivery:
          $ref: '#/components/schemas/StatusDetailEntry'
        businessDisposition:
          $ref: '#/components/schemas/StatusDetailEntry'
        settlement:
          $ref: '#/components/schemas/StatusDetailEntry'

    StatusDetailEntry:
      type: object
      required: [axis, jurisdiction, code, label, codeSystem, codeVersion]
      properties:
        axis:
          type: string
          enum: [platformFiscal, delivery, businessDisposition, settlement, unmapped]
        jurisdiction:
          type: string
          description: 'Jurisdiction the code belongs to (e.g. "FR", or "*" for network-wide)'
        code:
          type: string
          description: 'Native status code (e.g. "213") — not the label'
        label:
          type: string
          description: 'Human label for the native code (e.g. "Rejetée")'
        codeSystem:
          type: string
          description: 'Code system identifier (e.g. "fr-ctc-lifecycle")'
        codeVersion:
          type: string
        standardCode:
          type: object
          properties:
            system:
              type: string
            code:
              type: string
        reason:
          type: string
        warnings:
          type: array
          items:
            type: string
        failureCategory:
          type: string
          enum: [transport, routing, syntax, semantic, authority]
        payment:
          type: object
          properties:
            amount:
              type: number
            currency:
              type: string
            date:
              type: string
        paymentSemantics:
          type: string
          enum: [received, initiated]
        actor:
          type: string
          enum: [seller, buyer]

    InvoiceStatus:
      type: object
      required: [id, submissionId, providerDocumentId, number, status, createdAt]
      description: >-
        The invoice as getpeppr recorded it. Until 2026-08 this endpoint read
        Storecove's sending-evidence envelope and reshaped it as if it were a
        document — the envelope carries no status at all, so a delivered invoice
        answered with almost nothing. It now projects the submission ledger, the
        same source `GET /invoices` reads.

        ⚠️ `number` here is the field `GET /invoices` calls `invoiceNumber`, and
        `id` is the provider GUID where the list's `id` is the submission ID.
        Both asymmetries are historic and frozen rather than broken; use
        `submissionId` / `providerDocumentId`, which mean the same thing
        everywhere.
      properties:
        id:
          type: string
          deprecated: true
          description: >-
            The provider (Storecove) document GUID. **Deprecated** because `id`
            names a different value on `GET /invoices`; read `providerDocumentId`
            or `submissionId`. Unchanged, and staying until a removal date is
            decided.
        submissionId:
          type: string
          format: uuid
          description: getpeppr submission ID — the local record of this document.
        providerDocumentId:
          type: string
          description: Provider (Storecove) document GUID — the same value as `id`.
        accountId:
          type: string
          format: uuid
        environment:
          type: string
          enum: [sandbox, production]
        number:
          type: string
          description: Invoice number as submitted (`invoiceNumber` on `GET /invoices`).
        issueDate:
          type: string
          format: date
        currency:
          type: string
          example: EUR
        recipientName:
          type: [string, 'null']
        recipientPeppolId:
          type: [string, 'null']
        recipientCountry:
          type: [string, 'null']
        isCreditNote:
          type: boolean
        totalAmount:
          type: [integer, 'null']
          description: Grand total including tax, in minor currency units.
        hasAttachment:
          type: boolean
        status:
          $ref: '#/components/schemas/DocumentStatus'
          description: >-
            The document's current status, as recorded by getpeppr from the
            provider's webhooks.
        legalEntityId:
          type: [string, 'null']
          format: uuid
        legalEntityName:
          type: [string, 'null']
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        detail:
          type: object
          description: >-
            Layer-2 national status detail, when a jurisdiction reports one.
        peppolMessageId:
          type: string
          description: >-
            The Peppol AS4 message id. Returned **only** with
            `?include=evidence`, and only once the document has actually gone
            out — it is read from the network on demand rather than stored.

    ValidationError:
      type: object
      required: [field, message]
      properties:
        field:
          type: string
          description: 'Field path (e.g., "to.street")'
        message:
          type: string
          description: Error message
        ruleId:
          type: string
          description: 'Peppol rule ID (e.g., "BR-50")'
        suggestion:
          type: string
          description: Suggested fix

    ValidationWarning:
      type: object
      required: [field, message]
      properties:
        field:
          type: string
          description: 'Field path (e.g., "lines[0].vatRate")'
        message:
          type: string
          description: Warning message
        ruleId:
          type: string
          description: 'Peppol rule ID (e.g., "BR-CO-17")'

    ClientValidationResult:
      type: object
      required: [valid, errors]
      properties:
        valid:
          type: boolean
          description: Whether the invoice passed all validation checks
        errors:
          type: array
          items:
            type: string
          description: List of validation error messages
      example:
        valid: true
        errors: []

    ServerValidationMessage:
      type: object
      required: [severity, message]
      properties:
        severity:
          type: string
          enum: [error, warning]
        message:
          type: string
          description: Validation message
        location:
          type: string
          description: Location in the XML document (XPath)
        ruleId:
          type: string
          description: Rule ID (e.g., Schematron rule)

    ServerValidationResult:
      type: object
      required: [valid, errors, warnings, ubl, xsd, schematron]
      properties:
        valid:
          type: boolean
          description: Overall validation result
        errors:
          type: array
          items:
            $ref: '#/components/schemas/ValidationError'
          description: SDK-level validation errors from validateInvoice
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/ValidationWarning'
          description: SDK-level validation warnings from validateInvoice
        ubl:
          type: object
          required: [valid, errors]
          properties:
            valid:
              type: boolean
              description: UBL XML generation completed without gateway build errors
            errors:
              type: array
              items:
                $ref: '#/components/schemas/ServerValidationMessage'
        xsd:
          type: object
          required: [valid, errors]
          deprecated: true
          description: Deprecated compatibility field. The gateway verifies UBL XML generation but does not run a standalone XSD validator.
          properties:
            valid:
              type: boolean
              description: Mirrors `ubl.valid` for backward compatibility; not a standalone XSD validation result.
            errors:
              type: array
              items:
                $ref: '#/components/schemas/ServerValidationMessage'
            note:
              type: string
              description: Compatibility note explaining that standalone XSD validation is not performed
        schematron:
          type: object
          required: [valid, coverage, errors, warnings]
          properties:
            valid:
              type: boolean
              description: |
                No problem found among the rules this endpoint checks — **not** a
                statement of Peppol conformance, and not a prediction that the
                document will be accepted.

                This offline pass runs 18 business rules. The network applies 333
                fatal rules to a document with no national family, and more when
                there is one, so `true` means "none of those 18 fired" and
                nothing more. Read `coverage` for what was actually checked.

                To run the complete official rulebooks instead, build your UBL
                and send it to `POST /validate/ubl`.
            coverage:
              type: object
              required: [rulesChecked, ofNetworkFatalRules]
              description: |
                What `valid` is worth. Present so that a caller never has to
                infer the scope of the check from the verdict alone.
              properties:
                rulesChecked:
                  type: integer
                  description: |
                    How many rules ran. `0` means none did — the document failed
                    to build as UBL, so `valid` carries no information at all.
                  example: 18
                ofNetworkFatalRules:
                  type: string
                  enum: [partial]
                  description: |
                    Always `partial`. A single-valued enum on purpose: this pass
                    is never complete, so there is no value a caller could branch
                    on to conclude otherwise.
            errors:
              type: array
              items:
                $ref: '#/components/schemas/ServerValidationMessage'
            warnings:
              type: array
              items:
                $ref: '#/components/schemas/ServerValidationMessage'
        countryRules:
          type: array
          description: >-
            Gateway-level country-rule findings the SDK validators cannot see
            (they need the account's registered Peppol identity). Currently
            covers the Dutch document rules (NL-R-001..008). A non-empty array
            means POST /v1/invoices would reject the same payload with a 422
            `country_rule_violation` carrying the same code. Does not affect
            `valid`.
          items:
            type: object
            required: [code, message, docs]
            properties:
              code:
                type: string
                description: Official Peppol rule id (e.g. "NL-R-003")
                example: NL-R-003
              message:
                type: string
                description: Actionable explanation of the violated rule
              docs:
                type: string
                format: uri
                description: Official Peppol documentation page for the rule

    # ─── Contact ────────────────────────────────────────────────

    Contact:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
          format: uuid
          description: Unique contact ID
          example: "6f1c2b3a-9d4e-4c5f-8a7b-1e2d3c4b5a69"
        accountId:
          type: string
          format: uuid
          description: Internal account identifier (surrogate key, not the organization ID)
        name:
          type: string
          description: Business name
          example: "ACMEDIA"
        peppolId:
          type: string
          description: Peppol participant ID
          example: "0208:0685660237"
        vatNumber:
          type: string
          description: VAT number
          example: "BE0685660237"
        companyId:
          type: string
          description: Company registration number
        street:
          type: string
          description: Street address
        city:
          type: string
          description: City
        postalCode:
          type: string
          description: Postal/zip code
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code
          example: "BE"
        email:
          type: string
          format: email
          description: Email address
        phone:
          type: string
          description: Phone number
        isClient:
          type: boolean
          description: Whether this contact is a client
        isProvider:
          type: boolean
          description: Whether this contact is a provider/supplier
        directoryVerified:
          type: boolean
          description: Whether the Peppol ID was found in the Peppol Directory at last check
        directoryLastChecked:
          type: [string, 'null']
          format: date-time
          description: Timestamp of the last Peppol Directory check (null if never checked)
        createdAt:
          type: string
          format: date-time
          description: Creation timestamp
        updatedAt:
          type: string
          format: date-time
          description: Last update timestamp

    ContactUpdate:
      type: object
      description: >-
        Partial update — only the fields present in the body are overwritten;
        omitted fields keep their current value. No field is required.
      properties:
        name:
          type: string
        peppolId:
          type: string
        vatNumber:
          type: string
        companyId:
          type: string
        street:
          type: string
        city:
          type: string
        postalCode:
          type: string
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code
        email:
          type: string
          format: email
        phone:
          type: string
        isClient:
          type: boolean
        isProvider:
          type: boolean

    ContactInput:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: Business name (required)
          example: "ACMEDIA"
        peppolId:
          type: string
          description: Peppol participant ID
        vatNumber:
          type: string
          description: VAT number
        companyId:
          type: string
          description: Company registration number
        street:
          type: string
        city:
          type: string
        postalCode:
          type: string
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code
        email:
          type: string
          format: email
        phone:
          type: string
          description: Phone number
        isClient:
          type: boolean
          description: Whether this contact is a client (default true)
          default: true
        isProvider:
          type: boolean
          description: Whether this contact is a provider/supplier (default false)
          default: false

    # ─── Bank Account ───────────────────────────────────────────

    BankAccount:
      type: object
      required: [id, name, type]
      properties:
        id:
          type: string
          format: uuid
          description: Unique bank account ID
          example: "2a7b4c1d-5e6f-4a8b-9c0d-3f2e1d4c5b6a"
        accountId:
          type: string
          format: uuid
          description: Internal account identifier (surrogate key, not the organization ID)
        name:
          type: string
          description: Display name
          example: "Main EUR Account"
        type:
          type: string
          enum: [iban, number]
          description: Account type
          example: "iban"
        iban:
          type: string
          description: IBAN (when type is "iban")
          example: "BE68539007547034"
        number:
          type: string
          description: Account number (when type is "number")
        bic:
          type: string
          description: BIC/SWIFT code
          example: "BBRUBEBB"
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code
          example: "BE"
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    BankAccountInput:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: Display name (required)
          example: "Main EUR Account"
        type:
          type: string
          enum: [iban, number]
          default: "iban"
          description: Account type
        iban:
          type: string
          description: IBAN (for type "iban" accounts; not enforced server-side)
        number:
          type: string
          description: Account number (for type "number" accounts; not enforced server-side)
        bic:
          type: string
          description: BIC/SWIFT code
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code

    BankAccountUpdate:
      type: object
      description: >-
        Partial update — only the fields present in the body are overwritten;
        omitted fields keep their current value. No field is required.
      properties:
        name:
          type: string
        type:
          type: string
          enum: [iban, number]
        iban:
          type: string
        number:
          type: string
        bic:
          type: string
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code

    # ─── Directory ──────────────────────────────────────────────

    DirectoryEntry:
      type: object
      required: [name, peppolId, country, capabilities]
      properties:
        name:
          type: string
          description: Registered business name
          example: "ACMEDIA"
        peppolId:
          type: string
          description: Peppol participant ID
          example: "0208:0685660237"
        country:
          type: string
          description: Country of registration
          example: "BE"
        capabilities:
          type: array
          items:
            type: string
          description: 'Supported document types (e.g., "invoice", "credit_note")'
          example: ["invoice", "credit_note"]
        registrationDate:
          type: string
          description: Date of Peppol registration (ISO 8601 date)
          example: "2020-11-20"
        vatNumber:
          type: string
          description: VAT number if available from directory
          example: "0685660237"
        additionalIds:
          type: array
          description: Additional identifiers (e.g., GLN, DUNS)
          items:
            type: object
            properties:
              scheme:
                type: string
                example: "be:cbe"
              value:
                type: string
                example: "0685660237"
        contactInfo:
          type: object
          description: Contact information from directory
          properties:
            name:
              type: string
            email:
              type: string
            phone:
              type: string
        website:
          type: string
          description: Website URL
          example: "https://acmedia.be"

    # ─── Onboarding ─────────────────────────────────────────────

    OnboardingResult:
      type: object
      required: [legalEntityId]
      properties:
        legalEntityId:
          type: string
          description: Legal entity identifier for your account
          example: "12345"
        peppolIdentifier:
          type: [object, 'null']
          description: Peppol ID registration result (null if not requested)
          properties:
            registered:
              type: boolean
              description: Whether the Peppol ID was successfully registered
            id:
              type: object
              description: Registered Peppol identifier (present when registered=true)
              properties:
                scheme:
                  type: string
                  example: "0208"
                identifier:
                  type: string
                  example: "0685660237"
            error:
              type: string
              description: Error message (present when registered=false)
            schemeWarning:
              type: string
              description: Warning if an uncommon Peppol scheme was used
        directoryWarning:
          type: string
          description: Warning if the Peppol ID was not found in the Peppol Directory

    # ─── Identity ───────────────────────────────────────────────

    IdentityResponse:
      type: object
      required: [environment, legalEntity, identifiers]
      properties:
        environment:
          type: string
          enum: [sandbox, production]
          description: The environment your API key targets
          example: "sandbox"
        legalEntity:
          type: ['object', 'null']
          description: >-
            Your account's own legal entity in this environment. `null` when no
            identity has been registered here yet — never a `404`.
          properties:
            companyName:
              type: ['string', 'null']
              example: "Bright Health Ltd"
            country:
              type: ['string', 'null']
              minLength: 2
              maxLength: 2
              description: ISO 3166-1 alpha-2 country code
              example: "GB"
            address:
              type: object
              description: Fields not captured at registration are `null`
              properties:
                line1:
                  type: ['string', 'null']
                  example: "10 King Street"
                city:
                  type: ['string', 'null']
                  example: "London"
                zip:
                  type: ['string', 'null']
                  example: "EC2V 8EA"
            createdAt:
              type: string
              format: date-time
              example: "2026-06-01T10:00:00.000Z"
        identifiers:
          type: array
          description: >-
            The Peppol identifiers registered for your own account in this
            environment. May be empty.
          items:
            type: object
            required: [scheme, value, status, createdAt]
            properties:
              scheme:
                type: string
                description: Peppol identifier scheme
                example: "GB:VAT"
              value:
                type: string
                example: "gb123456789"
              status:
                $ref: '#/components/schemas/PeppolLifecycleStatus'
              createdAt:
                type: string
                format: date-time
                example: "2026-06-01T10:05:00.000Z"

    # ─── Platform Legal Entities ───────────────────────────────

    PeppolLifecycleStatus:
      type: string
      enum: [pending, verifying, verified, no_registry, verification_failed, awaiting_authz, expired, attested, provisioning, active, provisioning_failed, registration_failed, archived]
      description: |
        Public lifecycle status — one vocabulary shared by platform sub-tenants
        (`GET /legal-entities/{id}`) and your own account's identifiers
        (`GET /identity`). Production `GET /legal-entities/{id}` layers
        attestation status on top of registry verification.

        `no_registry` means the published Peppol code list states that no entity
        stands behind this identifier's scheme ("No entity behind id"), so there is
        no registry to query and none was consulted. It is settled and sendable in
        **sandbox**, which is what lets you exercise the platform flow without
        registering an identifier that belongs to someone else. It is deliberately
        not reported as `verified`: it proves the identifier is registered and
        routable on the test network, and nothing about a company existing or about
        your right to act for it. Registering such a scheme with a production key is
        refused.
      example: "active"

    LegalEntityCreateRequest:
      type: object
      required: [externalId, companyName, country, address, identifier]
      properties:
        externalId:
          type: string
          minLength: 1
          maxLength: 64
          description: Your stable customer reference, echoed on responses and webhooks
          example: "customer_8412"
        companyName:
          type: string
          minLength: 2
          maxLength: 64
          description: Legal company name matched against the business registry
          example: "Bright Health Ltd"
        country:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code
          example: "GB"
        address:
          type: object
          required: [line1, city, zip]
          properties:
            line1:
              type: string
              minLength: 2
              maxLength: 192
              example: "10 King Street"
            city:
              type: string
              minLength: 2
              maxLength: 64
              example: "London"
            zip:
              type: string
              minLength: 2
              maxLength: 32
              example: "EC2V 8EA"
        identifier:
          type: object
          required: [scheme, value]
          properties:
            scheme:
              type: string
              minLength: 1
              maxLength: 16
              description: Peppol identifier scheme
              example: "GB:VAT"
            value:
              type: string
              minLength: 1
              maxLength: 64
              description: Identifier value
              example: "gb123456789"

    LegalEntity:
      type: object
      required: [id, externalId, companyName, country, identifier, status, environment, createdAt]
      properties:
        id:
          type: string
          format: uuid
          example: "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b"
        externalId:
          type: ['string', 'null']
          description: Your stable customer reference
          example: "customer_8412"
        companyName:
          type: ['string', 'null']
          example: "Bright Health Ltd"
        country:
          type: ['string', 'null']
          example: "GB"
        identifier:
          type: ['object', 'null']
          properties:
            scheme:
              type: string
              example: "GB:VAT"
            value:
              type: string
              example: "gb123456789"
        status:
          $ref: '#/components/schemas/PeppolLifecycleStatus'
        verificationDetail:
          type: object
          properties:
            reason:
              type: string
              enum: [name_mismatch, not_found]
            checkedAt:
              type: string
              format: date-time
            registryStatus:
              type: string
              enum: [inactive]
              description: >-
                Present only when the registry knows the company but does not
                consider it active — struck off, in liquidation, or not yet
                active. Re-sending the same details will not change the answer
                until the registry changes it. When this field is ABSENT, we simply
                have no such finding to show for the current state — which is not
                the same as no finding existing. Any of these produce it: no
                registry entry for the identifier, a registry unreachable at check
                time, a finding our team has since reviewed and superseded,
                evidence aged out of retention, or simply no inactive finding on
                record — verification can succeed on the VAT registration alone,
                so the national registry is not always consulted, and where it
                is, it is not always the source that decides.
                Absence is therefore not evidence about the company —
                treat this field as a POSITIVE signal only.
              example: "inactive"
        environment:
          type: string
          enum: [sandbox, production]
          example: "production"
        createdAt:
          type: string
          format: date-time
          example: "2026-06-01T10:00:00.000Z"

    LegalEntityPagination:
      type: object
      required: [total_count, offset, limit, has_more]
      properties:
        total_count:
          type: integer
          example: 42
        offset:
          type: integer
          example: 0
        limit:
          type: integer
          example: 50
        has_more:
          type: boolean
          example: true

    LegalEntityArchiveResult:
      type: object
      required: [id, externalId, status]
      properties:
        id:
          type: string
          format: uuid
          example: "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b"
        externalId:
          type: ['string', 'null']
          example: "customer_8412"
        status:
          type: string
          enum: [archived]
          example: "archived"

    LegalEntityAttestationRequest:
      type: object
      required: [contactEmail]
      properties:
        contactEmail:
          type: string
          format: email
          maxLength: 254
          description: Customer contact who can authorise production sending
          example: "owner@brighthealth.co.uk"
        contactName:
          type: string
          maxLength: 128
          description: Optional display name for the authorisation email
          example: "Dr Jane Okafor"

    LegalEntityAttestationResult:
      type: object
      required: [id, externalId, status, expiresAt]
      properties:
        id:
          type: string
          format: uuid
          example: "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b"
        externalId:
          type: ['string', 'null']
          example: "customer_8412"
        status:
          type: string
          enum: [awaiting_authz]
          example: "awaiting_authz"
        expiresAt:
          type: string
          format: date-time
          example: "2026-06-08T10:00:00.000Z"

    # ─── Health ──────────────────────────────────────────────────

    HealthStatus:
      type: object
      required: [status, checks]
      properties:
        status:
          type: string
          enum: [healthy, unhealthy]
          description: Overall health status (readiness — unhealthy as soon as any check fails)
        checks:
          type: object
          required: [db, redis]
          properties:
            db:
              type: boolean
              description: Database connectivity
            redis:
              type: boolean
              description: Redis connectivity

    # ─── Webhook Payload ─────────────────────────────────────────

    WebhookEventPayload:
      type: object
      required: [id, type, data, createdAt]
      properties:
        id:
          type: string
          description: 'Unique event ID (format: evt_{random16})'
          example: "evt_abc123def456"
        type:
          type: string
          enum: [invoice.sent, invoice.accepted, invoice.refused, invoice.error, invoice.registered, invoice.received, invoice.paid, invoice.undeliverable, invoice.delivery_unconfirmed, invoice.partially_paid, invoice.under_query, invoice.conditionally_accepted, invoice.status_changed, legal_entity.registered, legal_entity.verification_failed, legal_entity.awaiting_authz, legal_entity.registration_failed, peppol_identifier.verified, peppol_identifier.verification_failed, test.ping, inbound.invoice.received, inbound.creditnote.received]
          description: 'Event type. The inbound.* values (pilot) represent documents received from the Peppol network addressed to your Legal Entity, distinct from the outbound invoice.received status (acknowledgement of a document you sent).'
          example: "invoice.sent"
        data:
          description: >-
            Event-specific data. The shape depends on the event family:
            invoice.* status events carry WebhookStatusEventData
            (invoice.status_changed adds the per-axis state),
            inbound.* carry WebhookInboundDocumentData,
            peppol_identifier.* carry WebhookPeppolIdentifierData,
            legal_entity.* carry WebhookLegalEntityData and
            test.ping carries WebhookTestPingData.
          anyOf:
            - $ref: '#/components/schemas/WebhookStatusEventData'
            - $ref: '#/components/schemas/WebhookStatusChangedData'
            - $ref: '#/components/schemas/WebhookInboundDocumentData'
            - $ref: '#/components/schemas/WebhookPeppolIdentifierData'
            - $ref: '#/components/schemas/WebhookLegalEntityData'
            - $ref: '#/components/schemas/WebhookTestPingData'
        createdAt:
          type: string
          format: date-time
          description: Event timestamp (ISO 8601)
          example: "2026-02-26T10:30:00.000Z"

    WebhookStatusEventData:
      title: Status event data (invoice.*)
      type: object
      description: >-
        Payload of the outbound document status events (invoice.sent,
        invoice.accepted, invoice.paid, invoice.undeliverable,
        invoice.delivery_unconfirmed, …).
      required: [invoiceId, invoiceNumber, providerDocumentId, status, environment]
      properties:
        invoiceId:
          type: [string, 'null']
          format: uuid
          deprecated: true
          description: >-
            getpeppr submission ID (null if the submission row is not yet
            linked). **Deprecated:** the name says "invoice id" while `POST
            /invoices` returns the provider GUID under `id`, so a consumer that
            fed one to the other's endpoint used to get a 404. Read
            `submissionId` — same value, unambiguous. This field is unchanged
            and is not going away without a decided removal date.
        submissionId:
          type: [string, 'null']
          format: uuid
          description: >-
            getpeppr submission ID — the same value as `invoiceId`. Usable
            directly against GET /invoices/{id} and the ?invoiceId= filter on
            /events, as is `providerDocumentId` below.

            ⚠️ **Not `required`, deliberately.** Every live emitter sends it, but
            a delivery that failed before it existed is retried from a payload
            stored at the time it was queued — so our own outbox can still
            replay events without the field. Marking it required would make a
            subscriber that validates this schema reject exactly those retries.
            Read `invoiceId` when it is absent; the two are the same value.
        invoiceNumber:
          type: [string, 'null']
        providerDocumentId:
          type: string
          description: Provider (Storecove) document GUID
        status:
          $ref: '#/components/schemas/DocumentStatus'
        environment:
          type: [string, 'null']
          description: sandbox or production (null if unresolved)
        submittedAt:
          type: string
          format: date-time
          description: >-
            When the document was submitted. Sent only on
            `invoice.delivery_unconfirmed`, where the age of the send is the
            point of the notification. Optional: no other event carries it.
        observedAt:
          type: string
          format: date-time
          description: >-
            When we established that no delivery evidence had arrived yet. Sent
            only on `invoice.delivery_unconfirmed`. Optional.

    WebhookStatusChangedData:
      title: Generic status notification data (invoice.status_changed)
      allOf:
        - $ref: '#/components/schemas/WebhookStatusEventData'
        - type: object
          required: [axes]
          properties:
            axes:
              type: object
              description: >-
                Per-axis state of the document (multi-jurisdiction status
                model). Each axis is null until a signal has been received
                for it.
              required: [platformFiscal, delivery, businessDisposition, settlement]
              properties:
                platformFiscal:
                  type: [string, 'null']
                delivery:
                  type: [string, 'null']
                businessDisposition:
                  type: [string, 'null']
                settlement:
                  type: [string, 'null']
            detail:
              $ref: '#/components/schemas/StatusDetail'

    WebhookInboundDocumentData:
      title: Inbound document data (inbound.*)
      type: object
      description: Payload of inbound.invoice.received / inbound.creditnote.received (pilot).
      required: [receivedDocumentId, legalEntityId, documentType, sender, receivedAt, providerDocumentId, document]
      properties:
        receivedDocumentId:
          type: string
          format: uuid
        legalEntityId:
          type: string
        externalSubTenantId:
          type: [string, 'null']
          description: Your external sub-tenant reference (platform accounts only)
        documentType:
          type: string
          enum: [invoice, creditnote]
        sender:
          type: object
          properties:
            peppolId:
              type: [string, 'null']
            name:
              type: [string, 'null']
        invoiceNumber:
          type: [string, 'null']
        receivedAt:
          type: string
          format: date-time
        providerDocumentId:
          type: string
        document:
          type: object
          description: The received UBL document, base64-embedded when within the size cap.
          properties:
            format:
              type: string
              enum: [ubl]
            encoding:
              type: string
              enum: [base64]
            content:
              type: [string, 'null']
              description: Base64 UBL content (null when omitted for size)
            contentOmittedReason:
              type: [string, 'null']
              enum: [size, null]
            sizeBytes:
              type: integer

    WebhookPeppolIdentifierData:
      title: Peppol identifier verification data (peppol_identifier.*)
      type: object
      required: [identifierId, scheme, identifier, verifiedAt]
      properties:
        identifierId:
          type: string
          format: uuid
        scheme:
          type: string
          example: "0208"
        identifier:
          type: string
          description: The identifier value (may be partially masked on collision events)
        companyName:
          type: [string, 'null']
        verifiedAt:
          type: string
          format: date-time
        similarity:
          type: [number, 'null']
          description: Registry name similarity score when applicable
        reason:
          type: [string, 'null']
          description: Failure or collision reason when applicable

    WebhookLegalEntityData:
      title: Platform sub-tenant lifecycle data (legal_entity.*)
      type: object
      required: [subTenantId, legalEntityId, peppolId, status, environment, occurredAt]
      properties:
        subTenantId:
          type: [string, 'null']
          description: Your external sub-tenant reference
        legalEntityId:
          type: string
        peppolId:
          type: [string, 'null']
          description: 'scheme:value form, when registered'
        status:
          type: string
          description: Sub-tenant lifecycle status (same vocabulary as LegalEntity.status)
        environment:
          type: string
        reason:
          type: string
          description: Present on failure events
        occurredAt:
          type: string
          format: date-time

    WebhookTestPingData:
      title: Test ping data (test.ping)
      type: object
      required: [message]
      properties:
        message:
          type: string
          example: "This is a test webhook from getpeppr."

    # ─── Transports ─────────────────────────────────────────────

    Transport:
      type: object
      properties:
        id:
          type: string
          description: Unique transport ID
        transportTypeCode:
          type: string
          description: Transport type code
        name:
          type: string
          description: Human-readable name
        status:
          type: string
          description: 'Transport status (e.g., "active", "inactive")'

    TransportType:
      type: object
      required: [code, name]
      properties:
        code:
          type: string
          description: Transport type code
          example: "peppol"
        name:
          type: string
          description: Human-readable name
          example: "Peppol BIS 3.0"

    # ─── Events ─────────────────────────────────────────────────

    ReceivedDocument:
      type: object
      description: |
        A Peppol document received on behalf of one of your Legal Entities.

        Every field except the identifiers is derived from the document the
        sender transmitted, so treat the text as third-party input.
      # All 14 keys are ALWAYS emitted — the DTO builds them one by one and never
      # omits any (nullable ≠ optional). Listing only the non-nullable ones would
      # let a future projection drop `senderName` while both the spec and the
      # drift test stayed green.
      required:
        [id, invoiceNumber, documentType, dispatchStatus, environment, senderName,
         senderPeppolId, receivedAt, ublSizeBytes, legalEntityId, legalEntityName,
         currency, totalAmountMinor, issueDate]
      properties:
        id:
          type: string
          format: uuid
        invoiceNumber:
          type: [string, 'null']
          description: Document number as written by the sender. Not unique across senders.
          example: "SUP-99"
        documentType:
          type: string
          enum: [invoice, creditnote, unknown]
          description: >-
            `unknown` means the document reached you but its type is outside what
            getpeppr currently classifies — the original UBL is still retrievable.
        dispatchStatus:
          type: string
          enum: [pending, dispatched, no_subscriber, skipped_unknown_type]
          description: >-
            State of the webhook that notifies you of this document. It describes
            OUR delivery to you, never the document's standing on the network.
        environment:
          type: string
          enum: [sandbox, production]
        senderName:
          type: [string, 'null']
        senderPeppolId:
          type: [string, 'null']
          description: The sender's Peppol participant identifier, `scheme:value`.
          example: "0208:0999999999"
        receivedAt:
          type: string
          format: date-time
          description: When the document reached getpeppr (UTC).
        ublSizeBytes:
          type: [integer, 'null']
          description: Size of the original UBL. Null on list responses.
        legalEntityId:
          type: string
          format: uuid
          description: The Legal Entity this document was addressed to.
        legalEntityName:
          type: [string, 'null']
        currency:
          type: string
          description: ISO 4217 code taken from the document; defaults to EUR when absent.
          example: EUR
        totalAmountMinor:
          type: [integer, 'null']
          description: >-
            Total including VAT, in minor units of `currency`. **Null when the
            amount could not be derived** from the UBL — never a silent zero,
            which would be indistinguishable from a genuine zero-rated total.
        issueDate:
          type: string
          description: Issue date as written by the sender; empty string when absent.
          example: "2026-06-18"

    EventEntry:
      type: object
      required: [id, eventType, createdAt]
      properties:
        id:
          type: string
          format: uuid
          description: Unique event ID
          example: "3f9e1a7c-4b2d-4e8f-9c1a-7b6d5e4f3a2b"
        eventType:
          type: string
          description: 'Event type (e.g., "invoice.sent", "invoice.accepted", "inbound.invoice.received")'
          example: "invoice.sent"
        documentId:
          type: ['string', 'null']
          description: Associated invoice/document ID (null for non-document events)
          example: "12345"
        metadata:
          type: ['object', 'null']
          description: Additional event metadata (varies by event type, may be null)
        createdAt:
          type: string
          format: date-time
          description: Event timestamp
          example: "2026-02-26T10:30:00Z"

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            missing:
              summary: Missing Authorization header
              value:
                error: "Missing or invalid Authorization header"
            invalid:
              summary: Invalid API key
              value:
                error: "Invalid API key"

    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          description: Number of seconds to wait before retrying
          schema:
            type: integer
          example: 42
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "Rate limit exceeded. Try again later."

    PayloadTooLarge:
      description: Request body exceeds the 1 MB limit
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "Payload too large. Maximum size: 1024KB"

    InternalError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "Internal server error"
