openapi: 3.1.0

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

    **oneface** resolves a living person to a coordinate and proves they were present.
    **Mandates** record what that person authorised a machine to do, and enforce 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.

    ## Versioning
    Versions are dates. Send `Sidaxis-Version: 2026-09-14`. A breaking change creates a new
    version; the previous one stays available for twelve months and returns a deprecation
    header for the last ninety days of that window.

    ## Idempotency
    Every write accepts `Idempotency-Key`. Replaying a key returns the original response and
    never consumes a ceiling twice. Keys are retained for 24 hours.

    ## Billing
    A call that does not complete is never billed. Recognising a person you have already
    claimed costs nothing, at any volume.
  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/v1
    description: Production
  - url: https://sandbox.api.sidaxis.com/v1
    description: Sandbox — synthetic identities, forced failures, no billing

x-environments:
  mirror: |
    The sandbox mirrors production: same paths, fields, status codes, receipts, webhook
    envelope and dated version. A call that works in one and not the other is a bug.
  simulates: [liveness_failure, refused_check_every_reason, expiry_and_revocation, hosted_session_cancel_and_expiry, webhook_retries, rate_limit_429, allowance_exhausted_402]
  does_not: [move_money, reproduce_production_latency, share_ids_across_environments, copy_mandates_or_facetokens, cross_verify_webhook_secrets]

x-rate-limits:
  - { scope: "POST /mandates/{id}/check", limit: "1000/s per key", burst: 2000 }
  - { scope: "POST /identity/recognitions", limit: "200/s per key", burst: 400 }
  - { scope: "POST /identity/claims", limit: "50/s per key", burst: 100 }
  - { scope: "POST /sessions", limit: "100/s per key", burst: 200 }
  - { scope: "GET *", limit: "300/s per key", burst: 600 }
  - { scope: "workspace", limit: "3000/s per workspace", burst: 5000 }
  - { scope: "GET /receipts/{id}/verify", limit: "no account limit", burst: "fair use per IP" }
  headers: [Sidaxis-RateLimit-Limit, Sidaxis-RateLimit-Remaining, Sidaxis-RateLimit-Reset, Retry-After]

x-changelog:
  - version: "2026-09-14"
    state: current
    date: 14 September 2026
    summary: First public version. Identity, Mandates, Receipts and hosted Sessions, with names frozen.
    added:
      - Identity claims, recognitions and attributes
      - Mandates issue, list, retrieve, check and revoke
      - Receipts retrieve, and the public unauthenticated verify
      - Hosted sessions create and read, with white-label branding in the free tier
      - Six webhook events under one signed envelope
      - MCP server, twelve tools, one per endpoint
  policy:
    added: New endpoints, fields or events. Never breaking.
    changed: Compatible behaviour only.
    deprecated: Served with a Sidaxis-Deprecation header for the last ninety days of the twelve-month window.
    breaking: Only in a new dated version.

x-status:
  page: https://status.sidaxis.com
  note: >-
    Live per-region availability and incident history live at status.sidaxis.com, outside
    this domain and off our main infrastructure on purpose — a status page that goes down
    with the API tells you nothing.
  components:
    - name: Identity
      sla: "99.95%"
      note: Claims and recognitions.
    - name: Mandates
      sla: "99.99%"
      note: Includes the check path. The strictest target we publish.
    - name: Receipts
      sla: "99.95%"
      note: Reads and sealing.
    - name: Public verify
      sla: "99.99%"
      note: Unauthenticated. Independent verification has to be there when someone doubts us.
    - name: Hosted sessions
      sla: "99.95%"
      note: The white-labelled screen and its callbacks.
    - name: Webhooks
      sla: "99.9% dispatch"
      note: Plus 72 hours of retries and 30 days of replay.
  anchoring:
    target: Next batch, within 10 minutes of the action
    note: >-
      A receipt is sealed the moment the action happens and is verifiable against the
      issuer immediately. The public anchor follows in the next batch, so a receipt read
      seconds after the event can report the anchor as pending. That is expected, and it
      is not an incident.
  incident:
    - label: Detection to first post
      value: 15 minutes
      note: Posted on the status page and sent to the subscribers list, before we know the cause.
    - label: Updates while open
      value: Every 30 minutes
      note: Even when the update is that nothing has changed.
    - label: Written post-mortem
      value: 5 business days
      note: For anything customer-visible, including what we are changing so it does not repeat.
    - label: Per-region detail
      value: On the status page
      note: São Paulo, Frankfurt and Virginia are reported separately — one region degraded is not all of them.

