openapi: 3.1.0

info:
  title: Sidaxis API
  version: "1.0.0-beta"
  summary: Proof that a live human authorised a machine.
  description: |
    Sidaxis is the human authorisation layer. Two primitives:

    **oneface** proves a real, living person is present, on their own device. Nothing is
    stored: no image, no template. Speech correlated with the pulse read by the camera at
    the same instant proves a living person is there at that moment. **Voice is never used
    to say who the person is.**

    **Mandates** record what that person authorised a machine to do — scope, ceiling,
    expiry — and the layer refuses anything outside it.

    Sidaxis never moves money, holds funds or settles. It answers whether an action was
    authorised and records that the answer was given. The executor is your own system or
    your PSP.

    ## Shape of the API

    Every endpoint is a function under one base path:

    ```
    https://api.sidaxis.com/functions/v1/<function-name>
    ```

    There is no `/v1` resource façade today. Function names are the contract.

    ## Authentication

    `x-sidaxis-api-key: sk_live_… | sk_test_… | pk_live_… | pk_test_…`

    The legacy header `x-sieve-api-key` is accepted with identical semantics. When both are
    present, `x-sidaxis-api-key` wins. Some reads accept a first-party user JWT via
    `Authorization: Bearer` instead.

    - `pk_*` (publishable, browser) reaches `face-identify`,
      `process-biovital-enrollment`, `handoff-register-public`, `merchant-theme-get`,
      `alias-availability`.
    - `sk_*` (secret, server) reaches everything else. A `pk_*` on a server endpoint is
      `401 AUTH_REQUIRED`.

    Sandbox is not a separate host. Sandbox is the **merchant** plus the **key prefix**
    (`sk_test_`, `pk_test_`). Isolation is by `merchant_id`.

    ## Refusals are not errors

    A refusal is `HTTP 200` with `allowed: false` and three message layers: a stable
    machine `reason`, an English `developer_message`, and a localised `user_message`
    (absent on integration errors, by design). Malformed requests and authentication
    failures are real HTTP errors shaped `{ "error": "...", "code": "..." }`.

    ## check versus consume

    **`mandate-check` is advisory.** No lock, nothing reserved, nothing debited. It can
    return a false positive under concurrency; that is accepted and documented.

    **`mandate-consume` is authoritative.** It is the only call that moves the ceiling, it
    does so under `SELECT … FOR UPDATE`, and it **requires** an `Idempotency-Key` header.

    ## Money

    Always an integer in the minor unit, on the way in and on the way out. `4000` is forty
    dollars. A float is refused.

    ## Agents

    The MCP server is in production at
    `https://api.sidaxis.com/functions/v1/mcp` (Streamable HTTP, OAuth 2.1 with dynamic
    client registration). Nine tools, each mapped one-to-one onto an endpoint documented
    here: the six mandate calls, public receipt verification, and the two One Face calls an
    agent uses to ask a person to prove she is present. Face and voice capture are not
    tools, and never will be — the descriptor is produced on the person's own device.

    ## Not published as a promise

    Dated versioning (`Sidaxis-Version`), numeric rate-limit headers on every endpoint,
    per-region SLAs and cursor pagination are intentions, not contract, and are absent from
    this document until they answer. Everything described in this document is live.

  contact:
    name: Sidaxis engineering
    url: https://www.sidaxis.com/contact
    email: engineering@sidaxis.com
  license:
    name: Proprietary
    url: https://www.sidaxis.com/legal

servers:
  - url: https://api.sidaxis.com/functions/v1
    description: Production and sandbox — the environment is the key prefix, not the host.

x-environments:
  isolation: |
    Same host, same tables. Isolation is by merchant_id, and the environment is the key
    prefix. There is no sandbox.api.sidaxis.com.
  scenarios:
    header: x-sidaxis-scenario
    note: Sandbox keys only. Forces a failure path; `success` injects nothing — the real engine runs and needs a real face. The sandbox never fabricates an approval.
    values: [success, liveness_fail, vrc_coerced, subject_mismatch, duplicate_enroll]
  differences: [no_blockchain_anchoring_so_receipts_have_no_public_seal]

security:
  - apiKeyHeader: []

tags:
  - name: Identity
    description: Prove a live human is present, and recognise them again.
  - name: Mandates
    description: Record and enforce what a verified human authorised a machine to do.
  - name: Receipts
    description: Proof that an action happened, with the strength of the proof recorded on it.
  - name: Hosted
    description: A single-use session token your server mints and our server exchanges when the hosted screen boots.
  - name: Surface
    description: Appearance and handle availability for a capture screen.
  - name: One Face
    description: A single-use verification card an agent hands to a person, and the result it reads back.
  - name: MCP
    description: The MCP server — nine tools, each mapped one-to-one onto a live endpoint.
  - name: Webhooks
    description: Signed events, delivered by an outbox worker every minute.

