openapi: 3.0.3

info:
  title: Archiles Peppol API
  version: 1.0.0-draft
  description: |
    **DRAFT — not the final 1.0 contract.** This document is circulated for review. Paths, schemas,
    field names and status codes may still change, without notice and without a version bump, until
    it is published as 1.0.0. Build against it to give feedback, not to ship.

    Allows a third party to use Archiles as a Peppol Access Point entirely over an API,
    with no Archiles UI interaction: onboarding, registry checks, pre-send validation,
    sending (Peppol BIS or other agreed formats, incl. attachments), sent-status tracking,
    and pull-based retrieval of received e-invoices with optional PDF visualisation.

    Two surfaces over one core:
      * `/v1/sapi/*` — SAPI (sapi-sk.sk) compatibility profile, frozen to their schema.
      * `/v1/peppol/*` and `/v1/e-invoices/*` — Archiles native: onboarding and SMP under
    `/v1/peppol/*`; validation, sending, sent-status and the inbox under `/v1/e-invoices/*`.

    The `/v1/sapi/*` half is verified field-by-field against the published SAPI-SK v1.0.0 schema.
    Three deliberate, documented deviations, none of which can break a conforming client:
      1. payload limit is 20 MB (SAPI's wording is "Maximum size: 10 MB", i.e. a ceiling — accepting
         more than a conforming client will ever send is a superset);
      2. for RECEIVED documents Archiles assigns `documentId`, because SAPI's client-assigned
         reading is not unique across senders — two senders can use the same invoice number towards
         one receiver, which would make the receive path ambiguous;
      3. `metadata.creationDateTime` is treated as a submission timestamp and is excluded from the
         payload-mismatch check, because UBL carries no equivalent field. It is recorded, not
         compared.

    Three further differences are matters of declaration rather than behaviour, and none of them can
    break a conforming client:
      * `429` and `500` are documented on the SAPI operations. SAPI's schema omits them, but they are
        transport realities of any HTTP API and a client already handles both.
      * `402` is documented on `POST /v1/sapi/document/send` — an account with no active subscription
        or an exhausted document allowance cannot send on either surface, and saying so is better than
        answering with something vaguer.
      * `TokenResponse` includes `refresh_token`. SAPI's schema omits it while its own `/auth/renew`
        requires one, so a strictly conforming response could never supply the token the client is
        expected to present. It is returned on both surfaces.

    `/v1/sapi/auth/*` and `/v1/oauth/*` are ALIASES of one another: same handlers, same token store.
    A token obtained from either works on both surfaces.

    This is the ONLY authentication contract: `client_credentials` in, a bearer JWT out. Every
    call made with the resulting token acts on behalf of the account owner, with admin rights on
    the account. How tokens are issued internally is an implementation detail and is deliberately
    not part of this contract.

    Errors use SAPI's envelope on BOTH surfaces (`{"error": {...}}` with `category`, `code`,
    `message`, `retryable`, `correlation_id`). RFC 7807 problem+json is NOT used.

    **Roadmap:** optional webhooks/callbacks are planned as an alternative to polling — one
    notifying a client of outbound send-status changes, one notifying a client of a newly received
    e-invoice. Until then, both are polled (`GET /v1/e-invoices/submissions*`, `GET /v1/e-invoices/inbox*`).

    **Conventions that hold everywhere in this contract**

    * *Arrays are always present.* Every array-typed response field is returned as `[]` when it has
      no members — never omitted, never `null`. This covers `documentTypes`, `errors`, `warnings`,
      `items`, `results` and `attachments`, each of which is declared `required` so a generated client
      types it as guaranteed. **The one exception is `error.details`**, which is absent when there is
      nothing to say about individual fields.
    * *Optional scalars are omitted, not nulled.* A scalar with no value is absent from the JSON;
      clients must treat absent and `null` as the same thing.
    * *Paging.* The native surface (`/v1/peppol/*`, `/v1/e-invoices/*`) always returns `nextCursor`, including on the
      last page; a client stops when a page comes back with an empty `items` array. The SAPI surface
      (`/v1/sapi/*`) omits `nextPageToken` entirely on the last page, as SAPI specifies. A client
      using both surfaces must implement both stop conditions — they are deliberately different
      because the SAPI half is frozen to its published schema.
    * *Correlation.* Every response of every participant, registry, validation, submission and inbox
      operation — successful or not — carries an `X-Correlation-Id` header, and error bodies repeat it
      as `error.correlation_id`. Quote it in any support request. **The token routes (`/v1/oauth/*`,
      `/v1/sapi/auth/*`) are the exception and return no correlation id**; for a problem there, quote
      your `client_id` and the time of the call instead.
    * *Rate-limit headers.* The same operations carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
      `X-RateLimit-Reset`; the token routes do not. The SAPI document operations return both sets of
      headers as well, but deliberately do not declare them, because that half of the contract is
      frozen to SAPI's published schema.
    * *Maintenance and infrastructure faults.* Any operation may answer `503` during a deployment or an
      infrastructure fault, whether or not `503` is listed on it. Treat it as `category: TEMPORARY` and
      honour `Retry-After`.
    * *Identifiers.* `documentId` always means the Archiles document id, on both surfaces and in both
      directions — a received document has the same id on `/v1/e-invoices/inbox/*` and on
      `/v1/sapi/document/receive/*`. `submissionId` identifies one *submission* of an outgoing document
      and is what the submission and retry routes address. It is issued when the submission is accepted,
      which can be before the document itself exists — so a batch item that fails later carries a
      `submissionId` and no `documentId`. A retry keeps both; a corrected resend is a new submission
      *and* a new document. SAPI's `providerDocumentId` on send **is** the `submissionId`, so a SAPI
      client can poll `GET /v1/e-invoices/submissions/{submissionId}` with the value it was handed. The single
      exception is SAPI's `metadata.documentId` **on send**, which SAPI defines as the client's own
      invoice number.

    **Error handling.** Branch on the HTTP status and on `error.retryable` — never on `error.code`.
    Codes are stable strings for support and logging, and they are meant to be **shown to the user
    together with `error.message`**; the set is open and grows without notice, so no client should
    switch on it. In particular `402` is a single condition — "this account cannot send more documents
    right now" — and does not distinguish a subscription that never existed from one that expired or
    exhausted its document allowance: the remedy is the same, extend the subscription.

    **Retention.** Nothing is purged automatically. A received document, its payload and its
    attachments are kept for as long as the account keeps them, and a `404` on a document id means it
    was deleted by the account's own users, not aged out. The one time-bounded record is
    `Idempotency-Key` state (24 hours), which is a transport artefact, not a document.

    **Rate limits** are per API client, not per participant. An integrator that fronts many
    participants therefore shares one budget across all of them and has to pace its own work; see
    `X-RateLimit-*` on every response.

    Companion narrative documentation: `integration-guide.md`. Version history and change policy:
    `CHANGELOG.md`.

servers:
  - url: https://api.test.archiles.sk
    description: Test / sandbox
  - url: https://api.archiles.sk
    description: Production (placeholder — final hostname TBC)

tags:
  - name: Auth
    description: OAuth2 client_credentials, shared by both surfaces.
  - name: Participants
    description: Onboarding and participant state (native only).
  - name: Registry
    description: Peppol/SMP registration and capability checks (native only).
  - name: Validation
    description: Pre-send validation with no side effects (native only).
  - name: Submissions
    description: Sending and sent-status (native).
  - name: Inbox
    description: Received e-invoices, content retrieval, custody acknowledgement (native).
  - name: SAPI profile
    description: Wire-compatible subset for ERPs that implement SAPI only.

security:
  - bearerAuth: []