security:
  - bearerAuth: []

tags:
  - name: Identity
    description: Prove a live human is present, and recognise them again for free.
  - name: Mandates
    description: Record and enforce what a verified human authorised a machine to do.
  - name: Receipts
    description: Anchored proof that an action happened and was not altered.
  - name: Hosted
    description: |
      A session Sidaxis hosts. You create it with an API key, redirect the person to the
      returned URL, and read the outcome from a webhook or from your return_url. No SDK,
      no camera code, no proof handling. White-label of that screen — logo, colours, your
      own domain and copy — is included in the free tier.
  - name: Webhooks
    description: Signed events, ordered per resource, retried for 72 hours.

paths:

  /identity/claims:
    post:
      tags: [Identity]
      operationId: createClaim
      summary: Claim a coordinate for a person
      description: |
        The first claim on a person. Liveness, the document read and the face match run as
        one call at one price. The reading happens on the person's device; what reaches this
        endpoint is a proof, never an image or a template.

        Billed once per person, for life. Recognising the same person later is free — see
        `POST /identity/recognitions`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/Version'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [proof]
              properties:
                proof:
                  type: string
                  description: Zero-knowledge proof emitted by the SDK on the user's device.
                  examples: ["zkp_01J8Y3Q2…"]
                source:
                  type: string
                  enum: [open_finance, eid_nfc, network_auth, bureau]
                  description: |
                    How the legal identity is attached to the coordinate. Omit to claim the
                    coordinate alone, without a legal identity bound to it.
                metadata:
                  $ref: '#/components/schemas/Metadata'
            examples:
              openFinance:
                summary: Claim with an open finance account link
                value:
                  proof: "zkp_01J8Y3Q2VXK7"
                  source: open_finance
                  metadata: { customer_ref: "acct_4471" }
      responses:
        '201':
          description: Coordinate claimed. Store the `facetoken`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Claim' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '409':
          description: |
            This person already has a claim under your account. Use the returned `facetoken`
            and call `POST /identity/recognitions` instead — recognition is free.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: already_claimed
                message: This person is already known to you.
                facetoken: "0xA1f3c9b27d04"
        '422': { $ref: '#/components/responses/LivenessRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /identity/recognitions:
    post:
      tags: [Identity]
      operationId: recognisePerson
      summary: Recognise a person you already claimed
      description: |
        Confirms the live person in front of the camera is the holder of a FaceToken you
        already hold. **Free, at any volume, for life** — in your product or anyone else's
        on the network.
      parameters:
        - $ref: '#/components/parameters/Version'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [proof, facetoken]
              properties:
                proof: { type: string }
                facetoken: { $ref: '#/components/schemas/FaceToken' }
      responses:
        '200':
          description: Recognised, or not. Never billed either way.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recognised: { type: boolean }
                  facetoken: { $ref: '#/components/schemas/FaceToken' }
                  receipt: { $ref: '#/components/schemas/ReceiptRef' }
        '422': { $ref: '#/components/responses/LivenessRequired' }

  /identity/attributes:
    post:
      tags: [Identity]
      operationId: requestAttribute
      summary: Ask for one attribute, and receive an answer
      description: |
        Requests a predicate rather than a document. The person approves in their wallet and
        you receive the answer — `true`, `false`, or a value — never the underlying document.
        Both sides get a receipt naming exactly what was shared.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/Version'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [facetoken, attribute]
              properties:
                facetoken: { $ref: '#/components/schemas/FaceToken' }
                attribute:
                  type: string
                  enum: [over_18, over_21, residency, tax_number, address, right_to_work, professional_licence]
                purpose:
                  type: string
                  description: Shown to the person when they are asked. Recorded in the receipt.
      responses:
        '202':
          description: Request sent to the person's wallet. Resolve via webhook.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  status: { type: string, enum: [pending] }

  /mandates:
    post:
      tags: [Mandates]
      operationId: createMandate
      summary: Issue a mandate
      description: |
        Records what a verified human authorised a machine to do. Five fields, all required —
        remove any one and the mandate stops being enforceable.

        The grant requires proof of a live human at the moment it is made. It cannot be
        created from a session that was already open.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/Version'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MandateCreate' }
            examples:
              standingPayment:
                summary: A standing payment mandate with a monthly ceiling
                value:
                  granted_by: "0xA1f3c9b27d04"
                  granted_to: "agent_7f2c"
                  scope: ["payment"]
                  ceiling: { amount: 200, currency: USD, period: month }
                  until: "2027-03-01T00:00:00Z"
                  require: proof_of_live_human
              singleSignature:
                summary: A single-use signature mandate
                value:
                  granted_by: "0xA1f3c9b27d04"
                  granted_to: "agent_7f2c"
                  scope: ["signature"]
                  ceiling: { count: 1 }
                  until: "2026-09-21T00:00:00Z"
                  require: proof_of_live_human
      responses:
        '201':
          description: Mandate issued and anchored.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Mandate' }
        '422': { $ref: '#/components/responses/LivenessRequired' }

    get:
      tags: [Mandates]
      operationId: listMandates
      summary: List mandates
      parameters:
        - { name: granted_by, in: query, schema: { type: string } }
        - { name: granted_to, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [active, expired, revoked, exhausted] } }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of mandates.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Mandate' } }
                  next_cursor: { type: [string, 'null'] }

  /mandates/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Mandates]
      operationId: getMandate
      summary: Retrieve a mandate
      responses:
        '200':
          description: The mandate, with its remaining ceiling.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Mandate' }
        '404': { $ref: '#/components/responses/NotFound' }

  /mandates/{id}/check:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Mandates]
      operationId: checkMandate
      summary: Does this action fit the mandate?
      description: |
        Call this before every action. Scope, ceiling and validity are evaluated together and
        you get `allowed` true or false, with the reason when false.

        Median under 40 ms in every region. Idempotent — retrying never double-consumes a
        ceiling. A refused check is not billed and does not consume anything.

        **This endpoint does not move money.** Your system or your PSP settles, or stops.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/Version'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operation]
              properties:
                operation:
                  type: string
                  enum: [access, data_release, payment, signature]
                amount: { type: number }
                currency: { type: string, examples: [USD] }
                counterparty: { type: string }
            example:
              operation: payment
              amount: 240
              currency: USD
              counterparty: acme_ltd
      responses:
        '200':
          description: |
            Answered. `allowed: false` is a successful call, not an error — the rail did its
            job. Inspect `reason`.
          content:
            application/json:
              schema:
                type: object
                required: [allowed]
                properties:
                  allowed: { type: boolean }
                  reason:
                    type: [string, 'null']
                    enum: [ceiling_exceeded, scope_mismatch, mandate_expired, mandate_revoked, counterparty_not_permitted, null]
                  remaining: { type: number, description: What is left of the ceiling. }
                  receipt: { $ref: '#/components/schemas/ReceiptRef' }
              examples:
                refused:
                  summary: Over the ceiling
                  value: { allowed: false, reason: ceiling_exceeded, remaining: 160 }
                allowed:
                  summary: Within the mandate
                  value: { allowed: true, reason: null, remaining: 160, receipt: { id: "rcpt_9f41c7b2", anchor: "0x7c3d91a4f8e2" } }
        '404': { $ref: '#/components/responses/NotFound' }

  /mandates/{id}/revoke:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Mandates]
      operationId: revokeMandate
      summary: Revoke a mandate
      description: |
        Effective on the next request. Does not require the agent's cooperation and **is never
        billed** — charging a customer to protect themselves makes no sense.
      responses:
        '200':
          description: Revoked.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Mandate' }

  /sessions:
    post:
      tags: [Hosted]
      operationId: createSession
      summary: Create a hosted session
      description: |
        The whole reading on a screen we host. One session does one thing: `mode` decides
        whether it claims a person, recognises one, asks for an attribute or takes a mandate
        grant. Send the person to `url`, then read the result from the webhook or from
        `GET /sessions/{id}` — never from the redirect query string alone.

        The reading still happens on the person's device. Hosted changes who writes the
        screen, not where the biometric goes.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/Version'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionCreate' }
            examples:
              mandateGrant:
                summary: Take a mandate grant on a hosted screen
                value:
                  mode: mandate
                  return_url: "https://acme.example/agent/setup/done"
                  facetoken: "0xA1f3c9b27d04"
                  mandate:
                    granted_to: "agent_7f2c"
                    scope: [payment]
                    ceiling: { amount: 200, currency: USD, period: month }
                    until: "2027-03-01T00:00:00Z"
                  branding:
                    logo_url: "https://acme.example/logo.svg"
                    accent: "#2DD48F"
                    domain: "id.acme.example"
                    headline: "Authorise your assistant"
      responses:
        '201':
          description: Session open. Redirect the person to `url`; it is single-use and expires in 30 minutes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/InvalidReturnUrl' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /sessions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Hosted]
      operationId: getSession
      summary: Read a session result
      description: |
        The authoritative outcome. `result` carries whatever the mode produced — a facetoken,
        a recognition, an answer or a mandate id. Never billed, so it is safe to poll, though
        the webhook is cheaper for both of us.
      responses:
        '200':
          description: The session, with its status and result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/SessionExpired' }

  /receipts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Receipts]
      operationId: getReceipt
      summary: Retrieve a receipt
      description: |
        The full receipt, for a party to the transaction. The public view at
        `verify.sidaxis.com` shows only what was proven and when — never amounts, names or
        documents.
      responses:
        '200':
          description: The receipt.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Receipt' }
        '404': { $ref: '#/components/responses/NotFound' }

  /receipts/{id}/verify:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Receipts]
      operationId: verifyReceipt
      summary: Verify a receipt against the public record
      description: |
        Open, unauthenticated and unlimited. Anyone can call it, including someone with no
        relationship to Sidaxis. It is what makes the proof independent of us.
      security: []
      responses:
        '200':
          description: |
            `verified` means the document matches the anchor exactly. `pending` means it is
            sealed but not yet written to the public record.
          content:
            application/json:
              schema:
                type: object
                properties:
                  state: { type: string, enum: [verified, pending, not_found] }
                  sealed_at: { type: string, format: date-time }
                  anchor: { type: string }
                  seals:
                    type: array
                    items:
                      type: object
                      properties:
                        type: { type: string, enum: [proof_of_live_human, identity_matched, mandate_granted, settlement_sealed] }
                        note: { type: string }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer sk_live_…`. Sandbox keys start `sk_test_`. Keys are scoped per
        tool and per environment; a sandbox key never reaches production data.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string }
      description: Replaying a key returns the original response and never consumes a ceiling twice.
    Version:
      name: Sidaxis-Version
      in: header
      schema: { type: string, examples: ["2026-09-14"] }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }

  schemas:

    FaceToken:
      type: string
      description: |
        The durable handle you store in place of a person. Derived from their coordinate and
        your public identifier together, so it is stable for you forever and meaningless
        everywhere else. It carries no biometric and cannot be reversed into one.
      examples: ["0xA1f3c9b27d04"]

    Metadata:
      type: object
      additionalProperties: { type: string }
      description: Your own key-value pairs, returned unchanged. Never included in a public receipt.

    Claim:
      type: object
      properties:
        facetoken: { $ref: '#/components/schemas/FaceToken' }
        claimed_at: { type: string, format: date-time }
        source: { type: string, enum: [open_finance, eid_nfc, network_auth, bureau] }
        receipt: { $ref: '#/components/schemas/ReceiptRef' }

    MandateCreate:
      type: object
      required: [granted_by, granted_to, scope, ceiling, until]
      properties:
        granted_by:
          allOf: [{ $ref: '#/components/schemas/FaceToken' }]
          description: Who authorised it — a human proven alive at the moment of the grant.
        granted_to:
          type: string
          description: |
            Who may act. A different agent presenting the same mandate is refused, so a
            compromised integration cannot inherit someone else's authority.
        scope:
          type: array
          items: { type: string, enum: [access, data_release, payment, signature] }
          description: |
            What may be done, in terms the rail enforces without interpreting language.
            Purpose lives in the receipt, not here — a rail that has to understand intent
            can be argued with.
        ceiling:
          type: object
          description: Up to how much. Enforced at the layer that moves the value.
          properties:
            amount: { type: number }
            currency: { type: string }
            count: { type: integer }
            period: { type: string, enum: [once, day, month, year] }
            counterparties: { type: array, items: { type: string } }
        until:
          type: string
          format: date-time
          description: An absolute expiry, so an abandoned agent lapses instead of lingering.
        require:
          type: string
          enum: [proof_of_live_human]
          default: proof_of_live_human
        metadata: { $ref: '#/components/schemas/Metadata' }

    Mandate:
      allOf:
        - $ref: '#/components/schemas/MandateCreate'
        - type: object
          properties:
            id: { type: string, examples: ["mnd_9f41c7b2"] }
            status: { type: string, enum: [active, expired, revoked, exhausted] }
            remaining: { type: number }
            created_at: { type: string, format: date-time }
            receipt: { $ref: '#/components/schemas/ReceiptRef' }

    ReceiptRef:
      type: object
      properties:
        id: { type: string, examples: ["rcpt_9f41c7b2"] }
        anchor: { type: string, examples: ["0x7c3d91a4f8e2"] }
        url: { type: string, examples: ["https://verify.sidaxis.com/r/9f41c7b2"] }

    Receipt:
      allOf:
        - $ref: '#/components/schemas/ReceiptRef'
        - type: object
          properties:
            sealed_at: { type: string, format: date-time }
            mandate_id: { type: string }
            operation: { type: string }
            seals: { type: array, items: { type: object } }

    Branding:
      type: object
      description: |
        White-label of the hosted screen. Included in the free tier. Point a CNAME at Sidaxis
        and set `domain` to serve the session from your own hostname.
      properties:
        logo_url: { type: string }
        accent: { type: string, examples: ["#2DD48F"] }
        domain: { type: string, examples: ["id.acme.example"] }
        headline: { type: string }
        footer_note: { type: string }

    SessionCreate:
      type: object
      required: [mode, return_url]
      properties:
        mode:
          type: string
          enum: [claim, recognise, attribute, mandate]
          description: Each mode maps to the headless endpoint of the same name.
        return_url:
          type: string
          description: |
            Absolute https URL on a host you registered. Sidaxis appends `session_id` and
            `status`; treat them as a hint and read the result from the API.
        cancel_url: { type: string }
        facetoken:
          allOf: [{ $ref: '#/components/schemas/FaceToken' }]
          description: Required for modes recognise, attribute and mandate.
        attribute:
          type: string
          enum: [over_18, over_21, residency, tax_number, address, right_to_work, professional_licence]
        mandate:
          type: object
          description: Required for mode mandate. The fields of MandateCreate minus granted_by — the session proves who is granting.
        branding: { $ref: '#/components/schemas/Branding' }
        locale: { type: string }
        metadata: { $ref: '#/components/schemas/Metadata' }

    Session:
      type: object
      properties:
        id: { type: string, examples: ["ses_2a91c4f7"] }
        status: { type: string, enum: [open, completed, expired, cancelled] }
        mode: { type: string, enum: [claim, recognise, attribute, mandate] }
        url: { type: string, description: Where to send the person. Single-use. }
        result:
          type: object
          description: What the session produced — facetoken, recognised, answer or mandate_id.
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        receipt: { $ref: '#/components/schemas/ReceiptRef' }
        metadata: { $ref: '#/components/schemas/Metadata' }

    WebhookEvent:
      type: object
      description: |
        Every delivery uses the same envelope. Headers carry the signature and the event id:

        ```
        Sidaxis-Signature: t=1789413842,v1=<hex hmac-sha256>
        Sidaxis-Event-Id: evt_8f21c0a7
        Sidaxis-Event-Type: mandate.revoked
        Sidaxis-Delivery-Attempt: 1
        ```

        Verify with HMAC-SHA256 over `"<t>.<raw body>"` using the endpoint secret (`whsec_…`),
        compared in constant time, and reject a `t` older than five minutes. An unverified
        event is an unauthenticated one: anyone who learns your URL could otherwise forge a
        revocation and stop real work, or forge silence and let stopped work continue.

        Delivery: a 2xx within 10 seconds is success. Anything else retries — immediately,
        then at 1m, 5m, 30m, 2h, 6h, 12h and every 12h up to 72 hours. Order is not
        guaranteed; deduplicate on `id` and order by `created_at`.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          description: Unique per event and stable across retries. Deduplicate on this.
          examples: ["evt_8f21c0a7"]
        type:
          type: string
          enum: [mandate.granted, mandate.consumed, mandate.at_limit, mandate.revoked, session.completed, session.expired]
        created_at:
          type: string
          format: date-time
          description: When the event happened. Order by this, never by arrival.
        api_version: { type: string, examples: ["2026-09-14"] }
        attempt: { type: integer, description: 1 on first delivery. }
        data: { type: object }

    Error:
      type: object
      required: [code, message]
      properties:
        code: { type: string }
        message: { type: string }
        docs: { type: string, examples: ["https://docs.sidaxis.com/errors#ceiling_exceeded"] }

  responses:
    PaymentRequired:
      description: The account has no payment method and the monthly free allowance is used.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: allowance_exhausted, message: Add a payment method to continue. }
    LivenessRequired:
      description: |
        No living tissue was detected, or the proof was issued for a different request. A proof
        for a sign-in cannot be replayed to approve a payment.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: liveness_required, message: No live human detected in this reading. }
    NotFound:
      description: No such resource under this key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: not_found, message: No mandate with that id. }
    SessionExpired:
      description: The session's 30-minute window passed, or its URL was already used.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: session_expired, message: This session is no longer open. }
    InvalidReturnUrl:
      description: The return_url or cancel_url is missing, not https, or on an unregistered host.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: invalid_return_url, message: return_url must be an absolute https URL on an allowed host. }
    RateLimited:
      description: Too many requests. Back off and retry with the same idempotency key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: rate_limited, message: Retry after 2s. }

webhooks:

  mandate.granted:
    post:
      summary: A verified human issued a mandate
      description: Sent once, after the grant is anchored. `data` is the whole mandate.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEvent'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/Mandate' }
      responses: { '200': { description: Acknowledged within 10 seconds. } }

  mandate.consumed:
    post:
      summary: An action ran against a mandate
      description: One event per consuming check. `remaining` is authoritative — do not subtract your own.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEvent'
                - type: object
                  properties:
                    data:
                      type: object
                      properties:
                        mandate_id: { type: string }
                        operation: { type: string, enum: [access, data_release, payment, signature] }
                        used: { type: number }
                        remaining: { type: number }
                        receipt: { $ref: '#/components/schemas/ReceiptRef' }
      responses: { '200': { description: Acknowledged within 10 seconds. } }

  mandate.at_limit:
    post:
      summary: The remaining ceiling crossed your threshold
      description: Fires before the agent is refused, so you can top up first.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEvent'
                - type: object
                  properties:
                    data:
                      type: object
                      properties:
                        mandate_id: { type: string }
                        remaining: { type: number }
                        threshold: { type: number }
                        ceiling: { type: object }
      responses: { '200': { description: Acknowledged within 10 seconds. } }

  mandate.revoked:
    post:
      summary: The granter revoked a mandate
      description: |
        Effective on the next request. Always free. Never act on this event without verifying
        the signature — a forged revocation stops work that was authorised.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEvent'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/Mandate' }
      responses: { '200': { description: Acknowledged within 10 seconds. } }

  session.completed:
    post:
      summary: A hosted session ended with an outcome
      description: |
        How the hosted mode returns its result. Read `GET /sessions/{id}` and continue from
        that response; the redirect query string is a hint, not a result.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEvent'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/Session' }
      responses: { '200': { description: Acknowledged within 10 seconds. } }

  session.expired:
    post:
      summary: A hosted session was never finished
      description: Nothing was billed. The url will not work again; create a new session.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEvent'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/Session' }
      responses: { '200': { description: Acknowledged within 10 seconds. } }