paths:

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

  /face-identify:
    post:
      tags: [Identity]
      operationId: faceIdentify
      summary: Is this face already known?
      description: |
        Browser-safe (`pk_*`). Answers before a flow branches. The device sends only a
        derived descriptor — never video, never frames, never audio.

        `subject_ref` is `HMAC(sieve_id, merchant_id)`: a per-merchant pseudonym, never the
        real identifier. The match gate is 0.75.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
        - $ref: '#/components/parameters/Timestamp'
        - $ref: '#/components/parameters/Nonce'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [face_template, capture_nonce]
              properties:
                face_template: { $ref: '#/components/schemas/FaceTemplate' }
                capture_nonce: { type: string, minLength: 8, maxLength: 128 }
      responses:
        '200':
          description: Answered.
          content:
            application/json:
              examples:
                matched: { value: { matched: true, subject_ref: "sub_9f41c7b2", confidence: 0.83 } }
                unmatched: { value: { matched: false, confidence: 0.41 } }
              schema:
                type: object
                properties:
                  matched: { type: boolean }
                  subject_ref: { type: [string, 'null'] }
                  confidence: { type: number }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: '`TEMPLATE_STORE_UNAVAILABLE`.'

  /process-biovital-enrollment:
    post:
      tags: [Identity]
      operationId: enrollIdentity
      summary: Create the identity
      description: |
        Browser-safe (`pk_*`). Creates the identity, or recognises the face if it already
        exists. Consent is mandatory and there is no bypass flag.

        The account is born with **face and voice**. No document is asked for here.

        `user_id` is minted by the engine — the `auth.users` UUID. A surface never supplies
        or chooses one. An alias is only a public handle attached to that identity.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EnrollmentCreate' }
      responses:
        '200':
          description: Enrolled.
          content:
            application/json:
              example:
                success: true
                status: enrolled
                subject_ref: "sub_9f41c7b2"
                session_token_hash: "…"
                consent: { version: v1, retention_days: 7 }
        '400':
          description: '`INVALID_INPUT` — carries `fields`.'
        '403':
          description: '`CONSENT_REQUIRED` · `BIOMETRIC_LIVENESS_FAILED`.'
        '409':
          description: '`SESSION_PRESENT` · `IDENTITY_UNREACHABLE`.'
        '422':
          description: '`FACE_TEMPLATE_REQUIRED`.'
        '500':
          description: '`IDENTITY_CREATE_FAILED` · `TEMPLATE_STORE_FAILED` · `SESSION_MINT_FAILED`.'

  /process-biovital-transaction:
    post:
      tags: [Identity]
      operationId: authoriseAction
      summary: Authorise an action with a live capture
      description: |
        Server key or first-party user JWT. The authorisation sink: `ACTION_LOGIN`,
        `ACTION_PAYMENT`, `ACTION_SIGN`, `ACTION_SIGNUP`.

        The row it writes is the `capture_id` that `mandate-issue` accepts as proof, valid
        for **five minutes**.

        `assurance_level` is derived here, at the instant of the act, and frozen on the
        receipt. It is never sent by the client and there is no identity-level assurance.

        `mandate_id` is optional. Absent, the call behaves exactly as it always has.
        Present, the mandate stands in for the live capture and the response gains a
        `mandate` block.

        `ACTION_PAYMENT` and `ACTION_SIGN` require a valid document record on the identity
        — see `403 DOCUMENT_REQUIRED`.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
        - $ref: '#/components/parameters/Timestamp'
        - $ref: '#/components/parameters/Nonce'
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TransactionCreate' }
      responses:
        '200':
          description: 'Authorised. A replayed `Idempotency-Key` returns the original body with `Idempotency-Status: replayed`.'
          content:
            application/json:
              example:
                success: true
                status: Payment Authorized
                transaction_id: "3e976a4a-4186-49e8-bd78-b604a2f36ed3"
                sieve_hash: "0x…"
                immutability_seal: "SEAL-…"
                mandate:
                  mandate_id: "mnd_live_b8158ce8513523e91f74c6bc5a0a13e8"
                  consumption_id: "…"
                  scope_used: purchase
                  granted_to: agent_shopping_assistant
                  agent_display_name: your travel assistant
                  agent_descriptor: Books flights within your limits
                  assurance_level: face_voice
                  idempotent_replay: false
                  remaining: { amount: 3990, currency: BRL }
                  at_limit: false
        '400':
          description: Missing `sieve_hash` or `user_id`; `AMOUNT_INVALID` when money is not an integer in the minor unit.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            `DOCUMENT_REQUIRED` · `IDENTITY_MISMATCH` · `FACE_MATCH_BELOW_THRESHOLD` ·
            `COERCION_DETECTED` · `UNAUTHORIZED_DEVICE` · `ACCESS_REVOKED`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DocumentRequired' }
        '409':
          description: Replay or nonce reuse.
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: '`SLO_BREACH_PAUSED` — the circuit breaker paused this function.'

  /disclose-claims:
    post:
      tags: [Identity]
      operationId: discloseClaims
      summary: Release an attribute, or prove one without revealing it
      description: |
        Server key. Resolves each requested field into a plain `value`, a `zk_proof`
        (a derived boolean claim such as `age_over_21: true`, with the raw date of birth
        never leaving the server), or a `document` behind a consent-token envelope.

        Ends with an atomic write to the consent ledger. The merchant validates the
        disclosure with `consent_token`; `revoke-consent` kills the token and the fetch
        turns into a 403.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [zk_proof_audit_id, merchant_id, transaction_type, requestedFields, biometric_attestation]
              properties:
                zk_proof_audit_id: { type: string }
                merchant_id: { type: string }
                transaction_type: { type: string }
                requestedFields:
                  type: array
                  items:
                    type: object
                    required: [key, disclosure_type]
                    properties:
                      key: { type: string }
                      disclosure_type: { type: string, enum: [value, zk_proof, document] }
                      requires_kyc: { type: boolean }
                      circuit_id: { type: string }
                      public_input: { type: object }
                biometric_attestation:
                  type: object
                  properties:
                    liveness_passed: { type: boolean }
                    vrc_confidence: { type: number }
                    device_pubkey_pem: { type: string }
                ttl_seconds: { type: integer }
      responses:
        '200':
          description: Disclosed, with a consent token.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Consent missing or revoked.

  # ───────────────────────────── Mandates ─────────────────────────────

  /mandate-scopes:
    get:
      tags: [Mandates]
      operationId: mandateScopes
      summary: The closed scope vocabulary, with human labels
      description: |
        Public and cacheable — it carries no subject data. Labels come from the engine in
        `en`, `pt-BR` and `es` (`Accept-Language`, silent English fallback), so every
        client's consent screen reads identically instead of each one writing its own.

        `scope` and every other field name are always English and never localised. Only
        `label` and `description` change with the header. The two examples below are the
        same call with two different `Accept-Language` values.
      parameters:
        - $ref: '#/components/parameters/AcceptLanguage'
      responses:
        '200':
          description: The catalogue.
          content:
            application/json:
              examples:
                english:
                  summary: 'Accept-Language: en (default)'
                  value:
                    version: v2
                    locale: en
                    matching: exact
                    money_scopes: [purchase, payment]
                    constraint_required_scopes: [task]
                    scopes:
                      - scope: login
                        accepts_amount_ceiling: false
                        requires_constraints: false
                        label: Sign in
                        description: Sign in on your behalf.
                portuguese:
                  summary: 'Accept-Language: pt-BR — labels localised, field names and scope ids unchanged'
                  value:
                    version: v2
                    locale: pt-BR
                    matching: exact
                    money_scopes: [purchase, payment]
                    constraint_required_scopes: [task]
                    scopes:
                      - scope: login
                        accepts_amount_ceiling: false
                        requires_constraints: false
                        label: Entrar
                        description: Entrar em seu nome.
        '405':
          description: '`METHOD_NOT_ALLOWED`.'

  /mandate-issue:
    post:
      tags: [Mandates]
      operationId: mandateIssue
      summary: Issue a mandate
      description: |
        Requires a capture no older than **five minutes**, belonging to the principal, with
        liveness passed and no coercion signal.

        `assurance_level` is read from the level **frozen on that capture** — never sent by
        the client. A capture that included a verified document yields `face_voice_doc`, so
        a ten-thousand mandate cannot rest on the same proof as a hundred one.

        A mandate is **immutable**. There is no update endpoint. Only `status`,
        `revoked_at` and `updated_at` ever change. Need more room? Issue a new mandate with
        `step_up_of` pointing at the old one, so an old receipt always describes an object
        that never changed. The standing mandate's balance is untouched by a step-up.

        A money scope (`purchase`, `payment`) requires a valid document record on the
        identity — `403 DOCUMENT_REQUIRED` otherwise.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
        - $ref: '#/components/parameters/Timestamp'
        - $ref: '#/components/parameters/Nonce'
        - $ref: '#/components/parameters/AcceptLanguage'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MandateCreate' }
            examples:
              standingPayment:
                summary: A standing payment mandate with a daily ceiling
                value:
                  granted_by: "5099c9cd-4a97-4c4d-bcd8-79d1f8c68ae4"
                  granted_to: agent_shopping_assistant
                  granted_to_kind: agent
                  agent_display_name: your travel assistant
                  agent_descriptor: Books flights and hotels within your limits
                  scopes: [purchase]
                  constraints: { merchant_allowlist: [rostopay] }
                  ceiling: { kind: amount, amount: 4000, currency: USD, period: daily }
                  expires_at: "2026-12-31T00:00:00Z"
                  proof: { capture_id: "3e976a4a-4186-49e8-bd78-b604a2f36ed3" }
              constrainedTask:
                summary: A task mandate — constraints are mandatory
                value:
                  granted_by: "5099c9cd-4a97-4c4d-bcd8-79d1f8c68ae4"
                  granted_to: agent_ops_bot
                  scopes: [task]
                  constraints: { merchant_allowlist: [rostopay], country_allowlist: [BR] }
                  ceiling: { kind: count, count: 20, period: weekly }
                  expires_at: "2026-10-31T00:00:00Z"
                  proof: { capture_id: "3e976a4a-4186-49e8-bd78-b604a2f36ed3" }
      responses:
        '201':
          description: Mandate issued. `scope_labels` comes ready in the caller's locale.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Mandate' }
        '400':
          description: |
            `UNKNOWN_SCOPE` · `WILDCARD_SCOPE_FORBIDDEN` · `AMOUNT_CEILING_NOT_APPLICABLE` ·
            `CONSTRAINTS_REQUIRED_FOR_SCOPE` · `AGENT_DISPLAY_NAME_TOO_LONG` ·
            `AGENT_DESCRIPTOR_TOO_LONG` · `AGENT_DISPLAY_NAME_EMPTY` ·
            `AGENT_DESCRIPTOR_EMPTY`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransportError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: '`DOCUMENT_REQUIRED` on a money scope with no valid document record.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DocumentRequired' }

  /mandate-check:
    post:
      tags: [Mandates]
      operationId: mandateCheck
      summary: Advisory — does this action fit?
      description: |
        **Advisory only.** No `Idempotency-Key`, no lock, nothing reserved, nothing
        debited. It **can** return a false positive under concurrency; that is accepted and
        documented. A reservation would need expiry, release and an orphan collector —
        three mechanisms for a problem the idempotency key on `mandate-consume` already
        solves.

        Never treat an allowed check as authorisation to act without consuming. Rate
        limited.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
        - $ref: '#/components/parameters/Timestamp'
        - $ref: '#/components/parameters/Nonce'
        - $ref: '#/components/parameters/AcceptLanguage'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MandateActionRequest' }
      responses:
        '200':
          description: 'Answered. `allowed: false` is a successful call, not an error.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MandateAnswer' }
              examples:
                allowed:
                  value: { allowed: true, remaining: { amount: 3000, currency: USD }, period_resets_at: "2026-09-16T00:00:00+00:00" }
                refused:
                  value:
                    allowed: false
                    reason: ceiling_exceeded
                    developer_message: Consumption would exceed the mandate ceiling for the current period.
                    user_message: This agent has reached its limit for the period.
                    step_up_available: true
                    remaining: { amount: 0, currency: USD }
                    period_resets_at: "2026-09-16T00:00:00+00:00"

  /mandate-consume:
    post:
      tags: [Mandates]
      operationId: mandateConsume
      summary: Authoritative — consume against the mandate
      description: |
        The **only** call that moves the ceiling. All arithmetic happens inside
        `mandate_consume_atomic()`, which takes `SELECT … FOR UPDATE` on the mandate row
        **before** summing consumptions. Reading the balance unlocked and writing
        afterwards lets two agents through and blows the ceiling — that is the bug this
        design exists to prevent.

        `Idempotency-Key` is **mandatory**. Without it a network timeout becomes a double
        charge, and the call is refused with `idempotency_key_required`. The same key
        replayed returns the original consumption with `idempotent_replay: true` and debits
        once.

        Consumptions are append-only: there is no balance column, because a counter loses
        the reason and the reason is what an audit needs. The event is written to the
        outbox in the same transaction.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
        - $ref: '#/components/parameters/Timestamp'
        - $ref: '#/components/parameters/Nonce'
        - $ref: '#/components/parameters/AcceptLanguage'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MandateActionRequest' }
            example:
              mandate_id: "mnd_live_b8158ce8513523e91f74c6bc5a0a13e8"
              scope: purchase
              amount: 1000
              currency: USD
              action_ref: order_4471
              context: { merchant: rostopay, country: US }
      responses:
        '200':
          description: Consumed, or refused. Both are 200.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MandateAnswer' }
              examples:
                consumed:
                  value:
                    allowed: true
                    consumption_id: "c1f0…"
                    idempotent_replay: false
                    remaining: { amount: 3000, currency: USD }
                    at_limit: false
                    period_resets_at: "2026-09-16T00:00:00+00:00"
                missingKey:
                  value:
                    allowed: false
                    reason: idempotency_key_required
                    developer_message: POST /consume requires an Idempotency-Key header. Without it a network timeout becomes a double charge.

  /mandate-get:
    get:
      tags: [Mandates]
      operationId: mandateGet
      summary: Read a mandate, or list them by status
      description: |
        Authorisation on **every** read. Unknown and unauthorised answer identically, so no
        one can probe for existence.

        The response reports the **effective** status: `expires_at` compared against the
        server clock at read time is the authority, so a mandate reads `expired` even
        before the tidy-up job runs. `stored_status` shows the untouched row.

        A one-mandate read includes the last 100 consumptions — the statement, free. There
        is no cursor pagination today.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
        - { name: id, in: query, schema: { type: string }, description: One mandate. }
        - { name: status, in: query, schema: { type: string, enum: [active, consumed, exhausted, expired, revoked] }, description: A list by effective status. }
      responses:
        '200':
          description: The mandate and its consumptions, or the list.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Mandate'
                  - type: object
                    properties:
                      mandates: { type: array, items: { $ref: '#/components/schemas/Mandate' } }
        '404':
          description: '`not_found` — unknown or not visible to the caller. Identical either way, on purpose.'

  /mandate-revoke:
    post:
      tags: [Mandates]
      operationId: mandateRevoke
      summary: Revoke a mandate
      description: |
        Immediate and never cached: the next action fails with no window. `revoked` is
        terminal; nothing returns to `active`. Callable by the granting human, the issuing
        merchant or the service role — **not** by the agent holding it. Repeat calls answer
        `already_revoked: true`.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mandate_id]
              properties:
                mandate_id: { type: string }
                reason: { type: string }
      responses:
        '200':
          description: Revoked, or already revoked.
          content:
            application/json:
              example: { revoked: true, already_revoked: false, mandate_id: "mnd_live_b815…", status: revoked, revoked_at: "2026-09-15T22:41:02Z" }

  # ───────────────────────────── Hosted sessions ─────────────────────────────

  /hosted-session-create:
    post:
      tags: [Hosted]
      operationId: hostedSessionCreate
      summary: Mint a hosted capture session
      description: |
        Called by **your server** with a secret key. A publishable `pk_*` is rejected:
        minting a session is a server act.

        The session — not the URL — carries merchant, action, reference and amount, and it
        is what decides the values the screen shows. A browser cannot tamper with an amount
        it never carries.

        There is **no redirect URL** in this contract. You hand `session_token` to the
        browser and open the hosted screen with it. Branding is not sent here: the screen
        reads it from `merchant-theme-get`.

        Single use, 32 random bytes, returned exactly once — only `SHA-256(token)` is
        persisted, so the plaintext is never stored, logged or recoverable. Default TTL
        300 s, minimum 60, maximum 900.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HostedSessionCreate' }
            example:
              action: authorize
              external_txn_ref: ord_10231
              amount_cents: 4990
              currency: USD
              brand: Sidaxis
              ttl_seconds: 300
              metadata: { locale: pt-BR }
      responses:
        '201':
          description: Session minted. The token is shown once.
          content:
            application/json:
              example:
                session_token: "hs_test_4ad6…0a17"
                session_id: "a87f81d8-…"
                merchant_id: sidaxis-surface-sandbox
                action: authorize
                external_txn_ref: ord_10231
                amount_cents: 4990
                currency: USD
                brand: Sidaxis
                expires_at: "2026-09-15T22:44:39.349Z"
                ttl_seconds: 300
                single_use: true
        '400':
          description: |
            `INVALID_ACTION` · `INVALID_EXTERNAL_TXN_REF` · `INVALID_AMOUNT_CENTS` ·
            `INVALID_CURRENCY` · `AMOUNT_AND_REF_REQUIRED` · `INVALID_TTL` ·
            `INVALID_BRAND` · `INVALID_METADATA` · `METADATA_TOO_LARGE`.
        '401':
          description: '`API_KEY_REQUIRED` · `INVALID_API_KEY`.'
        '403':
          description: '`MERCHANT_MISMATCH`.'
        '500':
          description: '`SESSION_WRITE_FAILED`.'

  /hosted-session-consume:
    post:
      tags: [Hosted]
      operationId: hostedSessionConsume
      summary: Exchange the token for the session
      description: |
        Called by **our server** when the hosted screen boots — service role, or the
        merchant's own secret key. Publishable keys rejected.

        The exchange is one atomic statement, so a replay can never win a second exchange.
        A refusal is **200 with a reason, never an error**. A malformed token answers
        `SESSION_NOT_FOUND` so an attacker learns nothing, and a token offered under the
        wrong merchant credentials is burned rather than left available for a second
        attempt.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session_token]
              properties:
                session_token: { type: string }
      responses:
        '200':
          description: Consumed, or refused — both 200.
          content:
            application/json:
              examples:
                consumed:
                  value:
                    consumed: true
                    session:
                      session_id: "a87f81d8-…"
                      merchant_id: sidaxis-surface-sandbox
                      action: authorize
                      external_txn_ref: ord_10231
                      amount_cents: 4990
                      currency: USD
                      brand: Sidaxis
                      metadata: {}
                      created_at: "2026-09-15T22:42:39.408Z"
                      expires_at: "2026-09-15T22:44:39.349Z"
                replayed:
                  value: { consumed: false, reason: SESSION_ALREADY_USED, consumed_at: "2026-09-15T22:43:10.100Z" }
                expired:
                  value: { consumed: false, reason: SESSION_EXPIRED, expires_at: "2026-09-15T22:44:39.349Z" }

  # ───────────────────────────── Surface ─────────────────────────────

  /merchant-theme-get:
    get:
      tags: [Surface]
      operationId: merchantThemeGet
      summary: The merchant's theme tokens
      description: |
        Appearance only — no key, no policy, no threshold, no subject data. A publishable
        `pk_*` is accepted because the hosted surface must paint itself before any identity
        exists, and nothing sensitive is reachable here even with a stolen browser key.

        22 tokens on the capture surface; the mandate surface consumes the 19 that remain
        after dropping `logo_icon_url`, `color_success` and `color_danger`.

        A merchant with no row — or with any token left NULL — receives the fully populated
        **Sidaxis** theme, and `customized: false` says so. The surface never receives NULL
        and never needs a local fallback. Logos default to `null`; the component is
        designed to render without one.

        **Read-only by design.** There is no endpoint to write a theme: themes are written
        in the developer console, as the authenticated merchant owner, and only published
        themes are served. If you are looking for a write API, it does not exist — do not
        build against one.

        **Hosted mode applies the theme for you.** When the buyer is on a Sidaxis-hosted
        screen, the screen fetches and applies the theme itself — do not call this endpoint
        in that flow. Call it only in headless/SDK integrations, where your own UI renders
        and you must apply the tokens yourself.

        White-label is **included** — no per-check cost, no add-on. Branding every hosted
        screen with the merchant's identity is part of the service.

        `cache-control: public, max-age=60`.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
        - { name: merchant_id, in: query, required: true, schema: { type: string } }
        - { name: surface, in: query, schema: { type: string, enum: [capture, mandate], default: capture } }
      responses:
        '200':
          description: The theme.
          content:
            application/json:
              example:
                version: theme.v1
                merchant_id: sidaxis-surface-sandbox
                surface: capture
                customized: false
                tokens: [brand_name, mode, logo_wide_url, "…"]
                theme: { brand_name: Sidaxis, mode: light, color_primary: "212 78% 15%", radius_md_px: 12 }
        '400':
          description: '`MERCHANT_ID_REQUIRED` · `INVALID_SURFACE`.'
        '401':
          description: '`INVALID_API_KEY`.'
        '405':
          description: '`METHOD_NOT_ALLOWED`.'
    post:
      tags: [Surface]
      operationId: merchantThemeGetPost
      summary: The merchant's theme tokens (POST form)
      parameters:
        - $ref: '#/components/parameters/ApiKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [merchant_id]
              properties:
                merchant_id: { type: string }
                surface: { type: string, enum: [capture, mandate] }
      responses:
        '200':
          description: The theme.

  /alias-availability:
    post:
      tags: [Surface]
      operationId: aliasAvailability
      summary: Is this handle free — and hold it briefly
      description: |
        Real-time availability for a typing surface. A publishable `pk_*` is valid; an
        anonymous caller (no merchant key) may call `check` only.

        Format is engine-owned: `^[a-z][a-z0-9_]{2,29}$` — lowercase, digits and
        underscore, must start with a letter, no trailing `_`, no `__`. Input is normalised
        before validation (`@` dropped, trimmed, lowercased, invalid characters stripped)
        and the normalised alias is echoed back, so the screen shows exactly what would be
        claimed.

        A bad format is **200 with `format_ok: false`** and a named code, so keystroke
        feedback never looks like an error.

        About 90 aliases can never be claimed: brand, institutional, technical surfaces and
        ambiguity traps. Suggestions are deterministic — no randomness — and every one is
        verified free before it is offered.

        A reservation expires on its own; nothing has to run for the alias to come back.

        Rate limits per minute: `check` 120 with a merchant key, 30 anonymous per IP;
        `reserve` and `release` 10. Responses are `cache-control: no-store`. Debounce
        keystrokes around 250 ms.

        `user_id` is never accepted and never returned here. The identity id is minted by
        the engine at enrollment; an alias is only a public handle.
      parameters:
        - $ref: '#/components/parameters/ApiKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [alias]
              properties:
                alias: { type: string }
                action: { type: string, enum: [check, reserve, release], default: check }
                ttl_seconds: { type: integer, minimum: 60, maximum: 900, default: 300 }
                claim_token: { type: string, description: Required on release. }
      responses:
        '200':
          description: Answered.
          content:
            application/json:
              examples:
                free:
                  value: { alias: silvio, available: true, format_ok: true, suggestions: [], rules: { min_length: 3, max_length: 30, pattern: "^[a-z][a-z0-9_]{2,29}$" } }
                taken:
                  value: { alias: silvio, available: false, format_ok: true, suggestions: [silvio_id, silvio_official, the_silvio] }
                badFormat:
                  value: { alias: si, available: false, format_ok: false, code: ALIAS_TOO_SHORT, message: "At least 3 characters." }
                reserved:
                  value: { alias: silvio, reserved: true, claim_token: "36402df4…", expires_at: "2026-09-15T20:44:27.092Z", ttl_seconds: 300 }
        '409':
          description: '`ALIAS_TAKEN` — already held, already an identity, or lost the race.'
        '429':
          description: '`RATE_LIMITED` with `retry_after_seconds`, `Retry-After` and `X-RateLimit-*`.'

  # ───────────────────────────── Receipts ─────────────────────────────

  /get-merchant-receipts-v2:
    get:
      tags: [Receipts]
      operationId: merchantReceipts
      summary: Receipts for a merchant you own
      description: |
        Auth here is a **first-party Sieve ID JWT** (`Authorization: Bearer`), not an API
        key: the ownership model is "the Sieve ID user owns the merchant". A merchant key
        gets `403 sieve_id_jwt_required`.

        Because the merchant is party to the transaction, these receipts carry the numeric
        `vrc_confidence`, which the public projection never shows.
      parameters:
        - { name: merchant_id, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: receipt_code, in: query, schema: { type: string } }
      security: []
      responses:
        '200':
          description: The receipts.
          content:
            application/json:
              example: { success: true, merchant_id: "…", merchant_name: "…", total: 2, receipts: [] }
        '400': { description: '`missing_required_fields`.' }
        '401': { description: '`unauthorized`.' }
        '403': { description: '`caller_not_owner` · `sieve_id_jwt_required`.' }
        '404': { description: '`unknown_merchant`.' }

  /rpc/get_receipt_v2:
    post:
      tags: [Receipts]
      operationId: verifyReceipt
      summary: Public verification — no API key
      description: |
        A `SECURITY DEFINER` RPC on the Data API, reachable with the publishable anon key.
        Note the different path: `https://api.sidaxis.com/rest/v1/rpc/get_receipt_v2`.

        An unknown hash returns `200 null`, never a 404, so nobody can probe for existence.

        The public projection omits `vrc_confidence` and `biovital_token_hash`, and reports
        proof strength honestly. A receipt written before assurance existed says so
        (`code: not_recorded`) instead of inferring a level after the fact.

        Human-facing page: `/verify/:hash`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lookup_key]
              properties:
                lookup_key: { type: string, description: A sieve_hash or a receipt_code. }
      responses:
        '200':
          description: The redacted receipt, or `null`.
          content:
            application/json:
              example:
                receipt_code: RCP-7F2C91
                sieve_hash: "0x…"
                immutability_seal: SEAL-…
                assurance:
                  code: face_voice
                  i18n_key: assurance.face_voice
                  label: Face and voice
                  description: A living person was present and spoke, with speech correlated to the pulse read by the camera at the same instant.
                  recorded: true
                  requires: { face: true, voice: true, document: false }
                mandate:
                  id: "mnd_live_b815…"
                  assurance_level: face_voice
                  granted_to: agent_shopping_assistant
                  agent_display_name: your travel assistant
                  agent_descriptor: Books flights within your limits
        extra: { liveness_passed: true, vrc_pass: true, biovital_sealed: true }

  # ────────────────────── One Face for agents ──────────────────────

  /agent-verification-request:
    post:
      tags: [One Face]
      operationId: agentVerificationRequest
      summary: Mint a One Face card an agent can hand to a person
      description: |
        In production. An agent asks for a verification; the person proves it on her own
        device. The agent never touches a camera or a microphone.

        Auth is a **first-party Sieve ID JWT** (`Authorization: Bearer`), or the OAuth 2.1
        bearer issued to an MCP client. A merchant API key is refused with
        `USER_TOKEN_REQUIRED`: the card belongs to the merchant the agent represents, and
        only an owner of that merchant can mint one.

        The response carries a single-use URL on the hosted surface
        (`https://id.sidaxis.com/capture?hs=…`). Default lifetime 300 s, maximum 900 s.
        This is not a mandate and issues no authority — it is a verification of presence,
        read back with `agent-verification-result`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [merchant_id]
              properties:
                merchant_id: { type: string }
                external_ref: { type: string, description: Your own reference for this verification. }
                purpose: { type: string, maxLength: 140, description: Shown to the person on the screen. }
                ttl_seconds: { type: integer, minimum: 60, maximum: 900, default: 300 }
      responses:
        '200':
          description: Card minted.
          content:
            application/json:
              example:
                verification_id: "a87f81d8-…"
                url: "https://id.sidaxis.com/capture?hs=hs_live_4ad6…0a17"
                merchant_id: acme-travel
                external_ref: chk_10231
                expires_at: "2026-09-16T12:05:39.349Z"
                ttl_seconds: 300
        '401': { description: '`USER_TOKEN_REQUIRED` — a merchant API key cannot mint a card.' }
        '403': { description: '`caller_not_owner` — the caller does not own that merchant.' }

  /agent-verification-result:
    get:
      tags: [One Face]
      operationId: agentVerificationResult
      summary: Read the outcome of a One Face card
      description: |
        In production. Polling, not webhooks. Every state is `HTTP 200` with three message
        layers — `reason`, `developer_message`, `user_message` — never an error.

        States: `pending` (never opened), `opened` (in progress), `verified`, `refused`,
        `expired`. The outcome is derived from what the capture recorded; nothing and nobody
        can declare `verified` on its own. A verified result carries the assurance level and
        the receipt.
      security: []
      parameters:
        - { name: verification_id, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: The current state.
          content:
            application/json:
              examples:
                pending:
                  value:
                    state: pending
                    reason: verification_not_opened
                    developer_message: The person has not opened the card yet.
                    user_message: Waiting for the person to open the link.
                verified:
                  value:
                    state: verified
                    reason: verified
                    developer_message: Liveness proof recorded at capture time.
                    user_message: Identity verified.
                    assurance: { code: face_voice, label: Face and voice, recorded: true }
                    receipt: { receipt_code: RCP-7F2C91, sieve_hash: "0x…" }
        '401': { description: '`USER_TOKEN_REQUIRED`.' }

  # ───────────────────────────── MCP ─────────────────────────────

  /mcp:
    post:
      tags: [MCP]
      operationId: mcpServer
      summary: MCP server — nine tools, each on a live endpoint
      description: |
        In production at `https://api.sidaxis.com/functions/v1/mcp` (Streamable HTTP).
        Authorisation is OAuth 2.1 with dynamic client registration; the person signs in
        and approves the client, and every tool then acts as that user.

        Nine tools, each mapped one-to-one onto an endpoint in this document:

        | Tool | Endpoint |
        |---|---|
        | `mandate_scopes` | `mandate-scopes` |
        | `mandate_check` | `mandate-check` (advisory) |
        | `mandate_consume` | `mandate-consume` (authoritative, `idempotency_key` required) |
        | `mandate_get` | `mandate-get` |
        | `mandate_revoke` | `mandate-revoke` |
        | `mandate_issue` | `mandate-issue` (requires a live `capture_id`) |
        | `receipt_verify` | `rpc/get_receipt_v2` |
        | `identity_verification_request` | `agent-verification-request` |
        | `identity_verification_result` | `agent-verification-result` |

        Face and voice capture are deliberately absent, and the absence is the argument: a
        descriptor is produced on the person's own device by camera and microphone, so no
        agent can produce one. An agent asks; the person proves.
      security: []
      responses:
        '200':
          description: JSON-RPC response over the MCP Streamable HTTP transport.
        '401':
          description: Missing or invalid OAuth bearer; the resource metadata points at the authorisation server.

components:

  securitySchemes:
    apiKeyHeader:
      type: apiKey
      in: header
      name: x-sidaxis-api-key
      description: |
        `sk_live_…` / `sk_test_…` for server calls, `pk_live_…` / `pk_test_…` for browser
        calls. The legacy header `x-sieve-api-key` is accepted with identical semantics;
        when both are present, `x-sidaxis-api-key` wins.

  parameters:
    ApiKey:
      name: x-sidaxis-api-key
      in: header
      required: true
      schema: { type: string }
      description: Legacy `x-sieve-api-key` accepted, identical semantics.
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 8, maxLength: 200 }
      description: Mandatory on consume. Without it a network timeout becomes a double charge.
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      schema: { type: string, minLength: 8, maxLength: 200 }
      description: 'Strongly recommended. A replay returns the original body with `Idempotency-Status: replayed`.'
    Timestamp:
      name: x-sidaxis-timestamp
      in: header
      required: true
      schema: { type: integer }
      description: Unix seconds, ±300 s.
    Nonce:
      name: x-sidaxis-nonce
      in: header
      required: true
      schema: { type: string, minLength: 8 }
      description: Single use.
    AcceptLanguage:
      name: accept-language
      in: header
      schema: { type: string, enum: [en, pt-BR, es] }
      description: Localises `user_message` and scope labels only. Never `reason`.

  schemas:

    FaceTemplate:
      type: object
      description: |
        A descriptor derived on the person's device. Not video, not frames, not audio.
        Payload cap 1 MB. The vector is shredded in-browser after use.
      required: [vector, quality]
      properties:
        vector:
          type: array
          items: { type: number }
          minItems: 128
          maxItems: 128
        quality: { type: number, minimum: 0, maximum: 1 }

    BiometricVitals:
      type: object
      properties:
        bpm: { type: number }
        hrv: { type: number }
        stress_score: { type: number }
        voice_pulse_correlation: { type: number, description: Speech correlated with the pulse read by the camera at the same instant. Gate 0.70. }
        liveness_confidence: { type: number }
        liveness_proof: { type: string }

    EnrollmentCreate:
      type: object
      required: [action, merchant_id, sieve_hash, consent, face_template, biometric_vitals]
      properties:
        action: { type: string, enum: [ACTION_GENESIS] }
        merchant_id: { type: string }
        sieve_hash: { type: string }
        consent:
          type: object
          required: [version, locale, accepted_at]
          properties:
            version: { type: string }
            locale: { type: string }
            accepted_at: { type: string, format: date-time }
        device_signature: { type: string }
        client_instance_id: { type: string }
        device_label: { type: string }
        face_template: { $ref: '#/components/schemas/FaceTemplate' }
        biometric_vitals: { $ref: '#/components/schemas/BiometricVitals' }

    TransactionCreate:
      type: object
      required: [action, sieve_hash, user_id, merchant_id]
      properties:
        action: { type: string, enum: [ACTION_LOGIN, ACTION_PAYMENT, ACTION_SIGN, ACTION_SIGNUP] }
        sieve_hash: { type: string }
        user_id: { type: string, format: uuid, description: Minted by the engine at enrollment. Never chosen by a surface. }
        merchant_id: { type: string }
        amount: { type: integer, description: Minor unit. A float is refused with AMOUNT_INVALID. }
        currency: { type: string }
        external_txn_ref: { type: string }
        device_signature: { type: string }
        client_instance_id: { type: string }
        biometric_vitals: { $ref: '#/components/schemas/BiometricVitals' }
        mandate_id: { type: string, description: 'Optional. Present, the mandate stands in for the live capture.' }

    MandateCreate:
      type: object
      required: [granted_by, granted_to, scopes, ceiling, expires_at, proof]
      properties:
        granted_by: { type: string, format: uuid, description: The human principal. }
        granted_to: { type: string, description: An opaque machine identifier. Never the label a person reads. }
        granted_to_kind: { type: string, enum: [agent, application, service] }
        agent_display_name:
          type: string
          maxLength: 64
          description: |
            The readable agent name for the screens ("your travel assistant"). Frozen with
            the mandate. Absent stays absent — the screen falls back to the identifier
            rather than inventing a name.
        agent_descriptor: { type: string, maxLength: 140, description: One line saying what the agent does. }
        scopes:
          type: array
          items: { $ref: '#/components/schemas/Scope' }
          description: Explicit list, matched exactly. No wildcard, no prefix.
        constraints:
          type: object
          description: Mandatory for `task` — at least one non-empty list.
          properties:
            merchant_allowlist: { type: array, items: { type: string } }
            category_allowlist: { type: array, items: { type: string } }
            country_allowlist: { type: array, items: { type: string } }
        ceiling:
          type: object
          required: [kind]
          properties:
            kind: { type: string, enum: [amount, count, both, none] }
            amount: { type: integer, description: Minor unit. 4000 is forty dollars. Never a float. Only on `purchase` and `payment`. }
            currency: { type: string, description: Required when the ceiling is amount-based. }
            count: { type: integer, description: Required when the ceiling is count-based. }
            period: { type: string, enum: [lifetime, daily, weekly, monthly], description: The bucket is computed server-side. A client-sent period anchor is ignored. }
        expires_at: { type: string, format: date-time, description: Mandatory. }
        step_up_of: { type: string, description: 'The mandate this one supersedes. The parent is never consumed, changed or reset.' }
        proof:
          type: object
          required: [capture_id]
          properties:
            capture_id: { type: string, description: 'A capture no older than five minutes, with liveness passed and no coercion signal.' }

    Scope:
      type: string
      enum: [login, purchase, payment, task, signature, data_share, id_proof, access]
      description: |
        The closed v2 vocabulary, matched exactly. Recurrence lives in `ceiling.period` and
        nowhere else. Only `purchase` and `payment` accept an amount ceiling; `task`
        requires constraints.

    Mandate:
      allOf:
        - $ref: '#/components/schemas/MandateCreate'
        - type: object
          properties:
            id: { type: string, examples: ["mnd_live_b8158ce8513523e91f74c6bc5a0a13e8"] }
            merchant_id: { type: string }
            status: { type: string, enum: [active, consumed, exhausted, expired, revoked] }
            stored_status: { type: string, description: 'The untouched row, when it differs from the effective status.' }
            assurance_level: { $ref: '#/components/schemas/AssuranceLevel' }
            issuance_proof_id: { type: string }
            device_key_id: { type: string }
            issued_at: { type: string, format: date-time }
            remaining:
              type: object
              properties:
                amount: { type: integer }
                currency: { type: string }
                count: { type: integer }
            period_resets_at: { type: string, format: date-time }
            scope_labels:
              type: array
              description: Ready in the caller's locale, so the consent screen does not write its own.
              items:
                type: object
                properties:
                  scope: { type: string }
                  label: { type: string }
                  description: '{ type: string }'
                  limits: { type: array, items: { type: string } }
            consumptions:
              type: array
              description: The last 100, on a one-mandate read.
              items: { type: object }

    AssuranceLevel:
      type: string
      enum: [face, face_voice, face_voice_doc]
      description: |
        Derived at the instant of the act and frozen on the receipt. Never sent by the
        client. There is no identity-level assurance column and there must never be one —
        the level belongs to the act.

    MandateActionRequest:
      type: object
      required: [mandate_id, scope]
      properties:
        mandate_id: { type: string }
        scope: { $ref: '#/components/schemas/Scope' }
        amount: { type: integer, description: Minor unit. }
        currency: { type: string }
        action_ref: { type: string }
        context:
          type: object
          properties:
            merchant: { type: string }
            category: { type: string }
            country: { type: string }

    MandateAnswer:
      type: object
      required: [allowed]
      description: |
        Always HTTP 200. Three message layers: `reason` is machine-stable and never
        translated; `developer_message` is always English, for logs; `user_message` is
        localised by `Accept-Language` and is deliberately absent on integration errors.
      properties:
        allowed: { type: boolean }
        reason: { $ref: '#/components/schemas/Reason' }
        developer_message: { type: string }
        user_message: { type: string, description: 'Absent on `not_found`, `rate_limited` and `idempotency_key_required`.' }
        step_up_available: { type: boolean, description: Present on `ceiling_exceeded` and `scope_mismatch`. }
        consumption_id: { type: string }
        idempotent_replay: { type: boolean }
        at_limit: { type: boolean }
        remaining: { type: object }
        period_resets_at: { type: string, format: date-time }

    Reason:
      type: string
      enum:
        - ceiling_exceeded
        - scope_mismatch
        - mandate_expired
        - mandate_revoked
        - constraint_violated
        - liveness_required
        - not_found
        - rate_limited
        - idempotency_key_required
      description: |
        Stable forever, never translated, never in a locale file. Branch on this, never on
        the HTTP status. `liveness_required` is a refusal in a 200 body — not an HTTP error.

    DocumentRequired:
      type: object
      description: |
        `403` from `process-biovital-transaction` (payment, signature) and from
        `mandate-issue` on a money scope. Once per identity, not per operation: with a
        valid record the operation never asks.

        `liveness_reusable: false` is not decoration — the retry needs a fresh capture. The
        document step does not extend the liveness window.
      properties:
        success: { type: boolean, examples: [false] }
        code: { type: string, examples: [DOCUMENT_REQUIRED] }
        required_assurance_level: { type: string, examples: [face_voice_doc] }
        current_assurance_level: { type: string, examples: [face_voice] }
        document_status: { type: string, enum: [missing, expired] }
        document_valid_until: { type: [string, 'null'], format: date-time }
        liveness_reusable: { type: boolean, examples: [false] }
        rule: { type: string, examples: [once_per_identity] }

    HostedSessionCreate:
      type: object
      required: [action]
      properties:
        action: { type: string, enum: [authorize, checkin, sign, verify, enroll] }
        external_txn_ref: { type: string, maxLength: 128, description: 'Required for `authorize`. Charset `[\w.:@/-]`.' }
        amount_cents: { type: integer, description: Required for `authorize`. Minor unit. }
        currency: { type: string, description: ISO-4217. Defaults to BRL when an amount is present. }
        brand: { type: string, maxLength: 40, description: 'Display label, sanitized. Full theming comes from merchant-theme-get.' }
        ttl_seconds: { type: integer, minimum: 60, maximum: 900, default: 300 }
        metadata: { type: object, description: 'Display and passthrough only, max 2 KB.' }
        merchant_id: { type: string, description: 'If sent, must equal the key''s merchant.' }

    WebhookEvent:
      type: object
      description: |
        Written to an outbox in the **same transaction** as the consumption and delivered by
        `mandate-outbox-worker`, which runs **every minute**. A webhook inside the
        transaction would hold the ceiling lock hostage to a slow client; a send before
        commit would announce something that never happened.

        At-least-once. Deduplicate on the event id.

        Signature over the exact bytes sent:

        ```
        signature = "sha256=" + HMAC_SHA256(webhook_secret, `${unix_seconds}.${raw_body}`)
        ```

        Headers: `x-sidaxis-signature`, `x-sidaxis-timestamp`, `x-sidaxis-event-id`, plus
        the legacy `x-sieve-*` triple with identical values. Verify against the raw body —
        re-serialising the JSON breaks the check.

        Backoff: 30 s → 2 m → 10 m → 1 h → 6 h, then parked.
      required: [event_id, type, created_at, data]
      properties:
        event_id: { type: string }
        type:
          type: string
          enum: [mandate.granted, mandate.consumed, mandate.at_limit, mandate.revoked, mandate.expired]
        created_at: { type: string, format: date-time }
        data: { type: object }

    TransportError:
      type: object
      description: A real HTTP error — malformed request or authentication failure. Not a refusal.
      required: [error, code]
      properties:
        error: { type: string }
        code: { type: string }

  responses:
    InvalidRequest:
      description: '`INVALID_REQUEST` — malformed body.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/TransportError' }
    Unauthorized:
      description: '`API_KEY_REQUIRED` · `INVALID_API_KEY` · `AUTH_REQUIRED` (a `pk_*` on a server endpoint).'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/TransportError' }
    RateLimited:
      description: '`RATE_LIMITED`, with `Retry-After`.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/TransportError' }