paths:

  # ────────────────────────────────────────────────────────────────────────────
  # Auth (shared by both surfaces)
  # ────────────────────────────────────────────────────────────────────────────

  /v1/oauth/token:
    post:
      operationId: getToken
      tags: [Auth]
      summary: Exchange client credentials for an access token
      description: |
        Alias of `POST /v1/sapi/auth/token` — same handler, same token store, interchangeable tokens.
        Access tokens are valid for 15 minutes (`expires_in: 900`), matching SAPI.

        The issued token identifies the **account owner user, with admin rights on the account**:
        every subsequent call acts on their behalf, and Archiles' authorization checks and audit
        trail record them as the actor. Scoping is therefore per participant
        (`X-Peppol-Participant-Id` + your entitlements), not per operation.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type:
                  type: string
                  enum: [client_credentials]
                client_id:
                  type: string
                client_secret:
                  type: string
      responses:
        '200':
          description: Token issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '423':
          description: |
            Account temporarily locked after too many failed authentication attempts. Identical to
            `POST /v1/sapi/auth/token`'s `423` — same handler, same lockout counters. Back off rather
            than retrying a rejected secret in a loop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/oauth/token/status:
    get:
      operationId: getTokenStatus
      tags: [Auth]
      summary: Check access-token validity and expiry
      responses:
        '200':
          description: Token status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenStatus' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/oauth/renew:
    post:
      operationId: renewToken
      tags: [Auth]
      summary: Issue a new token pair from a refresh token (with rotation)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        '200':
          description: New token pair; the presented refresh token is invalidated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/oauth/revoke:
    post:
      operationId: revokeToken
      tags: [Auth]
      summary: Invalidate a refresh token
      description: |
        **No client authentication, by decision.** SAPI's `/auth/revoke` is unauthenticated and takes
        a bare `token`, and Archiles keeps that shape on **both** the SAPI route and this alias rather
        than diverging on one of them: possession of the refresh token is the only check. The exposure
        is bounded — the only thing an attacker who already holds your refresh token can do here is
        invalidate it, which is what you would want to happen anyway — and revocation is idempotent,
        so a replayed call changes nothing.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                token_type_hint: { type: string, enum: [refresh_token], default: refresh_token }
      responses:
        '200':
          description: |
            Revoked (idempotent — also returned for an unknown token). 200 rather than 204 so this
            alias behaves identically to `POST /v1/sapi/auth/revoke`.
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  # ────────────────────────────────────────────────────────────────────────────
  # Participants — scenario 1 (vytvoriť nové konto)
  # ────────────────────────────────────────────────────────────────────────────

  /v1/peppol/participants:
    post:
      operationId: createParticipant
      tags: [Participants]
      summary: Create a new participant account for a DIČ
      description: |
        Creates a new Archiles account for the given DIČ, ready to be used as a Peppol participant.

        **Deliberately takes no `X-Peppol-Participant-Id`**: the participant does not exist yet, so
        there is no participant context to send. Entitlement to create is a property of the
        credential itself.

        **The SMP registration itself is NOT performed by this call** — it continues to arrive
        inbound via the PDS webhook, out of band. **A newly created participant can neither send nor
        receive until `registrationState` becomes `Active`**, which happens once that webhook
        confirms SMP registration. Poll `GET /v1/peppol/participants/{participantId}` for readiness.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateParticipantRequest' }
      responses:
        '201':
          headers:
            Location:
              description: URL of the created participant.
              schema: { type: string, format: uri }
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Participant' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: A participant with this DIČ already exists
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422':
          description: |
            The request is well-formed and the DIČ is free, but the account cannot be provisioned
            automatically — for example the integrator's account allowance is exhausted, or the
            subject needs manual review. `category: PERMANENT`, `retryable: false`; retrying the
            same request will not succeed. Contact support@archiles.sk.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      operationId: listParticipants
      tags: [Participants]
      summary: List participants this API client is entitled to
      description: |
        **Deliberately takes no `X-Peppol-Participant-Id`** — it is the operation that tells a client
        which participants exist for it. Returns every participant the credential is entitled to,
        so there is nothing to scope and no 403 to emit.
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Participant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/peppol/participants/{participantId}:
    get:
      operationId: getParticipant
      tags: [Participants]
      summary: Get participant state, including SMP registration readiness
      description: |
        The `participantId` in the path **is** the tenancy key for this call, so no
        `X-Peppol-Participant-Id` header is sent. A participant the credential is not entitled to
        returns 403 whether or not it exists.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdPath'
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Participant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
  # ────────────────────────────────────────────────────────────────────────────
  # Registry — scenario 2 (overiť DIČ v Peppole + typy dokumentov)
  # ────────────────────────────────────────────────────────────────────────────

  /v1/peppol/registry/{participantId}:
    get:
      operationId: checkRegistry
      tags: [Registry]
      summary: Check whether a participant is registered in Peppol and what it can receive
      description: |
        Returns the participant's registration flag and the fully qualified Peppol document type
        identifiers it advertises in the SMP. No request parameter selects document types — whatever
        the SMP publishes is returned in full.

        **Not entitlement-scoped, and therefore no `X-Peppol-Participant-Id` and no 403.** SMP
        registration data is public Peppol directory information: any authenticated client may look
        up any participant, including ones it does not manage. That is deliberate — the normal use
        is checking a *trading partner* before invoicing them.

        When the SMP lookup cannot be performed (misconfiguration, network, SML/SMP failure) the
        response is `503 TEMPORARY` rather than a third boolean state. Archiles itself remains
        fail-closed on send.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdPath'
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: Verdict obtained
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RegistryResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/TemporaryUnavailable' }

  /v1/peppol/registry/lookup:
    post:
      operationId: lookupRegistryBulk
      tags: [Registry]
      summary: Bulk registry check
      description: |
        Per-item errors are reported inside the result array, so a single flaky lookup does not
        fail an entire multi-thousand-partner sweep. For the same reason there is **no
        whole-request 503**: an SMP failure surfaces as an `error` on the affected entries, with the
        remaining entries answered normally. Retry only the failed participants.

        Like the single registry check, this is public SMP data: **not entitlement-scoped, no
        `X-Peppol-Participant-Id`, no 403.**
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [participantIds]
              properties:
                participantIds:
                  type: array
                  maxItems: 1000
                  items: { type: string, example: '0245:2022182030' }
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: One result per requested participant, in request order
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items: { $ref: '#/components/schemas/RegistryResultOrError' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
  # ────────────────────────────────────────────────────────────────────────────
  # Validation — scenario 3 (validovať pred odoslaním)
  # ────────────────────────────────────────────────────────────────────────────

  /v1/e-invoices/validate:
    post:
      operationId: validateDocument
      tags: [Validation]
      summary: Validate a document without sending it
      description: |
        Runs the identical chain the send path runs — XSD, CEN/Peppol Schematron, SK national
        rules, and an Archiles **mapping dry-run** — with zero side effects. No `Document` is
        created and nothing is enqueued.

        The mapping dry-run is included because successful mapping into the Archiles archive model is a
        hard precondition for sending; without it a client could not predict a send-time rejection.

        **Why this side-effect-free operation still requires `X-Peppol-Participant-Id`:** validation
        is performed *as* a participant, not in the abstract. The header selects the account whose
        settings shape the outcome — the validation-strictness setting below, the EAS scheme used to
        build participant identifiers from the payload, and the default currency/country applied
        when the payload omits them — and it is the value the entitlement check runs against, so
        metering and rate limiting attribute the call correctly. A client therefore cannot validate
        before it has a participant; create one first.

        `clientReference` is your own identifier for the document being validated. It is not used
        by the validation logic itself — it is only echoed back in `ValidationResult` so you can
        match a result to the document you sent, which matters most for
        `POST /v1/e-invoices/validate/batch`, where results are correlated by this field rather than
        by array position.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ValidationRequest' }
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: Validation completed (check `valid`; an invalid document is still a 200)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/validate/batch:
    post:
      operationId: validateDocumentBatch
      tags: [Validation]
      summary: Validate many documents without sending them
      description: |
        Each item's `clientReference` is your own identifier for that document, and results carry it
        back so a result can be matched to its source. Results are returned in request order, so
        position works as a fallback when an item has no `clientReference` — but a reference is the
        sturdier key and is recommended for every item.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  maxItems: 500
                  items: { $ref: '#/components/schemas/ValidationRequest' }
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: One result per item, keyed by clientReference
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    description: |
                      One entry per item, always present, **in request order**. Correlate by
                      `clientReference` when you supplied one — it survives any future change to
                      ordering — and fall back to position when you did not.
                    items: { $ref: '#/components/schemas/ValidationResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
  # ────────────────────────────────────────────────────────────────────────────
  # Submissions — scenarios 4 and 5 (odoslať / overiť stav)
  # ────────────────────────────────────────────────────────────────────────────

  /v1/e-invoices/submissions:
    post:
      operationId: sendDocument
      tags: [Submissions]
      summary: Send a single e-invoice
      description: |
        Validation, mapping into the Archiles archive model, and enqueueing all happen
        **synchronously**; network delivery is asynchronous. `202` therefore means *technical
        acceptance*, not delivery.

        When `format` is `peppol-bis`, the supplied payload is transmitted **byte-for-byte** —
        it is never re-serialised through the Archiles exporter.

        Mapping into the Archiles archive model is a hard precondition: a structural mapping failure
        rejects the submission with `400 VALIDATION`. Unmappable optional fields are accepted and
        recorded as warnings on the archived document.

        In the `application/json` body, `payload` **must be base64-encoded** (see
        `DocumentEnvelope`); the `multipart/form-data` alternative below takes the raw binary
        payload directly.

        **Attachments must already be embedded in the UBL you submit**, as
        `AdditionalDocumentReference/cac:Attachment/cbc:EmbeddedDocumentBinaryObject`. There is no
        separate `attachments` field on send: the payload is transmitted byte-for-byte, so Archiles
        cannot add anything to it without rewriting a document it promised not to touch. (Inbound is
        the other direction and does expose attachments as sub-resources — see
        `GET /v1/e-invoices/inbox/{documentId}/attachments/{attachmentId}`.)

        **`X-Peppol-Participant-Id` must match the payload.** The supplier endpoint derived from the
        UBL (`cac:AccountingSupplierParty`, built via the account's EAS scheme) has to be the
        participant in the header; a disagreement is a `400 VALIDATION` and is not retryable without
        changing one of the two. Sending participant A's invoice in participant B's context is a client
        bug, and refusing it here is cheaper than discovering it after transmission. The same rule holds
        on `POST /v1/sapi/document/send`, which additionally compares the whole declared `metadata`
        block against the payload.

        **Planned: account-level validation strictness setting.** An upcoming account setting will
        control how strictly submitted documents are checked before sending: `strict` (the planned
        default) runs the full chain, including CEN/Peppol Schematron, identical to
        `POST /v1/e-invoices/validate`; a `basic` mode will check only well-formed UBL structure. The
        relaxed mode is intended for accounts that also work through the Archiles UI, where a human
        can add or correct data before the document is actually transmitted. This setting is
        account-wide and will be configured outside this API.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DocumentEnvelope' }
          multipart/form-data:
            schema:
              type: object
              required: [envelope, payload]
              description: |
                Alternative body that avoids base64-expanding a large payload: the document is sent
                as raw bytes in its own part. The `envelope` part carries the metadata **without**
                `payload` (that is why it is `SendEnvelopeMetadata`, not `DocumentEnvelope`); the
                binary `payload` part carries the document itself. Supplying document bytes in both
                parts is a `400 VALIDATION`.

                Native single send only. `POST /v1/e-invoices/submissions/batch` is `application/json` only,
                even though its total limit is higher — a multipart batch would need per-item part
                naming that no client library agrees on.
              properties:
                envelope:
                  $ref: '#/components/schemas/SendEnvelopeMetadata'
                payload:
                  type: string
                  format: binary
                  description: The raw, unencoded document bytes. Max 20 MB.
            encoding:
              envelope:
                contentType: application/json
              payload:
                contentType: application/xml
      responses:
        '202':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: |
            Accepted for delivery; the submission is in status `Queued`. Technical acceptance only —
            it says nothing about delivery.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubmissionAccepted' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      operationId: listSubmissions
      tags: [Submissions]
      summary: List sent documents and their states
      description: |
        Lists submissions **for the participant in `X-Peppol-Participant-Id`** only; there is no
        cross-participant listing.

        Polling is currently the only way to observe status changes. An optional webhook/callback
        for send-status changes is planned — see the top-level API description.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - name: since
          in: query
          schema: { type: string }
          description: |
            Opaque cursor from a previous page, encoding `(lastStateChangeAt, submissionId)`. Paging is
            keyset-based over a value that **changes as submissions progress**, which has one
            consequence worth designing for: a submission whose status changes while you page moves to a
            later position and is therefore returned **again**, rather than being skipped.

            **So this list is at-least-once: the same `submissionId` can appear on two pages of one
            sweep.** Deduplicate on `submissionId` and make your processing idempotent. (The inbox does
            not behave this way — `receivedAt` never changes, so `GET /v1/e-invoices/inbox` is
            exactly-once.) Skipping is the failure mode that would lose data, and it is the one this
            ordering rules out.
        - name: changedFrom
          in: query
          schema: { type: string, format: date-time }
          description: |
            Return only submissions whose state last changed at or after this instant. Bounds a first
            sweep the same way `receivedFrom` does on the inbox; ignored when `since` is supplied.
        - name: status
          in: query
          description: |
            Filter by current status. The filter and the `status` in the response always agree: both read
            the same stored value, which is written by the same handler that advances the underlying
            conversation.
          schema: { $ref: '#/components/schemas/SubmissionState' }
        - name: clientReference
          in: query
          schema: { type: string }
          description: Filter by the caller's own reference.
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items, nextCursor]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Submission' }
                  nextCursor:
                    type: string
                    description: Always present; the client stops when a page returns no items.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/submissions/batch:
    post:
      operationId: sendDocumentBatch
      tags: [Submissions]
      summary: Send many e-invoices (bulk, asynchronous)
      description: |
        Only cheap checks run synchronously: entitlement, well-formedness, and size/count limits.
        Schematron validation, mapping and publishing run **asynchronously per item**; retrieve
        outcomes from `GET /v1/e-invoices/submissions/batch/{batchId}`.

        Results are returned in request order and echo each item's `clientReference`. Correlate by the
        reference where you supplied one, by position otherwise.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  maxItems: 500
                  description: Also bounded by a total request size of 100 MB.
                  items: { $ref: '#/components/schemas/DocumentEnvelope' }
      responses:
        '202':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: Batch accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchAccepted' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/submissions/batch/{batchId}:
    get:
      operationId: getSubmissionBatch
      tags: [Submissions]
      summary: Get per-item outcomes for a batch
      description: |
        The batch must belong to the participant in `X-Peppol-Participant-Id`; a batch id belonging
        to another participant returns 404, not 403, so ids cannot be probed across tenants.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - name: batchId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchStatus' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/submissions/{submissionId}:
    get:
      operationId: getSubmission
      tags: [Submissions]
      summary: Get the status of one sent document
      description: |
        The submission must belong to the participant in `X-Peppol-Participant-Id`; a submission id
        belonging to another participant returns 404, not 403.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - name: submissionId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Submission' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/submissions/{submissionId}/retry:
    post:
      operationId: retrySubmission
      tags: [Submissions]
      summary: Retransmit a submission that was not delivered
      description: |
        Re-enqueues **the payload already stored for this submission** — the same bytes, the same
        archived document, the same Peppol conversation and the same transmission identity. It is a
        retransmission, not a new invoice, so it cannot duplicate the document in the archive; the
        submission returns to status `Queued` and keeps its `submissionId`.

        **Allowed from `DeliveryUnconfirmed`, `Undeliverable` and `Failed`.** Everything else is a
        `409`:

        | Current status | Result | Why |
        |---|---|---|
        | `DeliveryUnconfirmed` | 202, back to `Queued` | no MLS arrived; retransmitting is the documented remedy |
        | `Undeliverable` | 202, back to `Queued` | retry once the cause is fixed — typically after the receiver has registered in the SMP for this document type. Retrying before that simply fails the same way |
        | `Failed` | 202, back to `Queued` | our side failed; a retry is a genuine second attempt |
        | `Queued`, `Sent` | 409 | a transmission is already in flight |
        | `Delivered` | 409 | the document was delivered — a resend risks a duplicate invoice at the buyer |
        | `Rejected` | 409 | the recipient rejected the content; the same bytes will be rejected again. Send a corrected document as a new submission |

        A retry is **not** rate-limit-free: it counts as one item, like a send.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - name: submissionId
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: Accepted for retransmission; the submission is back in status `Queued`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubmissionAccepted' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The submission is not in a retryable status. The body's `message` names the current status
            and, for `Delivered` and `Rejected`, why retransmitting is refused rather than merely
            pointless.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/submissions/retry:
    post:
      operationId: retrySubmissions
      tags: [Submissions]
      summary: Retransmit many submissions
      description: |
        Bulk form of `POST /v1/e-invoices/submissions/{submissionId}/retry`, with the same per-status rules.
        Per-item outcomes are reported in the response, so one non-retryable submission does not fail
        the whole request — which is the point when re-driving a night's worth of
        `DeliveryUnconfirmed` items.

        This is also the natural way to re-drive the failed items of a batch: a batch item that was
        *admitted* has a `submissionId` and belongs here. An item **rejected at admission** never got
        one — nothing was created for it, so resubmit it as a new send or a new batch (see
        `BatchAccepted`).
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [submissionIds]
              properties:
                submissionIds:
                  type: array
                  maxItems: 500
                  items: { type: string }
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: Per-item outcome, in request order.
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      required: [submissionId, retried]
                      properties:
                        submissionId: { type: string }
                        retried:
                          type: boolean
                          description: True when the submission was re-enqueued.
                        status:
                          allOf:
                            - $ref: '#/components/schemas/SubmissionState'
                          description: |
                            Status after the call — `Queued` when retried, otherwise the status that
                            made the submission ineligible.
                        error:
                          allOf:
                            - $ref: '#/components/schemas/ApiError'
                          description: Present only when `retried` is false.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  # ────────────────────────────────────────────────────────────────────────────
  # Inbox — scenarios 6 and 7 (nové prijaté / stiahnuť + prílohy ± PDF)
  # ────────────────────────────────────────────────────────────────────────────

  /v1/e-invoices/inbox:
    get:
      operationId: listInbox
      tags: [Inbox]
      summary: List received e-invoices (cursor-paged descriptors)
      description: |
        Descriptors carry no payload, so polling for new mail is cheap.

        `since` takes the `nextCursor` from the previous page. That value is an opaque encoding of
        `(receivedAt, documentId)`, and paging compares the pair — a document is returned when its
        `receivedAt` is later than the cursor's, or equal to it with a greater document id. That is what
        makes paging tie-safe when a bulk arrival stamps many documents with the same timestamp, and
        because `receivedAt` never changes, **each document is returned exactly once** across a sweep.

        Polling is currently the only way to learn about new mail. An optional webhook/callback
        notifying a client when a new e-invoice arrives is planned — see the top-level API
        description.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - name: since
          in: query
          schema: { type: string }
          description: |
            Opaque cursor from a previous page. Omit to start from the oldest document — on an account
            with history that is a full replay, so bound the first call with `receivedFrom` and use
            the returned cursor from then on.
        - name: receivedFrom
          in: query
          schema: { type: string, format: date-time }
          description: |
            Return only documents received at or after this instant. Intended for the **first** poll
            of an account: pass "now" to start from empty, or a date to backfill a bounded window.
            Ignored once `since` is supplied — the cursor already carries a position.
        - name: status
          in: query
          description: |
            Custody filter. `All` is a filter value only — a response never carries it.

            The SAPI surface names the same two states `RECEIVED` (= `New`) and `ACKNOWLEDGED`, and both
            surfaces read and write **one** custody state per document: an acknowledgement made through
            either is visible through the other.
          schema: { type: string, enum: [New, Acknowledged, All], default: New }
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items, nextCursor]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/InboxDescriptor' }
                  nextCursor:
                    type: string
                    description: Always present; the client stops when a page returns no items.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/inbox/{documentId}:
    get:
      operationId: getInboxMessage
      tags: [Inbox]
      summary: Get full metadata for a received e-invoice (no payload)
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/DocumentIdPath'
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InboxDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/inbox/{documentId}/content:
    get:
      operationId: getInboxMessageContent
      tags: [Inbox]
      summary: Download a received e-invoice, optionally converted, with PDF and attachments
      description: |
        Response shape is chosen by content negotiation:

        * `Accept: application/json` — metadata plus the document base64-encoded, PDF and
          attachments inline or omitted.
        * `Accept: application/zip` — a bundle containing `document.xml` (in the requested format),
          `document.pdf` (when requested), `attachments/NNN_<original filename>` (collision-suffixed)
          and `manifest.json` (ids, filenames, MIME types, SHA-256 hashes, source metadata).

        `format=peppol-bis` returns the **original received bytes**, and is the only format v1
        supports (see `DocumentFormat`).

        Inbound PDF generation is asynchronous. When `pdfState` is `Pending`, retry rather than
        assuming no PDF exists.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/DocumentIdPath'
        - name: format
          in: query
          schema: { $ref: '#/components/schemas/DocumentFormat' }
        - name: pdf
          in: query
          schema: { type: string, enum: [none, include, only], default: none }
        - name: attachments
          in: query
          schema: { type: string, enum: [none, inline], default: none }
          description: 'JSON responses only; ignored for `Accept: application/zip`, which always bundles them.'
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InboxContent' }
            application/zip:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '406':
          description: |
            The `Accept` type or the `format` requested is not one this document can be returned as.
            Retry with `Accept: application/json` or `Accept: application/zip`, and `format=peppol-bis`.
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/inbox/{documentId}/attachments/{attachmentId}:
    get:
      operationId: getInboxAttachment
      tags: [Inbox]
      summary: Download a single attachment
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/DocumentIdPath'
        - name: attachmentId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: The attachment bytes, with its original Content-Type
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/inbox/{documentId}/acknowledge:
    post:
      operationId: acknowledgeInboxMessage
      tags: [Inbox]
      summary: Acknowledge custody of one received e-invoice
      description: |
        Custody transfer Archiles → client. Flips `New` to `Acknowledged`. Idempotent — a repeated
        call succeeds and returns the original `acknowledgedAt`.

        This is **not** the Peppol MLS (transport acknowledgement to the sending AP, sent
        automatically by Archiles), and **not** a business accept/reject
        (UBL `ApplicationResponse`, out of scope for v1).
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/DocumentIdPath'
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: |
            Acknowledged. `acknowledgedAt` is the timestamp of the **first** acknowledgement, so a
            repeated call returns the original value unchanged — which is how a client can observe
            that re-acknowledging is idempotent.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AcknowledgeResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/e-invoices/inbox/acknowledge:
    post:
      operationId: acknowledgeInboxMessages
      tags: [Inbox]
      summary: Acknowledge custody of many received e-invoices
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  maxItems: 1000
                  items: { type: string }
      responses:
        '200':
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/CorrelationId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          description: Per-item outcome
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/AcknowledgeResult'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
  # ────────────────────────────────────────────────────────────────────────────
  # SAPI compatibility profile
  # ────────────────────────────────────────────────────────────────────────────

  # ── SAPI auth routes (aliases of /v1/oauth/*, same handlers and same tokens) ─

  /v1/sapi/auth/token:
    post:
      operationId: sapiGetToken
      tags: [SAPI profile]
      summary: '[SAPI] Obtain access and refresh tokens'
      description: |
        Alias of `POST /v1/oauth/token` — same handler, same token store. A token obtained here is
        valid on the native surface as well. Identical semantics to `/v1/oauth/token`, including acting
        on behalf of the account owner with admin rights.

        `403` ("IP address not allowed for this client") is defined by SAPI but **never emitted by
        Archiles in phase 1** — per-client IP restriction is not implemented. It is listed here for
        contract fidelity.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, client_secret, grant_type]
              properties:
                client_id: { type: string }
                client_secret: { type: string }
                grant_type: { type: string, enum: [client_credentials] }
                scope:
                  type: string
                  description: |
                    Optional, per SAPI. Accepted, but **neither enforced nor echoed back in v1** — see
                    `TokenResponse.scope`.
                  example: document:send document:receive
      responses:
        '200':
          description: Tokens issued successfully
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: IP address not allowed for this client (never emitted in phase 1)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '423':
          description: Account temporarily locked due to too many failed attempts
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/sapi/auth/token/status:
    get:
      operationId: sapiGetTokenStatus
      tags: [SAPI profile]
      summary: '[SAPI] Check token validity and expiration'
      responses:
        '200':
          description: Token status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenStatus' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/sapi/auth/renew:
    post:
      operationId: sapiRenewToken
      tags: [SAPI profile]
      summary: '[SAPI] Renew tokens using a refresh token'
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        '200':
          description: New tokens issued; the presented refresh token is invalidated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/sapi/auth/revoke:
    post:
      operationId: sapiRevokeToken
      tags: [SAPI profile]
      summary: '[SAPI] Revoke a refresh token'
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string }
                token_type_hint: { type: string, enum: [refresh_token], default: refresh_token }
      responses:
        '200':
          description: Token revoked successfully (idempotent — also returned for an unknown token)
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  # ── SAPI document routes ────────────────────────────────────────────────────

  /v1/sapi/document/send:
    post:
      operationId: sapiSendDocument
      tags: [SAPI profile]
      summary: '[SAPI] Submit a document for Peppol delivery'
      description: |
        Delegates to the same application service as `POST /v1/e-invoices/submissions`.

        `202 ACCEPTED` confirms **technical receipt only**. It does not imply semantic validation,
        successful Peppol delivery, or legal effect.

        `metadata` is required and is **compared against the payload**; any disagreement is rejected
        with `400 VALIDATION`. `X-Peppol-Participant-Id` must equal
        `metadata.senderParticipantId`.

        Archiles accepts payloads up to **20 MB** (SAPI mandates 10 MB) — a documented superset.

        Subject to the same planned account-level validation-strictness setting as
        `POST /v1/e-invoices/submissions` (see there) — it is account-wide, not per-surface.

        **SAPI defines no sent-status endpoint, so within SAPI this 202 is the last thing a client
        hears about the document.** Archiles does not extend the SAPI surface to fill that gap (it stays
        frozen to SAPI-SK v1.0.0) — it hands you the key to the native one instead:
        **`providerDocumentId` in the response is the `submissionId`**, so
        `GET /v1/e-invoices/submissions/{providerDocumentId}` returns the full delivery status, and the
        token from `/v1/sapi/auth/token` works there unchanged. Store it against your own invoice number at send
        time; nothing else in the SAPI response identifies the submission. A client that does not want
        to leave the SAPI surface at all can instead treat `202` as technical receipt only and reconcile
        out of band.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/SapiIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SapiSendDocumentRequest' }
      responses:
        '202':
          description: Document accepted for processing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SapiSendDocumentResponse' }
        '400':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: Idempotency-Key already used with a different request payload
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/sapi/document/receive:
    get:
      operationId: sapiListReceivedDocuments
      tags: [SAPI profile]
      summary: '[SAPI] List received documents'
      description: |
        Sorted **oldest first**. `nextPageToken` is **absent when there are no more pages** — this
        differs deliberately from the native `/v1/e-invoices/inbox`, which always returns a cursor.

        Note that SAPI list items carry neither `status` nor `receivedAt`, as SAPI specifies. To
        see custody status or receipt time, use the `status` filter, or the native
        `GET /v1/e-invoices/inbox`, which returns both.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - name: pageToken
          in: query
          required: false
          schema: { type: string }
          description: Opaque cursor from a previous response's `nextPageToken`.
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
        - name: status
          in: query
          required: false
          description: |
            SAPI's custody vocabulary, and the same custody state the native surface calls `status`:
            **`RECEIVED` is the native `New`**, and `ACKNOWLEDGED` is the native `Acknowledged`. There is
            one custody state per document, not one per surface — acknowledging through
            `POST /v1/sapi/document/receive/{documentId}/acknowledge` is immediately visible as
            `Acknowledged` on `GET /v1/e-invoices/inbox`, and vice versa. Omit the parameter to list both.
          schema: { type: string, enum: [RECEIVED, ACKNOWLEDGED] }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SapiReceivedDocumentListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/sapi/document/receive/{documentId}:
    get:
      operationId: sapiGetReceivedDocument
      tags: [SAPI profile]
      summary: '[SAPI] Retrieve a received document'
      description: |
        `documentId` is the **Archiles-assigned** identifier returned in the listing, not the
        sender's invoice number. The payload is returned exactly as received
        from the Peppol network, unaltered.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/DocumentIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SapiReceivedDocumentDetailResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/sapi/document/receive/{documentId}/acknowledge:
    post:
      operationId: sapiAcknowledgeDocument
      tags: [SAPI profile]
      summary: '[SAPI] Acknowledge document receipt'
      description: |
        Confirms handover to the software endpoint. Idempotent — re-acknowledging returns success
        without changing `acknowledgedDateTime`.
      parameters:
        - $ref: '#/components/parameters/ParticipantIdHeader'
        - $ref: '#/components/parameters/DocumentIdPath'
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SapiAcknowledgeResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }


components:

  headers:
    CorrelationId:
      description: |
        Tracing identifier for this exchange, logged server-side. Returned on every response,
        successful or not; error bodies repeat it as `error.correlation_id`.
      schema: { type: string, format: uuid }
    RateLimitLimit:
      description: Requests (or items, on bulk endpoints) permitted in the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Remaining allowance in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Unix epoch seconds at which the window resets.
      schema: { type: integer }

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    ParticipantIdHeader:
      name: X-Peppol-Participant-Id
      in: header
      required: true
      schema: { type: string, example: '0245:2022182030' }
      description: |
        The participant this request acts for. Resolved to an Archiles account and checked against
        the API client's entitlements before any other processing — which is also why it is required
        rather than derived from the payload: an unentitled call is refused before anything is parsed.
        A participant outside your entitlements returns 403 without revealing whether it exists.

        **On send it must also agree with the document.** The supplier endpoint derived from the payload
        has to be this participant, and on the SAPI surface it must equal `metadata.senderParticipantId`
        as well; either disagreement is a `400 VALIDATION`.
    ParticipantIdPath:
      name: participantId
      in: path
      required: true
      schema: { type: string, example: '0245:2022182030' }
    DocumentIdPath:
      name: documentId
      in: path
      required: true
      schema: { type: string }
      description: |
        The Archiles document identifier. **One identifier space, both surfaces**: the value returned
        as `InboxDescriptor.id` on the native surface and as `SapiDocumentMetadata.documentId` for a
        received document on the SAPI surface is the same id, so an id obtained from either surface
        works on the other. It is not the sender's invoice number — read that from the payload, or
        from the informational `documentNumber`.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, format: uuid }
      description: |
        Retained 24 hours, scoped per API client. The same key with the same payload returns the
        original response verbatim; the same key with a different payload is a 409.
        Optional on the native surface, but strongly recommended on every send.

        **On a batch, the key covers the whole batch.** Replaying it returns the original 202, so it
        cannot be used to re-drive part of a batch. To re-drive failed work: items that were
        *admitted* have a `submissionId` and belong to `POST /v1/e-invoices/submissions/retry`; items rejected
        *at admission* have no `submissionId` — nothing was created for them — so resubmit those as a
        new send or a new batch, under a new key.
    SapiIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, format: uuid }
      description: |
        **Required** on the SAPI surface, per SAPI v1.0. Same semantics as the native header.
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }

  schemas:

    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token: { type: string, description: "JWT access token, valid 15 minutes." }
        refresh_token: { type: string }
        token_type: { type: string, enum: [Bearer], example: Bearer }
        expires_in: { type: integer, description: 'Access token lifetime in seconds.', example: 900 }
        scope:
          type: string
          description: |
            **Not returned in v1.** The field is declared for SAPI wire fidelity, and a scope sent on
            the request is accepted but neither enforced nor echoed back — a credential grants the full
            set of operations on every participant it is entitled to (entitlement is per participant,
            via `X-Peppol-Participant-Id`, not per operation). It exists so that narrower scopes can be
            introduced later without a breaking change; do not build authorization logic on it today.
          example: document:send document:receive

    TokenStatus:
      type: object
      properties:
        valid: { type: boolean }
        token_type: { type: string, enum: [access] }
        client_id: { type: string }
        issued_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        expires_in_seconds: { type: integer }
        should_refresh: { type: boolean, description: True when under 3 minutes remain. }
        refresh_recommended_at: { type: string, format: date-time }

    DocumentFormat:
      type: string
      enum: [peppol-bis]
      default: peppol-bis
      description: |
        Document format, on send and on retrieval alike — **one shared enum, and v1 supports
        `peppol-bis` (Peppol BIS Billing 3.0 UBL) only.** It is the default when omitted, so the field
        can be left out entirely.

        On send the payload is transmitted byte-for-byte, never re-serialised. On retrieval
        `peppol-bis` returns the **original received bytes**.

        `peppol-bis` is the only value v1 defines, in either direction. Values may be added in a
        later version; because a new value is additive it cannot break a client, which is why one
        shared enum is used rather than a split per direction.

    ValidationRequest:
      type: object
      required: [payload]
      description: |
        Input for pre-send validation, used by both `POST /v1/e-invoices/validate` and each item of
        `POST /v1/e-invoices/validate/batch`. Identical in shape to `DocumentEnvelope`: the document
        being validated is exactly the document that would be sent — so you can post the same body to
        both. The send envelope's reserved delivery fields (`emailDelivery`, `email`) have no meaning
        here, because validation performs no delivery; they are ignored rather than rejected, so a
        client reusing one body needs no second variant of it.
      properties:
        clientReference:
          type: string
          maxLength: 128
          description: |
            Your own identifier for the document being validated, echoed back in `ValidationResult` so
            you can match a result to its source. Recommended on every item of
            `POST /v1/e-invoices/validate/batch`: results come back in request order, so position works
            without it, but a reference is what keeps correlation correct independently of ordering.
          example: ERP-2026-000123
        format:
          $ref: '#/components/schemas/DocumentFormat'
        payload:
          type: string
          description: Base64-encoded document.

    SendEnvelopeMetadata:
      type: object
      description: |
        Everything in a send envelope **except** the document bytes. Used on its own as the
        `envelope` part of a `multipart/form-data` send, where the bytes travel in a separate part;
        `DocumentEnvelope` is this schema plus a required base64 `payload`.

        There is no `attachments` field: in Peppol BIS attachments live inside the UBL, and v1 accepts
        no other send format.
      properties:
        clientReference:
          type: string
          maxLength: 128
          description: |
            The caller's own identifier, echoed in every response and queryable via
            `GET /v1/e-invoices/submissions?clientReference=…`. Indexed per participant, not enforced
            unique — `Idempotency-Key` remains the deduplication mechanism.
          example: ERP-2026-000123
        format:
          $ref: '#/components/schemas/DocumentFormat'
        emailDelivery:
          type: string
          enum: [none, fallback, only, always]
          default: none
          description: |
            **Reserved. v1 accepts `none` only**; any other value is rejected with `400 VALIDATION`,
            `category: PERMANENT`. Declared now so the delivery-channel axis is visible from the start
            and no client hard-codes its absence.

            When the email channel ships: `fallback` sends over Peppol when the buyer is a registered
            participant and by email when it is not (the registry check Archiles performs anyway
            decides); `only` sends by email without attempting Peppol; `always` does both. The outcome
            of the email leg is reported in `Submission.emailDelivery`, separately from the Peppol
            `status`, and `Delivered` remains a Peppol-only statement — email cannot prove delivery.
        email:
          type: object
          description: |
            Recipients for the email channel. **Reserved — supplying it in v1 is a `400 VALIDATION`**,
            since no value of `emailDelivery` other than `none` is accepted yet.
          properties:
            to:
              type: array
              items: { type: string, format: email }
            cc:
              type: array
              items: { type: string, format: email }
            subject:
              type: string
              description: Omit to use the account's configured template.

    DocumentEnvelope:
      description: |
        One schema, two uses: single send (`POST /v1/e-invoices/submissions`) and each batch item
        (`POST /v1/e-invoices/submissions/batch`). For pre-send validation, see `ValidationRequest`, which has
        the same shape.
      allOf:
        - $ref: '#/components/schemas/SendEnvelopeMetadata'
        - type: object
          required: [payload]
          properties:
            payload:
              type: string
              description: |
                Base64-encoded document. (The `multipart/form-data` alternative on
                `POST /v1/e-invoices/submissions` takes the raw binary payload in its own part — see there.)

    ValidationResult:
      type: object
      required: [valid, errors, warnings]
      properties:
        valid: { type: boolean }
        clientReference:
          type: string
          description: |
            Echoed from the request, so a result can be matched to its source document. Present
            whenever the request supplied it, on the single and the batch operation alike; batch results
            are in request order, so a result is identifiable either way.
        errors:
          type: array
          description: Always present; `[]` when the document is valid.
          items: { $ref: '#/components/schemas/ValidationIssue' }
        warnings:
          type: array
          description: Always present; `[]` when there is nothing to warn about.
          items: { $ref: '#/components/schemas/ValidationIssue' }

    ValidationIssue:
      type: object
      required: [source, message]
      properties:
        source:
          type: string
          enum: [Xsd, Schematron, Mapping]
          description: Distinguishes a CEN/Peppol rule violation from an Archiles mapping limitation.
        rule: { type: string, example: BR-CO-10 }
        location: { type: string, description: "XPath into the document, when available." }
        message: { type: string }

    SubmissionAccepted:
      type: object
      required: [submissionId, documentId, conversationId, status]
      properties:
        clientReference:
          type: string
          description: Echoed from the request; absent when the request did not supply one.
        submissionId:
          type: string
          description: |
            Identifier of **this submission**. The only id accepted by
            `GET /v1/e-invoices/submissions/{submissionId}`.
        documentId:
          type: string
          description: |
            Id of the Archiles document created for this submission — the archive identity of the
            invoice. A retry keeps this id; a corrected resend produces a new document, and so a
            new id.
        conversationId:
          type: string
          description: Peppol conversation this submission belongs to.
        status: { $ref: '#/components/schemas/SubmissionState' }

    BatchAccepted:
      type: object
      required: [batchId, accepted, rejected, items]
      description: |
        Outcome of the **synchronous admission** step only. `accepted` and `rejected` count items
        that passed or failed the cheap checks run before the 202 — entitlement, well-formedness,
        and size/count limits (see the operation description). Schematron, mapping and publishing
        have not run yet at this point, so an item counted in `accepted` can still fail later; those
        outcomes appear only in `GET /v1/e-invoices/submissions/batch/{batchId}`.
        `accepted + rejected` always equals the number of submitted items.
      properties:
        batchId: { type: string }
        accepted:
          type: integer
          description: Items admitted for asynchronous processing (`submissionId` assigned).
        rejected:
          type: integer
          description: Items rejected by the synchronous admission checks (no `submissionId`).
        items:
          type: array
          description: |
            One entry per submitted item, in request order. Each entry echoes the item's
            `clientReference` when it had one; correlate by that, or by position when it did not.
          items:
            type: object
            properties:
              clientReference:
                type: string
                description: Echoed from the submitted item; absent when the item did not carry one.
              submissionId:
                type: string
                description: Present only for admitted items.
              error:
                allOf:
                  - $ref: '#/components/schemas/ApiError'
                description: Present only for items rejected at admission.

    BatchStatus:
      type: object
      required: [batchId, state, items]
      properties:
        batchId: { type: string }
        state:
          type: string
          enum: [Processing, Completed]
          description: |
            `completed` means every item has reached an outcome of the asynchronous per-item
            processing (either a `submissionId` with a status, or an error). It does not mean every
            document has been delivered — delivery is tracked per item via `status`.
        items:
          type: array
          description: |
            One entry per submitted item, in the order the batch was submitted; correlate by
            `clientReference` where the item carried one, by position otherwise.
          items:
            type: object
            properties:
              clientReference:
                type: string
                description: Echoed from the submitted item; absent when the item did not carry one.
              submissionId: { type: string }
              status: { $ref: '#/components/schemas/SubmissionState' }
              error:
                allOf:
                  - $ref: '#/components/schemas/ApiError'
                description: Present only for items that failed; mutually exclusive with `status`.

    SubmissionState:
      type: string
      enum:
        - Queued
        - Sent
        - Delivered
        - Rejected
        - Undeliverable
        - DeliveryUnconfirmed
        - Failed
      description: |
        Public delivery status of one submission. **Under Slovak legislation a document receives at
        most one MLS (message level status), and that MLS is final** — `AB` (forwarded), `AP`
        (accepted) or `RE` (rejected). There is therefore no separate "business response" phase to
        wait for after delivery, and no status that a client can be left polling indefinitely.

        | Status | Meaning | Pipeline may still change it | Retry allowed |
        |---|---|---|---|
        | `Queued` | Validated, Archiles document created, waiting to be handed off | yes | no — already in flight |
        | `Sent` | Handed to the access point; on the network, awaiting the MLS | yes | no — already in flight |
        | `Delivered` | **Final, positive.** The recipient's access point confirmed the document (`AP`, or `AB` where its access point forwarded without a verifiable acknowledgement) | no | no — a resend would duplicate the invoice |
        | `Rejected` | **Final, negative — the recipient rejected the document** (`RE`). The content must change; retransmitting the same bytes will be rejected again | no | no — send a corrected document instead |
        | `Undeliverable` | **Final, negative — permanent transport failure.** The document cannot be delivered as addressed: most often the receiver is not a registered Peppol participant, or is not registered for this document type | no | yes, once the cause is fixed |
        | `DeliveryUnconfirmed` | **Not final.** No MLS arrived within the SLA window. **Not** proof of failure — a late MLS still resolves it to `Delivered` | yes | yes |
        | `Failed` | **Final — an Archiles-side failure**, not the network's: asynchronous validation or mapping failed for a batch item, retries were exhausted, or the submission was parked for manual resolution. `failureReason` says which | rarely — see below | yes |

        **When to stop polling.** Stop at `Delivered`, `Rejected`, `Undeliverable` or `Failed` — the
        pipeline will not move them again. `DeliveryUnconfirmed` is the only status worth continuing
        to watch, and it is also actionable: see `POST /v1/e-invoices/submissions/{submissionId}/retry`.

        Two refinements to that rule, both in the client's favour:

        * A **positive or negative final verdict is never overturned**. Once a submission is
          `Delivered` or `Rejected`, a contradicting message arriving later is refused rather than
          applied, so what you read is what stands.
        * `Failed` is the one final status the network can still resolve: it means *Archiles* could not
          complete the send, and if a delivery confirmation for that document does turn up afterwards
          the submission moves to `Delivered` or `Rejected`. Treat `Failed` as final for your own
          bookkeeping, and treat that late resolution as good news rather than a contract violation.

        **An unrecognised status must be treated as non-final.** New values may be added (see
        `CHANGELOG.md`), and the safe reading of a value your client does not know is "not finished
        yet": keep the submission under observation and surface it to a human rather than filing it as
        an outcome. A new value's finality is always stated in the changelog entry that introduces it.

        **Transitions**

        | From | To |
        |---|---|
        | `Queued` | `Sent`, `Undeliverable`, `Failed` |
        | `Sent` | `Delivered`, `Rejected`, `Undeliverable`, `DeliveryUnconfirmed`, `Failed` |
        | `DeliveryUnconfirmed` | `Delivered`, `Rejected`, `Failed` |
        | `Delivered`, `Rejected`, `Undeliverable` | — final; only an explicit retry moves a submission out, back to `Queued` |
        | `Failed` | — final; an explicit retry moves it back to `Queued`, and a late delivery confirmation can still resolve it to `Delivered` or `Rejected` (see above) |

        A polling client may miss intermediate statuses: a GET returns the current status, not a log.
        Use `lastStateChangeAt` when you need to know when the status last moved.

        **Not to be confused with SAPI's `status`.** On `POST /v1/sapi/document/send`,
        `status: ACCEPTED`/`REJECTED` is the *synchronous* verdict on the submission itself —
        `REJECTED` means Archiles refused to accept the document, which on the native surface is a
        `400`, not a delivery status. SAPI defines no delivery-status model at all, so these seven
        statuses are Archiles-native and cannot conflict with a conforming SAPI client.

    Submission:
      type: object
      required: [submissionId, status, senderParticipantId, submittedAt, lastStateChangeAt]
      properties:
        submissionId: { type: string }
        clientReference: { type: string }
        documentId:
          type: string
          description: |
            Id of the archived document created for this submission. **Absent only when no document was
            ever created** — a batch item that failed validation or mapping asynchronously has a
            `submissionId` and status `Failed` but never got as far as an archived document. Present in
            every other case, including all single sends.
        conversationId: { type: string }
        cursor:
          type: string
          description: |
            Cursor positioned **immediately after this item**. Supply it as `since` to resume from
            here — useful when a page is processed item by item and the work is interrupted
            mid-page. For ordinary paging use the list-level `nextCursor`, which equals the `cursor`
            of the last item on the page.
        status: { $ref: '#/components/schemas/SubmissionState' }
        failureReason:
          type: string
          description: Present only in status `Failed`.
        senderParticipantId: { type: string }
        receiverParticipantId:
          type: string
          description: |
            Read from the payload. Absent in the same case as `documentId` — a submission that failed
            before its payload could be interpreted has no receiver to report.
        documentType: { $ref: '#/components/schemas/PeppolDocumentType' }
        submittedAt: { type: string, format: date-time }
        lastStateChangeAt: { type: string, format: date-time }
        emailDelivery:
          type: object
          description: |
            Outcome of the email delivery leg for this document. **Reserved — never returned in v1**,
            because v1 sends over Peppol only (see `emailDelivery` on the send envelope). It is declared
            now so that the field's shape is known before the feature exists: like `taxReporting`, it is
            a side-leg with its own status, which keeps `status` above meaning *Peppol* delivery and
            nothing else.
          required: [status]
          properties:
            status:
              type: string
              enum: [NotApplicable, Queued, Sent, Undeliverable, Failed]
              description: |
                Note that there is no `Delivered`: email cannot prove delivery, and a status that can
                never be reached on a channel is worse than its absence.
            message: { type: string }
        taxReporting:
          type: object
          description: |
            SK TDD (tax-data document) side-flow outcome for this document. Archiles-specific; SAPI
            has no equivalent. Absent when no TDD leg applies.
          required: [state]
          properties:
            state: { $ref: '#/components/schemas/TaxReportingState' }
            message: { type: string }

    TaxReportingState:
      type: string
      enum:
        - NotApplicable
        - Pending
        - Sent
        - Delivered
        - DeliveryUnconfirmed
        - Unreachable
        - Rejected
        - Disregarded
      description: |
        Outcome of the SK tax-data-document (TDD) leg, which Archiles reports to the tax authority
        alongside the invoice. Legally significant, so it is enumerated rather than free text.

        | Value | Meaning |
        |---|---|
        | `NotApplicable` | No TDD leg applies (e.g. the seller is not VAT-registered) |
        | `Pending` | Produced and queued, not yet handed off |
        | `Sent` | Handed off; awaiting delivery confirmation |
        | `Delivered` | Delivery confirmed by a positive final MLS |
        | `DeliveryUnconfirmed` | No confirmation within the SLA window — not proof of failure |
        | `Unreachable` | The tax-authority endpoint could not be reached |
        | `Rejected` | The tax authority returned a negative final MLS (RE) |
        | `Disregarded` | Exchange cancelled after exhausted retries (SK SA v1.2 §5.3.3) |

        **Read these against `SubmissionState` value by value, not by assuming one shared vocabulary.**
        `Sent`, `Delivered`, `DeliveryUnconfirmed`, `Rejected` and `Disregarded` mean the same kind of
        thing on both, but the two enums are not interchangeable: `Pending` and `NotApplicable` exist
        only here, `Unreachable` has no `SubmissionState` counterpart (it is about reaching the tax
        authority's endpoint, not about the buyer being unaddressable), and there is deliberately no
        `Failed` here — an Archiles-side failure is what `SubmissionState.Failed` reports about the
        invoice itself, whereas everything in this table is the tax-reporting leg's own outcome.

    InboxDescriptor:
      type: object
      required: [id, cursor, receivedAt, senderParticipantId, attachmentCount, status, pdfState]
      description: |
        Lightweight — carries no payload, so polling for new mail is cheap.

        **`documentType`, `documentNumber`, `issueDate`, `totalAmount` and `currency` are
        informational.** They are produced by the Archiles mapping layer for display and search,
        and any of them may be absent when the received document does not map cleanly. The
        **payload retrieved from `/content` is the authoritative document** — post to your ledger
        from that, never from these fields.
      properties:
        id:
          type: string
          description: |
            The Archiles document id — accepted by every `/v1/e-invoices/inbox/{documentId}*` route, and
            the same value the SAPI surface returns as `SapiDocumentMetadata.documentId` for this
            document. Not the sender's invoice number.
        cursor:
          type: string
          description: |
            Cursor positioned **immediately after this item**, for resuming mid-page. Ordinary
            paging uses the list-level `nextCursor`, which equals the last item's `cursor`.
        receivedAt: { type: string, format: date-time }
        senderParticipantId: { type: string }
        documentType: { $ref: '#/components/schemas/PeppolDocumentType' }
        documentNumber:
          type: string
          description: "Informational: the sender's own invoice number, read from the payload."
        issueDate:
          type: string
          format: date
          description: 'Informational, from the payload.'
        totalAmount:
          type: string
          pattern: '^-?[0-9]+(\.[0-9]+)?$'
          description: |
            Informational. A **decimal string**, not a JSON number, so no value is lost to binary
            floating point. Parse into a decimal type.
          example: '1234.56'
        currency:
          type: string
          description: 'Informational: ISO 4217 code from the payload.'
          example: EUR
        attachmentCount: { type: integer }
        status:
          type: string
          enum: [New, Acknowledged]
          description: |
            Custody status. The `status` **query parameter** additionally accepts `All`, which is a
            filter value only and never appears here.
        pdfState: { $ref: '#/components/schemas/PdfState' }

    InboxDocument:
      allOf:
        - $ref: '#/components/schemas/InboxDescriptor'
        - type: object
          required: [attachments]
          properties:
            supplier: { $ref: '#/components/schemas/PartySummary' }
            customer: { $ref: '#/components/schemas/PartySummary' }
            attachments:
              type: array
              description: Always present; `[]` when the document has no attachments.
              items: { $ref: '#/components/schemas/AttachmentDescriptor' }

    InboxContent:
      type: object
      required: [id, format, document, pdfState, attachments]
      properties:
        id: { type: string, description: The Archiles document id. }
        format: { $ref: '#/components/schemas/DocumentFormat' }
        document: { type: string, description: "Base64-encoded, in the requested format." }
        pdf: { type: string, description: "Base64-encoded PDF visualisation, when requested and ready." }
        pdfState: { $ref: '#/components/schemas/PdfState' }
        attachments:
          type: array
          description: |
            Always present — `[]` when the document has none. Entries carry `content` only when the
            request asked for `attachments=inline`; otherwise they are descriptors only.
          items:
            allOf:
              - $ref: '#/components/schemas/AttachmentDescriptor'
              - type: object
                properties:
                  content: { type: string, description: Base64-encoded. }

    AcknowledgeResult:
      type: object
      required: [id, acknowledged]
      description: |
        Outcome of one custody acknowledgement. Used by both the single and the bulk operation, so a
        client parses one shape either way.
      properties:
        id: { type: string, description: The Archiles document id. }
        acknowledged:
          type: boolean
          description: True when custody is now with the client — including when it already was.
        acknowledgedAt:
          type: string
          format: date-time
          description: |
            When custody was **first** transferred. Unchanged by later calls. Present whenever
            `acknowledged` is true.
        error:
          allOf:
            - $ref: '#/components/schemas/ApiError'
          description: Present only when `acknowledged` is false.

    AttachmentDescriptor:
      type: object
      required: [id, filename, mimeType, sizeBytes, sha256]
      properties:
        id: { type: string }
        filename: { type: string }
        mimeType: { type: string }
        sizeBytes: { type: integer }
        sha256: { type: string }

    PdfState:
      type: string
      enum: [Ready, Pending, Unavailable]
      description: |
        Inbound PDF generation is asynchronous. `Pending` means retry later;
        it does not mean no PDF will exist.

    PeppolDocumentTypeId:
      type: string
      description: |
        A fully qualified Peppol document type identifier, in the SMP form
        `<root-namespace>::<root-element>##<customization-id>::<ubl-version>`. Open-ended: treat any
        value as possible and compare on the whole string.
      example: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'

    PeppolDocumentType:
      type: string
      enum: [invoice, creditnote, selfbilling, selfbilling-creditnote]
      description: |
        Short classification of a document Archiles has itself ingested or sent, derived from the
        UBL root element and customization id. Closed on purpose: it only ever describes the
        document types Archiles processes. For what a *remote* participant can receive, see
        `PeppolDocumentTypeId`, which is an open string.

    PartySummary:
      type: object
      properties:
        name: { type: string }
        taxNumber: { type: string, description: 'DIČ' }
        vatNumber: { type: string, description: 'IČ DPH' }
        countryCode: { type: string, example: SK }

    Participant:
      type: object
      required: [participantId, accountId, taxNumber, legalName, registrationState, createdDate]
      properties:
        participantId: { type: string, example: '0245:2022182030' }
        accountId: { type: string }
        taxNumber: { type: string }
        legalName: { type: string }
        registrationState:
          type: string
          enum: [Pending, Active]
          description: |
            `Pending` until the PDS registration webhook confirms SMP registration. A `Pending`
            participant can neither **send** nor **receive** — see `POST /v1/peppol/participants`.
        createdDate: { type: string, format: date-time }

    CreateParticipantRequest:
      type: object
      required: [taxNumber, legalName]
      properties:
        taxNumber: { type: string, description: 'DIČ' }
        vatNumber: { type: string, description: 'IČ DPH, when the subject is a VAT payer.' }
        legalName: { type: string }
        companyNumber: { type: string, description: 'IČO' }
        email: { type: string, format: email }
        phone: { type: string }
        address:
          type: object
          properties:
            street: { type: string }
            city: { type: string }
            postalCode: { type: string }
            countryCode: { type: string, example: SK }

    RegistryResult:
      type: object
      required: [participantId, registered, documentTypes]
      properties:
        participantId: { type: string }
        registered: { type: boolean }
        documentTypes:
          type: array
          description: |
            The **fully qualified Peppol document type identifiers** the participant advertises in
            the SMP, exactly as published there. Always present; `[]` when `registered` is false.

            This is an **open list of strings, not an enum** — the SMP may advertise document types
            Archiles does not itself process, and new BIS versions appear without a contract change.
            Match on the value you care about; do not assume the set is closed or ordered.
          items: { $ref: '#/components/schemas/PeppolDocumentTypeId' }
          example:
            - 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'
            - 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'

    RegistryResultOrError:
      description: |
        Exactly one of the two variants: a completed lookup, or a per-item failure. `error` and
        `registered` never appear together.
      oneOf:
        - $ref: '#/components/schemas/RegistryResult'
        - type: object
          required: [participantId, error]
          properties:
            participantId: { type: string }
            error: { $ref: '#/components/schemas/ApiError' }

    ErrorCategory:
      type: string
      enum: [AUTH, VALIDATION, PROCESSING, TEMPORARY, PERMANENT]
      description: Same categories on both surfaces (adopted from SAPI).

    ErrorDetail:
      type: object
      properties:
        field: { type: string, description: The field that caused the error., example: metadata.receiverParticipantId }
        issue: { type: string, example: Does not match payload }
        value: { type: string, description: The invalid value that was provided. }

    ApiError:
      type: object
      required: [category, code, message, retryable, correlation_id]
      description: |
        SAPI v1.0 structured error object, adopted verbatim on **both** surfaces.
      properties:
        category: { $ref: '#/components/schemas/ErrorCategory' }
        code:
          type: string
          description: |
            Stable, implementation-independent code. `SAPI-*` on the SAPI surface; `ARCH-*` for
            native-only conditions. Part of the contract — never repurposed once published.
          example: SAPI-VAL-001
        message: { type: string, example: Document payload is invalid }
        details:
          type: array
          description: |
            The one array in this contract that is **absent rather than empty** when it has nothing to
            report — it appears only for errors that can name individual fields.
          items: { $ref: '#/components/schemas/ErrorDetail' }
        retryable:
          type: boolean
          description: |
            Authoritative. Clients MUST retry only when true, with exponential backoff — they must
            not infer retry behaviour from the status code.
        correlation_id:
          type: string
          format: uuid
          description: |
            Tracing identifier, logged server-side. Also returned on **successful** responses as the
            `X-Correlation-Id` header, so any interaction can be quoted in a support request.

    ErrorEnvelope:
      type: object
      required: [error]
      description: Every error response on both surfaces uses this envelope.
      properties:
        error: { $ref: '#/components/schemas/ApiError' }
      example:
        error:
          category: VALIDATION
          code: SAPI-VAL-001
          message: Document payload is invalid
          details:
            - field: metadata.receiverParticipantId
              issue: Does not match payload
          retryable: false
          correlation_id: 550e8400-e29b-41d4-a716-446655440000

    # ── SAPI profile schemas (frozen to SAPI-SK v1.0.0) ────────────────────────

    SapiDocumentMetadata:
      type: object
      required: [documentId, documentTypeId, processId, senderParticipantId, receiverParticipantId, creationDateTime]
      properties:
        documentId:
          type: string
          description: |
            On **send**: client-assigned, and compared against the payload's `cbc:ID`; a
            disagreement is a `400 VALIDATION`.

            On **receive**: the **Archiles document id** (deviation 2 above) — the same value
            the native surface returns as `InboxDescriptor.id`, so an id from either surface works on
            the other. The sender's own invoice number is not carried here and must be read from the
            payload.
          example: INV-2026-0001
        documentTypeId:
          type: string
          example: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'
        processId:
          type: string
          example: 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0'
        senderParticipantId: { type: string, example: '0245:1234567890' }
        receiverParticipantId: { type: string, example: '0245:9876543210' }
        creationDateTime: { type: string, format: date-time }

    SapiSendDocumentRequest:
      type: object
      required: [metadata, payload, payloadFormat]
      properties:
        metadata: { $ref: '#/components/schemas/SapiDocumentMetadata' }
        payload:
          type: string
          description: |
            **Raw UTF-8 UBL XML — not base64** (this differs from the native envelope, which is
            base64). Max 20 MB in Archiles; SAPI mandates 10 MB.
        payloadFormat: { type: string, enum: [XML] }
        payloadEncoding: { type: string, description: Defaults to UTF-8., example: UTF-8 }
        checksum:
          type: string
          pattern: '^[a-f0-9]{64}$'
          description: |
            Optional SHA-256 over the exact payload bytes, lowercase hex. When supplied it is
            verified; a mismatch is a 400 VALIDATION.

    SapiSendDocumentResponse:
      type: object
      required: [providerDocumentId, status, timestamp]
      properties:
        providerDocumentId:
          type: string
          description: |
            **This is the `submissionId`** — the identifier of this submission in Archiles, and the value
            accepted by `GET /v1/e-invoices/submissions/{submissionId}` and
            `POST /v1/e-invoices/submissions/{submissionId}/retry`. It is the only handle on the submission the
            SAPI response carries, so a client that may ever want delivery status should store it.

            It is **not** the archived document's `documentId`: this value exists from the moment the
            submission is accepted, whereas the archived document is created during processing. See
            *Identifiers* in the API description.
          example: 8f14e45f-ea0a-4c1f-9b2e-71cbd4a1c2f0
        status:
          type: string
          enum: [ACCEPTED, REJECTED]
          description: ACCEPTED = technical receipt confirmed. Not delivery, not legal effect.
        receivedAt:
          type: string
          format: date-time
          description: Required when status = ACCEPTED.
        timestamp: { type: string, format: date-time }

    SapiReceivedDocumentListResponse:
      type: object
      required: [documents]
      properties:
        documents:
          type: array
          description: Sorted oldest first.
          items: { $ref: '#/components/schemas/SapiDocumentMetadata' }
        nextPageToken:
          type: string
          description: Absent when there are no more pages.

    SapiReceivedDocumentDetailResponse:
      type: object
      required: [metadata, payload, payloadFormat]
      properties:
        metadata: { $ref: '#/components/schemas/SapiDocumentMetadata' }
        payload: { type: string, description: "The document exactly as received, unaltered." }
        payloadFormat: { type: string, example: XML }

    SapiAcknowledgeResponse:
      type: object
      required: [documentId, status, acknowledgedDateTime]
      properties:
        documentId: { type: string }
        status: { type: string, enum: [ACKNOWLEDGED] }
        acknowledgedDateTime: { type: string, format: date-time }

  responses:
    ServerError:
      description: |
        Unexpected server fault. `category: PROCESSING`, `retryable: true` — retry with exponential
        backoff, reusing the same `Idempotency-Key` on send so a retry cannot duplicate an invoice.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    UnsupportedMediaType:
      description: |
        The request `Content-Type` is not supported by this operation.
        `category: PERMANENT`, `retryable: false`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    BadRequest:
      description: Malformed request or validation failure
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Missing, invalid or expired token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Forbidden:
      description: |
        The API client is not entitled to the participant in `X-Peppol-Participant-Id`.
        Deliberately does not reveal whether that participant exists.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: No such resource for this participant
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    PayloadTooLarge:
      description: Payload or batch exceeds the configured limit
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    PaymentRequired:
      description: |
        There is no active Archiles service subscription for this account, or its prepaid document
        allowance has been fully consumed. Returned on both the native and SAPI surfaces.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    IdempotencyConflict:
      description: |
        The `Idempotency-Key` was already used with a different payload. Reusing a key is only
        valid for an identical request; an identical request replays the original response instead.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: |
        Rate limit exceeded. Bulk endpoints are counted **per item**, not per request, and the budget
        is **per API client** — an integrator acting for many participants shares one budget across
        all of them.

        A batch that would exceed the remaining budget is **still accepted whole** and drained at the
        permitted rate; it is never partially accepted, and never split. This `429` is returned only
        when the budget is already exhausted at admission, in which case nothing was enqueued and the
        whole request can be retried after `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds.
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer }
          description: Unix epoch seconds.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    TemporaryUnavailable:
      description: Transient failure; retry after the indicated interval
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
