openapi: 3.1.0
info:
  title: Swaps API
  version: 2026-09-04T00:00:00.000Z
  summary: >-
    Resource-shaped REST for Swaps — payment links, payouts, payroll, wallet, quotes and orders, capabilities,
    screening, crypto processing.
  description: |
    Single source of truth for the public `/v1` surface. Generated artefacts — the reference docs,
    the TypeScript and Python SDKs, the MCP tool list and `llms.txt` — are derived from this file
    and never edited by hand (API-CANON §12, "rule of six").

    Three tenses, never a fourth (`x-swaps-status`): `available` — a live backend exists and `/v1` is
    a thin router in front of it. `dark-flag` — live, but gated behind a cohort or feature flag;
    while the flag is closed, calling it answers `503 temporarily_unavailable` (an availability
    lever, never an authorization one), never `capability_unavailable`. `proposed` — does not
    exist yet, documented for the contract it will carry, never presented as callable and never
    hidden.

    Money is an object (`Money`), never a bare number. Every object carries `livemode`. Every
    mutating request carries `Idempotency-Key`. Every response echoes `Swaps-Version`.

    **Terms acceptance (inactive until a legal release is explicitly enabled).** Selected new live
    operations may return `409 capability_unavailable`, code `terms_acceptance_required`, with
    `details.revision` and `details.action`. The person must explicitly accept the displayed
    revision in Swaps; only an owner can accept for an account. Personal wallet intents require
    that user's own receipt. An API key or agent cannot manufacture acceptance. Acceptance does not
    authorize a payment or replace provider agreements. An unavailable acceptance registry returns
    `503 temporarily_unavailable`, code `legal_acceptance_unavailable`; respect `Retry-After`.
    Existing completed idempotent responses remain replayable. Reads and recovery are not gated.

    **Response tolerance (A1-SDK-TOLERANT, fixer round 1 finding #3), `x-swaps-response-tolerant`.**
    `additionalProperties: false`/`unevaluatedProperties: false` on a schema reachable from a
    RESPONSE — a resource, a list envelope, or a nested object such as `Money`, at any depth, even
    when the SAME schema is also reachable from a request — is NOT a promise that an unrecognized
    key fails deserialization on that side: the generated `@swaps/sdk` strips it instead
    (`packages/contracts-api/generated/schemas.ts`, `scripts/openapi/gen-zod.mjs` §3b), matching
    this document's own forward-compatibility promise above. Any other client generated from this
    document should do the same: on a response object, treat the keyword as advisory only, never as
    license to fail deserialization on an unknown key. A REQUEST body keeps the keyword's literal
    meaning — an unrecognized key there is still a `400` (a schema reachable from BOTH sides gets a
    request-only strict variant server-side; see the SDK generator). This item does not remove the
    keyword from the ~200 response schemas that carry it (spread across every resource file, mostly
    outside this item's file ownership) — this paragraph is the explicit publication of the true
    behavior instead, so a consumer reading only this document, not the SDK source, still gets it
    right.

    **Forward compatibility (A1-2).** A RESPONSE enum tagged `x-swaps-open-enum: true` may gain
    a new member within `/v1` without a version cut: treat an unrecognized member as an opaque
    string and never fail deserialization on it — the generated SDK types these as a union of the
    known literals widened with `string` so a new member still type-checks. A REQUEST enum is never
    open: an unrecognized value there stays `400 invalid_request`, because it names a choice the
    caller is making, not a fact the server is reporting. The one deliberate pairing this produces:
    `Event.type` (a response) is open, so a new event type is delivered rather than rejected, while
    `webhook_endpoints.create`/`.update`'s `event_types` (the SAME catalogue, submitted as a
    request) still 400s an unrecognized name — an endpoint asking to receive a type this deployment
    does not know cannot be honoured, open catalogue or not. This is a narrowing of API-CANON §3's
    additive-fields rule to enum members specifically; it does not relax anything else there.

    **Test mode (A1-3, reconciled against merged K11-1/K11-2 code — fixer round 2).** `/v1` cannot be
    exercised in test mode by anyone today: `api_v1.test_mode` (the master switch K11-2 added) is OFF,
    and no `livemode=false` account can exist in production yet either (K11-4, the piece that lets one
    be created, has not landed) — no `sk_test_` caller exists to reach any of the behaviour below.
    While the switch is off, the router refuses EVERY `/v1` operation the same way, before any
    operation-specific code runs: `caller.livemode === false` forks to `dispatchTestMode`
    (`api-v1/router.ts`, `api-v1/test_mode.ts`), which answers `503 temporarily_unavailable`
    (`sideEffectFree`, `Retry-After: 60`) for every disposition, including one the router would run
    locally once the switch is on. This is a platform-wide gate, not a claim about any one operation —
    it is why every `available`/`dark-flag` operation below is annotated `x-swaps-test-mode:
    unavailable`. Four operations additionally enforce the same refusal a second time in their own
    handler code (`quotes.create`, `quotes.get`, `orders.create`, `customers.create`) — an independent
    guard, not the only one. On every other operation, `unavailable` records only that K11 has not yet
    individually verified and evidenced a `full`/`sandbox`/`fixtures`/`dev-cron` claim for it, one
    operation at a time, each with its own test (`K11_EVIDENCE_LIST`,
    `packages/contracts-api/__tests__/contract/test-mode-truth.test.ts`). The router matches that
    value exactly: every `unavailable` operation is routed `refused` and answers `503
    temporarily_unavailable` before any handler runs, whether the switch is on or off — with the switch
    on, that refusal is permanent and carries no `Retry-After`. Restoring one operation flips the
    router disposition, this contract value and `K11_EVIDENCE_LIST` together, in one change (API-CANON
    §12, "what is unavailable is documented with its reason, not hidden").
    The handful of `proposed` operations keep an aspirational
    `full`/`sandbox`/`fixtures` value instead — they are not callable at all yet, so there is no live
    behaviour for `unavailable` to correct.
  termsOfService: https://www.swaps.app/legal/api-terms
  license:
    name: Swaps API Terms
    url: https://www.swaps.app/legal/api-terms
  contact:
    name: Swaps developers
    url: https://docs.swaps.app
    email: developers@swaps.app
servers:
  - url: https://api.swaps.app/v1
    description: >-
      Production only. Live and test mode both reach this same host — the key prefix (`sk_live_` / `sk_test_`) selects
      which one, and must match the calling account's own `livemode` or the request is `401 invalid_api_key`. A
      development base URL, on a separate non-production project, is issued out of band alongside a development key —
      see /environments in the docs for what that base URL points at and what it does not promise.
tags:
  - name: Payment links
    description: Request money — links, clients, products, payer sessions.
  - name: Payouts
    description: Pay an invoice — fiat payouts funded with crypto.
  - name: Payroll
    description: Pay people — runs, items, recipients, templates.
  - name: Wallet
    description: >-
      The holder's non-custodial Tempo wallet — balances, deposits, sends, conversions, bank details. Nothing here can
      sign.
  - name: Quotes and orders
    description: Buy and sell across the provider fan-out.
  - name: Crypto processing
    description: Crypto invoices, pay-ins and subscriptions settled to the merchant's own wallet.
  - name: Capabilities
    description: What this account can execute right now, rail by rail, and public eligibility by country.
  - name: Customers
    description: Verification state — status only, never documents.
  - name: Screening
    description: Address risk, traces and on-chain transaction inspection.
  - name: Developers
    description: API keys, webhook endpoints, events, usage.
security:
  - businessKey: []
paths:
  /account:
    get:
      operationId: account.get
      summary: Read the signed-in holder's own account
      description: >
        The caller's own profile — display name, email, verification tier, locale, access state, the persona's `market`
        (BL-25, D-PF-9a — country + default currency for the embedded Buy & sell widget) and the dashboard's saved
        preferences. Reads "the selected account" (D-109: `Swaps-Account`, or the caller's default) — never a lookup by
        id; `404` names the pre-existing, rare case where the account row a valid credential resolves to is itself gone.
        `?expand=readiness,setup_guide` (API-PERF-1 M5) merges either or both of those reads' payloads in under
        `readiness`/`setup_guide` — folding the dashboard's own account-load sequence into one round trip instead of
        three; omitted when not requested. A REQUESTED expansion that fails (fix round 1) never fails the account read
        or answers a `404` of its own: the account, and any other requested expansion that succeeded, are still
        published at `200`, and `expand_errors` names which field is missing and why. `caller` names who is calling and,
        for a session, its role on this account, so a client can withhold an owner/admin-only action (e.g. `PATCH
        /account`) from a `member` instead of learning it from `403 role_denied`. Callers: dashboard session, agent
        (holder), business key (read-only). Test mode: full. With an `sk_test_` business key on a test twin, `email` is
        the only field read from the live owner; the other user-level fields are defaults, never the owner's values:
        `language` and `support_contact` are `null`, `notification_preferences` is `{marketing: false, product_tips:
        true}` and `dashboard_preferences` is `{}`. A test-mode dashboard session sees its own values. `caller`
        describes the credential, not the owner, and is published in both modes (`business_key`, role `null`, for a
        key).
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
        - agent
        - business_key
      x-swaps-test-mode: full
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/AccountExpand'
      responses:
        '200':
          description: >-
            The account. When `?expand=` was requested, may additionally carry `readiness`/`setup_guide` (on success)
            and/or `expand_errors` (on a per-field failure — see the operation description).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: account.read
    patch:
      operationId: account.update
      summary: Update the holder's own profile fields
      description: >
        Updates display name, language, notification preferences, dashboard preferences and the opt-in support contact
        (L4-9) payer surfaces may show. Email, country, customer type and access state are not writable here — email is
        an identity-plane change and the rest are derived elsewhere. Callers: dashboard session, agent (holder), never a
        business key. Test mode: unavailable (503). Language, dashboard preferences, notification preferences and the
        support contact belong to the holder and are shared with live mode; the handler also refuses them from a
        test-mode caller with `400 invalid_request`, code `livemode_boundary`, before anything is written.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - dashboardSession: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AccountUpdateRequest'
      responses:
        '200':
          description: The updated account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: account.write
  /accounts:
    get:
      operationId: accounts.list
      summary: List every account this login can reach
      description: >
        "Mine" — every account the caller holds a membership on (D-109). One live account per login is supported today;
        a login that already held more than one keeps seeing all of them here and can still select any of them with
        `Swaps-Account`. A business key is bound to a single account and always sees a one-item list. Callers: dashboard
        session, agent (holder), business key (read-only). Test mode: full. With an `sk_test_` business key on a test
        twin, `email` is the only field read from the live owner; the other user-level fields are defaults, never the
        owner's values: `language` and `support_contact` are `null`, `notification_preferences` is `{marketing: false,
        product_tips: true}` and `dashboard_preferences` is `{}`. A test-mode dashboard session sees its own values.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
        - agent
        - business_key
      x-swaps-test-mode: full
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of accounts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: account.read
    post:
      operationId: accounts.create
      summary: Create another account under the same login — not open today
      description: >
        Not open today: one account per login is supported (since 2026-10-05). A login that already owns a live account
        gets `409 conflict`, code `account_already_exists`, and nothing is written: no account and no membership. Every
        signed-in login is given its account on its first request without `Swaps-Account`, and only the owner of the
        live account a request acts on gets past the earlier refusals, so through `/v1` this operation never answers
        `201` today: a request that gets past them gets this `409`. Every refusal this operation answered before still
        comes first, unchanged: `403` for a business key or a `member`, `503` for a test-mode session, `400` for a
        malformed body or an unrecognised `country`. An account's type follows its owner's Bridge customer (see
        `Account.customer_type`). Opening a second account under one login waits for a later program that gives every
        account its own verification; the request and the `201` below are the contract it will carry, an individual or a
        business account whose owner is the caller. A business key cannot call this — it is bound to one account and
        never mints another. Callers: dashboard session only. Test mode: unavailable (503) — a new account is always
        live.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - dashboardSession: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AccountCreateRequest'
      responses:
        '201':
          description: >-
            The new account — the contract a later program will open. Today no `/v1` caller receives it: a request that
            gets past the earlier refusals always comes from the owner of the live account it acts on, and gets the
            `409` below.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `account_already_exists` — this login already owns a live account and one account per login is supported
            today; nothing was created, and repeating the call changes nothing — read the account with `GET
            /v1/account`. Also the idempotency-conflict codes every mutating `/v1` operation with an `Idempotency-Key`
            shares: `idempotency_error` (the key was reused with a different body), `idempotency_in_progress` (a request
            with this key is still running) and `idempotency_failed` (the first attempt answered a 5xx — reconcile, then
            retry with a new key).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: account.write
  /account/readiness:
    get:
      operationId: account.get_readiness
      summary: Resolve the one next-action readiness kind
      description: >
        Resolves the eight-kind readiness machine to the single kind the persistent Today card shows right now — never a
        list, one kind wins; re-derive on every call. Callers: dashboard session, agent (holder), business key
        (read-only). Test mode: full. BL-14 (2026-09-15): implemented — server-side mirror of the client's
        `deriveDashboardReadinessState`, Bridge-only today (Paybis/Transak not yet resolved server-side). On a degraded
        Bridge read (the last persisted snapshot, published as `Customer.degraded: true`) no fact or sentence says the
        identity is verified: `accept_terms` then carries only the terms fact, and the card carries `degraded: true`.
        The kind is resolved exactly as on a live read.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
        - agent
        - business_key
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The resolved readiness kind.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountReadiness'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: account.read
  /account/setup_guide:
    get:
      operationId: account.get_setup_guide
      summary: Read the six-section onboarding checklist
      description: >
        Six sections, eighteen steps (Overview, Wallet, Payment links, Payroll, Pay an invoice, Crypto processing) with
        per-step and per-section completion and the single next step to surface — replaces the multi-request client-side
        derivation prod pays for today on every dashboard page. Callers: dashboard session, agent (holder), business key
        (read-only). Test mode: full. BL-14 (2026-09-15): implemented — `pay_invoice` steps stay permanently undone (no
        backing resource yet). On a degraded Bridge read (the last persisted snapshot, published as `Customer.degraded:
        true`) `verify_identity` and `confirm_eligible` report `done: false` and are not counted, and the guide carries
        `degraded: true`; `next_step` is the step a live read of the same status names, never a step withheld for that
        reason, and every other step reads as on a live read.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
        - agent
        - business_key
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The setup guide.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SetupGuide'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: account.read
  /account/sessions/revoke_all:
    post:
      operationId: account.revoke_all_sessions
      summary: Sign the holder out of every device
      description: >
        The single published exception to the identity-plane carve-out (RESOURCE-MODEL §0.12): every other sign-in,
        passkey and session concern stays outside the resource contract, but a global revoke is destructive enough to
        publish. The calling session is not guaranteed to survive it either — expect to re-authenticate. Callers:
        dashboard session only. Test mode: full.
      tags:
        - Developers
      x-swaps-status: proposed
      x-swaps-caller:
        - dashboard_session
      x-swaps-test-mode: full
      security:
        - dashboardSession: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: Every session is revoked.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: account.write
  /activity:
    get:
      operationId: activity.list
      summary: List the cross-product activity ledger
      description: >
        One row per money object across payment links, payouts, payroll, buy & sell, wallet and crypto processing — the
        read model over the events outbox (RESOURCE-MODEL §0.12). Today's recent-activity slot is this list's newest
        three rows, byte for byte. `object{id, type}` points back to the full resource; do not treat `status` as a
        shared cross-product vocabulary. `status_group` is this resource's own six-bucket set, not shared with
        payment_links, payouts, payroll_runs or orders — read each of those resources' own `status_group` for their set.
        K8b moved every `payment_link.*`/`payment.*`/`payout.*` producer off the api-v1 route layer and into the
        product's own service/action layer (`EventType`'s own description names the full type list) — a dashboard
        action, a Bridge webhook reducer or a cron sweep now emits too, not only an api-v1-originated mutation. K8c did
        the same for eight `payroll_run.*` lifecycle types
        (`created`/`approved`/`funding_verified`/`execution_started`/`completed`/ `partial`/`failed`/`cancelled`),
        hooked into `payroll/lib.ts`'s own single `appendEvent` choke point — so `payroll` rows now exist here for
        RUN-level lifecycle transitions, driven by any caller (an employer action, an ops-asserted funding attestation,
        the funding poll). Payroll ITEM-level events (`payroll_item.*`/`payroll_template.*`), the run's own
        non-lifecycle audit types (`funding_instructions_*`, `execution_requested`/`.blocked`, `.underfunded`) and
        payout webhook/RPC-driven states still have no producer outside their own SQL RPCs (a named follow-up). No
        pre-K8 history is backfilled. `buy_sell`/`wallet`/`crypto_processing` rows do not exist until those products
        wire their own producers. `product` filters at the query (`resource_type IN (...)`), never by scanning an
        already-fetched page. Callers: business key, agent, dashboard session. Test mode: fixtures.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_activity
        description: >
          Return the account's unified activity feed — payment links, payouts, payroll, buy & sell, wallet transfers and
          crypto processing in one newest-first list. Use it for "what happened recently" instead of guessing which
          single product resource to poll. Each row points at its own object for full detail; do not treat the row's
          `status` word, or its `status_group`, as a vocabulary shared across products — `status_group` here is this
          resource's own six-bucket set only.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: product
          in: query
          description: >-
            Filters at the query (`resource_type IN (...)`, K8c) — pushed into the underlying read, never applied after
            a capped fetch. A product with no live producer yet (`buy_sell`, `wallet`, `crypto_processing`) legitimately
            returns zero rows.
          schema:
            type: string
            enum:
              - payment_links
              - payouts
              - payroll
              - buy_sell
              - wallet
              - crypto_processing
        - name: status_group
          in: query
          description: >-
            This resource's own six buckets (`draft`, `pending`, `processing`, `completed`, `failed`, `returned`) — not
            the per-product sets `payment_links`, `payouts`, `payroll_runs` and `orders` use for their own
            `status_group`.
          schema:
            type: string
            enum:
              - draft
              - pending
              - processing
              - completed
              - failed
              - returned
        - name: since
          in: query
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: A page of activity rows.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivityList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: activity.read
  /activity/summary:
    get:
      operationId: activity.get_summary
      summary: Read unbounded counts across every product
      description: >
        Honest, unbounded counts by date window, product and status group — the figures behind the Activity filter
        chips, covering every row on record, not just the loaded page. A cursor list never returns a total; this is
        where it lives. `by_product_status` (K8c, §52 C4-D14) crosses product × status_group into one count-and-
        minor-unit-sum grid for the product hubs and Today tiles; a cell's `amount` is `null` when the cell has no rows
        or when its rows carry more than one currency — this endpoint never fabricates an FX rate to combine them.
        Callers: business key, agent, dashboard session. Test mode: fixtures.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The summary.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivitySummary'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: activity.read
  /events:
    get:
      operationId: events.list
      summary: List the cross-domain event stream
      description: >
        The append-only outbox the product's own service layer writes to for every transition it drives — an api-v1
        mutation, a dashboard action, a Bridge webhook reducer or a cron sweep alike (K8b moved the producers off the
        api-v1 route layer for exactly this reason) — filter by `type` (a dotted event name from RESOURCE-MODEL §3) and
        `object` (the affected resource's id). Poll this only when a live connection is unavailable; prefer `GET
        /v1/events/stream` otherwise. `EventType`'s own description names which of the 22
        `payment_link.*`/`payment.*`/`payout.*` types (K8) and the eight `payroll_run.*` RUN-level lifecycle types (K8c,
        §52 C4-D14) currently have a producer; the five `order.*` types also have one now (L5-1) —
        `_shared/services/api_events.ts`'s `emitOrderOutboxEvent`, wired from the Bridge-native
        order-create/cancel/status-update paths and the Bridge webhook reducer — and so do the three `customer.*` types
        (L5-2), the same file's `emitCustomerOutboxEvent`, wired from `bridge-webhook/index.ts`'s single
        `bridge_customers` writer. See RESOURCE-MODEL §3 for the full writer list. No pre-K8 history is backfilled.
        Callers: business key, agent, dashboard session. Test mode: unavailable (A4-FIX-6). Test-mode events are not
        readable through /v1 today — no test object can be created yet; once creates are restored, poll the forwarded
        per-object `*/events` lists.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: type
          in: query
          description: A dotted event type, e.g. `payout.settled`.
          schema:
            type: string
        - name: object
          in: query
          description: The affected resource's id.
          schema:
            type: string
        - name: since
          in: query
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: events.read
  /events/{id}:
    get:
      operationId: events.get
      summary: Read one event by id
      description: >
        One immutable event, read-only — events are never created through the API. K8b moved every
        `payment_link.*`/`payment.*`/`payout.*` producer off the api-v1 route layer and into the product's own
        service/action layer (`EventType`'s own description names the full list) — a dashboard action, a Bridge webhook
        reducer or a cron sweep emits too, not only an api-v1-originated mutation. No pre-K8 history is backfilled.
        Callers: business key, agent, dashboard session. Test mode: unavailable (A4-FIX-6). Test-mode events are not
        readable through /v1 today — no test object can be created yet; once creates are restored, poll the forwarded
        per-object `*/events` lists.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^evt_
      responses:
        '200':
          description: The event.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Event'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: events.read
  /events/stream:
    get:
      operationId: events.stream
      summary: Subscribe to events over Server-Sent Events
      description: >
        The live channel every client should prefer over polling `/v1/events` — same auth, same envelope, one event per
        SSE frame with `id:` set to the event id. Authenticate exactly as on every other `/v1` call, with the
        `Authorization` bearer token or `x-api-key` header, sent by opening this connection with `fetch()` against a
        readable stream — never native browser `EventSource`, which cannot set a header and would force the credential
        into the URL. A credential in the query string or path is never accepted here, or anywhere else in the API. On a
        dropped connection, reconnect with the `Last-Event-ID` header set to the last id received; the stream resumes
        immediately after it, never replaying from the start or skipping ahead. A gap wider than the retention window is
        not silently bridged — fall back to `GET /v1/events?since=` to recover it. K8b moved every
        `payment_link.*`/`payment.*`/`payout.*` producer off the api-v1 route layer and into the product's own
        service/action layer (`EventType`'s own description names the full list) — a dashboard action, a Bridge webhook
        reducer or a cron sweep emits too, not only an api-v1-originated mutation. No pre-K8 history is backfilled.
        Callers: business key, agent, dashboard session. Test mode: unavailable — a test-mode caller gets `503
        temporarily_unavailable` (side-effect free, no `Retry-After`); the stream is never forwarded to the dev project,
        because prod could not revalidate the caller's credential on every tick of a forwarded stream (A4-FIX-6).
        Test-mode events are not readable through /v1 today — no test object can be created yet; once creates are
        restored, poll the forwarded per-object `*/events` lists.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: Last-Event-ID
          in: header
          required: false
          description: On reconnect, the id of the last event received — the stream resumes immediately after it.
          schema:
            type: string
      responses:
        '200':
          description: An open `text/event-stream` connection, one `Event` object per frame.
          content:
            text/event-stream:
              schema:
                type: string
                description: 'One SSE frame per event: `id: evt_...` / `event: <type>` / `data: <Event JSON>`.'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: events.read
  /webhook_endpoints:
    get:
      operationId: webhook_endpoints.list
      summary: List the account's webhook endpoints
      description: |
        Callers: business key or a first-party bearer session (dashboard/agent). Test mode: sandbox — the request
        forwards to the DEV project (K11-6 fixer round 1, finding #7); the endpoint row, its deliveries and the
        signing all live there, never in prod.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of webhook endpoints.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.read
    post:
      operationId: webhook_endpoints.create
      summary: Register a new webhook endpoint
      description: >
        Registers an https endpoint and returns its signing secret once, in this response only — a replayed
        Idempotency-Key gets the same body back with secret redacted to null, never the cleartext twice. 409 when the
        account already has 20 endpoints (the limit). Callers: business key, or a first-party bearer session
        (dashboard/agent) whose account_members.role is owner — a non-owner bearer gets 403 scope_denied, the same bar
        api_keys management uses. Test mode: sandbox — the request forwards to the DEV project (K11-6 fixer round 1,
        finding #7, corrected from a draft claim that the row is registered in prod); the endpoint row, its deliveries
        and the signing all live there, never in prod.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreateRequest'
      responses:
        '201':
          description: The new endpoint, with its secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
  /webhook_endpoints/{id}:
    get:
      operationId: webhook_endpoints.get
      summary: Read one webhook endpoint
      description: >
        Never returns `secret` after creation — read the delivery log to verify an endpoint is receiving events.
        Callers: business key or a first-party bearer session (dashboard/agent). Test mode: sandbox (K11-6, §4.2 drift,
        was declared `full`) — see `webhook_endpoints.list`.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^whe_
      responses:
        '200':
          description: The endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.read
    patch:
      operationId: webhook_endpoints.update
      summary: Update url, event types, description or enabled state
      description: >
        Updates the url, subscribed event types, description, or enables/disables the endpoint. Never returns or rotates
        `secret` — use `rotate_secret` for that. Callers: business key, or a first-party bearer session
        (dashboard/agent) whose account_members.role is owner — a non-owner bearer gets 403 scope_denied, the same bar
        api_keys management uses (a bearer who could repoint `url` to a host they control would receive the account's
        signed deliveries). Test mode: sandbox (K11-6, §4.2 drift, was declared `full`) — see `webhook_endpoints.list`.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^whe_
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointUpdateRequest'
      responses:
        '200':
          description: The updated endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
    delete:
      operationId: webhook_endpoints.delete
      summary: Remove a webhook endpoint
      description: >
        Deletes the endpoint and stops all future deliveries to it; past deliveries stay in the log. Callers: business
        key, or a first-party bearer session (dashboard/agent) whose account_members.role is owner — a non-owner bearer
        gets 403 scope_denied, the same bar api_keys management uses. Test mode: sandbox (K11-6, §4.2 drift, was
        declared `full`) — see `webhook_endpoints.list`.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^whe_
      responses:
        '204':
          description: Deleted.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
  /webhook_endpoints/{id}/rotate_secret:
    post:
      operationId: webhook_endpoints.rotate_secret
      summary: Rotate the endpoint's signing secret
      description: >
        Issues a new signing secret and keeps the OLD one live for a 24h overlap window — every delivery in that window
        is signed with BOTH keys, so a receiver still configured with the old secret keeps validating. Returns the new
        secret once, in this response only, exactly like `create` (redacted from a replayed Idempotency-Key's stored
        body). Callers: business key, or a first-party bearer session (dashboard/agent) whose account_members.role is
        owner — a non-owner bearer gets 403 scope_denied, the same bar api_keys management uses. Test mode: sandbox
        (K11-6, §4.2 drift, was declared `full`) — see `webhook_endpoints.list`.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^whe_
      responses:
        '200':
          description: The endpoint, with its new secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
  /webhook_endpoints/{id}/send_test_event:
    post:
      operationId: webhook_endpoints.send_test_event
      summary: Send a synthetic test event to this endpoint
      description: >
        Emits one `test.ping` event and enqueues exactly one delivery, to this endpoint only — never fanned out to any
        other endpoint, even one that also subscribes to `test.ping` (or subscribes to "all" via an empty
        `event_types`). The event's `livemode` matches the caller's own key/session mode. 409 when the endpoint is
        disabled; 503 while api_v1.webhooks_delivery itself is off (queuing a test would sit forever with nothing to
        process it). Callers: business key, or a first-party bearer session (dashboard/agent) whose account_members.role
        is owner — gated the same way as create/update/rotate_secret/delete, for consistency: a non-owner bearer gets
        403 scope_denied. Test mode: sandbox (K11-6, §4.2 drift, was declared `full`) — the queued delivery must be
        signed and sent by the DEV project's api-webhooks-worker, or the test ping never fires.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^whe_
      responses:
        '202':
          description: The queued test delivery.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDelivery'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
  /webhook_deliveries:
    get:
      operationId: webhook_deliveries.list
      summary: List webhook delivery attempts
      description: >
        Every delivery attempted or scheduled across this account's endpoints, newest first — filter by `endpoint_id` or
        `status`. Callers: business key or a first-party bearer session (dashboard/agent). Test mode: sandbox (K11-6,
        §4.2 drift, was declared `full`) — a test caller's deliveries only ever exist on the DEV project, written by its
        api-webhooks-worker.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: endpoint_id
          in: query
          schema:
            type: string
            pattern: ^whe_
        - name: status
          in: query
          schema:
            type: string
            enum:
              - pending
              - succeeded
              - failed
              - exhausted
      responses:
        '200':
          description: A page of deliveries.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.read
  /webhook_deliveries/{id}:
    get:
      operationId: webhook_deliveries.get
      summary: Read one webhook delivery
      description: |
        Callers: business key or a first-party bearer session (dashboard/agent). Test mode: sandbox (K11-6, §4.2
        drift, was declared `full`) — see `webhook_deliveries.list`.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^whd_
      responses:
        '200':
          description: The delivery.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDelivery'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.read
  /webhook_deliveries/{id}/replay:
    post:
      operationId: webhook_deliveries.replay
      summary: Replay a webhook delivery
      description: >
        Enqueues a NEW delivery for the same event, to the same endpoint — a fresh `WebhookDelivery` row, distinct from
        (and never mutating) the one replayed; the original stays in the log exactly as it was. Safe to call repeatedly.
        409 when the endpoint is currently disabled, or when its CURRENT event_types no longer include this event's
        type. Callers: business key or a first-party bearer session (dashboard/agent). Test mode: sandbox (K11-6, §4.2
        drift, was declared `full`) — the new delivery it enqueues is signed and sent by the DEV project's
        api-webhooks-worker, same as the original.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^whd_
      responses:
        '200':
          description: The new delivery, queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDelivery'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
  /webhook_waitlist:
    post:
      operationId: webhook_waitlist.create
      summary: Join the webhook-delivery waitlist
      description: >
        Captures an email ahead of the full webhook system existing — the "Join the waitlist" action on the Developers
        hub. Callers: business key or a first-party bearer session (dashboard/agent). Test mode: full.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookWaitlistRequest'
      responses:
        '202':
          description: Captured.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: account.write
  /card_waitlist:
    post:
      operationId: card_waitlist.create
      summary: Join the card waitlist
      description: >
        Write-only capture ahead of a card product existing — no corresponding read and no card-issuing machinery behind
        this call today. Callers: business key, agent, dashboard session (A1-2: widened to match the router's own
        `BOTH_CALLERS` entry — the security block below, `businessKey`, was already the truthful half). Test mode: full.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CardWaitlistRequest'
      responses:
        '202':
          description: Captured.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: account.write
  /address_book:
    get:
      operationId: address_book.list
      summary: List saved payment destinations
      description: >
        Every saved destination across all seven rail shapes — crypto and six bank rails. Bank-rail fields are masked on
        every read regardless of caller. Callers: agent (holder), dashboard session — never a business key (review
        ruling P1-2: no account-level address book exists yet, so a business-key read would show one member's saved
        destinations to any other caller holding a key for the same account). Test mode: fixture addresses.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of saved destinations.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressBookList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: address_book.read
    post:
      operationId: address_book.create
      summary: Save a new payment destination
      description: >
        Saves one of seven rail shapes, discriminated by `rail`. Bank-rail details cross the boundary once here and are
        masked on every subsequent read — most clients log tool arguments verbatim. Callers: agent (holder), dashboard
        session, never a business key. Test mode: fixture addresses.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddressBookEntryCreateRequest'
      responses:
        '201':
          description: The saved destination.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressBookEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: address_book.write
  /address_book/{id}:
    get:
      operationId: address_book.get
      summary: Read one saved destination
      description: |
        Callers: agent (holder), dashboard session — never a business key (review ruling P1-2, same reasoning as
        `address_book.list`). Test mode: fixture addresses.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^adr_
      responses:
        '200':
          description: The destination.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressBookEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: address_book.read
    patch:
      operationId: address_book.update
      summary: Rename a saved destination
      description: >
        Updates the label only — every rail field is immutable after creation; remove and re-add to change a
        destination. Callers: agent (holder), dashboard session, never a business key. Test mode: fixture addresses.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^adr_
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddressBookEntryUpdateRequest'
      responses:
        '200':
          description: The updated destination.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressBookEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: address_book.write
    delete:
      operationId: address_book.delete
      summary: Remove a saved destination
      description: |
        Callers: agent (holder), dashboard session, never a business key. Test mode: fixture addresses.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^adr_
      responses:
        '204':
          description: Deleted.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: address_book.write
  /address_book/{id}/recheck:
    post:
      operationId: address_book.recheck
      summary: Re-screen a saved crypto address
      description: >
        Re-runs a free screening against a saved crypto destination on its own `network` and returns the entry carrying
        that fresh screening and its `last_checked_at` — a `200` never answers `screening: null` for a crypto entry; a
        no-op on a bank-rail entry. An entry whose network screening does not cover (Tempo, for one), or whose address
        cannot exist on it, is refused `409 network_not_supported` before anything is screened or recorded — never
        screened as Ethereum. Callers: agent (holder), dashboard session, never a business key. Test mode: fixture
        addresses.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: check_saved_address
        description: >
          Re-check the risk on one of the holder's own saved addresses and return the refreshed entry. Use this instead
          of `check_address_risk` when the address is already in the address book — it keeps the saved projection in
          sync. It is a risk signal, never a verdict or a block, exactly like the underlying screening. An entry on a
          network screening does not cover (Tempo, for one) is refused with `network_not_supported`, never checked as
          Ethereum.
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^adr_
      responses:
        '200':
          description: The destination, with a refreshed screening.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressBookEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `capability_unavailable` — code `network_not_supported`: screening does not cover the entry's `network`
            (Tempo, Litecoin, Dogecoin, any name outside the networks `ScreeningCreateRequest.chain` lists) or the
            address cannot exist on it; nothing was screened or recorded and the entry is unchanged — do not retry. Also
            the idempotency-conflict codes every mutating `/v1` operation with an `Idempotency-Key` shares:
            `idempotency_error` (the key was reused with a different body), `idempotency_in_progress` (a request with
            this key is still running) and `idempotency_failed` (the first attempt answered a 5xx — reconcile, then
            retry with a new key).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: address_book.write
  /address_book/{id}/beneficiary:
    post:
      operationId: address_book.beneficiary.update
      summary: Update the Travel Rule beneficiary/counterparty details for a saved destination
      description: >
        Drift blocker from the 28.09 v1→v2 audit (DRIFT-v1-v2-2026-09-28.md item 1) — mirrors the dashboard's Manage tab
        (`CounterpartyForm`/`CounterpartyModal`), the actual v1 write path for these fields (a direct `address_book`
        update, not the separate append-only `travel_rule_attestations` audit log, which is stamped only mid-transfer,
        by `attestTransfer`, with originator KYC context and a `transaction_id` this operation does not have). Sets
        `beneficiary_type` to `third-party` and stores `beneficiary_subtype` + the counterparty details; it never writes
        `travel_rule_last_attested_at` (fixer round 1 #1) — that column stays read-only here and is stamped only by the
        transfer-time attestation. `counterparty_*` are readable on every read (fixer round 1 #2), so a Manage-tab
        re-save can pre-fill from them. Proof-of-control verification is a separate, out-of-scope flow;
        `proof_of_control_*` on the response reflect the entry's existing state, untouched by this call. Callers: agent
        (holder), dashboard session, never a business key — same reasoning as every other `address_book` write. Test
        mode: fixture addresses.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^adr_
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TravelRuleAttestationCreateRequest'
      responses:
        '200':
          description: The destination, with the beneficiary details updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressBookEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: address_book.write
  /screenings:
    post:
      operationId: screenings.create
      summary: Screen one address before sending funds
      description: >
        Runs a free risk screen on one address and chain — unmetered by design (matches prod: `guard.check` has never
        spent a credit; only `screenings.reports.create`'s full report does, D-84, which for a business key spends the
        account owner's own credit balance — see that operation). The result is a signal, never a verdict — `action` is
        a suggestion, never an authorization. The account-scoped `api_screenings` row stores the address itself
        (case-preserved, never a hash) so it can be listed back and embedded in `address_book`; it is never shared
        across accounts. `chain` must name a network screening covers (`ScreeningCreateRequest.chain`); any other value,
        or an address that cannot exist on that network, is refused `409 network_not_supported` before any check runs or
        any row is written — never screened as Ethereum. Do not call this for a transaction hash — use `GET
        /v1/transactions/{chain}/{hash}` — and never chain `action` straight into a transfer without a human in the
        loop. Callers: business key, agent. Test mode: fixture addresses.
      tags:
        - Screening
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: check_address_risk
        description: >
          Screen one blockchain address before funds are sent to it: a level, a score, the named flags behind it, and
          per-source coverage so you can see what was actually checked. Pass the address's own network as `chain`; a
          network screening does not cover (Tempo, for one) is refused with `network_not_supported`, never checked as
          Ethereum. Do not call it for a transaction hash — use `inspect_transaction` — and do not call it to decide
          whether a transfer is allowed: it is a risk signal, never a verdict or a block.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScreeningCreateRequest'
      responses:
        '201':
          description: The screening.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Screening'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `capability_unavailable` — code `network_not_supported` (`param: chain`): `chain` names no network screening
            covers (blank, `tempo`, `litecoin`, …) or the address cannot exist on it; nothing was screened or recorded —
            do not retry with the same `chain`. Also the idempotency-conflict codes every mutating `/v1` operation with
            an `Idempotency-Key` shares: `idempotency_error` (the key was reused with a different body),
            `idempotency_in_progress` (a request with this key is still running) and `idempotency_failed` (the first
            attempt answered a 5xx — reconcile, then retry with a new key).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.write
    get:
      operationId: screenings.list
      summary: List the caller's past screenings
      description: Callers business key, agent. Test mode fixture addresses.
      tags:
        - Screening
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of screenings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScreeningList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.read
  /screenings/{id}:
    get:
      operationId: screenings.get
      summary: Read one screening by id
      description: Callers business key, agent. Test mode fixture addresses.
      tags:
        - Screening
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^scr_
      responses:
        '200':
          description: The screening.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Screening'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.read
  /screenings/{id}/reports:
    post:
      operationId: screenings.reports.create
      summary: Generate the full report and evidence PDF
      description: >
        Spends 1 credit unless an equivalent report for this address was generated within the last 30 days, in which
        case the cached report is returned at no cost (`cached: true`). No account-level credit ledger exists yet (D-82)
        — a business-key call spends the account's OWNER member's own personal credit balance (`resolveBillingUserId`),
        the same balance `GET /v1/credits` reads. Callers: business key, agent. Test mode: fixture addresses.
      tags:
        - Screening
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^scr_
      responses:
        '201':
          description: The report — freshly generated or served from the 30-day cache.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScreeningReport'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.write
    get:
      operationId: screenings.reports.list
      summary: List the reports run on one screening
      description: Callers business key, agent. Test mode fixture addresses.
      tags:
        - Screening
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^scr_
      responses:
        '200':
          description: A page of reports.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScreeningReportList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.read
  /screenings/{id}/reports/{report_id}:
    get:
      operationId: screenings.reports.get
      summary: Read one full screening report
      description: Callers business key, agent. Test mode fixture addresses.
      tags:
        - Screening
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^scr_
        - name: report_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^rpt_
      responses:
        '200':
          description: The report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScreeningReport'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.read
  /screenings/{id}/reports/{report_id}/evidence_pdf:
    get:
      operationId: screenings.reports.evidence_pdf.get
      summary: Download a report's evidence PDF
      description: >
        Returns the generated PDF only when `evidence_pdf.available` is true on the report — a 404 otherwise, never an
        empty 200. Callers: business key, agent. Test mode: fixture addresses.
      tags:
        - Screening
      x-swaps-status: proposed
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: fixtures
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^scr_
        - name: report_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^rpt_
      responses:
        '200':
          description: The PDF.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.read
  /traces:
    post:
      operationId: traces.create
      summary: Follow where a transaction's funds moved
      description: >
        Starting from one origin transaction hash, follows value across hops and names the counterparties reached. Slow
        and expensive relative to a screening — call it after `check_address_risk` returns high or critical, not before;
        on a clean address it tells you nothing new. Takes a transaction hash, not a bare address — an address alone has
        no single flow to follow; use the incident transaction (e.g. the transfer that sent funds to a scammer).
        Callers: business key, agent. Test mode: fixture addresses.
      tags:
        - Screening
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: trace_transaction
        description: >
          Starting from one origin transaction hash, follow where value moved across hops and name the counterparties
          reached. Call it after `check_address_risk` returns high or critical, not before: it is slow and expensive,
          and on a clean address it tells you nothing new.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TraceRequest'
      responses:
        '201':
          description: The trace.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Trace'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.write
  /transactions/{chain}/{hash}:
    get:
      operationId: transactions.get
      summary: Inspect one on-chain transaction
      description: >
        Reads one on-chain transaction by hash: amounts, transfers, both sides, and a short risk brief on each
        counterparty. Use `screenings.create` for an address and `orders.get` for a Swaps order — this is neither.
        Callers: business key, agent. Test mode: fixture addresses.
      tags:
        - Screening
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: inspect_transaction
        description: >
          Read one on-chain transaction by hash: amounts, transfers, both sides, and a short risk brief on each
          counterparty. Use `check_address_risk` for an address and `get_order_status` for a Swaps order.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: chain
          in: path
          required: true
          schema:
            type: string
        - name: hash
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The transaction.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChainTransaction'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: screenings.read
  /credits:
    get:
      operationId: credits.get
      summary: Read the holder's credit balance
      description: >
        Free and paid balances plus a lifetime check count — the figures behind every credit-gated screen in Tools and
        Settings. Callers: dashboard session, agent (holder) — never a business key (review ruling P1-2: no
        account-level credit ledger exists yet, so this resolves the account OWNER's personal balance; a business-key
        read would show it to any other caller holding a key for the same account). Test mode: `unavailable` (K11-6
        fixer round 1, finding #6) — the ledger is billed through Stripe and there is no test purchase to read against
        yet (`credit_checkouts.create`, design §9 D-4), so this read carries no restored test-mode claim.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
        - agent
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The balance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Credits'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: credits.read
  /credit_events:
    get:
      operationId: credit_events.list
      summary: List the credit ledger
      description: >
        One of seven event types per row (`consume_free`, `consume_paid`, `cache_hit`, `insufficient`, `purchase`,
        `refund`, `admin_grant`), newest first, including the screened `address`/`chain` on consume rows. Callers:
        dashboard session, agent (holder) — never a business key (review ruling P1-2, same reasoning as `credits.get`).
        Test mode: `unavailable` (K11-6 fixer round 1, finding #6) — same reasoning as `credits.get` above.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
        - agent
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of ledger rows.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditEventList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: credits.read
  /credit_checkouts:
    post:
      operationId: credit_checkouts.create
      summary: Start a Stripe checkout for a credit pack
      description: >
        Creates a Stripe Checkout session for one of the three credit packs (5, 10 or 20) and returns its url — redirect
        the holder there; this call never itself grants credits. Callers: dashboard session, agent (holder). Test mode:
        unavailable (K11-6, §4.2 drift, corrects an earlier "Stripe test mode" claim) — it opens a real payment for
        credits and there is no test purchase; refused pending the founder's decision (design D-4). `credits.get`/
        `credit_events.list` are unaffected — they read the account's own credit ledger, not this checkout.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - dashboardSession: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreditCheckoutRequest'
      responses:
        '201':
          description: The checkout session.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditCheckout'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: credits.write
  /api_keys:
    get:
      operationId: api_keys.list
      summary: List the account's API keys
      description: >
        Never returns `secret` — only `create` and `roll` ever do, once each. Callers: dashboard session or business key
        (read-only) — `create`/`roll`/`revoke` are never a business key, only dashboard session. Test mode: full (K11-4)
        — credentials and the outer usage log are prod-only by design (§2.3), so a `livemode=false` caller's own test
        key(s) answer from the same real row this operation always read, scoped to the caller's test twin account; a
        live caller's keys and a test caller's are never each other's to see.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: full
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of keys.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.read
    post:
      operationId: api_keys.create
      summary: Create a new API key
      description: >
        Issues a key through the create_api_key_for_account RPC (K10) and returns its plaintext secret once, in this
        response only — a replayed Idempotency-Key gets the same body back with secret redacted to null, never the
        cleartext twice (the same mechanism K9 introduced for webhook_endpoints.create). Callers: dashboard session only
        — a key can never mint another key. Test mode: full (K11-4) — `livemode:false` resolves-or-creates the caller's
        test twin account (a second, `livemode=false` account row idempotently linked to the caller's own) and mints the
        `sk_test_` key on the twin, never on the caller's live account; this is the one call site a `livemode=false`
        account can be created from.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
      x-swaps-test-mode: full
      security:
        - dashboardSession: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApiKeyCreateRequest'
      responses:
        '201':
          description: The new key, with its secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyCreated'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
  /api_keys/{id}:
    get:
      operationId: api_keys.get
      summary: Read one API key
      description: >
        Callers: dashboard session or business key (read-only) — `create`/`roll`/`revoke` are never a business key, only
        dashboard session. Test mode: full (K11-4) — scoped by `account_id`, which for a test caller is the twin
        account, never the live one.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: full
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^key_
      responses:
        '200':
          description: The key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKey'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.read
  /api_keys/{id}/roll:
    post:
      operationId: api_keys.roll
      summary: Rotate a key's secret
      description: >
        Issues a new secret for the same key identity (via the roll_api_key RPC) and invalidates the old one; the new
        secret is returned once, in this response only — a replayed Idempotency-Key gets the same body back with secret
        redacted to null, never the cleartext twice. Callers: dashboard session only. Test mode: full (K11-4) — rolls a
        key already owned by the caller's own account (live or twin); never resolves a twin itself.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
      x-swaps-test-mode: full
      security:
        - dashboardSession: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^key_
      responses:
        '200':
          description: The key, with its new secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyCreated'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
  /api_keys/{id}/revoke:
    post:
      operationId: api_keys.revoke
      summary: Revoke a key permanently
      description: >
        Sets `status: revoked` and `revoked_at`; every request against the key then fails `authentication_error`.
        Irreversible — issue a new key instead of expecting an un-revoke. Callers: dashboard session only. Test mode:
        full (K11-4) — revokes a key already owned by the caller's own account (live or twin).
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
      x-swaps-test-mode: full
      security:
        - dashboardSession: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^key_
      responses:
        '200':
          description: The revoked key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKey'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.write
  /api_keys/{id}/usage:
    get:
      operationId: api_keys.get_usage
      summary: Read one key's today-only usage
      description: >
        Today-only counters — no trend history exists yet; use `/v1/request_logs` for the per-call detail behind these
        counts. Callers: dashboard session or business key (read-only). Test mode: full (K11-4) — the caller's own
        account's usage (live or twin), never the other's.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: full
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^key_
      responses:
        '200':
          description: Today's usage.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyUsage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.read
  /request_logs:
    get:
      operationId: request_logs.list
      summary: List raw per-call telemetry for own keys
      description: >
        Raw per-call HTTP telemetry across the caller's own keys — request id, operation, status code, latency and error
        code — distinct from the product-lifecycle `/v1/events` catalogue. Callers: dashboard session or business key
        (read-only), scoped to the caller's own keys. Test mode: full (K11-4) — the usage log of the caller's own
        account's keys (live or twin), never the other's; this is the outer leg's own log, kept in prod by design
        (§2.3), not the per-operation `/v1/events` catalogue a test caller's dev-side calls emit into.
      tags:
        - Developers
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: full
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of request logs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RequestLogList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: developers.read
  /payment_links:
    get:
      operationId: payment_links.list
      tags:
        - Payment links
      summary: List payment links
      description: >-
        List this account's payment requests, newest first (business key, agent, dashboard session; test mode is
        unavailable (`503 temporarily_unavailable`)). `status_group` matches the dashboard's chip buckets
        (RESOURCE-MODEL §2.1 v2 amendment); `q` searches title, memo and invoice number; `settlement_kind` narrows to
        one settlement kind. Do not use this to poll one link — use `GET /v1/payment_links/{id}`.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: list_payment_requests
        description: >-
          List this merchant's payment requests, newest first, to find one before acting on it. Ask for a narrowing
          detail rather than dumping hundreds of rows. Do not use it to poll for a payment — call check_payment_request
          on the id. A `processing` link's `partial_payment` flags a short payment (§8.4) — `received` is the most
          recent one recorded, not a running total, and is `null` when the settled amount could not be confirmed yet.
          The field can be OMITTED entirely when the underlying signal could not be read — that is not the same as
          `null`; do not report "no short payment" for a request where the key is missing. `payable_rails` is computed
          live on every read, not frozen when the link was activated, and lists `crypto_relay` only when the payer page
          offers it right now. One known exception (#3977): an individual (`p2p`) merchant whose bank pay-in is not
          active yet still has its bank rails listed although the payer page withholds them.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status_group
          in: query
          description: >-
            This resource's own bucket set (`open`, `needs_attention`, `paid`, `ended`), not shared with other resources
            (CMP-6).
          schema:
            type: string
            enum:
              - open
              - needs_attention
              - paid
              - ended
        - name: q
          in: query
          schema:
            type: string
        - name: client_id
          in: query
          schema:
            type: string
            pattern: ^cli_
        - name: settlement_kind
          in: query
          description: >-
            Only links whose published `settlement_kind` is this value (CP-T1). `crypto_only` is the Crypto processing
            hub's list; `bridge` is every Bridge-settled link. Applies to the page and to `?expand=summary`. A draft
            created with `accepted_rail_kinds: [crypto]` publishes `settlement_kind: null` until it activates, so
            neither value matches it. Any other value is `400 invalid_request` with `param: settlement_kind`.
          schema:
            type: string
            enum:
              - bridge
              - crypto_only
        - name: expand
          in: query
          description: >-
            Comma-separated. `summary` (LIST-SUMMARY-1) adds `summary`: counts computed server-side over EVERY row
            matching this request's filters, not over the returned page, so a client never adds up a paginated page and
            calls it a total. `limit`/`cursor` never change it. An unrecognised value is ignored rather than rejected
            (the `GET /account` `expand` rule).
          schema:
            type: string
            example: summary
      responses:
        '200':
          description: A page of payment links.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLinkList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
    post:
      operationId: payment_links.create
      tags:
        - Payment links
      summary: Draft a payment link
      description: >-
        Create a draft — nothing is charged and no rail goes live until activate (D-15). Business key, agent, dashboard
        session; test mode is unavailable (`503 temporarily_unavailable`). Setting `accepted_rail_kinds: [crypto]`
        drafts a crypto-only invoice (R19 CP-G2) — there is no separate `settlement_kind` input; it is system-set at
        activation. That one combination is answered `409 capability_unavailable` only while the capability is closed
        for this account; every OTHER non-empty `accepted_rail_kinds` value (`[bank]`, `[card]`, or more than one kind)
        is answered `409 capability_unavailable` unconditionally, flag or no flag — no activation path exists for it at
        all. An `allowed_rails` the link could not be paid under is refused here rather than stored: `400
        allowed_rails_invalid_for_currency` when the currency has no matching rail, with the rails that would work in
        `error.details.offerable_rails`; `400 allowed_rails_invalid_for_settlement` when a crypto-only draft's
        restriction names anything but `crypto_tempo`, its one payable rail, with the same
        `error.details.offerable_rails`. `settlement_destination` (BL-50) names where activation will settle funds — a
        saved `/v1/address_book` entry the account already owns, never a raw address or bank detail on the wire; an id
        you do not own answers `404 not_found`, identical to an unknown one. It may also be set later with `update`
        (which RETIRES a Swaps-Wallet intent instead of refusing it). Naming both `settlement_destination` and
        `accepted_rail_kinds: [crypto]` on this SAME create request is refused `400
        settlement_conflicts_with_destination` — reachable only while the crypto-only capability is open for this
        account; closed, `accepted_rail_kinds: [crypto]` is itself `409 capability_unavailable` first.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: create_payment_request
        description: >-
          Draft a payment request the merchant can review. It creates a draft only and returns a confirmation link the
          merchant opens themselves — it cannot activate, cannot produce a shareable payment link, and has no
          attestation argument to pass. Going live is a legal attestation the account holder makes in person, over REST
          or that link; there is no way to make it for them. It only issues a request someone else pays; it cannot
          charge a card or move money. Setting `accepted_rail_kinds: [crypto]` drafts a crypto-only invoice (R19 CP-G2)
          — a payer-pays-crypto invoice with no Bridge customer required; every constraint above still applies
          identically.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PaymentLinkCreateRequest'
      responses:
        '201':
          description: The created draft.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLink'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            `temporarily_unavailable`. A failed dependency read (`details.reason`: `counterparty_read_failed`,
            `account_read_failed`, `wallet_read_failed`, `address_book_read_failed`, …). Not side-effect free: no
            `Retry-After`, a same-key replay answers `409 idempotency_failed`; retry with a new `Idempotency-Key`. A
            kill switch also answers here.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /payment_links/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^pl_
      - $ref: '#/components/parameters/SwapsVersion'
    get:
      operationId: payment_links.get
      tags:
        - Payment links
      summary: Get a payment link
      description: >-
        Read one payment link the caller owns (business key, agent, dashboard session; test mode is unavailable (`503
        temporarily_unavailable`)).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: check_payment_request
        description: >-
          Return one payment request's state, its payment attempts and its timeline in one call. Prefer it over listing
          when you already know the id. A payer's I've sent it mark is a self-report, not settlement. `partial_payment`
          is set only while `status` is `processing` and a payment arrived short of `amount` (§8.4) — `received` is the
          most recent short payment recorded, not a cumulative total, and `null` means the settled amount could not be
          confirmed, not that nothing arrived. The `partial_payment` field itself can be OMITTED entirely when the
          underlying signal could not be read — that is not the same as `null`; do not report "no short payment" when
          the key is missing. `payable_rails` is computed live on every read, not frozen when the link was activated,
          and lists `crypto_relay` only when the payer page offers it right now. One known exception (#3977): an
          individual (`p2p`) merchant whose bank pay-in is not active yet still has its bank rails listed although the
          payer page withholds them.
      responses:
        '200':
          description: The payment link.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLink'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
      parameters:
        - $ref: '#/components/parameters/SwapsAccount'
    patch:
      operationId: payment_links.update
      tags:
        - Payment links
      summary: Update a draft payment link
      description: >-
        Draft-only (RESOURCE-MODEL §2.1) — refused with conflict once the link has left draft. A partial merge: an
        omitted field keeps its stored value. `allowed_rails: null` is the one way to REMOVE a rail restriction, after
        which the link offers every rail available for its currency; `[]` is a `400`, not a clear. A request that
        CHANGES the currency or the rails re-checks that pair against the same yardstick `activate` uses (`400
        allowed_rails_invalid_for_currency`, with `error.details.offerable_rails`); re-sending the stored value
        alongside another edit changes nothing and is never refused. A `settlement_destination`-only change is NOT
        re-checked against the stored rail restriction here — that pairing is judged at `activate`, which can still
        refuse a combination this PATCH accepted. `settlement_destination` (BL-50) follows the identical three-state
        convention as `allowed_rails`: omit to leave it, `null` to clear it, `{address_book_id}` to set it — a foreign
        or unknown id is `404 not_found`, never a distinguishing error. Business key, agent, dashboard session; test
        mode is unavailable (`503 temporarily_unavailable`).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PaymentLinkUpdateRequest'
      responses:
        '200':
          description: The updated draft.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLink'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            `temporarily_unavailable`. A failed dependency read (`details.reason`: `link_read_failed`,
            `items_read_failed`, `account_read_failed`, …). Not side-effect free: no `Retry-After`, a same-key replay
            answers `409 idempotency_failed`; read the link, then retry with a new `Idempotency-Key`. A kill switch also
            answers here.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /payment_links/{id}/activate:
    post:
      operationId: payment_links.activate
      tags:
        - Payment links
      summary: Activate a payment link
      description: >-
        The money boundary (D-15): runs the eligibility gate, records the merchant's compliance attestation as evidence,
        takes a settlement snapshot, mints the live url. REST-only — excluded from MCP because an agent must never set
        attestation_accepted on the merchant's behalf. A draft whose `allowed_rails` leaves it with no payable rail at
        all is refused `409 allowed_rails_invalid_for_currency` (with `error.details.offerable_rails`) and stays a draft
        — clear the restriction with `allowed_rails: null` or change the currency, then activate. A draft whose only
        payer rail would be `crypto_relay` above its per-invoice cap (the `crypto_relay` corridor's `max_amount` in
        `capabilities.get`) is refused `422 no_payable_rail` with `error.details` = {`rail`, `reason`:
        `amount_above_rail_maximum`, `max_amount`} and stays a draft; a draft any other rail can pay activates as before
        (Relay delivers on the Tempo leg, so `crypto_tempo` pays such a link today). A draft edited between the read and
        the write is refused `409` too, rather than activated on a value that has moved. A wallet request (`settlement:
        'swaps_wallet'`, settling to the merchant's own Swaps Wallet) is routed by currency (§52.33,
        PL-TEMPO-BRIDGE-4-T, issue #3839): a `USD` link takes the Bridge-less `crypto_only` splitter — `currency` not
        `USD` there is refused `422 crypto_only_currency_not_usd` (the `crypto_tempo` rail has no FX leg, so a non-USD
        link would pay out its face amount in USD-stablecoins 1:1, not the honest converted figure — C4-D27; unreachable
        through this operation today since a non-USD wallet request never reaches this branch, kept as a
        defense-in-depth backstop). A NON-`USD` wallet request instead settles through the SAME Bridge collection path
        (`payment_rail: tempo` + the account's own wallet address, resolved server-side — no address-book entry
        required) an address-book Tempo destination uses, gated by `payment_links_tempo_via_bridge` alone — the SAME
        flag `capabilities.get`'s `settlement_currencies[currency].tempo_wallet` reads, so an `available: true` account
        can always activate: `off` for this merchant is refused `422 tempo_via_bridge_not_enabled` (mirrors
        capabilities' own `reason`), an admitted merchant with no Swaps Wallet on file is refused `422
        wallet_not_provisioned`, and an admitted merchant whose Swaps Wallet is not on THIS project's Tempo network
        (`tempo-mainnet` in production) is refused `422 wallet_network_not_supported` — `capabilities.get` reads the
        SAME wallet row and reports `available: false` with the matching `reason` for both. The identical no-FX hazard
        is refused `422 tempo_settlement_currency_not_usd` for an ORDINARY draft whose chosen address-book settlement
        destination resolves to the Tempo splitter (not routed via Bridge) — the same rail, reached by a different door.
        All five carry `error.details.currency` (plus `required_currency` on the two `_not_usd` codes). An ORDINARY
        (non-wallet) draft with no `settlement_destination` set (BL-50, via `create`/`update`) is refused `400
        settlement_destination_required` and stays a draft; one set but unusable for this link's rails is refused `422
        settlement_rail_unsupported` (the destination's rail has no offramp route), `422
        settlement_account_holder_missing` or `422 settlement_details_incomplete` (the address-book entry itself is
        missing a field Bridge requires) — pick or complete a different destination and retry. Business key, agent,
        dashboard session; test mode is unavailable (`503 temporarily_unavailable`). A USD crypto-only draft activates
        on the Bridge-less eligibility branch (R19 CP-G2); a non-USD wallet draft activates on the SAME Bridge
        eligibility (KYB/KYC, EEA, p2p cap) an address-book destination needs. A crypto-only draft
        (`accepted_rail_kinds: [crypto]`) activates without e-mailing its invoice, and one that still carries a reminder
        schedule is refused `422 invalid_request` (`param: reminder_schedule`) and stays a draft until reminders are
        disabled.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-swaps-money-boundary: true
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pl_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PaymentLinkActivateRequest'
      responses:
        '200':
          description: The activated link.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLink'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            `temporarily_unavailable`. `details.reason` `settlement_provider_unavailable` — the payment provider did not
            respond while the destination was set up, and the link is still a `draft`; or a failed dependency read
            (`account_read_failed`, `wallet_read_failed`, `address_book_read_failed`, `link_read_failed`,
            `eligibility_lookup_failed`, …). Not side-effect free: no `Retry-After`, a same-key replay answers `409
            idempotency_failed`; read the link, then retry with a new `Idempotency-Key`. A kill switch also answers
            here.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /payment_links/{id}/cancel:
    post:
      operationId: payment_links.cancel
      tags:
        - Payment links
      summary: Cancel a payment link
      description: >-
        Cancel an unpaid link and void any payment the payer has started. One operation covers every confirm copy the
        dashboard shows (discard a draft, cancel a live link, cancel while funds are pending) — there is no separate
        discard verb (R11 PL-G10). Calling it twice is safe. Business key, agent, dashboard session; test mode is
        unavailable (`503 temporarily_unavailable`). A partially paid (`underpaid`) `crypto_tempo`/`crypto_relay`
        payment is not voided: it is kept as `unmatched` for support (money held, `amount_received` = its running total,
        `unmatched_reason` = `partial_before_cancel`) and `payment.unmatched` is emitted once. While a payment is being
        settled — including a `crypto_relay` payment whose funds Relay has received and not refunded (a Relay refund
        counts only once its refund transaction is recorded) — the whole cancel answers `409 conflict` and changes
        nothing.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: cancel_payment_request
        description: >-
          Cancel an unpaid payment request and void any payment the payer has started. Refuse and warn if a payment is
          already completing; cancellation cannot claw back funds, and there is no refund tool because Swaps never
          initiates one. Calling it twice is safe. A partially paid payment is held as `unmatched` for support
          (`unmatched_reason: partial_before_cancel`), not voided — tell the merchant the payer's money is held, not
          returned. While a payment is being settled, including a `crypto_relay` one whose funds Relay has received and
          not refunded (a refund counts only once its refund transaction is recorded), it answers `409 conflict` and
          changes nothing. `payable_rails` in the answer is computed live and can list `crypto_relay`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pl_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The cancelled link.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLink'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            `temporarily_unavailable`. A failed dependency read (`details.reason`: `attempts_read_failed`,
            `link_read_failed`, `eligibility_lookup_failed`). Not side-effect free: no `Retry-After`, a same-key replay
            answers `409 idempotency_failed`; read the link, then retry with a new `Idempotency-Key`. A kill switch also
            answers here. A payment provider refusing to cancel an open transfer is `500 internal_error`.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /payment_links/{id}/send_invoice:
    post:
      operationId: payment_links.send_invoice
      tags:
        - Payment links
      summary: Email the invoice to the payer
      description: >-
        Email or re-email the payment link to its payer. Requires payer_email to already be set on the link; the
        reminder cadence is a separate call to PATCH .../reminder_schedule. A crypto-only link (`accepted_rail_kinds:
        [crypto]`) answers `409 conflict`: the invoice is not e-mailed on the crypto rail today, so share its `url`
        instead. Business key, agent, dashboard session; Test mode is unavailable (`503 temporarily_unavailable`).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: send_payment_request
        description: >-
          Email or re-email the invoice to the payer, and optionally set the automatic reminder schedule. It needs a
          payer email on the request; if there is none, ask rather than guess. Use send-now for a nudge and the schedule
          for unattended follow-ups — never both for the same intent. `payable_rails` in the answer is computed live and
          can list `crypto_relay`. A crypto-only request (`accepted_rail_kinds: [crypto]`) takes neither: the invoice is
          not e-mailed on the crypto rail today, so share its link instead.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pl_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Send accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLink'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            `temporarily_unavailable`. Code `mail_unavailable` (`details.reason: invoice_email_not_sent`) — the invoice
            email could not be confirmed as sent: it may not have left, may never be deliverable to this payer, or may
            already have arrived. Not side-effect free: no `Retry-After`, a same-key replay answers `409
            idempotency_failed`; read the link before sending again with a new `Idempotency-Key`. Code
            `temporarily_unavailable` — a kill switch, or a failed dependency read (`details.reason`, e.g.
            `link_read_failed`); not side-effect free, no `Retry-After`.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /payment_links/{id}/reminder_schedule:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^pl_
      - $ref: '#/components/parameters/SwapsVersion'
    get:
      operationId: payment_links.reminder_schedule.get
      tags:
        - Payment links
      summary: Get the reminder schedule
      description: >-
        Read the cadence and what has already fired. Business key, agent, dashboard session; test mode is unavailable
        (`503 temporarily_unavailable`).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      responses:
        '200':
          description: The reminder schedule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReminderSchedule'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
      parameters:
        - $ref: '#/components/parameters/SwapsAccount'
    patch:
      operationId: payment_links.reminder_schedule.update
      tags:
        - Payment links
      summary: Set the reminder cadence
      description: >-
        Set offsets_days[]. Editable while the link is draft, active or viewed (RESOURCE-MODEL §2.1). sent[] is
        server-owned and ignored if supplied. A crypto-only link (`accepted_rail_kinds: [crypto]`) takes no schedule:
        reminders are not delivered on the crypto rail today, so it answers `422 invalid_request` (`param:
        reminder_schedule`); disabling stays allowed. Business key, agent, dashboard session; test mode is unavailable
        (`503 temporarily_unavailable`).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReminderScheduleUpdateRequest'
      responses:
        '200':
          description: The updated schedule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReminderSchedule'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            `temporarily_unavailable`. A failed dependency read (`details.reason`, e.g. `link_read_failed`). Not
            side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; read the schedule,
            then retry with a new `Idempotency-Key`. A kill switch also answers here.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /payment_links/{id}/reminder_schedule/disable:
    post:
      operationId: payment_links.reminder_schedule.disable
      tags:
        - Payment links
      summary: Turn reminders off
      description: >-
        Sets the schedule's status to off without discarding offsets_days[], so re-enabling does not require re-entering
        the cadence. Business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pl_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The disabled schedule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReminderSchedule'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            `temporarily_unavailable`. A failed dependency read (`details.reason`, e.g. `link_read_failed`). Not
            side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; read the schedule,
            then retry with a new `Idempotency-Key`. A kill switch also answers here.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /payment_links/{id}/payments:
    get:
      operationId: payment_links.payments.list
      tags:
        - Crypto processing
      summary: List a link's payment attempts
      description: >-
        A view over the top-level /v1/payments resource, scoped to one link (RESOURCE-MODEL §2.1 v2 amendment). The
        detail screen must hold this before rendering the cancel confirmation, so the client can pick the right copy
        without an extra round trip (R11 PL-G11). Business key, agent (owner only), dashboard session; test mode is
        unavailable (`503 temporarily_unavailable`).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pl_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of payment attempts on this link.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /payment_links/{id}/events:
    get:
      operationId: payment_links.events.list
      tags:
        - Payment links
      summary: List a link's events
      description: >-
        This link's own event timeline, newest first (RESOURCE-MODEL §2.1 "events (per link)") — merges the K8 events
        outbox (`api_events`) with the older `payment_link_events` audit table, so a link's full history is returned
        whether or not it predates the outbox. Only the 11 publishable `payment_link.*` types are ever returned (D-12,
        RESOURCE-MODEL §3); 9 internal audit types have no public counterpart and are never published. Callers: business
        key, agent, dashboard session. Test mode: sandbox (K11-6, §4.2 drift, was declared `full`) — a test object's
        events are written by DEV handlers into DEV's own `api_events` outbox; a prod-local read would return an empty
        page forever. BL-16 (2026-09-14): closes the Timeline card's only missing read.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pl_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of events on this link.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLinkEventList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /payment_links/{id}/receipt:
    get:
      operationId: payment_links.receipt.get
      tags:
        - Payment links
      summary: Get the collection receipt
      description: >-
        The merchant's receipt for a collected link (RESOURCE-MODEL §2.1 v2 amendment: mirrors the payout receipt).
        Available only while the link's own status is `paid` or `settled` and exactly one payment completed it; any
        other state — draft, open, `processing` (including a short payment held for review), expired, cancelled,
        refunded — answers `409 receipt_not_available`. A link the caller does not own is `404`. JSON only: no PDF or
        HTML rendition exists yet; the payer's receipt is the e-mail `swaps-pl-payer-receipt`. Business key, agent,
        dashboard session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pl_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The receipt.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentLinkReceipt'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /clients:
    get:
      operationId: clients.list
      tags:
        - Payment links
      summary: List clients
      description: >-
        List this account's saved clients, active only — archived clients are hidden. Business key, agent, dashboard
        session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: q
          in: query
          schema:
            type: string
      responses:
        '200':
          description: A page of clients.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClientList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
    post:
      operationId: clients.create
      tags:
        - Payment links
      summary: Save a client
      description: Save a client so future payment links can be addressed to them. Business key, agent, dashboard session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: save_payment_client
        description: >-
          Save or update a client so future requests can be addressed to them. Use it only when asked to remember
          someone; do not create a client record as a side effect of one-off invoicing. Removal is an archive, not a
          delete.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClientCreateRequest'
      responses:
        '201':
          description: The created client.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Client'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /clients/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^cli_
      - $ref: '#/components/parameters/SwapsVersion'
    get:
      operationId: clients.get
      tags:
        - Payment links
      summary: Get a client
      description: Read one client the caller owns. Business key, agent, dashboard session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      responses:
        '200':
          description: The client.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Client'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
      parameters:
        - $ref: '#/components/parameters/SwapsAccount'
    patch:
      operationId: clients.update
      tags:
        - Payment links
      summary: Update a client
      description: Business key, agent, dashboard session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: save_payment_client
        description: >-
          Save or update a client so future requests can be addressed to them. Use it only when asked to remember
          someone; do not create a client record as a side effect of one-off invoicing. Removal is an archive, not a
          delete.
      parameters:
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClientUpdateRequest'
      responses:
        '200':
          description: The updated client.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Client'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /clients/{id}/archive:
    post:
      operationId: clients.archive
      tags:
        - Payment links
      summary: Archive a client
      description: Sets archived_at; never a hard delete. Business key, agent, dashboard session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^cli_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The archived client.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Client'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /products:
    get:
      operationId: products.list
      tags:
        - Payment links
      summary: List products
      description: >-
        List this account's saved products, active only — archived products are hidden. Business key, agent, dashboard
        session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: q
          in: query
          schema:
            type: string
      responses:
        '200':
          description: A page of products.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
    post:
      operationId: products.create
      tags:
        - Payment links
      summary: Save a product
      description: Save a reusable line item. Business key, agent, dashboard session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductCreateRequest'
      responses:
        '201':
          description: The created product.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /products/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^prd_
      - $ref: '#/components/parameters/SwapsVersion'
    get:
      operationId: products.get
      tags:
        - Payment links
      summary: Get a product
      description: Read one product the caller owns. Business key, agent, dashboard session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      responses:
        '200':
          description: The product.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
      parameters:
        - $ref: '#/components/parameters/SwapsAccount'
    patch:
      operationId: products.update
      tags:
        - Payment links
      summary: Update a product
      description: Business key, agent, dashboard session. Existing line items on already-created links keep their locked price.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductUpdateRequest'
      responses:
        '200':
          description: The updated product.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /products/{id}/archive:
    post:
      operationId: products.archive
      tags:
        - Payment links
      summary: Archive a product
      description: Sets archived_at; never a hard delete. Business key, agent, dashboard session.
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^prd_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The archived product.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /payments:
    get:
      operationId: payments.list
      tags:
        - Crypto processing
      summary: List pay-ins across products
      description: >-
        List this account's pay-ins, newest first — payment-link attempts and crypto-processing attempts in one
        cross-product resource (RESOURCE-MODEL §2.1 v2 amendment). underpaid, overpaid, unmatched and processing are
        planned states (R19 X2/X4) — their absence from a result is not proof a payment never reached that condition.
        Business key, agent (owner only), dashboard session; test mode is unavailable (`503 temporarily_unavailable`).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: list_payments
        description: >-
          List the caller's most recent pay-ins across payment links and crypto processing, newest first. Use it to find
          a payment whose id is unknown, or to filter by link, subscription, settlement destination (`wallet`: crypto
          pay-ins to the merchant's Swaps Wallet) or status. Do not use it to poll one payment — call get_payment on the
          id. On `crypto_tempo` and `crypto_relay`, `fee` is the payer-borne Swaps fee and `amount_expected` includes
          it, both at the token's own scale (6 decimals: read them as given, never rounded to cents); `fee` is `null` on
          every other rail. `amount_received` is what was observed so far. `amount_missing` is set only while `status`
          is `underpaid`. `unmatched_reason` is set only while it is `unmatched`: `partial_before_cancel` or
          `deposit_after_close`, an open enum, and `null` on older rows — never guess one.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: payment_link_id
          in: query
          schema:
            type: string
            pattern: ^pl_
        - name: subscription_id
          in: query
          schema:
            type: string
            pattern: ^sub_
        - name: settlement
          in: query
          description: >-
            Filters by settlement destination (CP-T1). `wallet` is the merchant's own Swaps Wallet (RESOURCE-MODEL
            §2.3): the pay-ins of links whose `settlement_kind` is `crypto_only`, which the Crypto processing Payments
            list reads. Applies to the page and to `?expand=summary`. Any other value is `400 invalid_request` with
            `param: settlement`.
          schema:
            type: string
            enum:
              - wallet
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/PaymentStatus'
        - name: expand
          in: query
          description: >-
            Comma-separated. `summary` (LIST-SUMMARY-1) adds `summary`: counts computed server-side over EVERY row
            matching this request's filters, not over the returned page, so a client never adds up a paginated page and
            calls it a total. `limit`/`cursor` never change it. An unrecognised value is ignored rather than rejected
            (the `GET /account` `expand` rule).
          schema:
            type: string
            example: summary
      responses:
        '200':
          description: A page of payments.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /payments/{id}:
    get:
      operationId: payments.get
      tags:
        - Crypto processing
      summary: Get a pay-in
      description: >-
        Read one payment the caller owns — amount, fee, net amount, on-chain evidence and screening where it applies. No
        create, no refund: only a payer session creates one, and Swaps never initiates a refund (RESOURCE-MODEL §0.10).
        Business key, agent (owner only), dashboard session; test mode is unavailable (`503 temporarily_unavailable`).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_payment
        description: >-
          Return one pay-in the caller owns — status, rail, amount, fee, net amount, and on-chain evidence on a crypto
          rail. Never conclude money moved from the status label alone when a verdict state (underpaid, overpaid,
          unmatched) is present — read the amount fields. On `crypto_tempo` and `crypto_relay`, `fee` is the payer-borne
          Swaps fee and `amount_expected` includes it, both at the token's own scale (6 decimals: read them as given,
          never rounded to cents); `fee` is `null` on every other rail. `amount_received` is what was observed so far.
          `amount_missing` is set only while `status` is `underpaid`. `unmatched_reason` is set only while it is
          `unmatched`: `partial_before_cancel` (a short payment held when the link was cancelled) or
          `deposit_after_close` (a deposit after the payment had closed), an open enum, and `null` on older rows — never
          guess one. An unmatched payment's money is held for support, not returned.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pay_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The payment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /payments/{id}/events:
    get:
      operationId: payments.events.list
      tags:
        - Crypto processing
      summary: List a payment's events
      description: >-
        This payment's own event timeline, newest first (RESOURCE-MODEL §2.1, mirrors `/payment_links/{id}/events`
        exactly — BL-16 closed the same class for payment_links). A view over the K8 events outbox (`api_events`) scoped
        to this payment's own `resource_id`; unlike the link-level route there is no legacy audit table to merge, and
        `payment.*` had no producer before the outbox — so this page is complete since the events outbox was enabled for
        this account, not "the complete history" for a payment that predates the flip. Only the 5 publishable
        `payment.*` types this account's flag has actually produced are ever returned: `payment.created`, `.awaiting`,
        `.paid`, `.settled`, `.expired`. Callers: business key, agent, dashboard session. Test mode: sandbox (K11-6,
        §4.2 drift, was declared `full`) — a test payment's events are written by DEV handlers into DEV's own
        `api_events` outbox; a prod-local read would return an empty page forever. BL-33 (2026-09-15): closes the
        crypto-processing payment timeline's fallback to attempt.updated_at once the outbox has rows for the account;
        the timeline must keep that fallback until then. Ownership is checked against the payment link's
        `merchant_user_id`, but the event query is scoped to the caller's resolved `account_id`; a merchant who owns
        more than one account can see an empty page for a payment that legitimately belongs to their OTHER account,
        rather than a 404 (pre-existing on every `/v1/events`-family read, not introduced here —
        `_shared/services/api_events.ts`'s owner-walk always resolves to the OLDEST account).
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pay_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of events on this payment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentEventList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /subscriptions:
    get:
      operationId: subscriptions.list
      tags:
        - Crypto processing
      summary: List subscriptions
      description: >-
        List this account's version-1 scheduled-invoice subscriptions. Each row carries `overdue` (NB-2) — derived from
        its own open, past-due invoices, never from the subscription's own status. **Dark-flag** — gated behind
        `api_v1.subscriptions` (K13c), off in production pending legal sign-off (G-8, MiCA/crypto-processing). Business
        key, agent, dashboard session. With `api_v1.subscriptions` closed, the answer is `503 temporarily_unavailable`
        (an availability lever, never an authorization one), not `capability_unavailable`.
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema:
            type: string
            enum:
              - active
              - paused
              - cancelled
        - name: expand
          in: query
          description: >-
            Comma-separated. `summary` (LIST-SUMMARY-1) adds `summary`: counts computed server-side over EVERY row
            matching this request's filters, not over the returned page, so a client never adds up a paginated page and
            calls it a total. `limit`/`cursor` never change it. An unrecognised value is ignored rather than rejected
            (the `GET /account` `expand` rule).
          schema:
            type: string
            example: summary
      responses:
        '200':
          description: A page of subscriptions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriptionList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
    post:
      operationId: subscriptions.create
      tags:
        - Crypto processing
      summary: Create a subscription
      description: >-
        Create a version-1 scheduled-invoice subscription — a crypto invoice reissued on a schedule, never an authorised
        pull (RESOURCE-MODEL §2.6). Every subscription settles `crypto_only` (the no-FX `crypto_tempo` rail), so
        `amount.currency` must be `USD` — anything else is refused `422 crypto_only_currency_not_usd` (C4-D27), the same
        code and reason `payment_links.activate` refuses a non-USD crypto-only link with. **Dark-flag** — gated behind
        `api_v1.subscriptions` (K13c), off in production pending legal sign-off (G-8, MiCA/crypto-processing). Business
        key, agent, dashboard session.
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: create_subscription
        description: >-
          Create a version-1 crypto subscription: a series of invoices on a fixed interval, each paid one tap at a time
          by the payer — never an authorised pull. It creates the schedule only; no money moves until the payer pays
          each issued invoice.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubscriptionCreateRequest'
      responses:
        '201':
          description: The created subscription.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /subscriptions/{id}:
    get:
      operationId: subscriptions.get
      tags:
        - Crypto processing
      summary: Get a subscription
      description: >-
        Read one subscription the caller owns. `overdue` (NB-2) is derived from its own open, past-due invoices at read
        time and never stored — the subscription's own status never flips to reflect a missed payment. **Dark-flag** —
        gated behind `api_v1.subscriptions` (K13c), off in production pending legal sign-off (G-8,
        MiCA/crypto-processing). Business key, agent, dashboard session. With `api_v1.subscriptions` closed, the answer
        is `503 temporarily_unavailable` (an availability lever, never an authorization one), not
        `capability_unavailable`.
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_subscription
        description: >-
          Return one subscription the caller owns — schedule, amount, payer, its current status and whether it is
          currently overdue. An overdue invoice does not pause or cancel the subscription; read the invoice list for
          full payment history.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^sub_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The subscription.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /subscriptions/{id}/pause:
    post:
      operationId: subscriptions.pause
      tags:
        - Crypto processing
      summary: Pause a subscription
      description: >-
        Stops future invoices from issuing until resumed. **Dark-flag** — gated behind `api_v1.subscriptions` (K13c),
        off in production pending legal sign-off (G-8, MiCA/crypto-processing). Business key, agent, dashboard session.
        With `api_v1.subscriptions` closed, the answer is `503 temporarily_unavailable` (an availability lever, never an
        authorization one), not `capability_unavailable`.
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^sub_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The paused subscription.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /subscriptions/{id}/resume:
    post:
      operationId: subscriptions.resume
      tags:
        - Crypto processing
      summary: Resume a paused subscription
      description: >-
        **Dark-flag** — gated behind `api_v1.subscriptions` (K13c), off in production pending legal sign-off (G-8,
        MiCA/crypto-processing). Business key, agent, dashboard session.
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^sub_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The resumed subscription.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /subscriptions/{id}/cancel:
    post:
      operationId: subscriptions.cancel
      tags:
        - Crypto processing
      summary: Cancel a subscription
      description: >-
        Terminal — a cancelled subscription behaves like a cancelled payment link and issues no further invoices.
        **Dark-flag** — gated behind `api_v1.subscriptions` (K13c), off in production pending legal sign-off (G-8,
        MiCA/crypto-processing). Business key, agent, dashboard session. With `api_v1.subscriptions` closed, the answer
        is `503 temporarily_unavailable` (an availability lever, never an authorization one), not
        `capability_unavailable`.
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^sub_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The cancelled subscription.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.write
  /subscriptions/{id}/invoices:
    get:
      operationId: subscriptions.invoices.list
      tags:
        - Crypto processing
      summary: List a subscription's invoices
      description: >-
        The child-invoice ladder — one row per period, issued on its due date, never earlier. **Dark-flag** — gated
        behind `api_v1.subscriptions` (K13c), off in production pending legal sign-off (G-8, MiCA/crypto-processing).
        Business key, agent, dashboard session. Resuming a paused subscription deletes any never-issued invoice whose
        period fell inside the paused span, so a `sequence` gap can appear across a resume with no corresponding event.
        With `api_v1.subscriptions` closed, the answer is `503 temporarily_unavailable` (an availability lever, never an
        authorization one), not `capability_unavailable`.
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^sub_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of invoices.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriptionInvoiceList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /subscriptions/{id}/invoices/{invoice_id}:
    get:
      operationId: subscriptions.invoices.get
      tags:
        - Crypto processing
      summary: Get a subscription invoice
      description: >-
        No create: the emitter is a cron, not a caller. **Dark-flag** — gated behind `api_v1.subscriptions` (K13c), off
        in production pending legal sign-off (G-8, MiCA/crypto-processing). Business key, agent, dashboard session. With
        `api_v1.subscriptions` closed, the answer is `503 temporarily_unavailable` (an availability lever, never an
        authorization one), not `capability_unavailable`.
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^sub_
        - name: invoice_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^inv_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The invoice.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriptionInvoice'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payment_links.read
  /payment_sessions/{token}:
    get:
      operationId: payment_sessions.get
      tags:
        - Payment links
      summary: Read a payer session
      description: >-
        A pure read (D-4) — never advances active to viewed; call POST .../view for that transition. Resolves exactly
        one link's payer projection off the capability token in the path; structurally unreachable with a business key
        or a dashboard session (RESOURCE-MODEL §0.9). Public token only, no key. Test mode does not apply: a test-mode
        link's token lives on DEV and is `404` here.
      x-swaps-status: available
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: 'A plk_ capability token — a secret, never an id: never logged, never echoed.'
          schema:
            type: string
            pattern: ^plk_
        - $ref: '#/components/parameters/SwapsVersion'
      responses:
        '200':
          description: The payer's projection of the link.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /payment_sessions/by_code/{short_code}:
    get:
      operationId: payment_sessions.by_code.get
      tags:
        - Payment links
      summary: Resolve a short code to a payer session
      description: >-
        C5-SHORT-LINK — resolves a payment link's `short_code` (the last segment of `PaymentLink.short_url`) to its
        payer session token and payer page `url`, so a short URL reaches the payer page. A pure read, never a status
        change. Public, no key — the same caller model and error envelope as `GET /v1/payment_sessions/{token}`, but its
        own stricter per-IP rate limit (10/min), since a hit unlocks the full payer token. Upper-case input is accepted
        and normalized. Every miss is one `404 not_found`: a malformed or unknown code, a draft link, and a link that
        has no payer page on this project's host (the other plane's code, or no payer host configured). Test mode does
        not apply: a test-mode link lives on DEV and resolves only there.
      x-swaps-status: available
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - name: short_code
          in: path
          required: true
          description: A `PaymentLink.short_code`. Resolves to a capability token, so it is never logged.
          schema:
            type: string
        - $ref: '#/components/parameters/SwapsVersion'
      responses:
        '200':
          description: The payer session token and payer page for this code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSessionCodeResolution'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          description: >-
            `not_found` — no payer page for this code on this project: unknown or malformed code, a draft link, or a
            link on the other plane's host. One answer for every case, so it reveals nothing about which.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /payment_sessions/{token}/view:
    post:
      operationId: payment_sessions.view
      tags:
        - Payment links
      summary: Mark the session viewed
      description: >-
        Advances the link active to viewed (D-4). Idempotent: calling it again on an already-viewed link is a no-op — no
        `Idempotency-Key` is accepted; the handler's own state check is the replay guard. Public token only, no key.
        Test mode does not apply: a test-mode link's token lives on DEV and is `404` here.
      x-swaps-status: available
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: ^plk_
        - $ref: '#/components/parameters/SwapsVersion'
      responses:
        '200':
          description: The updated session.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /payment_sessions/{token}/payments:
    post:
      operationId: payment_sessions.payments.create
      tags:
        - Payment links
      summary: Select a rail and start a payment
      description: >-
        Selects a rail — for a crypto rail, also a network and stablecoin (RESOURCE-MODEL §2.1 v2 amendment) — and
        returns deposit instructions. A live attempt on the same rail returns its existing instructions rather than
        minting a second provider transfer (RESOURCE-MODEL §2.1 invariants) — idempotent per `(token, rail)` by the
        handler itself, so no `Idempotency-Key` is accepted here. The payer's consent checkbox is enforced client-side,
        not as a field here. Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV
        and is `404` here. `crypto_relay` (CP-X2) is live behind the `crypto_relay_rail` flag, off in production: it
        needs `network` and `refund_address`, allows one open payment per link (the same `network` and `refund_address`
        get that payment back; anything else is `409 payment_in_progress` until Relay can no longer fill it and either
        saw no transfer or refunded it with the refund transaction recorded), and answers the Relay deposit in
        `deposit_instructions` (never the splitter address) only while the address is payable;
        `deposit_instructions.quote_expires_at` is the last time to send.
      x-swaps-status: available
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      x-swaps-money-boundary: true
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: ^plk_
        - $ref: '#/components/parameters/SwapsVersion'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PaymentSessionSelectRailRequest'
      responses:
        '201':
          description: The started payment — the payer-safe projection (SEC-2), never the merchant's full `Payment`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSessionPayment'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `capability_unavailable` — code `network_not_supported`: `rail: crypto_relay` with a `network` that is not
            one of its five Relay sources (`tempo` uses `crypto_tempo`; `solana` stays dark). `conflict` — code
            `payment_in_progress`: this link already has an open `crypto_relay` payment for another `network` or
            `refund_address`; read the session and follow that payment. Nothing was recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          description: >-
            The payment provider returned a deposit destination we could not validate for this rail (`provider_error` —
            `provider_instruction_invalid` or `collection_va_payee_name_missing`). This needs operator attention; a
            caller retry of the identical request is not expected to succeed. Code `relay_quote_shortfall`
            (`crypto_relay`): Relay's quote does not deliver exactly the amount due to the splitter (`details.reason`),
            so no deposit address is issued and nothing is recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: >-
            `temporarily_unavailable`. Code `rail_unavailable` — the chosen rail is switched on but not configured to
            settle for this link right now (`details.rail`, `details.reason`); nothing was charged, no payment attempt
            is recorded and no `Retry-After` is sent, because a payer retry cannot fix it: offer another rail from the
            session. Once the rail is configured, the same request starts a fresh attempt. `details.reason`
            `tempo_rpc_unavailable` (the Tempo RPC failed) is the one transient rail case: it sends `Retry-After`. Code
            `temporarily_unavailable` — a kill switch is thrown or a dependency read failed (`details.reason` when it
            did); retry after `Retry-After`. Code `relay_route_unavailable` (`crypto_relay`) — `details.reason`
            `route_not_proven` (this `network` is not admitted — no `founder_accepted` or `proven` Relay route; pick one
            from `rails[].networks`), `provider_refused` or a `quote_*` reason (Relay refused or returned an unusable
            quote, including `quote_deadline_missing` / `quote_deadline_too_soon`), `provider_unreachable` (Relay did
            not answer; sends `Retry-After`), `link_expires_too_soon` (the link closes before a Relay payment could
            land), or a `tempo_*` reason for the Tempo leg (`tempo_rpc_unavailable` sends `Retry-After`). Nothing was
            recorded.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /payment_sessions/{token}/payments/{payment_id}:
    get:
      operationId: payment_sessions.payments.get
      tags:
        - Payment links
      summary: Poll a payment's status
      description: >-
        Read one payment attempt started on this session. The payer's own client polls this with a backoff, never the
        chain directly. Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV and
        is `404` here.
      x-swaps-status: available
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: ^plk_
        - name: payment_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pay_
        - $ref: '#/components/parameters/SwapsVersion'
      responses:
        '200':
          description: The payment — the payer-safe projection (SEC-2), never the merchant's full `Payment`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSessionPayment'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /payment_sessions/{token}/receipt_email:
    post:
      operationId: payment_sessions.receipt_email.set
      tags:
        - Payment links
      summary: Set the payer's receipt email
      description: >-
        An opt-in email address for a receipt. Delivery on the crypto rail is a known gap (R19 CP-G12) — the field
        exists and is accepted regardless. Idempotent — re-saving the same or a corrected email is a plain overwrite, so
        no `Idempotency-Key` is accepted. Public token only, no key. Test mode does not apply: a test-mode link's token
        lives on DEV and is `404` here.
      x-swaps-status: available
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: ^plk_
        - $ref: '#/components/parameters/SwapsVersion'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PaymentSessionReceiptEmailRequest'
      responses:
        '200':
          description: Saved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /payment_sessions/{token}/mark_sent:
    post:
      operationId: payment_sessions.mark_sent
      tags:
        - Payment links
      summary: 'Payer self-report: I''ve sent it'
      description: >-
        Records the payer's own claim that they sent the transfer. authoritative:false on the resulting event
        (RESOURCE-MODEL §3) — it is never settlement. Idempotent (a compare-and-swap update) — no `Idempotency-Key` is
        accepted. Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV and is
        `404` here.
      x-swaps-status: available
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: ^plk_
        - $ref: '#/components/parameters/SwapsVersion'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PaymentSessionMarkSentRequest'
      responses:
        '200':
          description: Recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /payment_sessions/{token}/pay_with_wallet:
    post:
      operationId: payment_sessions.pay_with_wallet
      tags:
        - Payment links
      summary: Prepare a one-tap Swaps Wallet payment
      description: >-
        Planned (R19 §2.4, CP-G11). Returns unsigned steps scoped to the splitter address — nothing leaves the payer's
        wallet until their own passkey signs on their device (RESOURCE-MODEL §0.10). This is the money boundary for the
        one-tap path: never chain these steps into execution without the payer's own confirmation. Public token only;
        test mode returns fixtures.
      x-swaps-status: proposed
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: fixtures
      x-swaps-money-boundary: true
      x-swaps-noncustodial: unsigned_steps
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: ^plk_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Unsigned steps for the payer's own passkey to sign.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSessionPayWithWalletResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /payouts:
    get:
      operationId: payouts.list
      summary: List payouts
      description: >
        List the caller's payouts, newest first. `status_group` mirrors the dashboard's own bucketing (`needs_you` =
        `draft` ∪ `awaiting_funds` ∪ `paid_with_shortfall` ∪ `needs_attention:true`; `in_progress` = `funds_received` ∪
        `processing` ∪ `paid`; `done` = `settled` ∪ `failed` ∪ `returned` ∪ `expired` ∪ `cancelled`; RESOURCE-MODEL §2.2
        v2 amendments). Do not promise a query by date, status or recipient beyond this grouping.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: list_payouts
        description: >
          Return the caller's most recent payouts, newest first. Use it to find a payout whose id is unknown. Do not
          promise a query by date, status or recipient.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status_group
          in: query
          description: >-
            This resource's own bucket set (`needs_you`, `in_progress`, `done`), shared with payroll runs and orders
            only.
          schema:
            type: string
            enum:
              - needs_you
              - in_progress
              - done
      responses:
        '200':
          description: A page of payouts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.read
    post:
      operationId: payouts.create
      summary: Create a draft payout
      description: >
        Creates a `draft` payout for an external invoice. Nothing is charged and no money moves — that happens later, at
        `POST /v1/payouts/{id}/funding_instructions`. Two different things are stamped onto the payout at this moment as
        `capability_snapshot` (RESOURCE-MODEL §2.2 v2 amendments, K4c): the capability-contract `version`, and the
        corridor's sender identity (`sender_display`/`legal_entity_name`) — neither is re-resolved later even if the
        corridor's terms or the customer's account naming change (see `PayoutCapabilitySnapshot`'s own description for
        why). `eta_seconds`/`minimum` are the OPPOSITE: presentation-only facts resolved fresh from the LIVE catalog on
        every read, never frozen. Refused with `409 capability_unavailable` when `corridor_id` is not currently
        executable — do not call this before `GET /v1/capabilities?product=payouts` (or `list_capabilities`) reports the
        corridor available, and do not retry on that error. `payer_type` is optional: Swaps derives it (`business` only
        when the account and the payer's Bridge customer are both business) and refuses a different value with `422
        payer_type_mismatch`. `source_chain` is optional too: a draft may be created before the payer picks a chain
        (`source_chain_chosen: false`); the chain is then sent when funding.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: prepare_invoice_payout
        description: >
          Create a payout for an external invoice and return a draft ready to fund. Nothing is charged and no money
          moves. Do not call it before `list_capabilities` reports the corridor available. Leave `payer_type` out: Swaps
          derives it from the account and its verified provider customer, and a different value is refused with
          `payer_type_mismatch`. Leave `source_chain` out until the payer has chosen one; it is then passed when
          funding. **The beneficiary's bank details are added over the REST API or a hosted link, never through a tool
          argument** — most clients log arguments verbatim.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutCreateRequest'
      responses:
        '201':
          description: The new draft payout.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payout'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            Idempotency-Key reused with a different body (`idempotency_error`), or the corridor cannot serve this
            request right now (`capability_unavailable` — do not retry).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: >
            The request is well-formed but cannot be processed. `payer_type_mismatch`: the `payer_type` sent differs
            from the payer type Swaps derives for the account the request acts for; `details.expected` is that type,
            `details.received` the value sent. Nothing is created: resend without `payer_type`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >
            A kill switch is thrown or a dependency is out (`temporarily_unavailable`), or the payer type could not be
            verified (`payer_type_unverifiable`); nothing was created. Retry after `Retry-After`, with the same
            `Idempotency-Key`.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payouts.write
  /payouts/eligibility:
    get:
      operationId: payouts.eligibility.get
      summary: Read the holder's payout eligibility
      description: >
        The holder's KYC status and, per corridor, whether a payout can be created and funded right now, with the
        blocker named when it cannot. This is availability, not authorization: the server re-checks at create and again
        at the funding money boundary (RESOURCE-MODEL §0.10). Treat `in_review` and `gathering_no_path` as different
        answers — never tell someone to submit a form for the second.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The holder's payout eligibility.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutEligibility'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.read
  /payouts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    get:
      operationId: payouts.get
      summary: Get a payout
      description: >
        Return one payout the caller owns. A payout paid with a shortfall is terminal, has no receipt by design, and
        never becomes `settled` on its own — read `provider_paid_amount`/`invoice_shortfall_amount` rather than waiting
        for a receipt. Cross-account ids answer `404 not_found`, never `403`.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_payout
        description: >
          Return one payout the caller owns — status, corridor, invoice amount, masked beneficiary, and, once confirmed,
          the settled amount or the provider-paid amount plus the shortfall, with the settlement receipt when one
          exists. A payout paid with a shortfall is terminal, has no receipt by design, and never becomes settled on its
          own. `capability_snapshot.sender_display` (and `legal_entity_name` when known) is frozen at the moment this
          payout was created — whose name the recipient sees on their bank statement — and is present on every payout
          created since that freeze; a payout created before it carries no snapshot at all.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The payout.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payout'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.read
  /payouts/{id}/beneficiary:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    post:
      operationId: payouts.beneficiary.set
      summary: Add the beneficiary to a draft payout
      description: >
        Adds the destination bank account to a `draft` payout — once, ever; there is no update and no second call
        (RESOURCE-MODEL §2.2). Raw bank details cross this boundary once and are then held only by the provider, so this
        operation is **excluded from MCP entirely** — no generated tool can take an IBAN or account number as an
        argument, because most MCP clients log tool arguments verbatim (D-6). Structurally REST-only: business key or
        agent over HTTPS, never a tool call.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-money-boundary: true
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutBeneficiaryRequest'
      responses:
        '201':
          description: The payout, now carrying its masked beneficiary.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payout'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            Idempotency-Key reused with a different body, or the payout is not `draft` (a beneficiary already exists, or
            the payout has moved past the point one can be added).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.write
  /payouts/{id}/funding_instructions:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    get:
      operationId: payouts.get_funding_instructions
      summary: Read the current funding instructions
      description: >
        Read the funding instructions already created for this payout, without side effects. `404 not_found` is reserved
        for a payout that does not exist or is not yours — identical body either way, no oracle. A payout that exists
        and is yours but was never funded (`draft`, or any other status `POST` was never called from) answers `409
        payout_funding_instructions_not_ready` instead, naming the status. To create the provider transfer, or to
        refresh the estimate, call `POST` on this same path.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-money-boundary: true
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The current funding instructions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutFundingInstructions'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            `payout_funding_instructions_not_ready` — this payout exists and is yours, but has never been funded (no
            provider transfer exists yet for its current status), so there is nothing to read.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.read
    post:
      operationId: payouts.fund
      summary: Create or refresh the funding instructions
      description: >
        The money boundary. On the first call, resolves a deposit address and creates the provider transfer; every later
        call on the same payout is a safe, idempotent refresh that returns the same address with a re-priced estimate —
        never a new address. Show this to a human and get per-transaction confirmation before anything is sent; never
        chain it from a quote or call it unattended.


        **Fund ordering** (RESOURCE-MODEL §2.2 invariants — fixed, never reordered): recover an already-succeeded
        provider operation, if one exists, before doing anything else → re- resolve the corridor from scratch (a
        capability read taken at create time never authorizes a transfer by itself) → verify the payer against a
        **fresh** provider customer read → price the transfer in USD, failing **closed** if pricing is unavailable →
        check the per-payer cap → check the Travel-Rule ceiling. Refused with `409 capability_unavailable` if the
        corridor is blocked at any of these checks — do not retry. The cap reads the payer type live (the account and
        the fresh provider customer), never the stored `payer_type`; a refresh of a payout stored `business` re-checks
        it and answers `403` above it.


        **The 1% fee law**: the Swaps fee is exactly 1% of the **gross source amount**, computed by dividing — `gross =
        invoice_amount × 10000 / (10000 − fee_bps)` — never `invoice_amount × 1.01`. A stored `fee_bps` that disagrees
        with the live fee contract is refused here, at fund, even if it was accepted at create. `deposit_address` is
        returned case-preserved, verbatim; `amount` is always an **estimate** of the source amount, never an exact total
        — do not represent it as final. It is the provider quote rounded **up** to the cent plus a buffer of at most
        0.05 % (`buffer`), so the recipient receives the full invoice; a receipt above the invoice by at most 0.05 % of
        it plus 10 minor units (25 for MXN) settles (`settled_amount` = what was paid). `paid_with_shortfall` is a
        terminal payout status: it produces no receipt and no success notification, and never becomes `settled`.


        Returns `503 temporarily_unavailable` (with `Retry-After`), not `provider_error`, when the source-amount quote
        or the deposit address cannot be resolved right now — this is a transient kill-switch condition, not a permanent
        block on the corridor.


        **Optional body** (additive; an absent body is the implicit fund above, unchanged). `source_chain` picks the
        USDC chain the payer funds from — accepted only while no provider transfer exists yet and only among the
        corridor's executable source chains; it is persisted in the same transaction as the transfer claim, and any
        later different value is `409 payout_source_chain_locked`. A payout created without `source_chain` needs it here
        (or `funding_source: swaps_wallet`): without one the call is `400 payout_source_chain_missing` (`param:
        source_chain`) and nothing is changed; Swaps never picks a chain for another wallet. `funding_source:
        swaps_wallet` («Wallet balance», behind the `payouts_wallet_funding` kill switch) runs the same fund ordering,
        then prepares ONE unsigned, non-custodial Tempo→Relay send from the payer's own Swaps wallet to exactly this
        deposit address on the chain the server picks (the first of base, arbitrum, ethereum that is both executable for
        the corridor and open for the wallet): Relay `EXACT_OUTPUT` for exactly `amount` (the same quote rounded up to
        the cent plus the buffer), no Swaps fee on the leg. The response then carries `funding_source: swaps_wallet` and
        `wallet_send_intent`, whose `send_instructions.steps[]` the holder signs with the wallet passkey and records
        through `POST /v1/wallet/send_intents/{id}/source_tx`. Swaps never signs. The send is handed to ONE session: the
        call claims it for the calling dashboard session (the verified sign-in's session; its status becomes
        `awaiting_signature`), so one signature is one transfer. A repeat call from the same session while that send is
        unsigned and unexpired returns it; another session gets `409 wallet_funding_claimed` and no send, with
        `details.retry_after` — when a fresh send can be prepared (30 minutes after it expires, if it is never
        broadcast). A claim is never handed to another session, also after it expires; sign only a send this call handed
        to your session. Use a fresh random `Idempotency-Key` per session: a replay (same key and body) returns the
        stored answer, send included, to any session of the account. Once its source transaction is recorded, or Relay
        has seen it, the attempt answers `409 wallet_funding_in_flight` forever — as it does for 30 minutes after an
        unsigned send expires (it may still be broadcast) and when the payout is already marked sent with no recorded
        wallet send. A send Relay refunded to the wallet is superseded by the next call. Nothing is prepared when the
        wallet balance does not cover the send (`409 wallet_balance_short`, `details.short_by`). `swaps_wallet` is
        dashboard-only: the wallet's passkey holder signs in a dashboard session, so a business key or an agent is
        refused with `409 wallet_funding_unavailable` (`wallet_not_provisioned`), and so is a sign-in that carries no
        session to hand the send to (`session_unbound`); `PAYOUTS_ENABLED` off refuses it too (`flag_off`), because a
        wallet send is new money. Whatever the body, an attempt funded from the wallet answers with `funding_source:
        swaps_wallet` (the unsigned `wallet_send_intent` only to the dashboard session), also for 30 minutes after its
        send expires. Without a body or with `funding_source: external_wallet`, a linked wallet send that was signed or
        seen by Relay (and not refunded) is `409 wallet_funding_in_flight`, and an unsigned one that can still be signed
        (until 30 minutes after it expires) is `409 wallet_funding_pending` with `details.retry_after` — never a second
        funding path while the first can still move. While a cancel of the payout is in progress, `funding_source:
        swaps_wallet` is `409 payout_not_fundable` and no send is handed out. The payout's settlement is still decided
        only by what the provider pays out.


        **Refund address** («Another wallet» only; PI-REFUND-ADDR-1-T). `refund_address` is where Bridge returns the
        USDC if this transfer can't be completed — an address on the funding chain that the payer controls. Swaps vets
        it before any provider call: EVM only and never the zero address, a mixed-case address must carry a valid EIP-55
        checksum (`422 payout_refund_address_invalid`); not the Swaps fee wallet, nor a deposit address Swaps issued for
        payouts, payment links, wallet funding, Relay deposits, wallet off-ramps or Bridge transfers (`422
        payout_refund_address_not_allowed`); never a Swaps Wallet address, which exists on Tempo only (`422
        destination_rail_keyless`); evidence that cannot be read refuses (`503 destination_risk_unverifiable`, nothing
        changed). With `funding_source: swaps_wallet` it is `400 payout_refund_address_unsupported`. On the first fund
        it is sent with the transfer as Bridge's `return_instructions.address`; a retry before the transfer exists must
        send the same `refund_address` (or none, if the first call sent none: `409 payout_refund_address_locked`
        otherwise), and if Bridge refuses the address there the transfer is not created and the payout can no longer be
        funded (`422 payout_refund_address_invalid`; create a new payout). Once the transfer exists, a new address is
        sent to Bridge under one claimed provider operation after a fresh read of the transfer, and only while Bridge
        reports it `awaiting_funds` (`409 payout_refund_address_locked` otherwise, nothing stored). The same address
        again changes nothing. It is recorded, and returned as `refund_address`, only once Bridge's own response carries
        it. Without one, Swaps has no refund address on file: a deposit Bridge cannot deliver may then wait in
        `missing_return_policy`, where the payout reads `processing` with `needs_attention` and `failure_code:
        missing_return_policy`.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-money-boundary: true
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_payout_funding_instructions
        description: >
          Return the deposit address, chain and estimated source amount to fund an existing draft payout, creating the
          provider transfer on the first call. This is the money boundary: show it to a human and confirm per
          transaction; never chain it from a quote. Calling it again is safe — it refreshes the estimate and returns the
          same address. The address is case-sensitive: reproduce it verbatim. The amount is an estimate, never an exact
          total. Never pass `funding_source: swaps_wallet`: funding from the Swaps wallet is dashboard-only and is
          refused for an agent. When the payout says `source_chain_chosen: false`, pass the `source_chain` the human
          will pay from. If the answer carries `funding_source: swaps_wallet`, the wallet already funds this payout — do
          not send from another wallet. While a wallet send for this payout can still move the call answers `409
          wallet_funding_pending` or `wallet_funding_in_flight` instead — do not send from another wallet then either.
          Pass `refund_address` only when the human gives you an address they control on that chain; never guess one.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutFundRequest'
      responses:
        '200':
          description: The funding instructions — created on the first call, refreshed on every later one.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutFundingInstructions'
        '400':
          description: >
            The request is malformed or fails validation — fix the argument and retry. Also
            `payout_source_chain_missing` (`param: source_chain`) and `payout_refund_address_unsupported` (`param:
            refund_address`: a refund address with `funding_source: swaps_wallet`); nothing was changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            Idempotency-Key reused with a different body; the corridor cannot serve this payout any longer
            (`capability_unavailable`, resolved fresh at this call — do not retry); the payout's own status genuinely
            never funds (`payout_not_fundable`, e.g. `settled`/`cancelled`/`failed`); or it is `awaiting_funds` with no
            provider transfer on record (`payout_transfer_missing` — a data anomaly, not a policy refusal; contact
            support rather than retrying). With a body: `payout_source_chain_locked` (a different `source_chain` after
            funding started); `wallet_funding_unavailable` (`details.reason`: `flag_off` — `payouts_wallet_funding` or
            `PAYOUTS_ENABLED` off, `wallet_not_provisioned` — also every non-dashboard caller, `session_unbound` — the
            sign-in carries no session, `wallet_paused`, `route_unavailable` — «Another wallet» still works);
            `wallet_funding_claimed` (the payout's wallet send was handed to another session to sign — no send in the
            answer; `details.retry_after`, RFC 3339, is when a fresh send can be prepared if it is never broadcast;
            retry then under a new `Idempotency-Key`); `wallet_balance_short` (`details.short_by`,
            `wallet_send_estimate`, `wallet_balance`, `required_delivery` — no send prepared);
            `wallet_funding_in_flight` (a wallet send for this attempt was already signed or seen by Relay — never a
            second one; the previous unsigned send is still inside its 30-minute broadcast grace; or the payout is
            already marked sent from another wallet; without a body or with `funding_source: external_wallet`: a wallet
            send for this payout was signed or seen by Relay and not refunded); `wallet_funding_pending` (no body or
            `funding_source: external_wallet` while an unsigned wallet send for this payout can still be signed —
            `details.retry_after`, RFC 3339, is when it no longer can; retry then under a new `Idempotency-Key`, nothing
            was changed); `payout_not_fundable` with `funding_source: swaps_wallet` also while a cancel of the payout is
            in progress (no send is handed out); `payout_refund_address_locked` (the refund address cannot change now:
            Bridge no longer reports the transfer `awaiting_funds`, could not confirm its state, did not accept or
            confirm the change, answered it with other transfer terms, or an earlier change of this payout is unresolved
            — nothing was stored; or the transfer is still being created with the `refund_address` the first call sent,
            or none — retry with that).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: >
            The request is well-formed but cannot be carried out (`invalid_request`). `payout_refund_address_invalid`:
            not an EVM address, the zero address, or a mixed-case address with a bad EIP-55 checksum — nothing was
            created; or Bridge refused it while creating the transfer — no transfer was created and the payout can no
            longer be funded, create a new payout. `payout_refund_address_not_allowed`: the Swaps fee wallet or a
            deposit address Swaps issued for payouts, payment links, wallet funding, Relay deposits, wallet off-ramps or
            Bridge transfers. `destination_rail_keyless`: a Swaps Wallet address, which nothing can sign for off Tempo.
            `param: refund_address` on each.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >
            The source-amount quote or the deposit address could not be resolved right now (`temporarily_unavailable`),
            or the payer type could not be verified for the cap (`payer_type_unverifiable`) — retry after `Retry-After`.
            Do not send funds while this is the answer. For `funding_source: swaps_wallet` also: `relay_quote_failed`
            (Relay cannot guarantee the delivery), `wallet_state_unverifiable` (the wallet balance, signer or send state
            could not be read — without a body or with `funding_source: external_wallet` too, then nothing was changed)
            and `test_mode_unsupported` (Relay has no sandbox). With `refund_address`: `destination_risk_unverifiable`
            (the address could not be vetted; nothing was changed) and `temporarily_unavailable` when Bridge did not
            confirm a refund address change in time (nothing stored; retry with the same address under a new
            `Idempotency-Key`).
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payouts.write
  /payouts/{id}/wallet_funding_quote:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    get:
      operationId: payouts.wallet_funding_quote.get
      summary: Quote funding this payout from the Swaps wallet
      description: >
        Side-effect-free read behind the «Pay with» step: whether «Wallet balance» can fund this payout right now, on
        which chain, what must arrive at the deposit address (the funding estimate rounded up to the cent plus at most 5
        bps), what leaves the wallet including the Relay leg's own cost, the wallet's spendable USDC.e and whether it
        covers the send. No transfer is created and nothing is written; before the payout is funded the figures are an
        estimate that `payouts.fund` re-quotes. `available: false` carries a `reason` (`flag_off` — also while
        `PAYOUTS_ENABLED` is off, `wallet_not_provisioned` — also every non-dashboard caller, `wallet_paused`,
        `route_unavailable`, `test_mode`, `marked_sent` — the payout is already marked sent with no recorded wallet
        send) and null figures. Dashboard-only: the wallet balance is never shown to a business key. Show «Another
        wallet» whatever this answers.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The wallet-funding quote, or why Wallet balance is unavailable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutWalletFundingQuote'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            `payout_not_fundable` (the payout's status never funds, or a cancel of it is in progress) or
            `wallet_funding_in_flight` (a wallet send for this payout was already signed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >
            `payout_source_quote_unavailable`, `relay_quote_failed` or `wallet_state_unverifiable` — retry after
            `Retry-After`.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payouts.read
  /payouts/{id}/receipt:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    get:
      operationId: payouts.receipt.get
      summary: Get the settlement receipt
      description: >
        Exists only once a payout has reached `settled`. A payout `paid_with_shortfall` is terminal and by design never
        produces one — `404 not_found` is the correct, expected answer there, not an error to retry past.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The settlement receipt.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutReceipt'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.read
  /payouts/{id}/attempts:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    get:
      operationId: payouts.attempts.list
      summary: List a payout's funding attempts
      description: List the source-chain funding attempts made against this payout, newest first.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of funding attempts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutAttemptList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.read
  /payouts/{id}/events:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    get:
      operationId: payouts.events.list
      summary: List a payout's events
      description: >
        List this payout's immutable event timeline, newest first. This is the only timestamp source for the dashboard's
        4-step tracker ladder — there are no separate ladder-step columns anywhere else in the contract (RESOURCE-MODEL
        §2.2 v2 amendments, "required"). Test mode: sandbox (K11-6, §4.2 drift, was declared `full`) — a test payout's
        events are written by DEV handlers into DEV's own `api_events` outbox; a prod-local read would return an empty
        page forever.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of payout events.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutEventList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.read
  /payouts/{id}/cancel:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    post:
      operationId: payouts.cancel
      summary: Cancel a payout
      description: >
        Cancel a payout that has not yet received funds. Re-reads the live provider transfer first and refuses if money
        may already be moving — never describe this as a way to claw back a payment in flight; there is no tool for
        that, only support. Calling it twice is safe.


        A wallet send prepared for this payout («Wallet balance», `payouts.fund` with `funding_source: swaps_wallet`) is
        signed by the holder outside Swaps, so while it can still be signed — until it expires, and for 30 minutes after
        that — cancel is refused with `409 wallet_funding_pending` and `details.retry_after`: wait until then, there is
        no earlier way. Once nothing can be signed, the payout's unsigned wallet sends are closed first, and only then
        is the provider transfer cancelled; if they cannot be closed nothing else is touched (`503
        wallet_funding_cancel_failed`).
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-money-boundary: true
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: cancel_payout
        description: >
          Cancel a payout that has not yet received funds. It re-reads the live provider transfer first and refuses if
          money may already be moving. Never describe it as a way to claw back a payment in flight — for that there is
          no tool, only support.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The cancelled payout.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payout'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            Idempotency-Key reused with a different body, or money may already be moving on this payout — re-read it
            before deciding anything. `wallet_funding_in_flight`: a wallet send toward this payout's deposit address was
            signed or seen by Relay, so its transfer is kept. `wallet_funding_pending`: an unsigned wallet send prepared
            for this payout can still be signed; `details.retry_after` (RFC 3339) is when that window ends and cancel
            can be retried under a new `Idempotency-Key` — nothing was changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >
            A kill switch is thrown or a dependency is out (`temporarily_unavailable`, `payout_cancel_retryable`,
            `payout_cancel_commit_pending`) — retry after `Retry-After`. `wallet_funding_cancel_failed`: the unsigned
            wallet send prepared for this payout could not be closed, so the provider transfer was not touched and the
            payout is still active (retry after `Retry-After`); without `Retry-After`, an earlier attempt had already
            cancelled the provider transfer and the payout needs attention — contact support, do not retry.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - businessKey: []
      x-swaps-scope: payouts.write
  /payouts/{id}/mark_sent:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    post:
      operationId: payouts.mark_sent
      summary: Record the payer's self-reported "I've sent it"
      description: >
        Records the payer's own claim that the funding transfer was sent. This is a self-report, `authoritative: false`
        (RESOURCE-MODEL §3) — it never advances `status` on its own and is never a substitute for the funds-received
        signal the provider webhook produces. REST-only; not curated as an MCP tool, since an agent should never
        manufacture this claim on a person's behalf.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The payout, with the self-report recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payout'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.write
  /payouts/{id}/replace:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^po_
    post:
      operationId: payouts.replace
      summary: Create a replacement payout
      description: >
        Creates a new `draft` payout carrying this one's corridor and invoice details forward, for a payout that failed,
        expired or was cancelled before funding — there is **no update** on `payouts`; the flow is always cancel (or a
        terminal failure) plus a fresh create (RESOURCE-MODEL §2.2). Emits `payout.replaced` on the old payout and
        `payout.created_as_replacement` on the new one, linking both ids. REST-only, no MCP tool: creating a replacement
        is a deliberate, reviewed action, never one an agent should chain automatically off a failure.
      tags:
        - Payouts
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: The new replacement payout, in `draft`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payout'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            Idempotency-Key reused with a different body, or this payout is not in a replaceable state (only pre-funding
            `failed`, `expired` and `cancelled` payouts may be replaced).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payouts.write
  /payroll_runs:
    get:
      operationId: payroll_runs.list
      summary: List pay runs
      description: |
        List this employer's pay runs, newest first. `status_group` mirrors the Today inbox
        grouping rather than the raw 8-status enum, so a caller does not have to re-derive it.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: list_payroll_runs
        description: >-
          List this employer's most recent pay runs, newest first. Use it to find a run whose id is unknown before
          acting on it.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status_group
          in: query
          description: >-
            This resource's own bucket set (`needs_you`, `in_progress`, `done`), shared with payouts and orders only —
            not a universal enum. `needs_you` = reviewed, funding_pending; `in_progress` = funded, executing; `done` =
            completed, partial, failed, cancelled. `draft` and `approved` (BL-15, G decision 2026-09-14) match NONE of
            these three buckets — a run in either status is published on the unfiltered list but is invisible to every
            `status_group`-filtered read, including `needs_you` even though `approved` genuinely is money-pending. Known
            gap, not yet bucketed (a G decision, since it changes tab counts).
          schema:
            type: string
            enum:
              - needs_you
              - in_progress
              - done
        - name: expand
          in: query
          description: >-
            Comma-separated. `summary` (LIST-SUMMARY-1) adds `summary`: counts computed server-side over EVERY row
            matching this request's filters, not over the returned page, so a client never adds up a paginated page and
            calls it a total. `limit`/`cursor` never change it. An unrecognised value is ignored rather than rejected
            (the `GET /account` `expand` rule).
          schema:
            type: string
            example: summary
      responses:
        '200':
          description: A page of pay runs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRunList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
    post:
      operationId: payroll_runs.create
      summary: Draft a pay run
      description: |
        Create a pay run with its recipients and amounts. This is a **draft only** — nothing is
        funded and nothing is paid. `runs_create` writes `reviewed` directly; there is no `draft`
        state on `/v1`. Refused with `422` over the caps ($25,000 per run, $10,000 per item, 500
        rows) — the same caps are re-checked at `execute`. `funding_method: crypto` drafts a run
        the employer funds by sending USDC to their Bridge payroll wallet — the same path the
        dashboard uses. `create` refuses it with `409 capability_unavailable` only while crypto
        funding is switched off (`crypto_funding.reason: crypto_funding_not_enabled`) and never
        substitutes fiat. For the other reasons (`bridge_funding_currency_unsupported`,
        `verification_required`) the run is created but cannot be funded: `approve` refuses it or
        its funding instructions read `blocked` — check `crypto_funding.available` first.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: prepare_payroll_run
        description: |
          Draft a pay run and return it for review — nothing is funded and nothing is paid.
          Recipients get their own link to add a destination afterwards; this tool never collects
          bank or wallet details itself. Pass `funding_method: crypto` only when
          `list_capabilities` (product `payroll`) shows `crypto_funding.available` for the run's
          currency — the employer then funds by sending USDC. It answers unavailable only while
          crypto funding is switched off; for another reason the run is still drafted but cannot
          be funded (approve refuses it or its funding instructions read `blocked`). It never
          falls back to a fiat instruction silently. The employer must send the whole amount in
          ONE transfer of that asset on that chain: deposits are not summed, and a top-up after a
          short deposit does not fund the run. Later, when this run reaches
          `funded`: that only means an inbound funding event was observed, not that the run is
          fully funded — read `funding_confirmed_amount` and `needs_attention` before treating
          it as ready to execute.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayrollRunCreateRequest'
      responses:
        '201':
          description: The created run.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.write
  /payroll_runs/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: ^pr_
    get:
      operationId: payroll_runs.get
      summary: Get a pay run
      description: Return one pay run this caller owns.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_payroll_run
        description: >-
          Return one pay run — status, funding state, recipient summary and amounts. Use `list_payroll_runs` first when
          the id is unknown.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The run.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_runs/{id}/approve:
    post:
      operationId: payroll_runs.approve
      summary: Approve a reviewed run
      description: |
        Approve a `reviewed` run. `runs_approve` writes `funding_pending` directly — there is no
        published `approved` state to observe afterwards. Refused when the readiness ladder is not
        clear; read `GET .../readiness` first to show why, rather than discovering it from this
        call's error. A run with zero rows answers `409 run_has_no_rows` before any provider call
        or funding-account provisioning. Approving prepares the run's funding instructions for its
        own `funding_method`; a `crypto` run answers `409 capability_unavailable` while crypto
        funding is switched off, before anything is provisioned — it is never approved onto a bank
        route silently.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The approved run.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.write
  /payroll_runs/{id}/cancel:
    post:
      operationId: payroll_runs.cancel
      summary: Cancel a pay run
      description: |
        Cancel a run that has not finished executing. Cancelling leaves any already-paid items
        exactly where they were — there is no item-level `cancelled` status, and this never claws
        back a payment already in flight.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The cancelled run.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.write
  /payroll_runs/{id}/execute:
    post:
      operationId: payroll_runs.execute
      summary: Execute a funded run
      description: |
        Pay every ready item in one funded run. This is the money boundary — require a per-run
        human confirmation before calling it, never chain it automatically from `approve` or from
        a funding webhook. Execution is a sequential, unbatched server loop with no per-item push;
        re-read the run or its items to watch progress, do not assume "N of M" from this response
        alone. REST-only — structurally excluded from the MCP tool set because a generated tool
        would let an agent cross the boundary without the human confirmation the flow requires.
        A `funded` run only means an inbound funding event was observed — it does not mean the run
        is fully funded; read `funding_state` and `funding_confirmed_amount` on the run before
        confirming this call with a human.

        A `409 conflict` names which rung of the readiness ladder blocked it, one of eight stable
        codes: `payroll_verified_customer_required` (the employer's Bridge customer is not yet
        verified), a caps check when the run is over $25,000, over $10,000 on an item, or over 500
        rows, `funding_not_verified` (no confirmed funding event yet),
        `payroll_run_underfunded` (an event was observed but the confirmed amount is short of
        `funding_amount`, or was never confirmed — the message names the shortfall),
        `recipient_destination_not_ready` (an item still needs a destination),
        `rail_not_ready`, `provider_adapter_disabled`, `bridge_wallet_missing` and
        `bridge_customer_missing`. None of these clear by retrying without changing the
        underlying state — re-read `GET .../readiness` after acting on the cause.
        A run with zero rows answers `409 run_has_no_rows` before any of these rungs and is
        never moved to `executing`.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-money-boundary: true
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: >-
                The explicit per-run human confirmation this money boundary requires. `confirm` must be `true` — never
                inferred from calling the endpoint alone, and never satisfied by any other value.
              required:
                - confirm
              properties:
                confirm:
                  type: boolean
              additionalProperties: false
      responses:
        '200':
          description: The run, now `executing` or further along.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.write
  /payroll_runs/{id}/readiness:
    get:
      operationId: payroll_runs.get_readiness
      summary: Preview the payer-compliance gate
      description: |
        Preview the payer-compliance gate `approve`, funding and `execute` each check FIRST,
        before their own state-specific checks — the same Bridge-customer verification, KYC, ToS
        and rail-endorsement ladder (`PayrollBlockerCode`), without executing anything. It also
        reports one run-state rung, `run_has_no_rows`, so a run with zero rows is never `ready`.
        `ready: true` means THIS gate is clear; it is not a promise the mutation will succeed — a
        run this reports ready can still be refused by a state check this preview does not run
        (missing recipient destinations, unconfirmed funding, an unresolved rail, the private-beta
        caps, the execution-adapter flag) and answer its own `409`/`422`/`503` with a curated code
        from `execute`'s own error responses. A future, separate preview over that state machine is
        not ruled out — it needs its own reason-code vocabulary, which `PayrollBlockerCode` does
        not carry.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: Whether the run can proceed, and why not.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollReadiness'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_runs/{id}/funding_instructions:
    get:
      operationId: payroll_runs.get_funding_instructions
      summary: Get funding instructions
      description: |
        Read the run's current funding instructions and their status. Detection runs on a
        five-minute poll, not a webhook, so `verified` can lag a real transfer by up to that
        window — do not treat an unchanged `preparing`/`pending` status as proof nothing arrived.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The funding instructions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollFundingInstructions'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            The run's own funding instructions were never provisioned yet (`payroll_run_funding_instructions_not_ready`
            — `approve` provisions the first one; not the same as "no such run / not yours", which stays `404`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
    post:
      operationId: payroll_runs.fund
      summary: Create or refresh funding instructions
      description: |
        Create the run's funding instructions on the first call; every later call is an idempotent
        refresh of the same attempt's estimate, never a second provider transfer. This is the
        money boundary — show it to a human and confirm before wiring or sending funds; never
        chain it automatically from `create` or `approve`. `funding_method` in the body switches
        the run between the bank route (`fiat`) and a USDC deposit to the employer's Bridge
        payroll wallet (`crypto`); omitted, the run keeps its own method. A crypto run or override
        answers `409 capability_unavailable` while crypto funding is switched off — it never falls
        back to the bank shape silently. On a crypto run whose instructions are `verified` with an
        amount, a call that does not switch method returns those same instructions unchanged: it
        never re-prices the amount or restarts the deposit window. Switching method once money has
        been observed on the run (`funding_state` other than `unfunded`, or a
        `funding_confirmed_amount`) answers `409 funding_method_locked`. A run with zero rows
        answers `409 run_has_no_rows` before any provider call.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-money-boundary: true
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              description: Optional override of the run's stored `funding_method` for this refresh.
              properties:
                funding_method:
                  type: string
                  enum:
                    - fiat
                    - crypto
      responses:
        '200':
          description: The funding instructions, created or refreshed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollFundingInstructions'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The requested funding method or route cannot serve this run — including `capability_unavailable` for
            `funding_method:'crypto'` while crypto funding is switched off, and `funding_method_locked` for a method
            switch after money was observed. Do not retry unchanged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.write
  /payroll_runs/{id}/items:
    get:
      operationId: payroll_runs.items.list
      summary: List a run's items
      description: |
        List the per-recipient items on one run, in the run's own frozen row order (the order
        recipients were added to the run, never re-sorted newest-first the way most other list
        operations default). This is the run detail screen's own source for its frozen-rows
        table — `payroll_runs.get` does not embed items — and that table's own row order must
        match the run's, not an activity-feed order.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of run items.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRunItemList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_runs/{id}/items/{item_id}:
    get:
      operationId: payroll_runs.items.get
      summary: Get one run item
      description: |
        Return one item on a run this caller owns.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - name: item_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pri_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRunItem'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_runs/{id}/items/{item_id}/reissue_link:
    post:
      operationId: payroll_runs.items.reissue_link
      summary: Rotate and re-send a recipient's link
      description: |
        The employer's way out of an expired, lost or never-delivered destination-request e-mail: rotates the
        item's recipient token, re-sends the destination-request e-mail, and returns the fresh URL directly — the
        only place the token reaches the wire, and only to this run's own employer (never a bare field, never in a
        response to the recipient's own read of this item). Refused `409 conflict` once the item can no longer
        accept a destination change (the same gate the recipient-facing destination write itself checks — reissuing
        past it would mail a link that can never do anything), and rate-limited per item over a rolling window
        shared with that same recipient-facing ceiling. A call inside the short idempotent reissue window right
        after a genuine rotation returns the SAME still-usable link without rotating or mailing again —
        `email.sent` is `false` for that call, never a claim that mail went out twice. `email.reason` names the
        transport's own failure class (never the raw provider body) so a caller can show why nothing arrived
        instead of a silent no-op.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-money-boundary: true
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: reissue_payroll_recipient_link
        description: >-
          Rotate and re-send a recipient's destination link when the e-mail did not arrive or expired; returns the fresh
          URL — hand it only to the person who asked for it, and never post, log or store it anywhere else. Refuses
          (`409 conflict`, mails nothing) only once the item can no longer take a destination: a `ready` item once the
          run has moved past `reviewed` (`approved` onward), or any item on a cancelled, completed or failed run — check
          `destination_status` and the run's own `status` first. Do not call this on a `ready` item just to copy its
          existing link: it revokes the working link and re-mails the recipient, so call it only when the recipient
          actually needs a fresh one.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - name: item_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pri_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The rotated link and whether the re-send mailed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRunItemLinkReissue'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.write
  /payroll_runs/{id}/attempts:
    get:
      operationId: payroll_runs.attempts.list
      summary: List a run's payout attempts
      description: |
        List the provider-side attempts behind a run's items, paginated. `handleRunsGet` already
        returns every attempt embedded and unbounded — this is the paginated, independently
        readable form of the same data.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of attempts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollAttemptList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_runs/{id}/events:
    get:
      operationId: payroll_runs.events.list
      summary: List a run's events
      description: |
        List one run's immutable event timeline, paginated. Same underlying data
        `handleRunsGet` already returns unbounded — this is the paginated, independently
        readable form. Test mode: sandbox (K11-6, §4.2 drift, was declared `full`) — a test run's events are written
        by DEV handlers into DEV's own `api_events` outbox; a prod-local read would return an empty page forever.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollEventList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_runs/{id}/export:
    get:
      operationId: payroll_runs.export
      summary: Export a run as CSV
      description: |
        Download the run as a CSV — recipients, amounts and masked destinations. Destinations stay
        masked in the export exactly as they are on every other read; this is a report, not a
        beneficiary-detail disclosure.

        **Columns, in order:** `row_number`, `recipient_name`, `recipient_email`, `worker_type`,
        `amount`, `currency`, `destination_type`, `destination_display`, `payout_status`,
        `provider_id`, `rail_type`, `provider_reference`, `provider_transfer_id`, `failure_reason`,
        `retry_eligible`, `paid_at`. `rail_type` carries the same provider-scoped rail identifier
        as `PayrollAttempt.rail_type` — `bank_bridge`, `bridge_crypto_wallet` or `tempo_wallet`
        today, an open set, and not a `Rail` value; like `provider_id` it names the provider. A
        provider-neutral `rail_type` vocabulary is an A1-10 (naming freeze) follow-up. The header
        row itself is part of the contract — do not parse this file positionally without checking it.

        **Cell neutralisation.** Any cell whose first character is `=`, `+`, `-`, `@`, a tab or a
        carriage return is emitted with a leading apostrophe (`'`). Those characters start a
        formula in Excel, Sheets and Numbers, and the values here are supplied by third parties
        (a recipient's own name or email), so an un-neutralised export would execute a stranger's
        formula on the employer's machine. Quoting alone does not prevent this — a spreadsheet
        unquotes the field first and evaluates it second. Strip the leading apostrophe if you are
        parsing the file programmatically rather than opening it in a spreadsheet.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^pr_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The CSV file.
          content:
            text/csv:
              schema:
                type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_recipients:
    get:
      operationId: payroll_recipients.list
      summary: List recipients
      description: |
        List this employer's payees across every run. A recipient exists only as a byproduct of a
        run's creation — there is no create, update or archive here, because nothing in the backend
        writes to a recipient outside that path. The one write is `adopt_pending_destination`: a
        recipient with a non-null `pending_destination` saved a destination through their payout
        link that is not their default yet.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of recipients.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRecipientList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_recipients/{id}:
    get:
      operationId: payroll_recipients.get
      summary: Get a recipient
      description: Return one recipient this caller owns.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^prcp_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The recipient.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRecipient'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_recipients/{id}/adopt_pending_destination:
    post:
      operationId: payroll_recipients.adopt_pending_destination
      summary: Save a recipient's submitted destination as their default
      description: |
        Confirm the destination a recipient saved through their own payout link (`pending_destination` on the
        recipient) and make it their default: new pay runs copy it from then on. A destination a recipient submits
        fills that one run's row and waits in `pending_destination` — it never replaces the employer's default on its
        own. Moves no money.

        Send `expected_submitted_at` = the `pending_destination.submitted_at` you showed. If the recipient saved a
        different destination since, the answer is `409 pending_destination_changed` and the default is unchanged:
        read the recipient again and show the new one. In a rare race — the recipient saves a new destination while
        this call runs — rows this call already filled with the destination you confirmed keep it; the next read of
        those runs shows them. `409 no_pending_destination` means nothing is waiting.

        The same call fills this recipient's rows that still wait for a destination in pay runs not yet approved
        (`draft`, `reviewed`), where the destination can pay the run's currency: those rows are listed in
        `updated_run_items`. A row the destination cannot pay keeps asking the recipient and is listed in
        `skipped_run_items` with a reason. A row that already has a destination, and every approved run, is left
        alone; approval checks every row again. A Swaps Wallet address on a network other than Tempo, which nothing
        can sign for, is refused `422 destination_rail_keyless` before anything is written.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^prcp_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayrollRecipientAdoptPendingDestinationRequest'
      responses:
        '200':
          description: The recipient with its new default, and the run rows this call filled or left asking.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRecipientDestinationAdoption'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.write
  /payroll_templates:
    get:
      operationId: payroll_templates.list
      summary: List templates
      description: List this employer's saved run templates.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of templates.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollTemplateList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
    post:
      operationId: payroll_templates.create
      summary: Save a run as a template
      description: |
        Save a completed run's current items as a reusable template. There is no operation to
        start a new run from a template — nothing in the backend consumes `items_snapshot` back
        into a run yet, so do not advertise that capability to a caller.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayrollTemplateCreateRequest'
      responses:
        '201':
          description: The created template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollTemplate'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.write
  /payroll_templates/{id}:
    get:
      operationId: payroll_templates.get
      summary: Get a template
      description: Return one saved template this caller owns.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^prt_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollTemplate'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - businessKey: []
      x-swaps-scope: payroll.read
  /payroll_recipient_sessions/{token}:
    get:
      operationId: payroll_recipient_sessions.get
      summary: Get a recipient's own session
      description: |
        Resolve one run item's payer-facing projection from its capability token. No identity is
        resolved and none is needed — the token is unguessable and scoped to exactly this item.
        Pure: unlike a payment session, reading this never advances the item's status.

        The token has a stated lifetime (14 days from issue) and is retired the moment it is used
        to change a destination. An unknown token, an expired one and a retired one are
        indistinguishable from here: all three answer `404`, so this endpoint cannot be used to
        confirm that a leaked link was ever real. Ask the employer to re-issue the link.
      tags:
        - Payroll
      x-swaps-status: available
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: >-
            A capability token, never an object id — do not log it, store it beyond the session, or treat it as a stable
            identifier.
          schema:
            type: string
            pattern: ^pyr_
        - $ref: '#/components/parameters/SwapsVersion'
      responses:
        '200':
          description: The recipient's session.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRecipientSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /payroll_recipient_sessions/{token}/destination:
    post:
      operationId: payroll_recipient_sessions.destination.set
      summary: Set a destination
      description: |
        Set where this item's payment lands — a bank rail or a crypto address. Only reachable
        while the item is `needs_destination` or `ready`; once it moves past that, the session is
        locked and this is refused with `409`. A CHANGE to an already-`ready` item is additionally
        refused with `409` once the run itself has moved past `draft`/`reviewed` (`approved`
        onward) — an approved/funded run's payout destination can no longer be swapped through
        this link. A FIRST submission on a still-`needs_destination` item is unaffected by the
        run's stage. The crypto branch answers `capability_unavailable` while that rail is closed
        behind a flag, whatever fields are supplied.

        This call moves money's destination, so it is bounded on three axes. The token must be
        live: expired, retired and unknown tokens all answer `404`, never a distinguishable error.
        The change is rate-limited per token — five in any rolling 24 hours, then `429`. And the
        token is retired on success and replaced: read `rotated_token` off the response and use it
        for every subsequent call.

        `bank` must carry the identifiers of exactly one rail (`iban`, `clabe`, `pix_key`,
        `sort_code`, or `account_number` + `routing_number`); an indeterminate set answers
        `400 destination_rail_indeterminate` rather than being guessed at, and the rail is then
        validated against the item's currency.

        The destination set here applies to THIS run item. It does not change the employer's
        stored default for the recipient — that stays an employer decision, and the employer is
        notified that a change is waiting.

        No `Idempotency-Key` is accepted: this route is dispatched off the public-token table,
        which never reaches the account-scoped idempotency reservation an authenticated route
        gets. The token itself is the real idempotency boundary — it is retired and rotated to
        `rotated_token` on every successful submission. A concurrent duplicate submission with the
        same token loses the compare-and-swap on that token and answers `409 recipient_locked`
        (indistinguishable from a genuine state-machine conflict); exactly one write lands. If the
        success response itself is lost in transit, the caller never receives `rotated_token`, the
        old token is already dead, and neither a further update nor a `GET` with it succeeds —
        there is no replay store, so the recipient can only get a fresh link from the employer.
      tags:
        - Payroll
      x-swaps-caller:
        - public_token
      x-swaps-status: available
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: A capability token, never an object id.
          schema:
            type: string
            pattern: ^pyr_
        - $ref: '#/components/parameters/SwapsVersion'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayrollRecipientDestinationRequest'
      responses:
        '200':
          description: The updated session.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayrollRecipientSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /quotes:
    post:
      operationId: quotes.create
      summary: Price a buy, sell or swap
      description: >
        Runs the full provider fan-out for one route and returns the best executable offer, every fully priced provider
        / payment-method offer (`offers[]`), plus every provider's own terminal result (`providers[]`, D-21) from the
        existing call, without additional provider requests. This route requires a business key or bearer with
        orders.write; anonymous quoting remains on the existing public widget transport. Quotes expire; re-quote rather
        than reusing a stale one, and treat `quote_status: indicative` as provisional (`indicative_as_of`). For Transak,
        include the crypto network (`to_network` for a buy, `from_network` for a sell); an unspecified network cannot
        authorize that checkout. A route with no executable offer is **409 `capability_unavailable`** carrying the
        dead-end context — never an empty 200 (RESOURCE-MODEL §2.4). To re-quote a specific method a caller (e.g. a
        payment-options chooser) just selected, call this again with that exact `payment_method` — the fan-out is pinned
        to it (BL-46); if the method has no route, the 409 names the reason in `message` and, structured, as
        `error.details.reason_code` (BS-MKT-1) whenever the dead end's reason code is one of the allowlisted reason
        codes this route names (`PAYMENT_METHOD_NOT_SUPPORTED`, `COUNTRY_NOT_SUPPORTED`, `LOCAL_CURRENCY_NOT_SUPPORTED`,
        `PROVIDER_UNAVAILABLE`, `PROVIDER_ERROR`, `PROVIDER_FILTER_MISMATCH`, `LIMIT_BELOW_MIN`,
        `QUOTE_NETWORK_REQUIRED`, `PROVIDER_PAUSED`), with `error.details.alternative_methods` when the server has one
        to suggest. **Paybis (BL-46, C4-D33):** every caller of this route holds `orders.write`, and
        `.claude/rules/money.md` freezes Paybis's own execution path pending review — `orders.create` has no reviewed
        way to execute it, so this route never returns Paybis as a selectable offer: `providers[]` always reports it
        `status: paused, reason: provider_paused`, `offers[]` never includes it, and the winning route is promoted to
        the next-best offer instead (or 409 `capability_unavailable` with `PROVIDER_PAUSED`, when Paybis was the only
        offer) rather than ever naming it the best route. The legacy public widget transport is unaffected. **Country
        (BSR-6, SEC-02):** `country`, when sent, must be an uppercase ISO 3166-1 alpha-2 code (`^[A-Z]{2}$`) — a
        different shape is `400 invalid_request`. A well-formed code this gateway does not support, or a
        comprehensively-sanctioned jurisdiction, is refused before the fan-out with `409 capability_unavailable`
        (`COUNTRY_NOT_SUPPORTED`), the same vocabulary `GET /v1/capabilities?product=buy_sell` already uses for
        `country`. This is judged even when `country` is omitted from the body: a caller-forwarded `x-vercel-ip-country`
        (or, on the direct Supabase-function host, the Cloudflare-set `cf-ipcountry`) is the same geoip fallback the
        fan-out itself would otherwise price with. Both forwarded headers are judged independently and unconditionally —
        a well-formed value on EITHER one naming a sanctioned jurisdiction is refused even when `country` names an
        unrelated, allowed one; neither header can mask the other, and an explicit `country` cannot mask either header.
        A header is held to a narrower rule than an explicit `country`: it is checked only against the
        sanctioned-jurisdiction list, not the full supported-country catalog, so a header that cannot name a real
        country (Cloudflare's `XX`/Tor's `T1`) or names a real, simply not-yet-catalogued one never causes a refusal on
        its own. **Amount scale (BSR-8):** for `side: 'buy'` only, `from_amount` is refused with `422 amount_invalid`
        when its fractional part carries more precision than `from_asset`'s own fiat scale — read from
        `packages/config/amountLimits.ts`'s `CURRENCY_PRECISION` (2 decimals for most currencies, 0 for the documented
        zero-decimal list, 3 for the Gulf dinars KWD/BHD/OMR/JOD/TND) — e.g. `from_amount: "20.999"` for `from_asset:
        "EUR"`. This is independent of the contract-layer shape bound (up to 18 fractional digits, wide enough for a
        full-precision crypto amount priced by destination): a value inside that shape can still be finer than its OWN
        currency can represent, and nothing downstream ever re-rounds it — it would otherwise be sealed verbatim into
        the deposit instructions on `POST /v1/orders`, an amount the payer has no way to actually send. A value padded
        with harmless trailing zeros beyond the scale (`"20.990"`) still quotes normally, as does a real 3-decimal
        amount for a Gulf dinar (`"1.001"` KWD). This check is scoped to `side: 'buy'` because that is the one direction
        whose `from_asset` is fiat and whose amount this gateway can seal into a live deposit instruction (a Bridge-
        native sell's own scale check, `orders.create`, is a separate crypto-aware one — L1-5) — `sell`/`swap` never run
        THIS check, so an unlisted CRYPTO `from_asset` (e.g. an ERC-20 not in any decimals table) is never misjudged
        against a fiat-oriented fallback scale. `to_amount` is NOT held to this check either: pricing by destination
        amount routinely uses full on-chain precision for what the caller wants to receive, and nothing seals a raw
        `to_amount` into a payer-facing instruction the way `from_amount` is. **Rate (BSR-9):** `rate` — on the winning
        offer, every `offers[]` entry, every `providers[].offer` echo and `route_facts.best_price` — is derived
        server-side from each offer's `final_in`/`final_out` DECIMAL values, computed BEFORE either is rounded into the
        published `Money` fields, the same basis for every offer regardless of provider or side; because the derivation
        runs pre-rounding, cross-checking `rate` against the published `Money.amount` fields can drift in the last
        digit(s) for an asset whose published decimals are coarser than the server's own internal precision. It is
        informational only: never rank or compare offers by `rate`. Compare by `final_out` (higher is better) when the
        request supplied `from_amount` — every offer then targets a DIFFERENT `final_out` for the SAME pay amount. When
        the request supplied `to_amount` instead, compare by `final_in` (lower is better) ONLY among offers whose
        `final_out` equals the requested `to_amount` — an offer whose `final_out` differs is not comparable this way,
        because not every provider honors an exact-output target (a still-open gap: `_shared/adapters/coinbase.ts`
        prices a fixed default amount and ignores `to_amount` entirely, so a Coinbase offer's `final_out` in this mode
        can be unrelated to the request).
      tags:
        - Quotes and orders
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_quote
        description: >
          Price a buy or a sell across every connected provider and return the best executable route with its fee
          breakdown and expiry plus every priced provider/method offer. Pass side, the assets, the amount, and the
          country and payment method when known. `country` must be an uppercase ISO 3166-1 alpha-2 code; a
          comprehensively-sanctioned or unsupported one is refused with capability_unavailable before pricing runs. This
          can also fire with no `country` in the request: the caller's forwarded geo headers (`x-vercel-ip-country` and,
          on the direct API host, `cf-ipcountry`) are each checked independently against the sanctioned-jurisdiction
          list, whether or not `country` was sent. Quotes expire — re-quote rather than reusing a stale one. A result
          marked indicative came from a warm cache: read `indicative_as_of`, and if the price is older than the quote's
          safety window, re-quote live before showing or acting on it. It prices only: it never starts a purchase, and
          execution always needs the person's own confirmation. Authentication with orders.write is required. Test-mode
          requests are currently refused before provider dispatch. Preserve offer_id and its payment_method when
          selecting a provider offer; a sibling offer from the same provider may use a different method. Availability
          and price do not establish customer eligibility or guarantee the eventual settlement rail. Transak offers
          require an explicit crypto network: to_network for buy, from_network for sell. If this removes the only usable
          offers, capability_unavailable includes QUOTE_NETWORK_REQUIRED; specify the network and re-quote. For a buy,
          from_amount must not carry more decimal precision than from_asset's own scale (2 decimals for most fiat
          currencies, 3 for KWD/BHD/OMR/JOD/TND, 0 for the documented zero-decimal list) — e.g. "20.999" for EUR is
          refused with amount_invalid before pricing runs; round to the currency's own scale first. This does not apply
          to sell or swap, or to to_amount on any side. Never rank or compare offers by rate — its basis, while now
          consistent within one response, still differs in scale by asset. When the request supplied from_amount,
          compare by final_out instead: every offer targets a different final_out for the same pay amount, so the
          largest final_out for the method you want is the best offer. When the request supplied to_amount, compare by
          final_in only among offers whose final_out equals the requested to_amount: the lowest final_in among THOSE is
          the best offer. An offer whose final_out differs from the requested to_amount is not comparable this way — not
          every provider honors an exact-output target, so check final_out before trusting final_in for that offer.
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QuoteRequest'
      responses:
        '200':
          description: The best executable route, plus the full per-provider fan-out.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
            Request-Id:
              $ref: '#/components/headers/RequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Quote'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            No provider returned an executable offer for this route (`capability_unavailable`, carrying a safe reason
            code and terminal provider outcomes in `error.details.providers` when available, plus
            `error.details.reason_code` (BS-MKT-1) whenever the dead end's reason code is one of the allowlisted reason
            codes this route already names in `message` (`PAYMENT_METHOD_NOT_SUPPORTED`, `COUNTRY_NOT_SUPPORTED`,
            `LOCAL_CURRENCY_NOT_SUPPORTED`, `PROVIDER_UNAVAILABLE`, `PROVIDER_ERROR`, `PROVIDER_FILTER_MISMATCH`,
            `LIMIT_BELOW_MIN`, `QUOTE_NETWORK_REQUIRED`, `PROVIDER_PAUSED`), and `error.details.alternative_methods`
            when a `PAYMENT_METHOD_NOT_SUPPORTED` re-quote has other methods to suggest). Raw provider errors are never
            exposed. No empty 200 is returned. Fixer round 1 (2026-09-22) — also carries the SAME
            `capability_unavailable` `code` when the winning offer's own asset has no verified decimal scale
            (`error.details.asset` names it): this gateway's own gap, never the provider's, and NOT retryable — do not
            retry the identical request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: >
            `amount_invalid` (BSR-8): a `side: 'buy'` `from_amount` carries more decimal precision than `from_asset`'s
            own scale (`error.param: "from_amount"`) — refused before the provider fan-out ever prices it. See
            `from_amount`'s own schema description for the full rule and its scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            Test-mode quotes are not yet supported, or current upstream quote facts are unavailable — retryable. An
            unverified asset decimal scale on the winning offer is NOT this: see `409` above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      x-swaps-scope: orders.write
  /quotes/{id}:
    get:
      operationId: quotes.get
      summary: Read a previously created quote
      description: >
        Re-reads a quote by id rather than re-running the fan-out (BL-46) — the exact `Quote` `quotes.create` already
        returned, replayed verbatim from the account-scoped snapshot it took at create time, never re-priced. `list` is
        still proposed. `404` covers both an unknown id and one that belongs to a different account — a caller never
        learns which, RESOURCE-MODEL §0.7. This is the read leg the Review screen's re-read-before-convert relies on
        (C4-D33): it lets a client fetch the exact quote it is about to act on instead of trusting a value it cached
        client-side.
      tags:
        - Quotes and orders
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^qt_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The quote as it was created.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Quote'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: orders.read
  /orders:
    get:
      operationId: orders.list
      summary: List this holder's buy, sell and swap orders
      description: >
        `transactions.list` behind a router (RESOURCE-MODEL §6). `status_group` is this resource's own bucket set
        (`needs_you`, `in_progress`, `done`), shared with payouts and payroll runs only — not a single cross-product
        enum.
      tags:
        - Quotes and orders
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status_group
          in: query
          schema:
            type: string
            enum:
              - needs_you
              - in_progress
              - done
      responses:
        '200':
          description: A page of orders, newest first.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: orders.read
    post:
      operationId: orders.create
      summary: Create an order from a quote
      description: >
        Collapses `redirect_intent_create` → `redirect_init` → `redirect_execute` into one idempotent create for an
        authenticated caller (D-20, BS-G1, BL-47/C4-D33). A Bridge-native order (`checkout_readiness.handoff_mode:
        backend_native`) completes in place, landing directly on an active `status`, for `side: buy` and `side: sell`
        (L1-5) — a `swap` offer answers `409 capability_unavailable`, never created through this endpoint. For a buy,
        the selected offer's `execution_context.to_network` must also be set — `to_network` is optional on `POST
        /v1/quotes`, but a quote priced without it cannot become a Bridge-native order (`409 capability_unavailable`;
        request a new quote with `to_network` set).


        **Hosted provider (L1-6c):** every other `handoff_mode` routes to a hosted-provider checkout — today only
        Transak (`checkout_readiness.handoff_mode: signed_url`/`hosted_session`), and BUY only (a hosted `side: sell`
        answers `409 capability_unavailable`; a hosted-sell deposit flow does not exist yet). `wallet_address` is
        required (`400 invalid_request` otherwise). The account resolved for this caller must have a verified email on
        file (`409 account_email_unavailable` otherwise — `email` on the request body is never substituted). A `201`
        carries `next_action {type: redirect, checkout_url, expires_at}` (`OrderSchema.checkout_stage` was dropped as a
        producer-less field, BL-38 #3086 — that hand-off window is expressed through `next_action`'s PRESENCE, not a
        separate enum) with `status: awaiting_payment`. A response replayed under the SAME `Idempotency-Key` is the
        stored body verbatim, so a replayed `next_action.expires_at` may already be in the past — check that field (or
        re-read `GET /v1/orders/{id}`, which resolves it fresh), do not assume a replay is fresh. **Re-create under a
        NEW `Idempotency-Key` (L1-6d):** a SEQUENTIAL second `POST /v1/orders` — one that starts after an earlier create
        for the SAME `quote_id` + `offer_id` has already returned or failed — never mints a second provider session for
        the active order that first create left behind. With a still-open checkout hand-off it returns `201` for that
        SAME order, its CURRENT `next_action`, and `Idempotent-Replayed: true`; with an expired/absent hand-off it
        answers `409 conflict` (`code: checkout_expired`, `error.details.order_id`, `sideEffectFree: true`) — request a
        new quote and create a new order, there is no way to re-mint a checkout session for an order that already has
        one. Fixer round 1 (#3/#8) — two creates for the same `quote_id` + `offer_id` genuinely CONCURRENT with each
        other (in flight at once, under different `Idempotency-Key`s) are not serialized by this check and can each mint
        their own provider session; reconcile any duplicate via `GET /v1/orders`. The rolling 24-hour value cap (below)
        applies in the source fiat; a currency with no explicit ceiling row is refused (`409 capability_unavailable`)
        rather than falling back to a Bridge-sized default. On a `503` from this branch with no `sideEffectFree` marker,
        a checkout session or a `pending` order row may already exist — reconcile via `GET /v1/orders`
        (`error.details.quote_id`), do not blindly retry under a new Idempotency-Key.


        Destination screening is fail-closed for a buy (both branches): a caller-supplied crypto `wallet_address` is
        refused (`403`) on a deny-list/velocity or sanctions hit, and on an unavailable screen under the default
        fail-closed policy (`503`). Paybis is refused (`409 capability_unavailable`, `provider_paused`) —
        `.claude/rules/money.md` still freezes `handleRedirectExecute`'s Paybis branch, and this route never reaches it
        (BL-46 already excludes Paybis from every `/v1/quotes` response; this is defense in depth for a quote created
        before that shipped). The offer's sealed source amount is also re-checked against its own decimal scale (`422
        amount_invalid`, BSR-8) — for `side: 'buy'`, `POST /v1/quotes` already refuses a sub-scale `from_amount` before
        it can be priced, and this is defense in depth for a quote row minted before that shipped; for `side: 'sell'`
        (L1-5), `POST /v1/quotes` does NOT scale-check `from_amount` (see that operation's own description — the check
        is scoped to `buy` only), so this order-create check is the ONLY scale gate a sub-scale sell amount meets, not a
        second one. The public-token payer redirect stays a separate, browser-shaped surface and is unaffected by this
        operation.


        **Bridge-native sell (L1-5):** `external_account_id` is required — the saved bank destination from `GET
        /v1/wallet/external_accounts`, which must belong to this account's own Bridge customer and match the quote's
        `to_asset` and the offer's payout rail; a foreign or nonexistent id is `404` (never `403` — RESOURCE-MODEL
        §0.7), a currency/rail mismatch is `409 capability_unavailable`. The selected offer's `execution_context.
        from_network` must be set (`409 capability_unavailable` otherwise; request a new quote with `from_network` set).
        `wallet_address` is refused (`400 invalid_request`) — Bridge matches the incoming deposit from any sending
        wallet, so a declared source address would be a promise the server cannot keep. The `201` carries
        `deposit_instructions` — the crypto address to send from any wallet you control. The daily and per-order risk
        ceilings apply in the SOURCE asset — the crypto being sold, not the fiat payout. A sell is refused (`409
        capability_unavailable`) for a payout rail outside the currently supported set (ACH, wire, SEPA, SPEI, Pix,
        Faster Payments); COP (`bre_b`) is not yet executable on this path even though `POST /v1/quotes` can price it.
        `POST`/`GET /v1/wallet/external_accounts` currently list and create only `ach`, `wire`, `sepa` and `spei`
        destinations (SPEC §13 D-5), so a Pix or Faster Payments sell needs an `external_account_id` created outside
        this API until that surface is widened — the sell itself validates against the ROUTE, not that narrower list,
        and accepts such an id.


        A `400` from this operation can also carry `code: 'bridge_execute_rejected'` (BSR-12, 2026-09-20) — Bridge
        rejects the selected offer's `execution_context.to_network` + wallet address for a quote this operation already
        priced and sold, because this route's own destination-network resolution never reaches the identical check `POST
        /v1/quotes` ran at quote time; a drift between the two surfaces here rather than a caller mistake. The envelope
        `type` stays `invalid_request` (this operation's `400` has no other canonical type); request a new quote and
        retry — do not treat it as the caller's own malformed request.
      tags:
        - Quotes and orders
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-swaps-money-boundary: true
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderCreateRequest'
      responses:
        '201':
          description: >-
            The created order. `Idempotent-Replayed` is present (`true`) when this response reconciled onto an existing
            order — created by a prior attempt under a different `Idempotency-Key`, recovered by the underlying
            provider-side idempotency key, OR (hosted provider, L1-6d) resumed by the pre-flight check for an active
            order already bound to this exact `quote_id` + `offer_id` — instead of creating a new one; absent on a
            genuinely fresh create. A response replayed under the SAME `Idempotency-Key` is the stored body verbatim, so
            its `next_action.expires_at` (when present) may already be in the past — check that field, or re-read `GET
            /v1/orders/{id}` for the current answer, rather than assume a replay is fresh.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: >-
            The `quote_id` is not visible to this caller, or has already expired past recovery. On a sell, also the
            answer for a foreign or nonexistent `external_account_id` (L1-5, RESOURCE-MODEL §0.7, D-17) — byte-identical
            for both, deliberately: `403 permission_error` is never used for a cross-account id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >-
            State moved, the idempotency key was reused with a different body, or this corridor cannot serve the request
            (`capability_unavailable` — do not retry). On a sell (L1-5), `capability_unavailable` also covers: the
            payout rail is outside the currently supported set (`code: capability_unavailable`); the chosen external
            account's currency does not match `to_asset` (`code: external_account_currency_mismatch`); its rail does not
            match the route's payout rail, or could not be inferred at all (`code: external_account_rail_mismatch`); and
            Bridge's own structural pre-flight rejected the route/account/ customer combination before any reservation
            was taken (`code: offramp_route_unavailable`, `sideEffectFree: true`). On a hosted provider (L1-6c),
            `capability_unavailable` also covers: a hosted `side: sell` (no deposit flow yet); the offer's provider is
            not one this endpoint wires to (`code: provider_not_supported`); and a source currency with no explicit
            daily-cap ceiling row. `code: account_email_unavailable` is a hosted-only refusal: the resolved account has
            no verified email on file. Every one of these is `sideEffectFree: true` — none reaches the hosted checkout.
            `code: checkout_expired` (L1-6d) is a distinct `conflict` refusal, not `capability_unavailable`: an active
            order already exists for this exact `quote_id` + `offer_id`, but its stored checkout hand-off has expired,
            is absent, could not be decrypted, or no longer validates against this API's host allowlist —
            `error.details.order_id` names the existing order; request a new quote and create a new order rather than
            retry this one (`sideEffectFree: true`; no upstream provider call was made).

            **Order-time executability and the next-ranked fallback (L1-6e):** every offer on the quote is independently
            classified before execution — `provider_paused` (Paybis), `provider_not_supported` (a hosted provider this
            endpoint has never wired, e.g. Partna/Stripe Onramp), `capability_unavailable` (any other unreachable offer,
            including one this endpoint's own no-`offer_id` selection refuses to advertise or fall through to — a hosted
            offer whose `side` is not `buy`, or a `backend_native` offer whose provider is not Bridge, whose `side` is
            not `buy`/`sell`, or that is missing the network its side needs), or stale (`409 conflict`, `code:
            requote_required`, `error.details` absent) — the same three non-stale reasons
            `Order.offer_selection.skipped[].reason` can publish. A stale offer is NEVER added to `skipped[]`: an
            ABSENT-`offer_id` walk stops at the first one and answers `requote_required` immediately rather than falling
            through to a different, unwired provider for the same `quote_id` — this closes a double-submit a retried
            `Idempotency-Key` could otherwise reach. A PINNED `offer_id` that is not executable is refused with its own
            typed 409 above and `error.details.eligible_offer_ids` (up to five, in the server's own rank order, so a
            caller can re-post with one that will run) — this endpoint never substitutes a route the caller did not
            choose; a pinned offer whose only problem is the request-shape/network checks `createBridgeNativeOrder`/
            `createHostedProviderOrder` themselves own (e.g. a missing `to_network`) still reaches that handler's own,
            more specific refusal — those stay authoritative for a PINNED request. An ABSENT `offer_id` walks
            `quote.offers` in that same rank order and executes the first offer that is BOTH independently classified
            executable AND structurally shaped for its execution path (the same hosted-`side`/`backend_native`-
            provider/side/network checks above, since selecting an offer no execution path could ever run would be false
            availability); when the executed offer is not the quote's own winning offer, the `201` names it in
            `offer_selection`. When no offer on the quote is executable, the refusal's `error.details` always carries
            both `eligible_offer_ids` and `skipped` (the same `{offer_id, provider_id, reason}` shape as
            `offer_selection.skipped`): every skipped offer sharing one reason answers with THAT reason and
            `eligible_offer_ids: []`; a genuinely mixed set of reasons answers the generic `capability_unavailable`,
            `eligible_offer_ids: []` — no single code would honestly describe it. `eligible_offer_ids` also excludes a
            hosted offer whose own `checkout_fresh_until`/`expires_at` has passed — advertising a stale one as "re-post
            with this and it will run" would be false availability too, even though the fallback walk itself still
            treats a stale HOSTED offer as reachable (the L1-6d pre-flight resume needs that).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: >-
            `amount_invalid`: either the sealed offer's amount exceeds the route's own ceiling (`resolved_limits.max`,
            further clamped by a config-owned risk ceiling; BSR-5), or its decimal precision exceeds the source asset's
            own scale (BSR-8) — defense in depth for a quote row minted before `POST /v1/quotes` started refusing that
            shape at its source. On a sell (L1-5) the source asset is the CRYPTO being sold — both the risk ceiling and
            the rolling 24-hour cap apply there, not to the fiat payout, and the cap message names the crypto currency.
            On a hosted provider (L1-6c), `amount_invalid` also covers: the payment method's own min/max, checked
            server-side at hand-off (`sideEffectFree: true`); and the rolling 24-hour value cap, scoped per account and
            currency across every hosted (non-Bridge) buy in the trailing 24h, not just this call's own reservation.
            `error.param` names `quote_id`. A hosted buy can also answer `travel_rule_originator_incomplete`,
            `travel_rule_proof_required` or `travel_rule_counterparty_required` — additional sender information is
            required before the transfer can proceed; NOT `sideEffectFree` (this fires after the order row is already
            written — reconcile via `GET /v1/orders`, `error.details.quote_id`, before retrying under a new key).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            A kill switch is thrown or a dependency is out (`temporarily_unavailable`) — retry after `Retry-After`, when
            the header is present. A Bridge-native buy can fail partway through Step 5 (`handleBridgeExecuteTransfer`)
            after its local order — and, for some failure branches, the Bridge transfer itself — already exists (BSR-3,
            2026-09-18); some of those branches carry `error.details.order_id` and some do not, shipping
            `error.details.quote_id` instead (BSR-13, 2026-09-20) — `BRIDGE_TRANSFER_RECONCILE_FAILED` proves a row
            exists via a `23505` unique violation but can still fail to read its id back, and the genuinely
            no-order-exists-yet tail never had an id to begin with. Neither of those two ships `Retry-After` any more
            either: the offer a `quote_id` prices has usually expired by the time any advertised delay would elapse (see
            the per-message breakdown below). Do not infer retry safety from `Retry-After`'s presence or absence on this
            operation — several other `temporarily_unavailable` branches from the SAME operation (an unreadable quote
            row, a stale gross-minimum snapshot, a route-claim sealing failure) are genuinely side-effect-free and still
            ship no `Retry-After` header.

            BSR-3 fixer round 6 (independent review, 2026-09-18), correcting round 5's revision of this text: `code`
            DOES vary — `destination_check_unavailable` and `customer_status_unavailable` are distinct, genuinely
            side-effect-free `503`s from the pre-Bridge-execute checks (a same-key replay is safe for both); every other
            `503` below carries the generic `code: 'temporarily_unavailable'`. There is no `retryable` field anywhere in
            the `/v1` error envelope (an internal Bridge-side detail this contract deliberately keeps server-side).
            Check `code` first; treat `error.details.order_id`'s presence/absence as the primary "does an order exist"
            signal, not `message` alone — several strings below are shared by branches that differ only in whether
            `details` is set, and by very different Idempotency-Key dispositions:
              - `destination_check_unavailable`: "Could not verify this destination right now" or "Address screening
                is temporarily unavailable" — before any Bridge call; `Retry-After: 5`; side-effect-free; same-key
                replay is safe.
              - `customer_status_unavailable`: "Could not verify this customer right now" — before any Bridge call;
                `Retry-After: 5`; side-effect-free; same-key replay is safe. (Fixer round 3 note, 2026-09-21 — this
                and the sibling above keep `Retry-After` on a STRUCTURAL argument only — earlier in the request
                usually means more offer TTL remains — not a measured one; BSR-14 tracks giving them the same
                treatment as the groups below if that stops holding.)
              - "Could not create this order — request a new quote and retry under a new Idempotency-Key" —
                `mapBridgeExecuteFailure`'s final unclassified tail only: no order exists yet. `Retry-After` is
                NEVER sent here (BSR-13, 2026-09-20 — was `Retry-After: 5`; LIVE evidence (dev, 3/3) proved a
                SAME-key replay after that advertised wait a dead end regardless — `409 idempotency_failed`,
                since this branch ships no `sideEffectFree` marker so the Idempotency-Key slot stays reserved).
                The message now names BOTH halves of the recovery explicitly (BSR-13 fixer round 1, 2026-09-21):
                a genuinely fresh `POST /v1/quotes` under a NEW Idempotency-Key — re-POSTing the SAME `quote_id`
                under only a new key instead re-prices a Bridge offer already partway through its own ~27-29s
                TTL by the time `handleBridgeExecuteTransfer` is reached, usually answering `409
                requote_required`/`404` on the offer/quote itself rather than the idempotency layer; a
                genuinely NEW quote's own offer TTL has not started counting down yet, so it does not have that
                problem. `error.details.quote_id` is set instead of `Retry-After`, so the caller has a handle to
                correlate this refusal with while getting that fresh quote.
              - "Could not create this order right now — nothing was created; retry with the same Idempotency-Key" —
                the gross-minimum/route-claim-sealing failures, before `handleBridgeExecuteTransfer` is called:
                side-effect-free, no `Retry-After`; a SAME-key replay is the correct retry. `error.details.quote_id`
                IS set (BSR-13 fixer round 3, 2026-09-21 — both shipped `details: {}` through round 2 even though
                `quoteRowId` was already in scope at both catches). Neither advertises a wait, so neither makes the
                broken-promise claim above — but "before `handleBridgeExecuteTransfer`" does not mean "before the
                offer TTL has run": both fire in the same last-few-steps window as the daily-value-cap check below,
                so the replay answers whatever the quote/offer's CURRENT state supports and can still 409
                `requote_required` on its own. BSR-14 tracks fixing this properly (longer offer TTL / re-pricing
                in place); this bullet only guarantees the Idempotency-Key semantics, not that the retry succeeds.
              - "Could not verify this account right now — request a new quote and retry" —
                `assertWithinBridgeNativeDailyCap`'s two structural-failure branches, taken at the LAST point
                before `handleBridgeExecuteTransfer`. `Retry-After` removed (BSR-13 fixer round 2, 2026-09-21 —
                was `Retry-After: 5`, the same broken-promise shape round 1 fixed on the tail above: the offer is
                already deep into its TTL by the time this runs). The unusable-inputs branch (before the RPC call)
                is genuinely side-effect-free. The RPC-failure branch is not provably so (BSR-13 fixer round 3,
                2026-09-21 — corrected from "nothing was reserved": the RPC inserts its reservation row before
                returning when it allows the request, so a failure observed after a committed call can leave a
                live reservation that self-expires on `BRIDGE_NATIVE_RESERVATION_TTL_SECONDS`). Both are safe on
                the Idempotency-Key axis either way — no order was created — so a SAME-key replay is correct; the
                message now names the real next step and `error.details.quote_id` gives a correlation handle.
              - "A transfer attempt for this request may already exist — check GET /v1/orders before retrying" —
                two different unclassifiable-outcome branches, both now setting `error.details.quote_id` (no
                `order_id` to name, but the quote is a real reconcile handle): `mapBridgeExecuteFailure`'s tail
                when a Bridge failure code proves (or cannot rule out) a row exists but no `transactionId` came
                back (`BRIDGE_TRANSFER_RECONCILE_FAILED` / `_INTENT_UNREADABLE` / `_RECOVERY_LOOKUP_FAILED`,
                BSR-13 2026-09-20); and a throw from `handleBridgeExecuteTransfer`'s own prologue (before its
                `try`/`catch` starts) — this one said nothing about which steps ran and shipped `details: {}`
                until BSR-13 fixer round 1 (2026-09-21) closed the same gap, since it fires strictly after the
                pre-Bridge-execute checks and `quoteRowId` is in scope there too. Neither carries `Retry-After`
                or is side-effect-free (a same-key replay answers `409`); poll `GET /v1/orders` before retrying
                under any key in both cases.
              - "Order was created but its id could not be determined — check GET /v1/orders shortly" — Bridge
                execute answered success with no `transactionId`; no `order_id` in `details`, but
                `error.details.quote_id` is set (BSR-13 fixer round 1, 2026-09-21 — this branch is reached only
                once Bridge execute has already succeeded, so a transactions row provably exists); poll
                `GET /v1/orders` (never a specific `{id}`).
              - "Order was created but could not be read back — check GET /v1/orders shortly" — the id IS known but
                this service's own read-back of it failed; `order_id` present; poll `GET /v1/orders/{id}`.
              - "Order was created but could not be confirmed — check GET /v1/orders shortly" — `order_id` present;
                poll `GET /v1/orders/{id}`, expected to resolve on its own.
              - "A compliance attestation for this order is incomplete (see order_id) — do not fund it yet. Check GET
                /v1/orders for the latest status." — `order_id` present; the transfer already committed and is not
                rejected; poll, but do not fund until it resolves.
              - "An order record exists for this attempt (see order_id); it may not be payable. Do not retry —
                contact support if it stays pending." — `order_id` present; a DEFINITIVE Bridge rejection with no
                mechanism to reach a terminal state on its own (BSR-20, github.com/swapsapp/swaps/issues/3312); never
                retry — a same-key retry answers `409`, a re-quoted retry mints a duplicate provider transfer.
              - "Could not verify this bank account right now" — sell only (L1-5): the external-account lookup at
                Bridge failed (never a 403/404, which mean the lookup itself succeeded); side-effect-free;
                `error.details.quote_id` set; no Bridge transfer was attempted. Distinct from the buy branch's
                "Could not verify this destination right now" (`destination_check_unavailable` above) — that one
                screens a crypto address and ships `Retry-After: 5`; this refuses a bank account and does not.
              - "Could not verify this route right now" — sell only (L1-5): the offramp dry-run pre-flight failed for
                a transport reason (not a clean route rejection, which is `409 offramp_route_unavailable` above);
                side-effect-free; `error.details.quote_id` set; no reservation was taken.
              - "Checkout could not be completed right now — check GET /v1/orders before retrying" — hosted provider
                (L1-6c) only: every unclassified outcome from the two internal hand-off calls, including a transport
                `502` and a provider-side failure at `internal_redirect_init` — by that point a `pending` `transactions`
                row and/or a live provider session may already exist. NEVER `sideEffectFree`; poll `GET /v1/orders`
                (`error.details.quote_id`) before retrying under any key, the same "reconcile, don't guess" rule the
                Bridge-native branches above already follow.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      x-swaps-scope: orders.write
  /orders/{id}:
    get:
      operationId: orders.get
      summary: Read one order's state and money truth
      description: >
        `status` is the public lifecycle. `money_state` is OPTIONAL — published only when the Paybis/Bridge
        failure-classification pipeline actually classified this row (BL-38, #3086); when present, read it with
        `money_status`: `money_state` says whether funds were ever captured, `money_status` carries refund truth — a
        classified row can read `status: completed` while `money_state` reads `never_authorized` (D-10). An ABSENT
        `money_state` means never classified — never read absence as `never_authorized` or as any other value.
        Cross-account ids are `not_found`, never `permission_error` (D-17).
      tags:
        - Quotes and orders
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_order_status
        description: >
          Read one order's state and, when it failed, where the money physically stopped and whether the customer was
          charged or refunded. `money_state` is present only when classified (BL-38, #3086) — never conclude money
          moved, or did not move, from the status label alone, and never treat a missing `money_state` as "not charged":
          read `money_state` and `money_status` together when both are present; when `money_state` is absent, this row
          was never classified and neither field tells you what happened to the money. For a Bridge-native `buy`
          (BSR-10), `rate` and `amounts.receive` can each be an unsettled ESTIMATE — always read `rate_basis` first:
          `quoted` means the figure is the taken offer's quote-time price and may still change at settlement; only
          `at_arrival` is the realized, settled figure. Never treat a `quoted` value as final. An ABSENT `rate_basis`
          means only that this route published no quote-time estimate of its own — `amounts.receive` is
          `transactions.to_amount` verbatim, and whether THAT figure is itself settled is provider-specific, not
          guaranteed by `rate_basis`'s absence (fixer round 3, P2, independent review 2026-09-20 — round 2's sentence
          here claimed the opposite for "every non-Bridge order": several providers, e.g. Transak, write `to_amount`
          well before settlement on a still-`processing` order, with no `rate_basis` alongside it to say so).
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^ord_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The order, current as of this read.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The id is not visible to this caller — also the answer for another account's order (D-17).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: orders.read
  /orders/{id}/cancel:
    post:
      operationId: orders.cancel
      summary: Cancel an order before it settles
      description: >
        The live writer of `status: cancelled` (`transactions.cancel`, D-18). Cancelling cannot claw back money that
        already moved — check `money_state` first when it is present (BL-38, #3086: it is OPTIONAL, published only when
        classified). If `money_state` is absent, this row was never classified — that is NOT a green light to treat the
        order as unfunded; there is no refund operation because Swaps never initiates one (RESOURCE-MODEL §0.10). BL-48
        narrows the success case beyond `status` alone: even a `status` that is otherwise cancellable is refused `409`
        when `money_state` has already been classified as `hold_placed` or `captured` — money moved or was reserved
        despite the row not yet having transitioned off a cancellable `status` (reconciliation lag). A `money_state` of
        `never_authorized`, or its absence (never classified), still cancels normally. BSR-2: for a Bridge-native buy
        this also asks Bridge to delete the upstream transfer before the local row moves — Bridge rows are never
        classified into `money_state` (it is Paybis-only today), so the dominant refusal for one of these orders is `409
        cancel_unavailable` (Bridge itself refused: funds may already be in flight), not the `money_state` gate above. A
        transient Bridge outage answers `503 temporarily_unavailable` instead — nothing moved, retry after
        `Retry-After`. A payment-link pay-in order answers `409 payment_link_pay_in`: it belongs to the payer, cancel
        the link itself from the Payment Links screen instead. An order that funds a payout, or whose Bridge transfer
        belongs to a payout, answers `409 cancel_unavailable` before any provider call: it is cancelled only through the
        payout (`POST /v1/payouts/{id}/cancel`).
      tags:
        - Quotes and orders
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^ord_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: 'The order, now `status: cancelled`.'
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            One of four refusals, distinguished by `code`: `conflict` — the order already moved past a cancellable state
            (e.g. `status: completed`), re-read and decide; `cancel_unavailable` — the BL-48 `money_state` gate
            (`hold_placed`/`captured`), or, for a Bridge-native buy, Bridge itself refused to delete the transfer (funds
            may already be in flight), or this order funds a payout and is cancelled only through `POST
            /v1/payouts/{id}/cancel` (`error.details.payout_id` names that payout when this order is its funding order)
            — never retry as-is; `payment_link_pay_in` — this order is a payment-link pay-in, which belongs to the
            payer, not this caller — cancel the link itself instead; `reusable_bridge_template` — this order is a
            reusable Bridge deposit template, not a one-time transfer, and cancelling it here would destroy a template
            meant to be reused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >
            A kill switch is thrown, a dependency is out, or — for a Bridge-native buy (BSR-2) — Bridge itself timed out
            or 5xx'd while this cancel tried to delete the transfer. Nothing moved and nothing was deleted; retry after
            `Retry-After`.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      x-swaps-scope: orders.write
  /orders/{id}/events:
    get:
      operationId: orders.events.list
      summary: List one order's timeline
      description: >
        `order.status_changed`, `.failed`, `.refunded`, `.cancelled` — lifecycle and `money_state` together, when
        `money_state` is present (it is OPTIONAL, published only when classified — BL-38, #3086) (RESOURCE-MODEL §3,
        D-92). A view over the K8 outbox (`api_events`) scoped to this order — there is no separate order-history table,
        so this reports exactly what the outbox recorded, never a value re-derived from the order's current row. While
        the `api_v1.events` outbox is off, this legitimately answers an empty page for an order that has moved through
        several statuses — it is reporting the outbox, not the order, and it never fabricates an event to fill the gap.
      tags:
        - Quotes and orders
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^ord_
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: The order's event timeline, newest first.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderEventList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: orders.read
  /capabilities:
    get:
      operationId: capabilities.get
      summary: What this account can execute right now, rail by rail
      description: >
        One resource, five `product` projections (`payment_links`, `payouts`, `payroll`, `buy_sell`, `wallet_bank` —
        D-23, D-34), always caller-specific: every projection resolves the CALLING account's own verification facts
        (Bridge status, or the existing payouts eligibility computation) and reports what that account can execute.
        `buy_sell` also takes `country`, `method` and `provider` to narrow the coverage grid. Its defaults and coverage
        cells come from one fresh route snapshot; this endpoint does not quote, reserve a price, or promise a first
        quote. Call it before quoting or offering anything — it reports availability only and authorizes nothing; the
        server re-checks at create and at the money boundary (RESOURCE-MODEL §0.10). **BL-60 (C4-D37)**: for
        `product=payroll`, `funding_currencies[]` additionally publishes, per fiat currency, whether Bridge can issue a
        FUNDING account for THIS account today — COP stays a selectable run currency (its `bank_bridge` payout corridor
        is live) even though it currently answers `funding_account_available: false`. **Fix round 1 (#1/#3)**: a Bridge
        funding rail existing is not sufficient — an unverified account reads `funding_account_available: false, reason:
        'verification_required'` for a currency whose own `bank_bridge` corridor (`corridors[]` above, same currency)
        has not itself resolved to `available` yet; `corridors[]` and `funding_currencies[]` answer different questions
        (paying a recipient in that currency vs. funding a run in it) and must never be read as the same fact.
        **PAYROLL-CRYPTO-1**: each `funding_currencies[]` entry also carries `crypto_funding` — whether a run in that
        currency can be funded with USDC sent to the employer's Bridge payroll wallet (and, when it can, which asset on
        which chain), gated by the same switch the funding service reads and never `available` while the currency itself
        cannot be funded. **K6 correction**: this operation requires a credential — an anonymous, account-free public
        claims projection (no entitlement, no route id, no flag, no preview row) is a DIFFERENT operation (a public
        corridor/rail grid with no caller identity behind it at all) and is not this one; the router has no
        optional-auth route class to serve it from yet. If a public projection is wanted, it should be specified and
        built as its own path, not folded into this operation's `security`. **BL-40**: for
        `payment_links`/`payroll`/`wallet_bank`, an account that already has a Bridge customer but whose live status
        read failed answers `503 temporarily_unavailable` for the whole projection — never a fabricated `not_started` on
        every corridor. **NB-5**: a `buy_sell` answer with `degraded: true` means the route snapshot could not be
        obtained (unreachable or no fresh lease) — report that the check failed and offer a retry, never report that no
        route exists. **A4-FIX-6** — Test mode: unavailable. Every projection resolves the calling account's own
        verification facts (Bridge status, payouts eligibility, the route snapshot), and a test-mode account has no such
        facts to answer from, so the router refuses a test-mode caller (`503 temporarily_unavailable`, side-effect free,
        no `Retry-After`) and the handler refuses it a second time before any read. It is never forwarded to the dev
        project. **CP-T2**: for `product=payment_links`, the `crypto_tempo` corridor carries `crypto_only` — whether
        this account can activate a crypto-only link (settled to its own Swaps Wallet) without any Bridge customer, and
        the first gate that blocks it. A read failure behind it answers `503 temporarily_unavailable` for the whole
        projection. **#3932**: the `crypto_relay` corridor reads `available` only while the `crypto_tempo` corridor does
        — Relay delivers on the Tempo leg and the payer page never offers it otherwise; its `max_amount` (the
        per-invoice cap) is published even while it reads `not_enabled`.
      tags:
        - Capabilities
      x-swaps-status: available
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: list_capabilities
        description: >
          List what this account can actually execute right now for a product — payout corridors, payment-link rails,
          settlement options, or (`product=buy_sell`) exchange providers — each with its minimum, allowed source chains,
          the fee contract, and, when unavailable, the exact blocker (kyc, address, tos, source_of_funds,
          additional_details, endorsement, capability). Call it before quoting or offering anything. It reports
          availability only and authorizes nothing; the server re-checks at create and at the money boundary. Treat
          `in_review` and `gathering_no_path` as different answers — never tell someone to submit a form for the second.
          Every corridor also carries `detail` (one sentence for its current status) and `action` (`kind`:
          `provide_details` | `verify` | `none` — what the caller can do about it right now); `requested_at` is reserved
          and always absent today. A `buy_sell` provider row's own `status` (`available` | `degraded` | `disabled`) is a
          DIFFERENT vocabulary from a quote fan-out's provider `status` — grey out or hide a `degraded`/`disabled`
          provider, never treat it as a quoting state; `logo_url` is present only when that provider actually has a
          brand asset published. A `buy_sell` answer with `degraded: true` means the route snapshot could not be
          obtained: report that the check failed and offer a retry, never report that no route exists. For
          `product=payroll`, `funding_currencies[]` says whether a run in that currency can be FUNDED — a corridor
          reading `available` is about paying a recipient in that currency, not about funding the run; never offer a run
          currency whose `funding_account_available` is false. Its `crypto_funding` says whether that run can instead be
          funded with USDC; offer the crypto method only when `crypto_funding.available` is true, and name its `asset`
          and `chain` — money sent on any other chain does not fund the run. For `product=payment_links`, the
          `crypto_tempo` corridor's `crypto_only` says whether a crypto-only link (settled to the account's own Swaps
          Wallet, no Bridge verification) can be activated now; when it cannot, `blocked_reason` names why. For
          `product=payment_links`, `fee` is the Swaps fee: `bps`, `applies_to: payer` (added on top of the invoice),
          `rails` (only `crypto_tempo` and `crypto_relay`; any other rail carries no fee claim) and `rounding` (`floor`)
          at `rounding_decimals` (the token's 6 decimals). It is published even while those rails read `not_enabled`.
          The `crypto_relay` corridor reads `not_enabled` with `blocked_reason: relay_requires_tempo_mainnet` on a Tempo
          test network: Relay is not offered there, whatever the flags say. For `product=wallet_bank`, the
          `wallet_bank_virtual_account` corridor reads `not_enabled` with `blocked_reason:
          collection_account_uses_currency` when a Payment links collection account already receives that currency into
          this wallet; no verification step opens it, so do not tell the person to verify.
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: product
          in: query
          required: true
          schema:
            type: string
            enum:
              - payment_links
              - payouts
              - payroll
              - buy_sell
              - wallet_bank
        - name: country
          in: query
          description: >-
            `product=buy_sell` only — narrows the coverage grid to one country. This filter never resolves residency or
            personal KYC.
          schema:
            type: string
        - name: method
          in: query
          description: '`product=buy_sell` only — narrows the coverage grid to one payment method.'
          schema:
            type: string
        - name: provider
          in: query
          description: '`product=buy_sell` only — narrows the coverage grid to one provider.'
          schema:
            type: string
      responses:
        '200':
          description: >-
            The capability snapshot for this product — public or authenticated projection depending on the credential
            presented.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Capabilities'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: capabilities.read
  /eligibility:
    get:
      operationId: eligibility.get
      summary: Which products this country and customer type can likely use
      description: >
        The compliance-navigator engine's own four statuses, verbatim — never collapse `likely_available` and
        `conditional` into one `available` (RESOURCE-MODEL §2.4). PUBLIC and UNAUTHENTICATED (L3-2): resolved off the
        router's public-token table with no caller identity at all (RESOURCE-MODEL §0.9), the same class
        `payment_sessions.get` uses. The public projection takes only `country` + `customer_type`. This PR drops
        `monthly_volume_band`/`purpose`/`regulated_activities` from this operation ENTIRELY, not just to "authenticated
        callers only": D-16 already forbids them on any anonymous path, this operation has no other path, and
        `capabilities.get`'s own description already names the structural reason an authenticated variant can't be
        folded into this one's `security` — "the router has no optional-auth route class to serve it from yet... it
        should be specified and built as its own path." A richer, authenticated `eligibility` projection accepting those
        three fields is a real future operation once that route class exists; it is not this one. `payroll` is one of
        the five published products (the compliance-navigator engine's `exchange`/`wallet`/`payment_links`/
        `payouts`/`payroll` — NOT `/v1/capabilities`'s unrelated `payment_links|payouts|payroll|buy_sell|wallet_bank`
        product enum, a different question for a different, authenticated caller). `limits` is populated only for
        `payouts`, and only for a country whose currency AND whose payout rail this catalog actually covers (a real
        per-currency corridor minimum, published as `Money` — fix round 1, P1: a currency match alone is not a country
        match, e.g. Zimbabwe uses USD but has no US-domestic ACH/wire rail; fix round 2, P2 corrected the EUR corridor's
        country scope again — Monaco/San Marino/Vatican City are EUR-currency SEPA members the EEA-only gate wrongly
        excluded — every other product omits `limits` rather than publish an approximate or fabricated figure, and
        `limits` itself never carries a `max`: no producer publishes one today (fix round 2, P3). `limits.confidence` is
        `exact` only for a hard-rule match (EEA membership, or `country === 'US'`); Monaco/San Marino/Vatican City's
        match is this operation's OWN widening onto a rail with no country dimension in its payout SSOT, so it reads
        `approximate` there, never the same `exact` an EEA country gets for the identical `eur_sepa` corridor.
        `requires_bridge_verification` (fix round 1, P1 as `requires_verification`; renamed fix round 2, P2 — the bare
        name over-claimed for `exchange`, whose `false` means only "no Bridge customer needed," not "no KYC anywhere")
        marks the three products gated on a Bridge customer/endorsement the caller may not have yet
        (`payment_links`/`payouts`/`payroll`) — those three also lead `reasons[]` with `bridge_customer_missing` and,
        for an EEA country, add `eea_verification_required` to both `reasons[]` and `blockers[]`; `exchange` also
        publishes `eea_verification_required` in `reasons[]` for an EEA country as of fix round 2, P3 (matching the
        canon engine's own code for that branch), even though it needs no Bridge customer and so never gets a
        `blockers[]` entry for it; never conflate a Bridge-gated `likely_available` with `wallet`'s true, unconditional
        one. `notes[]` carries the fact `reasons[]`' shared code cannot: `exchange` always publishes `dex_no_kyc` +
        `fiat_provider_kyc`, and adds `eea_assets` for an EEA country — the MiCA asset restriction its
        `eea_verification_required` reason code alone would otherwise conflate with the other three products' real
        Bridge-verification gate. `disclaimers[]` (fix round 1, P2) mirrors the engine's own hedging (`not_advice`
        always; `approximate`/`eea`/`sanctioned`/`unknown_country` per branch, spelling corrected fix round 2, P3) —
        this is the one operation whose `x-swaps-status: dark-flag` exists because public capability claims need the
        founder's legal sign-off, so the claims never ship without the hedges that go with them everywhere else this
        engine speaks. `x-swaps-test-mode: unavailable` (A1-3, 2026-09-22): every `available`/`dark-flag` operation in
        this document was corrected to `unavailable` because K11's cross-project test-mode dispatch does not exist yet
        (`apps/docs/pages/test-mode.mdx`) — this operation is a strong first K11 candidate to restore, since it takes no
        token/key of any kind (`security: []`) and so has no `sk_test_`/live distinction to make, unlike its two
        public-token siblings (`payment_sessions.get`/`payroll_recipient_sessions.get`, fix round 1, P3 note) whose
        resolved session DOES belong to a `livemode`-bearing resource, but the claim stays `unavailable` here too until
        K11 names it in its evidence list with its own test, exactly like every other operation below. CORS: the 200
        response carries a wildcard `Access-Control-Allow-Origin: *` for a header-free cross-origin GET (fix round 1,
        P2) — this is narrower than "any browser page can call this" (corrected fix round 2, P2): a caller that sends a
        custom request header (e.g. `Swaps-Version`) triggers a CORS preflight this router does not yet answer with a
        public wildcard, and every refusal this operation can return (400s, and the `503` while its flag row is
        unseeded) still carries an origin-specific ACAO. Fixing both needs a router-level change outside this
        operation's own file; tracked as a follow-up, not attempted here.
      tags:
        - Capabilities
      x-swaps-status: dark-flag
      x-swaps-caller:
        - public_token
      x-swaps-test-mode: unavailable
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - name: country
          in: query
          required: true
          description: >-
            ISO 3166-1 alpha-2 country code, matched against this exact pattern server-side — no leading/trailing
            whitespace is trimmed (fix round 1, P3: a previous handler bug trimmed before validating, accepting an input
            this published pattern refuses).
          schema:
            type: string
            pattern: ^[A-Za-z]{2}$
        - name: customer_type
          in: query
          required: true
          schema:
            type: string
            enum:
              - individual
              - business
      responses:
        '200':
          description: The engine's answer for this country and customer type.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Eligibility'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /customers:
    get:
      operationId: customers.list
      summary: List this business's customers
      description: >
        A business key lists only its own customers; an agent never lists across holders. **L3-1**: today's model allows
        at most ONE customer per account owner (`docs/api/RESOURCE-MODEL.md` D-3), so this is always a one-element or
        empty list, never paginated in practice — `has_more` is always `false`. Empty (never `404`) before
        `customers.create` has ever been called for this account. **Fix-pack (independent review, P2)**: a customer
        record that exists but whose mapping to our verification partner is stale answers the SAME `409
        customer_mapping_stale` `GET /v1/customers/{id}` answers for it — never a retry-forever `503`. **Dark-flag** —
        gated behind `api_v1.customers` (K6), the same flag `customers.get`/`.create` already use — no new flag.
      tags:
        - Customers
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of customers.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomerList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `customer_mapping_stale` — a customer record exists for this account, but our verification partner answered
            "not found" for it. PERMANENT until an operator repairs the mapping; never retry this specific failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: customers.read
    post:
      operationId: customers.create
      summary: Start verification for a new customer
      description: >
        Feeds the account-type choice (`onb-welcome`, R17 §1) into a new `not_started` customer. Never a public token —
        a customer is created only by the business key that owns it or by the agent acting for one holder, and it is
        ALWAYS that caller's own account holder (RESOURCE-MODEL D-3) — never a customer-of-a-customer. The request body
        never carries a name, email, date of birth, address, document or tax id, with ONE exception below; the server
        resolves the caller's account owner internally and reads its own email, and — for `customer_type: individual` —
        its own name, when a real first+last pair is already on file (never a merchant/brand name, or the email local
        part). **Round-2 architect ruling (2026-09-21)**: when no trustworthy name is on file, the SAME hosted
        verification flow starts WITHOUT one — our verification partner's hosted KYC reads the legal name off the
        identity document itself, never off this request. This removes the round-1 `409 customer_name_required` refusal,
        which had no `/v1` remedy and permanently blocked every API-only account (fix-pack, independent review, P1 —
        closed, not narrowed). For `customer_type: business`, the body may instead carry an OPTIONAL `legal_name` (1-200
        chars, trimmed) — a registered company name, not personal data — which becomes that entity's own legal name on
        file; omitted, the business customer is created without one. `legal_name` on an `individual` request answers
        `400 invalid_request` (`param: legal_name`) — it is a business-only field. If the account already has a
        customer, `409 customer_already_exists` — our verification partner bills per terminal KYC status, so this never
        creates a second one; a concurrent or retried call that already claimed the account's onboarding slot for a
        DIFFERENT `customer_type` answers `409 customer_type_conflict` instead (A4-FIX-L3). **Owner only** — a bearer
        session must be the account's owner, never an admin (architect ruling, 2026-09-21): this always acts as the
        account's oldest owner regardless of which member calls it (an `admin`-minted business key still reaches this
        route — key minting is the gate there, `POST /v1/api_keys`, owner or admin). `country`, when given, is validated
        against the same market registry `POST /v1/accounts` validates it against (`400 invalid_request` for an
        unrecognised code) and never overwrites a country the account already declared. A test-mode key (`livemode:
        false`) answers `503 temporarily_unavailable` here — our verification partner's own sandbox toggle is
        process-wide, not per-caller, so a test-mode call would otherwise reach the SAME live, billed account (fix-pack,
        independent review, P2; matches `orders.create`/`quotes.create`). The `201` response carries NO hosted link —
        mint one with the existing `POST /v1/customers/{id}/verification_links` (`kind: tos` first, per
        `R17-verification.md`). **Dark-flag** — gated behind `api_v1.customers` (K6), the same flag every other
        `customers.*` operation already uses — no new flag.
      tags:
        - Customers
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-swaps-money-boundary: true
      x-mcp:
        tool: start_verification
        description: >
          Start identity verification for the caller's own account. Takes the account type (individual or business),
          optionally a country, and — for a business account only — an optional legal_name (the REGISTERED COMPANY NAME;
          never a natural person's name). The account's own email and, for an individual, its own name are read from
          Swaps' own records, never from this call. Do NOT send documents or personal data of a natural person — a name,
          date of birth, address, document or tax id — this tool refuses an unrecognized field, and refuses legal_name
          outright on an individual account. Returns a status object only, with no hosted link; call
          `create_verification_link`'s `kind: tos` afterwards to get one.
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomerCreateRequest'
      responses:
        '201':
          description: 'The new customer, `verification_state: not_started`.'
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Customer'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `customer_already_exists` — this account already has a customer; our verification partner bills per terminal
            KYC status, so `customers.create` never mints a second one. Read `GET /v1/customers/{id}` instead.
            `customer_type_conflict` (A4-FIX-L3) — a concurrent or retried `customers.create` for this SAME account
            already claimed the onboarding slot for a DIFFERENT `customer_type`; resolve which type is intended and
            retry with a fresh `Idempotency-Key`, never the same one. `customer_mapping_stale` — a customer for this
            account was already created (or already held) upstream, but the local mapping to it is missing or stale;
            PERMANENT until an operator repairs it, never a retry with the same key (fix-pack round 2, P2). `conflict` —
            a generic pre-verification-call refusal raised defensively; not reachable for this always-authenticated,
            self-only route today (fix-pack round 2, P2). Also the SAME idempotency-conflict vocabulary every mutating
            `/v1` operation with an `Idempotency-Key` shares: `idempotency_error` (the key was reused with a different
            request body), `idempotency_in_progress` (a request with this key is already running), and
            `idempotency_failed` (the first attempt on this key answered a 5xx after a verification call may have taken
            effect — reconcile via `GET /v1/customers/{id}` before retrying with a NEW key, never the same one).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: customers.write
  /customers/{id}:
    get:
      operationId: customers.get
      summary: Read this customer's verification status
      description: >
        Self only — a customer reads its own record, and an agent reads the one holder it acts for. Status only: never a
        document, a submitted KYC form value, a bank detail or a beneficial owner's name. **Dark-flag** — gated behind
        `api_v1.customers` (K6). An email log/persist leak on the live status-read's unauthorized branch (D-11, SEC-5)
        is fixed in the same PR that wires this read (`swaps/index.ts`'s probe fingerprint no longer carries `email`).
        **BL-40**: `404 not_found` means no customer has ever been created for this account. A customer that exists but
        whose live verification-status read failed (a stale internal mapping, or a transient read failure) answers `503
        temporarily_unavailable` instead — retry, never treat it as day-zero.

        `{id}` is the account's own `cus_…` id, from `customers.list` or `customers.create`. The literal `me` is a
        deprecated alias for it (see the parameter below). Before `POST /v1/customers` has ever been called for this
        account, every id answers this exact `404 not_found`; call `customers.create` first.
      tags:
        - Customers
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-mcp:
        tool: get_verification_status
        description: >
          Read the account's verification state, how many requirements are outstanding, and the single next step that
          unblocks it. It returns statuses and requirement names only — never documents, bank details or KYC form
          contents. Do not call it hoping to read or submit personal data; the person completes verification themselves.
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          description: >-
            The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias,
            accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`.
          schema:
            type: string
            pattern: ^(cus_.+|me)$
          x-swaps-deprecated-alias:
            value: me
            replacement: the `cus_…` id from `customers.list` or `customers.create`
            accepted_until: next Swaps-Version date
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The customer's current verification status.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Customer'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: customers.read
  /customers/{id}/verification_links:
    post:
      operationId: customers.verification_links.create
      summary: Mint a hosted verification link
      description: >
        Accepts all five `kind` values, but only `tos` and `kyc` mint a real link today, through our verification
        partner's own hosted flow (K6). `business_questionnaire`, `business_ubo` and `remediation` have no backing
        implementation anywhere in this codebase yet — the partner's endorsement-scoped link shape must be confirmed to
        distinguish them before they ship for real, so each answers `409 capability_unavailable` rather than a
        fabricated link. `return_to` is accepted and validated (same-origin) but not yet threaded into the underlying
        hand-off — wiring a real override is tracked as a follow-up. **Owner only** — a bearer session must be the
        account's owner, never an admin (architect ruling, 2026-09-21): this always acts as the account's oldest owner
        regardless of which member calls it (an `admin`-minted business key still reaches this route — key minting is
        the gate there, `POST /v1/api_keys`, owner or admin). **Dark-flag** — gated behind `api_v1.customers` (K6).
      tags:
        - Customers
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          description: >-
            The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias,
            accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`.
          schema:
            type: string
            pattern: ^(cus_.+|me)$
          x-swaps-deprecated-alias:
            value: me
            replacement: the `cus_…` id from `customers.list` or `customers.create`
            accepted_until: next Swaps-Version date
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomerVerificationLinkRequest'
      responses:
        '201':
          description: A one-time hosted link.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomerVerificationLink'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            This customer is not in a state this `kind` of link can address (e.g. minting a `kyc` link before `tos` is
            accepted).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: customers.write
  /customers/{id}/associated_persons:
    get:
      operationId: customers.associated_persons.list
      summary: List this business customer's beneficial owners, name-free
      description: >
        `{label}` plus an OPTIONAL `ownership_percent`, never a name — for every caller kind, in this K6 pass (stricter
        than D-49's own default, which would allow `name` for the holder's own delegated session; that widening is not
        implemented here). Our verification partner's raw per-person field names are not independently confirmed
        anywhere in this codebase (only a count is read today) — ownership is read defensively, never guessed at, and is
        OMITTED (never defaulted to `0`) when the raw payload carries no recognized ownership field, so a client must
        render the absence honestly rather than as an observed 0%. `status` is omitted (optional on the schema) rather
        than a fabricated placeholder — no confirmed per-person status field exists anywhere in this codebase yet; a
        follow-up derives it from the same endorsement `missing[]` object-scoped bundles the Verification Hub already
        reads, once a live upstream response confirms the shape. **Dark-flag** — gated behind `api_v1.customers` (K6).
      tags:
        - Customers
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-swaps-pii: name
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          description: >-
            The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias,
            accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`.
          schema:
            type: string
            pattern: ^(cus_.+|me)$
          x-swaps-deprecated-alias:
            value: me
            replacement: the `cus_…` id from `customers.list` or `customers.create`
            accepted_until: next Swaps-Version date
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: This customer's associated persons — name-free unless the caller is the holder's own delegated session.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssociatedPersonList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: customers.read
  /customers/{id}/compliance_profile:
    patch:
      operationId: customers.compliance_profile.update
      summary: Submit structured compliance details (V4 — Questionnaires)
      description: >
        Write-only structured update for our verification partner's own compliance-questionnaire fields — this route
        never re-implements the enum whitelist or the underlying call, it only resolves the caller's own customer id
        server-side (never accepted from the request body — R17 PII rule) and forwards the body verbatim. Every field is
        optional; only the fields present are sent — the server definedOnly-merges, exactly as the underlying action
        already does. Closes V-G5 (`R17-verification.md`), the same kind of gap K6 already closed for `tos`/`kyc` via
        `verification_links`. Responds `202` with `{accepted: true}` only — per V-G5/V-G15, a submitted value is NEVER
        echoed back on any read (there is no `GET` counterpart by design; `GET /customers/{id}` still carries only
        status). Test mode: full — this route does not read `caller.livemode`; a `sk_test_` caller's answers are written
        to the SAME live account a `sk_live_` caller would reach (the underlying action has no test/live branch today,
        matching its sibling `customers.verification_links.create`). **Dark-flag** — gated behind `api_v1.customers`
        (K6).
      tags:
        - Customers
      x-swaps-status: dark-flag
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          description: >-
            The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias,
            accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`.
          schema:
            type: string
            pattern: ^(cus_.+|me)$
          x-swaps-deprecated-alias:
            value: me
            replacement: the `cus_…` id from `customers.list` or `customers.create`
            accepted_until: next Swaps-Version date
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ComplianceProfileRequest'
      responses:
        '202':
          description: >-
            Intake confirmed — the fields were forwarded to our verification partner. Not a re-read of the stored
            profile.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ComplianceProfileResult'
        '400':
          description: No known field was present, or a field's value is not in the published enum for it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: customers.write
  /customers/{id}/events:
    get:
      operationId: customers.events.list
      summary: List one customer's verification timeline
      description: >
        `customer.verification_state_changed`, `.rejected`, `.requirements_updated` (RESOURCE-MODEL §3, D-55) — gives a
        dated ladder for the review-in-progress screens without re-deriving readiness client-side. **Proposed.**
      tags:
        - Customers
      x-swaps-status: proposed
      x-swaps-caller:
        - business_key
        - agent
        - dashboard_session
      x-swaps-test-mode: sandbox
      security:
        - businessKey: []
      parameters:
        - name: id
          in: path
          required: true
          description: >-
            The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias,
            accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`.
          schema:
            type: string
            pattern: ^(cus_.+|me)$
          x-swaps-deprecated-alias:
            value: me
            replacement: the `cus_…` id from `customers.list` or `customers.create`
            accepted_until: next Swaps-Version date
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: This customer's event timeline, oldest first.
          headers:
            Swaps-Version:
              $ref: '#/components/headers/SwapsVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomerEventList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: customers.read
  /wallet/wallets:
    get:
      operationId: wallet.wallets.list
      summary: List the holder's wallets
      description: >
        List the caller's own non-custodial Tempo wallets. Business-key callers may read but never create or sync one
        (RESOURCE-MODEL §2.3). Only wallets on this environment's Tempo network are listed (`Wallet.network`); when the
        environment cannot resolve its network the answer is `503 temporarily_unavailable`, never a list across
        networks.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
        - business_key
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of the caller's wallets.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
    post:
      operationId: wallet.wallets.create
      summary: Create or sync the holder's wallet
      description: >
        Creates the holder's Tempo wallet on first use, or re-syncs it on reconnect. A second create while one is
        already active answers `409 conflict` (`wallet_already_exists`), matching prod (R13-wallet §1
        `wallet-gate.html`). Business-key callers cannot call this — they may only read (RESOURCE-MODEL §2.3). The
        wallet is always on this environment's Tempo network (`Wallet.network`): an already-active wallet found on
        another network is never returned, the answer is `409 conflict` (`wallet_already_exists`).
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WalletCreateRequest'
      responses:
        '201':
          description: The wallet, created or synced.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Wallet'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/wallets/{id}:
    get:
      operationId: wallet.wallets.get
      summary: Get one wallet
      description: >
        Read one of the caller's own wallets. A wallet on another Tempo network than this environment's reads as `404
        not_found`; an environment that cannot resolve its network answers `503 temporarily_unavailable`.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
        - business_key
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^wlt_
      responses:
        '200':
          description: The wallet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Wallet'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/balances:
    get:
      operationId: wallet.balances.get
      summary: Get the holder's wallet balances
      description: >
        Every stablecoin held on the caller's own wallet, plus a server-computed `usd_total`. The server resolves the
        caller's own wallet address server-side; a client-supplied address is never accepted, here or on any wallet read
        (K4 prerequisite — RESOURCE-MODEL §0.12, §2.3 D-9). A thrown error must never be read as a `$0.00` balance. Only
        a wallet on this environment's Tempo network is read, and `network` names it; an environment that cannot resolve
        its network answers `503 temporarily_unavailable`.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
        - business_key
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      x-mcp:
        tool: get_wallet_balance
        description: >
          Return the signed-in person's wallet balance on Tempo, every stablecoin held plus one server-computed dollar
          total, and the Tempo `network` it was read on: on `tempo-moderato` (the test network) the balance is test
          money, so never present it as real funds. Call it before quoting or proposing a send. Do not call it to
          inspect a stranger's address — that is `check_address_risk`. A degraded flag or an error means the chain read
          failed: report that, never report a zero balance.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The caller's wallet balances.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletBalances'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/transactions:
    get:
      operationId: wallet.transactions.list_recent
      summary: List recent wallet transfers
      description: >
        A bounded recent window of on-chain transfers for the caller's own wallet — never full history, and never
        paginated (A1-6: this operation carried a `cursor` parameter that the handler silently ignored; dropped here
        rather than wired up, since `WalletTransactionList` is a fixed window, not a cursor-list — `transfers[]` stays
        the array name, not `data`, since `packages/sdk-ts` and `claude/dashboard-v2` already key off it). The server
        resolves the caller's own wallet address; a client-supplied address is never accepted (K4 prerequisite —
        RESOURCE-MODEL §0.12, §2.3). `degraded: true` with an empty `transfers[]` means the chain read failed and must
        never be read as "no activity"; offer a retry instead.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
        - business_key
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      x-mcp:
        tool: list_wallet_activity
        description: >
          Return recent incoming and outgoing transfers over a bounded recent window. It is not full history and not a
          receipt: when the response is degraded, say activity could not be loaded and offer a retry — never say the
          wallet is empty. Every row carries `origin`: `{kind: bank_deposit, virtual_account_id, currency, rail}` on an
          incoming bank deposit, and `null` on every other row, on every outgoing transfer and whenever the origin
          cannot be named for this caller or read right now — never a guess.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A bounded window of the caller's wallet transfers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletTransactionList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/transactions/{hash}:
    get:
      operationId: wallet.transactions.get
      summary: Get one wallet transaction
      description: >
        Read one on-chain transaction by hash, scoped to the caller's own wallet. A transaction the wallet sent (receipt
        `from`) is `out`. One it did not send is read from the receipt's decoded TIP-20 `Transfer` logs of the accepted
        Swaps-dollar tokens, because a token transfer's receipt `to` is the token contract: a transfer to the wallet is
        `in` (checked first), one from it `out`, and `amount`, `symbol` and the counterparty come from that log when it
        is the only Transfer touching the wallet. A real hash that touches neither is 404; a hash with no receipt is
        `status: not_found`.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
        - business_key
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: hash
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The transaction.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletTransaction'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/deposit_routes:
    get:
      operationId: wallet.deposit_routes.list
      summary: List candidate deposit routes
      description: >
        The catalogue of cross-network paths onto Tempo. Auth is optional by design — this is route discovery, not an
        account read; a credential, if sent, is not checked for a specific account scope (RESOURCE-MODEL §2.3). K5a's
        `/v1` implementation currently requires a credential regardless (the router has no "optional auth" lane on the
        authenticated route table yet) — the response is identical with or without one, so no caller-specific data is
        withheld either way; a true anonymous-auth lane is a named follow-up.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
        - business_key
      x-swaps-test-mode: unavailable
      security:
        - {}
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The candidate deposit routes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DepositRouteList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/deposit_quotes:
    post:
      operationId: wallet.deposit_quotes.create
      summary: Preview a deposit's haircut
      description: >
        L2-4 (R13-wallet §2 W-G1). Nothing is written on Swaps' side and no `deposit_intent` row is created, unlike
        `deposit_intents.create` — but Relay itself still allocates a fresh, funds-accepting deposit address per call
        (the response never returns it, and Swaps records nothing that reconciles or expires it). Resolves the SAME live
        Relay quote `deposit_intents.create` itself uses for the candidate `source_chain`/`source_asset` and reports
        only what that quote returned — `amount_out` and `haircut_pct` are never a locally invented figure, and a Relay
        outage answers a typed `503`, never an estimate. `Idempotency-Key` is required — like `deposit_intents.create`,
        a repeat POST without it would allocate another Relay address and consume another provider quote; a replay under
        the SAME key returns the stored price for 24h rather than a fresh one, so use a new key per quote you actually
        want re-priced. Use it to show a "you send X, receive ~Y" line before the holder commits to a real deposit.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DepositQuoteRequest'
      responses:
        '200':
          description: The computed preview.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DepositQuote'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/deposit_intents:
    get:
      operationId: wallet.deposit_intents.list
      summary: List the holder's deposit intents
      description: List the caller's deposit intents, optionally filtered to the non-terminal set.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          description: Filter to one status, e.g. the non-terminal set for an "in progress" view.
          schema:
            $ref: '#/components/schemas/DepositIntentStatus'
      responses:
        '200':
          description: A page of the caller's deposit intents.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DepositIntentList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
    post:
      operationId: wallet.deposit_intents.create
      summary: Create a cross-network deposit address
      description: >
        Mints a one-time, amount-bound deposit address for a cross-network deposit onto Tempo. `Idempotency-Key` is
        mandatory — a repeat POST without it would mint a second address and a second provider quote (RESOURCE-MODEL
        §2.3 D-2). The API never signs and never holds funds; this only returns where to send and what will arrive.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      x-mcp:
        tool: create_deposit_address
        description: >
          Create a one-time cross-network deposit for the person's own wallet and return the address plus what will land
          on Tempo. The address is amount-bound and single-use, and it expires. Do not call it for a same-chain receive
          — the wallet's own address is the answer. Always repeat the network warning: the wrong asset or network can be
          lost permanently.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DepositIntentCreateRequest'
      responses:
        '201':
          description: The new deposit intent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DepositIntent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/deposit_intents/{id}:
    get:
      operationId: wallet.deposit_intents.get
      summary: Get one deposit intent
      description: >
        Read one deposit intent's status, hashes and timeline. Poll this until a terminal state (`settled`, `expired`,
        `failed`, `cancelled`, `manual_recovery_required`).
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      x-mcp:
        tool: get_transfer_status
        description: >
          Report where one wallet money movement stands — a deposit, a withdrawal or a bank payout — with its state, its
          transaction hashes and a step timeline. Use it for every "where is my money" question instead of re-reading
          the chain. States are authoritative: do not say funds arrived, were refunded or failed before this tool says
          so.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^din_
      responses:
        '200':
          description: The deposit intent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DepositIntent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/send_routes:
    get:
      operationId: wallet.send_routes.list
      summary: List candidate send routes
      description: >
        The catalogue of paths off the holder's Tempo wallet. `intent_required: false` marks a direct same-chain Tempo
        send, which never touches `/v1` at all — the holder's passkey signs a raw chain call directly (RESOURCE-MODEL
        §0.10, §2.3). Auth is optional.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
        - business_key
      x-swaps-test-mode: unavailable
      security:
        - {}
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The candidate send routes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendRouteList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/send_intents:
    get:
      operationId: wallet.send_intents.list
      summary: List the holder's cross-network send intents
      description: >
        List the caller's cross-network send intents, optionally filtered to the non-terminal set. A direct same-chain
        Tempo send never creates one of these (RESOURCE-MODEL §0.10).
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          description: Filter to one status, e.g. the non-terminal set for an "in progress" view.
          schema:
            $ref: '#/components/schemas/SendIntentStatus'
      responses:
        '200':
          description: A page of the caller's send intents.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendIntentList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
    post:
      operationId: wallet.send_intents.create
      summary: Prepare a cross-network send
      description: >
        Money boundary (RESOURCE-MODEL §0.10, §0.11) — this crosses the boundary and per-transaction human confirmation
        is required before its unsigned steps are signed. The API never signs: the response carries
        `send_instructions.steps[]`, an ordered list of unsigned calls the holder's own passkey must sign — nothing
        leaves the wallet until then. Never chain this from a quote straight into signing without a human confirming
        first; only a direct same-chain Tempo send bypasses this endpoint entirely.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-swaps-money-boundary: true
      x-swaps-noncustodial: unsigned_steps
      security:
        - businessKey: []
      x-mcp:
        tool: prepare_wallet_withdrawal
        description: >
          Prepare a cross-chain withdrawal and return the ordered steps the person's own passkey must sign, the quoted
          amount net of fees, and an expiry. This tool cannot move money: nothing leaves the wallet until they sign on
          their device. Do not use it for a same-chain send or for a bank payout.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendIntentCreateRequest'
      responses:
        '201':
          description: The new send intent, with unsigned steps to sign.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendIntent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/send_intents/{id}:
    get:
      operationId: wallet.send_intents.get
      summary: Get one send intent
      description: >
        Read one send intent's status, hashes and timeline. Poll this until a terminal state (`settled`, `expired`,
        `failed`, `cancelled`).
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      x-mcp:
        tool: get_transfer_status
        description: >
          Report where one wallet money movement stands — a deposit, a withdrawal or a bank payout — with its state, its
          transaction hashes and a step timeline. Use it for every "where is my money" question instead of re-reading
          the chain. States are authoritative: do not say funds arrived, were refunded or failed before this tool says
          so.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^sin_
      responses:
        '200':
          description: The send intent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendIntent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/send_intents/{id}/source_tx:
    post:
      operationId: wallet.send_intents.source_tx.set
      summary: Record a send intent's source transaction
      description: >
        Records the transaction hash the holder's wallet just broadcast for this intent's source leg, moving it to
        `source_submitted` only once the chain itself confirms an exact-token, exact-amount transfer from the holder's
        own wallet to the deposit step's own recipient — never a client assertion. Call once, immediately after signing.
        Re-sending the same hash is a safe no-op; a different hash for the same intent is refused — never retry with a
        fresh hash to force it through. A send prepared to fund a payout that is cancelled or closed without funds is
        never recorded against that payout: `409 send_intent_payout_closed` once the hash is recorded on the send
        (support reconciles it from there). If that write could not be made or verified, the call answers `503
        source_tx_record_failed`: the hash is not recorded yet — retry the same call with the same `Idempotency-Key`
        after `Retry-After`. A send `payouts.fund` handed to one session is recorded only from that session, also after
        the expiry sweep marked it `expired` (it moves on to `source_submitted`; `409 source_tx_already_recorded` if a
        replacement send is already live for the payout); any other session gets `409 wallet_funding_claimed` and
        nothing is recorded.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      x-mcp:
        tool: confirm_wallet_withdrawal
        description: >
          Record the transaction hash the wallet just broadcast, moving the withdrawal to submitted so settlement can be
          tracked. Call it once, immediately after signing. Re-sending the same hash is a safe no-op; a different hash
          for the same withdrawal is refused — never retry with a fresh hash to force it through.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^sin_
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendIntentSourceTxRequest'
      responses:
        '200':
          description: The send intent, updated to `source_submitted`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendIntent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            State moved or the idempotency key was reused with a different body. `source_tx_already_recorded`: another
            intent already holds this hash, or this intent a different one (also toward a closed payout); never retry
            it. `source_tx_not_yet_confirmed`: retry once the chain confirms it. `send_intent_payout_closed`: this send
            left the wallet toward a payout that is now cancelled or closed without funds, and its hash is recorded on
            the send, not against the payout; support reconciles it from there. `wallet_funding_claimed`: this send was
            handed to another session by `payouts.fund`; only that session records it, nothing was recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >
            A kill switch is thrown, test mode is refused, or a dependency is out (`temporarily_unavailable`); retry
            after `Retry-After`. `send_intent_no_steps`: the stored intent has no signable steps; contact support.
            `source_tx_record_failed`: the send left the wallet toward a payout that is now closed and its hash is not
            recorded yet (a database error); retry the same call with the same `Idempotency-Key` after `Retry-After`
            until it answers `409 send_intent_payout_closed` (or `200` when the hash is already on the send).
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      x-swaps-scope: wallet.write
  /wallet/offramp_quotes:
    post:
      operationId: wallet.offramp_quotes.create
      summary: Quote a bank withdrawal
      description: >
        Compute-only — nothing is persisted, no address is minted. Prices what the holder's wallet would need to send
        for a given bank-destination amount. Indicative only: `expected_source` is a point-in-time estimate, quantised
        to 2 decimals (`decimals: 2`, not USDC's native 6 — the quote engine rounds to cents), with no `expires_at` in
        this response — the actual rate is re-priced live at `offramp_intents.create`, which is the only figure that is
        ever binding.
      tags:
        - Wallet
      x-swaps-compute-only: true
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OfframpQuoteRequest'
      responses:
        '200':
          description: The computed quote.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OfframpQuote'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/external_accounts:
    get:
      operationId: wallet.external_accounts.list
      summary: List the holder's external bank accounts
      description: >
        List the holder's own bank-account destinations, masked. Own means added here with `attest_own_account`, or
        saved under the holder's provider customer and not recorded by Swaps as anyone else's (an account Swaps recorded
        as a Pay an invoice or payroll recipient appears only once it is added here in the verified legal name); a
        holder name that differs from the verified legal name never hides one. Filtered to the four rails this wallet's
        off-ramp actually supports (`ach`/`wire`/`sepa`/`spei`) — a Bridge customer's OTHER external accounts (e.g. one
        added through a different Swaps surface on a rail this wallet doesn't offer) are never returned here. KNOWN
        LIMITATION: Bridge's own account record cannot distinguish `wire` from `ach` on read (both share the identical
        account+routing shape) — a `wire` account you added still lists back with `rail: "ach"`.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of the holder's external accounts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExternalAccountList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
    post:
      operationId: wallet.external_accounts.create
      summary: Add a bank account the holder owns
      description: >
        Money boundary, REST-only — never an MCP tool argument, because most MCP clients log tool arguments verbatim and
        raw bank details must cross the boundary exactly once (RESOURCE-MODEL §2.3). `attest_own_account` must be
        `true`; an agent may never add a destination on a user's behalf. Bank details already saved as a Pay an invoice
        or payroll recipient are refused (`owner_name_mismatch`) unless the provider's holder name on that account is
        the verified legal name (ignoring case, spacing, punctuation, accents and word order); then this attestation
        makes it the holder's own. Bank details the provider already holds are reused only when exactly one saved
        account of the same currency carries the same account tail (and the same BIC, routing number or sort code when
        both sides have one); otherwise the answer is `409 conflict` and nothing is saved (`503` when the saved accounts
        can't be read right now; retry). The `201` publishes the rail on the provider's own record, not the `rail` you
        requested; the one exception is that a `wire` account whose provider record reads back as `ach` (both share the
        identical account+routing shape) still publishes the `wire` you requested — see `GET
        /wallet/external_accounts`'s description for the same read-side ambiguity.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-swaps-money-boundary: true
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExternalAccountCreateRequest'
      responses:
        '201':
          description: The new external account, masked.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExternalAccount'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/offramp_intents:
    get:
      operationId: wallet.offramp_intents.list
      summary: List the holder's bank withdrawals
      description: >
        List the caller's off-ramp intents, optionally filtered to the non-terminal set. Returns at most the 100 most
        recent off-ramp intents; `has_more: false` means the end of that window, not necessarily the end of the holder's
        history. Paging past 100 needs the upstream keyset tracked as https://github.com/swapsapp/swaps/issues/3460.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          description: Filter to one status, e.g. the non-terminal set for an "in progress" view.
          schema:
            $ref: '#/components/schemas/OfframpIntentStatus'
      responses:
        '200':
          description: A page of the caller's off-ramp intents.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OfframpIntentList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
    post:
      operationId: wallet.offramp_intents.create
      summary: Withdraw from the wallet to a bank account
      description: >
        Money boundary (RESOURCE-MODEL §0.10, §0.11) — per-transaction human confirmation is required. The transit
        deposit into `deposit_instructions` is a direct same-chain Tempo call the holder's own passkey signs; it is
        never a `/v1` write. Refuses over the per-rail minimum or the $3,000 V1 ceiling, and refuses outright if the
        amount cannot be priced. `external_account_id` must be one of the holder's own accounts (`GET
        /wallet/external_accounts`); any other id answers `404` before any provider call.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-swaps-money-boundary: true
      security:
        - businessKey: []
      x-mcp:
        tool: prepare_bank_withdrawal
        description: >
          Prepare a payout from the wallet to one of the person's **own** verified bank accounts. It refuses over the
          per-withdrawal cap and the V1 ceiling, and refuses outright if the amount cannot be priced. It can never add a
          bank account and can never pay a third party.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OfframpIntentCreateRequest'
      responses:
        '201':
          description: The new off-ramp intent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OfframpIntent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/offramp_intents/{id}:
    get:
      operationId: wallet.offramp_intents.get
      summary: Get one bank withdrawal
      description: >
        Read one off-ramp intent's status and deposit instructions. Poll until a terminal state (`payment_processed`,
        `refunded`, `refund_failed`, `canceled`, `failed`).
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      x-mcp:
        tool: get_transfer_status
        description: >
          Report where one wallet money movement stands — a deposit, a withdrawal or a bank payout — with its state, its
          transaction hashes and a step timeline. Use it for every "where is my money" question instead of re-reading
          the chain. States are authoritative: do not say funds arrived, were refunded or failed before this tool says
          so.
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^ofr_
      responses:
        '200':
          description: The off-ramp intent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OfframpIntent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/offramp_intents/{id}/cancel:
    post:
      operationId: wallet.offramp_intents.cancel
      summary: Cancel a bank withdrawal
      description: >
        Cancels an off-ramp intent only from `awaiting_funds` (RESOURCE-MODEL §2.3) — once the transit deposit is signed
        and broadcast, this refuses rather than clawing money back. The exact eligibility window (whether `quoted` also
        qualifies) is an open question the design surfaces before the transit deposit is signed (R13-wallet §2 W-G14,
        **проверить**).
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^ofr_
      responses:
        '200':
          description: The cancelled off-ramp intent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OfframpIntent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/virtual_accounts:
    get:
      operationId: wallet.virtual_accounts.list
      summary: List the holder's virtual accounts
      description: >-
        List the holder's bank-deposit accounts, per currency. Without `purpose` this includes Payment links collection
        accounts and `legacy_wallet` accounts (a wallet-purpose row settling outside this holder's Tempo wallet) that
        settle outside the wallet; a wallet screen asks for `purpose=wallet_funding`.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: purpose
          in: query
          description: Only accounts with this `purpose`. An unknown value is a `400 invalid_request`.
          schema:
            type: string
            enum:
              - wallet_funding
              - collection
              - legacy_wallet
          example: wallet_funding
      responses:
        '200':
          description: A page of the holder's virtual accounts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VirtualAccountList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
    post:
      operationId: wallet.virtual_accounts.create
      summary: Create a virtual account
      description: >
        An explicit confirm — a virtual account is a billable provider object and is never auto-created (RESOURCE-MODEL
        §2.3 invariants). `409 conflict` when a Payment links collection account already receives this currency into
        this wallet and no activated wallet-funding account does (the provider would hand the collection account back
        instead of issuing a wallet-funding one): refused before any provider call, and again if the provider hands one
        back anyway. When an activated wallet-funding account is already on that currency and wallet, the create returns
        it (`409 conflict` only if the provider hands back the collection account instead). `GET
        /v1/capabilities?product=wallet_bank` publishes that corridor as `not_enabled` with `blocked_reason:
        collection_account_uses_currency`.

        Refused before any provider write unless the `wallet_bank_virtual_account` corridor for `currency` reads
        `available` on `GET /v1/capabilities?product=wallet_bank` (W-R23): `409 capability_unavailable` with
        `details.reason: rail_not_enabled` while receive by bank is not launched for this account; `409
        network_not_supported` when the holder's wallet is not on Tempo mainnet (the provider settles USDC on Tempo
        mainnet, which a test-network wallet never sees) — except on a Swaps staging project whose provider runs in its
        sandbox, where a wallet on that project's own test network is accepted and nothing settles for real (a
        staging-only stub credits test tokens); otherwise `409 virtual_account_holder_not_verified`,
        `virtual_account_requirements_outstanding` or `capability_unavailable`, each with `details.currency`,
        `details.corridor_status` and, when the corridor has one, `details.blocked_reason`. Each currency needs its own
        endorsement (USD `base`, EUR `sepa`, MXN `spei`). `503 temporarily_unavailable` when the launch flag or the
        accounts already on the wallet cannot be read.

        Provider sandbox only (a Swaps staging project): `503 temporarily_unavailable` with `Retry-After` when the
        account was issued but could not be recorded as a sandbox account (one retry inside the request) — without that
        record its test deposits would not be credited. The account exists (listed with `provider_environment: null`);
        retry after `Retry-After` with the same `Idempotency-Key`: the retry reaches the same account and records it.
        Never answered for a live account.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VirtualAccountCreateRequest'
      responses:
        '201':
          description: The new virtual account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VirtualAccount'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/virtual_accounts/{id}:
    get:
      operationId: wallet.virtual_accounts.get
      summary: Get one virtual account
      description: Read one of the holder's virtual accounts, including the "in your name" rail guard fields.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^va_
      responses:
        '200':
          description: The virtual account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VirtualAccount'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/virtual_accounts/{id}/deactivate:
    post:
      operationId: wallet.virtual_accounts.deactivate
      summary: Deactivate a virtual account
      description: >-
        Deactivates a virtual account. Reversible via `reactivate`. `409 conflict` for a `collection` account: Payment
        links owns it. `409 conflict` (`legacy_wallet_read_only`) for a `legacy_wallet` account.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^va_
      responses:
        '200':
          description: The deactivated virtual account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VirtualAccount'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/virtual_accounts/{id}/reactivate:
    post:
      operationId: wallet.virtual_accounts.reactivate
      summary: Reactivate a virtual account
      description: >-
        Reactivates a previously deactivated virtual account. `409 conflict` for a `collection` account: Payment links
        owns it. `409 conflict` (`legacy_wallet_read_only`) for a `legacy_wallet` account.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^va_
      responses:
        '200':
          description: The reactivated virtual account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VirtualAccount'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/virtual_accounts/{id}/history:
    get:
      operationId: wallet.virtual_accounts.history.list
      summary: List a virtual account's deposit history
      description: >
        "Money in" rows for one virtual account — a pull read over `virtual_account.deposit_received` (RESOURCE-MODEL
        §3); the real push producer is the Bridge webhook. One row per deposit: Bridge's lifecycle steps of one deposit
        collapse into one row whose `amount` is the settled step's (`payment_processed`) when present, else the received
        step's (`funds_received`), never a sum, and whose `occurred_at` is the first recorded of those steps, so a
        deposit that settles keeps its place and an issued `next_cursor` stays valid. A deposit not yet reported
        received is not listed. Newest first.

        The `amount` is that one step's own value and currency, as the provider reports them. The settled step is net of
        the provider's fees, so a row's value can change on the same row, at the same `occurred_at` and cursor position,
        when its deposit settles: Bridge documents `usd` on both steps, so a deposit received as 120.00 USD reads 116.55
        USD once settled. The currency is the step's own; today the provider reports the source currency on both steps.
        Never label the account by a row's currency, and never sum rows across a settlement.
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^va_
      responses:
        '200':
          description: A page of deposit-received rows for this virtual account, one per deposit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VirtualAccountHistoryList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/conversions:
    post:
      operationId: wallet.conversions.create
      summary: Build an unsigned same-chain conversion
      description: >
        Money boundary (RESOURCE-MODEL §0.10, §0.11) — per-transaction human confirmation is required. The API never
        signs: `steps[]` is an ordered list of unsigned calls (an `approve` before a `swap`, when the DEX needs one) the
        holder's own passkey signs; nothing moves until then. `swap-execution` builds a transaction, it never sends one.
        Testnet has one token, so a conversion is honestly unavailable there.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      x-swaps-money-boundary: true
      x-swaps-noncustodial: unsigned_steps
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConversionCreateRequest'
      responses:
        '201':
          description: The built conversion, with unsigned steps to sign.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Conversion'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/conversions/{id}:
    get:
      operationId: wallet.conversions.get
      summary: Track a conversion
      description: |
        Read a conversion's tracked on-chain leg by its `id`. Never claim the swap executed before the chain says so.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - name: id
          in: path
          required: true
          description: >
            The conversion's `id` (`cnv_`-prefixed — A1-6, RESOURCE-MODEL §0.2). A bare, un-prefixed `tracking_id` is
            also accepted for one version (`claude/dashboard-v2`'s `WalletConvertScreen`/`useWalletConversions` already
            call this route with the raw `tracking_id`, not yet the prefixed `id`) — see `Conversion.tracking_id`.
          schema:
            type: string
            pattern: ^(cnv_)?[0-9a-fA-F-]{36}$
      responses:
        '200':
          description: The tracked conversion.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Conversion'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
  /wallet/conversions/{id}/source_tx:
    post:
      operationId: wallet.conversions.source_tx.set
      summary: Record a conversion's source transaction
      description: >
        Records the transaction hash the holder's wallet just broadcast for this conversion's swap step, moving it to
        `submitted` only once the chain itself confirms an exact-token, exact-amount transfer from the holder's own
        wallet to the swap step's own call target (the StablecoinDEX) — never a client assertion. Call once, immediately
        after signing. Re-sending the same hash is a safe no-op; a different hash for the same conversion is refused —
        never retry with a fresh hash to force it through.
      tags:
        - Wallet
      x-swaps-status: available
      x-swaps-caller:
        - agent
        - dashboard_session
      x-swaps-test-mode: unavailable
      security:
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          description: >
            The conversion's `id` (`cnv_`-prefixed — A1-6). A bare, un-prefixed `tracking_id` is also accepted for one
            version — see `wallet.conversions.get`'s own parameter description.
          schema:
            type: string
            pattern: ^(cnv_)?[0-9a-fA-F-]{36}$
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConversionSourceTxRequest'
      responses:
        '200':
          description: The conversion, updated to `submitted`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Conversion'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.write
  /wallet/conversion_pairs:
    get:
      operationId: wallet.conversion_pairs.list
      summary: List convertible token pairs
      description: >
        L2-4 (R13-wallet §2 W-G10) — the mainnet Swaps-dollar token set's convertible pairs, each with a live liquidity
        flag from the same on-chain DEX probe `conversions.create` itself calls. Discovery data, not account-scoped;
        `security` marks auth optional (the router still requires a credential today — see `deposit_routes.list`'s own
        identical, already-documented gap — the output never varies by caller either way).
      tags:
        - Wallet
      x-swaps-status: dark-flag
      x-swaps-caller:
        - agent
        - dashboard_session
        - business_key
      x-swaps-test-mode: unavailable
      security:
        - {}
        - businessKey: []
      parameters:
        - $ref: '#/components/parameters/SwapsVersion'
        - $ref: '#/components/parameters/SwapsAccount'
      responses:
        '200':
          description: The convertible token pairs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConversionPairList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-swaps-scope: wallet.read
webhooks:
  payment_link:
    post:
      operationId: webhooks.payment_link
      summary: payment_link.* events
      description: >-
        A `payment_link.*` event, POSTed to every enabled endpoint subscribed to its type (or to all types). Only the
        members marked `live` in `EventType`'s `x-swaps-event-status` fire today; a `catalogued` one delivers nothing
        until its outbox allowlist ships.
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  payment:
    post:
      operationId: webhooks.payment
      summary: payment.* events
      description: >-
        A `payment.*` event, POSTed to every enabled endpoint subscribed to its type (or to all types). Only the members
        marked `live` in `EventType`'s `x-swaps-event-status` fire today; a `catalogued` one delivers nothing until its
        outbox allowlist ships.
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  subscription:
    post:
      operationId: webhooks.subscription
      summary: subscription.* events
      description: >-
        A `subscription.*` event, POSTed to every enabled endpoint subscribed to its type (or to all types). Only the
        members marked `live` in `EventType`'s `x-swaps-event-status` fire today; a `catalogued` one delivers nothing
        until its outbox allowlist ships.
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  payout:
    post:
      operationId: webhooks.payout
      summary: payout.* events
      description: >-
        A `payout.*` event, POSTed to every enabled endpoint subscribed to its type (or to all types). Only the members
        marked `live` in `EventType`'s `x-swaps-event-status` fire today; a `catalogued` one delivers nothing until its
        outbox allowlist ships.
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  payroll_run:
    post:
      operationId: webhooks.payroll_run
      summary: payroll_run.* events
      description: >-
        A `payroll_run.*` event, POSTed to every enabled endpoint subscribed to its type (or to all types). Only the
        members marked `live` in `EventType`'s `x-swaps-event-status` fire today; a `catalogued` one delivers nothing
        until its outbox allowlist ships.
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  payroll_item:
    post:
      operationId: webhooks.payroll_item
      summary: payroll_item.* events
      description: >-
        No `payroll_item.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  payroll_template:
    post:
      operationId: webhooks.payroll_template
      summary: payroll_template.created
      description: >-
        No `payroll_template.created` type is in the event outbox yet (`catalogued` in `EventType`'s
        `x-swaps-event-status`): an endpoint may subscribe, and nothing is delivered until its outbox allowlist ships.
        Documented for the payload it will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  deposit_intent:
    post:
      operationId: webhooks.deposit_intent
      summary: deposit_intent.* events
      description: >-
        No `deposit_intent.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  send_intent:
    post:
      operationId: webhooks.send_intent
      summary: send_intent.* events
      description: >-
        No `send_intent.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  offramp_intent:
    post:
      operationId: webhooks.offramp_intent
      summary: offramp_intent.* events
      description: >-
        No `offramp_intent.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  virtual_account:
    post:
      operationId: webhooks.virtual_account
      summary: virtual_account.* events
      description: >-
        No `virtual_account.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`):
        an endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload
        it will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  wallet:
    post:
      operationId: webhooks.wallet
      summary: wallet.created
      description: >-
        No `wallet.created` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  conversion:
    post:
      operationId: webhooks.conversion
      summary: conversion.* events
      description: >-
        No `conversion.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  customer:
    post:
      operationId: webhooks.customer
      summary: customer.* events
      description: >-
        A `customer.*` event, POSTed to every enabled endpoint subscribed to its type (or to all types). Only the
        members marked `live` in `EventType`'s `x-swaps-event-status` fire today; a `catalogued` one delivers nothing
        until its outbox allowlist ships.
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  capability:
    post:
      operationId: webhooks.capability
      summary: capability.* events
      description: >-
        No `capability.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  order:
    post:
      operationId: webhooks.order
      summary: order.* events
      description: >-
        A `order.*` event, POSTed to every enabled endpoint subscribed to its type (or to all types). Only the members
        marked `live` in `EventType`'s `x-swaps-event-status` fire today; a `catalogued` one delivers nothing until its
        outbox allowlist ships.
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  screening:
    post:
      operationId: webhooks.screening
      summary: screening.* events
      description: >-
        No `screening.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  account:
    post:
      operationId: webhooks.account
      summary: account.access_state_changed
      description: >-
        No `account.access_state_changed` type is in the event outbox yet (`catalogued` in `EventType`'s
        `x-swaps-event-status`): an endpoint may subscribe, and nothing is delivered until its outbox allowlist ships.
        Documented for the payload it will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  address_book:
    post:
      operationId: webhooks.address_book
      summary: address_book.entry_rechecked
      description: >-
        No `address_book.entry_rechecked` type is in the event outbox yet (`catalogued` in `EventType`'s
        `x-swaps-event-status`): an endpoint may subscribe, and nothing is delivered until its outbox allowlist ships.
        Documented for the payload it will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  credit:
    post:
      operationId: webhooks.credit
      summary: credit.* events
      description: >-
        No `credit.*` type is in the event outbox yet (`catalogued` in `EventType`'s `x-swaps-event-status`): an
        endpoint may subscribe, and nothing is delivered until its outbox allowlist ships. Documented for the payload it
        will carry.
      tags:
        - Developers
      x-swaps-status: proposed
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  test:
    post:
      operationId: webhooks.test
      summary: test.ping
      description: The `test.ping` event, POSTed to every enabled endpoint subscribed to it (or to all types).
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
  webhook_endpoint:
    post:
      operationId: webhooks.webhook_endpoint
      summary: webhook_endpoint.disabled
      description: The `webhook_endpoint.disabled` event, POSTed to every enabled endpoint subscribed to it (or to all types).
      tags:
        - Developers
      x-swaps-status: available
      security: []
      parameters:
        - $ref: '#/components/parameters/SwapsSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2XX:
          description: Acknowledged. Any other status, a redirect or a 10-second timeout is a failed attempt and is retried.
components:
  securitySchemes:
    businessKey:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        Business key, `sk_live_…` or `sk_test_…`, hashed to SHA-256 against `public.api_keys` and checked for
        status, expiry, IP allowlist, plan and scopes on every request. Acts as the account that owns the key
        and reaches only that account's resources. Scopes are `<resource>.<read|write>`.
    dashboardSession:
      type: http
      scheme: bearer
      description: |
        Added 2026-09-16 (review P1-S2, decision C4-D32) for the small set of operations a business
        key must never reach (D-109, K1: account self-service where there is no single "the account"
        a business key could mean once it has more than one member; API-key lifecycle operations a key
        must never use to mint or revoke itself). The dashboard's own GoTrue session token; the router
        accepts it as the `bearer` caller class and derives scopes from `account_members.role`
        (SEC-A/P0-2). It is not bound to the dashboard client today — per-client binding is K12 and is
        not enforced yet, so a caller who holds their own session token (e.g. copied out of the
        dashboard) can present it directly. Not a general alternative to `businessKey` for the rest of
        this document.
  parameters:
    SwapsSignature:
      name: Swaps-Signature
      in: header
      required: true
      description: >-
        `t=<unix seconds>,v1=<hex HMAC-SHA256>` over `"<t>.<raw body>"`, keyed with the raw SHA-256 digest of the
        endpoint's signing secret. Two `v1` tokens during a `rotate_secret` overlap window: accept a match on any.
        Reject a `t` more than 300 seconds from your clock, and verify before parsing the body (docs/api/WEBHOOKS.md
        §3).
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        Caller-generated, 16-128 characters of `[A-Za-z0-9_-]` (a UUID or a base64url token both qualify). The value is
        part of a primary key, so the shape is enforced, not advisory: outside it the request is `400
        invalid_request/idempotency_key_invalid`. A replay within 24 hours returns the stored response; a different body
        under the same key is `idempotency_error`; a replay of a request that failed after it may have taken effect is
        `409 idempotency_failed` — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the
        request is new (D-108).
      schema:
        type: string
        pattern: ^[A-Za-z0-9_-]{16,128}$
        minLength: 16
        maxLength: 128
    SwapsVersion:
      name: Swaps-Version
      in: header
      required: false
      description: Pins behaviour within `/v1` to a dated version; defaults to the key's version; echoed on every response.
      schema:
        type: string
        format: date
        example: '2026-09-04'
    SwapsAccount:
      name: Swaps-Account
      in: header
      required: false
      description: >-
        A1-2. Selects which of the caller's account memberships this request acts on, by `Account.id` (`acct_…`), when
        the credential can reach more than one; the caller's default account otherwise (D-109,
        `supabase/functions/api-v1/auth.ts`). Declared on every operation a dashboard session can call (#3561: the
        runtime caller class, not the formal `security` list, which names only `businessKey` on operations both
        credential classes reach). A value the session is not a member of answers `404 not_found`. A business key
        already resolves to exactly one account (its own) and ignores this header.
      schema:
        type: string
        example: acct_01J9ZK3Q8M2F5A7C9E1G3H5J7K
    Limit:
      name: limit
      in: query
      description: >-
        Default 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a `400` —
        0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100
        is capped to 100. A documented clamp, not a silent one; `minimum`/`maximum` are deliberately absent from this
        schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
      schema:
        type: integer
        default: 25
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's `next_cursor`.
      schema:
        type: string
    AccountExpand:
      name: expand
      in: query
      description: >-
        Comma-separated resources to merge into this response (API-PERF-1 M5): `readiness`, `setup_guide`, or both. Each
        maps to its own `GET /account/{resource}` payload under the matching key — additive, never required, and answers
        the exact same data those standalone reads would; an unrecognised value is ignored rather than rejected. Exists
        to fold the dashboard's own account-load sequence (`account` + `readiness` + `setup_guide`, three round trips
        today) into one.
      schema:
        type: string
        example: readiness,setup_guide
  headers:
    SwapsVersion:
      description: The dated version this response was served under.
      schema:
        type: string
        format: date
    RequestId:
      description: Correlates the response with logs and support; also inside every error envelope.
      schema:
        type: string
    RetryAfter:
      description: Seconds to wait before retrying — set on 429 and 503 only.
      schema:
        type: integer
    IdempotentReplayed:
      description: >-
        Present and `true` whenever a response was answered from something already recorded rather than by performing a
        new mutation — a router-level convention, not specific to any one operation. Two layers can set it: (1) ANY
        mutating `/v1` request replayed under the same `Idempotency-Key` gets its originally-stored response replayed
        with this header attached, regardless of which endpoint it targets; (2) a create whose reconciliation happens
        deeper in the money path (bound to a provider-side idempotency key, not just the request-level
        `Idempotency-Key`) — `orders.create` today — can also set it when the response reconciled onto an existing
        resource created by a prior/concurrent attempt instead of creating a new one. The response status is unchanged
        either way; a client that reads this header knows not to book the result as a second liability. Absent (never
        `false`) on a genuinely fresh mutation.
      schema:
        type: string
        enum:
          - 'true'
  schemas:
    Money:
      type: object
      description: >-
        A money value. `amount` is a minor-unit integer as a string; `decimals` states the scale so a client never
        re-derives it (USDC is 6). Floats never appear on a money path.
      required:
        - amount
        - currency
        - decimals
      properties:
        amount:
          type: string
          pattern: ^-?[0-9]+$
          example: '100000000'
          description: >-
            Minor-unit integer string. A negative value appears only on signed ledger rows; every creating request uses
            `MoneyPositive` and the router rejects zero or negative amounts.
        currency:
          type: string
          description: ISO 4217 code or a stablecoin symbol (`USDC`, `USDC.e`, `pathUSD`).
          example: USDC
        decimals:
          type: integer
          minimum: 0
          maximum: 18
          example: 6
      additionalProperties: false
    MoneyPositive:
      allOf:
        - $ref: '#/components/schemas/Money'
        - type: object
          properties:
            amount:
              type: string
              pattern: ^[1-9][0-9]*$
      description: A `Money` value that must be strictly positive — used on every request field that creates or moves value.
    MoneyNonNegative:
      allOf:
        - $ref: '#/components/schemas/Money'
        - type: object
          properties:
            amount:
              type: string
              pattern: ^[0-9]+$
      description: >-
        A `Money` value that must be zero or positive (never negative) — for a real figure that may legitimately be
        zero, e.g. a free/included line item's unit price. `Money` itself permits a leading `-` for signed ledger rows;
        this variant never does.
    DepositInstructions:
      type: object
      description: |
        Where to send crypto, how much, on which chain. One shape for every product (payer sessions,
        self-custody sells, wallet deposits, off-ramp funding). `address` is case-preserved verbatim.
        For a bridged (non-Tempo) leg the amount is a quote: `amount_is_estimate` is true, `quote_expires_at`
        is set, `bridge_fee` states the provider haircut and `gross_up: true` means the payer's send amount
        already absorbs it so the destination receives the exact `amount_out` (D-60 default).
      required:
        - address
        - chain
        - amount
      properties:
        address:
          type: string
        chain:
          type: string
          enum:
            - tempo
            - base
            - ethereum
            - polygon
            - arbitrum
            - optimism
            - solana
          x-swaps-open-enum: true
        asset:
          type: string
          description: Asset symbol to send on `chain` (e.g. `USDC`, `USDC.e`).
        chain_id:
          type: integer
          description: |
            Numeric chain id, when `chain` alone does not pin a single concrete network — e.g.
            `tempo` covers both Moderato (testnet, 42431) and Mainnet (4217), and `asset`'s
            contract address is not guaranteed distinct across them (a signer must not infer the
            network from the symbol alone). Absent when the rail has no such ambiguity.
        token_address:
          type: string
          description: |
            On-chain contract address `asset` resolves to on `chain`, verbatim case, when this
            attempt has one frozen (e.g. the TIP-20 token address a Tempo Pay splitter accepts).
            The authoritative send target for an in-app signer — never re-derive it from `asset`
            against a local registry, which can drift from what the attempt actually froze.
            Absent when the rail's `asset` has no single on-chain contract identity.
        amount:
          $ref: '#/components/schemas/Money'
        amount_out:
          $ref: '#/components/schemas/Money'
        amount_is_estimate:
          type: boolean
          default: false
        quote_expires_at:
          type: string
          format: date-time
        bridge_fee:
          $ref: '#/components/schemas/Money'
        gross_up:
          type: boolean
        memo:
          type: string
          description: On-chain memo / reference when the rail requires one; never on Tempo pay-ins.
        expires_at:
          type: string
          format: date-time
        provider:
          type: string
          enum:
            - relay
          x-swaps-open-enum: true
          description: |
            The third party that moves the funds on this leg, when one does (`relay` on a
            `crypto_relay` payment). Absent when the payer pays the destination directly.
      unevaluatedProperties: false
    BankDepositInstructions:
      type: object
      description: |
        Bank details a holder funds from (virtual accounts, payroll funding, and — K3b — a payment-links payer
        session's bank-rail attempt). Masked on read where the canon requires it. Every field is an allowlist read
        off the provider's own payload for the chosen rail: a field this schema doesn't name is dropped, and a field
        it does name but the provider omitted for that rail is left unset — never guessed, never a fabricated value.
      properties:
        currency:
          type: string
        rail:
          type: string
        bank_name:
          type: string
        beneficiary_name:
          type: string
        holder_kind:
          type:
            - string
            - 'null'
          enum:
            - own_name
            - provider_name
            - developer_name
            - null
          description: >-
            D-111's classification. Binding on every `BankDepositInstructions` consumer, present and future: classify
            the beneficiary name the provider actually returned on THIS record — never derived from the currency or the
            rail, and never invented when the provider returned no name at all (`null`, explicit, never merely omitted).
            `own_name`: an ordinary beneficiary name (the holder's own, or — for a payment-links payer session —
            whatever name the provider actually returned). `provider_name`: the beneficiary is the provider's own
            settlement entity (e.g. Bridge's "Bridge Building S.A." prefix — KNOWN FALSE POSITIVE: a genuine beneficiary
            whose own name happens to start with "bridge", e.g. "Bridgewater Ltd", also reads as `provider_name`;
            tightening the match is a follow-up, not fixed here). `developer_name`: a memo-matched, flexible-amount
            collection account issued in the developer's own name ("Swaps") — payment-links bank-rail pay-ins are
            EXPECTED to route through exactly this shape, though that expectation has not yet been confirmed against a
            live provider response for that specific endpoint.
        account_number:
          type: string
        routing_number:
          type: string
        sort_code:
          type: string
          description: UK Faster Payments — 6-digit domestic sort code, alongside `account_number`.
        clabe:
          type: string
          description: Mexico SPEI — the 18-digit interbank account key (replaces `account_number`/`routing_number` for this rail).
        pix_key:
          type: string
          description: Brazil PIX — the key the payer sends to; often the only field this rail populates.
        bank_address:
          type: string
        iban:
          type: string
        bic:
          type: string
        reference:
          type: string
          description: >-
            The transfer memo/reference text. When `matching` is `reference`, this is what the provider matches the
            deposit BY — the payer must type it into their bank transfer exactly, or the deposit lands unmatched on a
            pooled account. When `matching` is `account_number`, this is recommended (it speeds up reconciliation) but
            never required — the deposit is already matched by the account it landed on. Absent `matching` altogether (a
            record from before this field existed), treat `reference` as required, matching this schema's pre-existing
            behavior.
        matching:
          type: string
          enum:
            - reference
            - account_number
          description: >-
            How the provider attributes THIS deposit to the payer, independent of `holder_kind`. `reference`: a
            memo-matched transfer on a pooled account — the provider matches by the `reference` text alone, which the
            payer must type exactly, or the deposit is unmatched. `account_number`: the account itself
            (`account_number`/`iban`/`clabe`/`sort_code`, whichever this rail populates) is unique to this customer, so
            the provider matches by account rather than memo; `reference`, when present, is recommended for faster
            reconciliation but is never required to complete the payment. Omitted when the provider payload carries no
            field this schema could use to decide (e.g. `pix`, where a Pix key alone is already a complete,
            self-matching destination).
      unevaluatedProperties: false
    ErrorType:
      type: string
      description: The recovery class an agent must distinguish (API-CANON §5).
      enum:
        - invalid_request
        - authentication_error
        - permission_error
        - not_found
        - conflict
        - idempotency_error
        - rate_limit_error
        - capability_unavailable
        - temporarily_unavailable
        - quota_exhausted
        - provider_error
        - api_error
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - type
            - code
            - message
            - request_id
          properties:
            type:
              $ref: '#/components/schemas/ErrorType'
            code:
              type: string
              description: Stable machine code, additive-only; maps onto the browser vocabulary in `src/lib/errors.ts`.
              example: individual_amount_above_limit
            message:
              type: string
            doc_url:
              type: string
              format: uri
            request_id:
              type: string
            param:
              type: string
              description: The offending request field, when one can be named.
            details:
              type: object
              additionalProperties: true
              description: >-
                Machine-readable context for the codes that publish some — the values an agent would otherwise have to
                parse out of `message`. Read it per `code`, never generically: a code documents its own keys, and a code
                that publishes none omits the object entirely. `allowed_rails_invalid_for_currency` carries `currency`,
                `allowed_rails` and `offerable_rails`. `rail_not_allowed` carries `reason` when the refusal has a more
                specific cause than the code alone — today only `merchant_fiat_payin_pending` (a p2p merchant's own
                Bridge fiat pay-in is not active, so no fiat rail can be offered to their payer); absent for every other
                `rail_not_allowed` refusal. Same underlying condition (C4-D23) as `CapabilityCorridor.blocked_reason:
                'payin_fiat_pending'` (`platform.yaml`) at `/v1/capabilities` — this is the payer's execution-time
                refusal, that is the merchant's own capability resource; the two scopes are never merged into one code.
                `temporarily_unavailable` from `orders.create` (`POST /v1/orders`) carries `order_id` when a
                Bridge-native buy's local order already exists before the failure that produced this response (BSR-3,
                2026-09-18) — but not always: `BRIDGE_TRANSFER_RECONCILE_FAILED` proves a row exists (a `23505` unique
                violation on `provider_idempotency_key`) yet can still fail to read its id back, so it ships `quote_id`
                instead (BSR-13, 2026-09-20) — as does the genuinely no-order-exists-yet tail, which used to ship no
                `details` at all alongside a `Retry-After` that could never be honored (see the `503` response's own
                description below for why). Treat `order_id`'s presence as "an order exists, reconcile against it" and
                `quote_id`'s presence (with no `order_id`) as "no order to name yet, but here is what to request a new
                quote against" — never absent together on this operation's `temporarily_unavailable` `503`s once the
                pre-Bridge-execute checks have passed. Do not infer retry safety from `Retry-After`'s presence or
                absence on this operation — several other `temporarily_unavailable` branches from the SAME operation (an
                unreadable quote row, a stale gross-minimum snapshot, a route-claim sealing failure) are genuinely
                side-effect-free (nothing was created) and still ship no `Retry-After` header.

                BSR-3 fixer round 6 (independent review, 2026-09-18), correcting round 5's revision of this text, which
                wrongly conflated `error.type` (always `temporarily_unavailable` for every `503` this operation returns)
                with `error.code`, which DOES vary: the three pre-Bridge-execute checks below fail closed on their own
                more specific codes, not the generic one. There is no `retryable` field anywhere in the `/v1` error
                envelope, and this operation's internal Bridge-side classification is deliberately kept server-side
                (`.claude/rules/money.md`). Check `code` FIRST; for the generic `temporarily_unavailable` codes,
                `message`'s exact text is stable and matched here by substring — but treat `error.details.order_id`'s
                presence/absence as the primary "does an order exist" signal, not `message` alone: several strings below
                are shared by branches that differ only in whether `details` is set, and by very different
                Idempotency-Key dispositions (`supabase/functions/api-v1/router.ts`'s `classifyFor5xx`):
                  - `code: "destination_check_unavailable"` — "Could not verify this destination right now" (a
                    destination-provenance/velocity lookup failed) or "Address screening is temporarily unavailable"
                    (the screening provider is down under the fail-closed policy) — both fire before any Bridge
                    call; `Retry-After: 5`, genuinely side-effect-free (nothing created), a SAME-key replay is safe.
                  - `code: "customer_status_unavailable"` — "Could not verify this customer right now" — the stored
                    Bridge customer-status snapshot is stale/degraded and this operation refuses to trust it for a
                    money decision; before any Bridge call; `Retry-After: 5`, side-effect-free, SAME-key replay safe.
                    (BSR-13 fixer round 3 note, 2026-09-21 — these two, and the offer-freshness check right after
                    them, all run BEFORE the gross-minimum/route-claim-sealing/daily-cap group above dropped
                    `Retry-After` from; keeping it here is a STRUCTURAL argument — earlier in the request means
                    more of the offer's ~27-29s TTL typically remains — not a measured one: no harness proved a 5s
                    wait always lands before that TTL elapses for these two specifically. Tracked under BSR-14
                    alongside the offer-TTL fix itself; treat `Retry-After` here as "usually still enough runway",
                    not a guarantee.)
                  - "Could not create this order — request a new quote and retry under a new Idempotency-Key" —
                    `code: "temporarily_unavailable"`; emitted ONLY by
                    `mapBridgeExecuteFailure`'s final, unclassified tail (no known Bridge failure code, no
                    `transactionId`): no order exists yet. `Retry-After` is NEVER present here (BSR-13,
                    2026-09-20 — was `Retry-After: 5`; LIVE evidence, dev, 3/3, proved a SAME-key replay after
                    that advertised wait a dead end regardless: `409 idempotency_failed`, since this branch
                    ships no `sideEffectFree` marker so `classifyFor5xx` keeps the Idempotency-Key slot
                    reserved). The message now names BOTH halves of the actual recovery explicitly (BSR-13
                    fixer round 1, 2026-09-21) — a genuinely fresh `POST /v1/quotes` under a NEW
                    Idempotency-Key, matching `docs/api/RESOURCE-MODEL.md`'s kept-reservation rule — rather
                    than leaving the NEW-key half only in this prose: a caller who re-POSTs the SAME
                    `quote_id` under a new key instead re-prices a Bridge offer already partway through its own
                    ~27-29s TTL by the time `handleBridgeExecuteTransfer` is reached, and usually gets
                    `409 requote_required`/`404` on the offer/quote itself — a genuinely NEW quote does not have
                    that problem, since its own offer TTL has not started counting down yet.
                    `error.details.quote_id` is set instead of `Retry-After`, so the caller has a handle to
                    correlate this refusal with while getting that fresh quote.
                  - "Could not create this order right now — nothing was created; retry with the same
                    Idempotency-Key" — the gross-minimum and route-claim-sealing failures, BEFORE
                    `handleBridgeExecuteTransfer` is ever called: genuinely side-effect-free (the router releases the
                    caller's Idempotency-Key reservation), no `Retry-After` ships, and a SAME-key replay is the
                    correct retry — a NEW key would only re-quote and mint a new offer for no reason.
                    `error.details.quote_id` IS set (BSR-13 fixer round 3, 2026-09-21 — these two shipped
                    `details: {}` through round 2 even though `quoteRowId` was already in scope at both catches;
                    closed to actually satisfy this operation's "never absent together" promise above). Neither
                    advertises a wait, so neither makes the BSR-13 broken-promise claim above (`Retry-After` that
                    cannot succeed) — but "before `handleBridgeExecuteTransfer`" does NOT mean "before the offer
                    TTL has run": both checks fire in the same last-few-steps window as the daily-value-cap check
                    below, seconds (not minutes) before Bridge execute, so the SAME-key replay answers whatever the
                    underlying quote/offer's CURRENT state supports — it can still surface `409 requote_required`
                    on THAT retry if the offer has since expired. Extending the offer TTL, or letting this
                    operation re-price in place, is tracked separately (BSR-14); this bullet only guarantees the
                    retry's Idempotency-Key semantics, not that the retry succeeds.
                  - "Could not verify this account right now — request a new quote and retry" —
                    `assertWithinBridgeNativeDailyCap`'s two structural-failure branches (unusable cap/amount
                    inputs; the daily-cap RPC itself missing or erroring), taken at the LAST point before
                    `handleBridgeExecuteTransfer` — the same position in the flow as the fixed `!rowMayExist` tail
                    above. `Retry-After` REMOVED here too (BSR-13 fixer round 2, 2026-09-21 — was `Retry-After: 5`,
                    flagged as the same broken promise the tail round 1 already fixed: by the time this check runs
                    the winning offer is already deep into its own ~27-29s TTL, so advertising a further wait
                    routinely lands the replay on an expired offer). The unusable-inputs branch (fires BEFORE the
                    RPC call) is genuinely side-effect-free — nothing was reserved. The RPC branch (fires AFTER
                    `reserve_bridge_native_daily_value` was called, on a transport error or an unreadable response
                    row) is NOT provably side-effect-free: that RPC inserts its reservation row before returning
                    when it allows the request, so a failure observed after a committed call leaves a live
                    reservation counting against the caller for up to `BRIDGE_NATIVE_RESERVATION_TTL_SECONDS`
                    (BSR-13 fixer round 3, 2026-09-21 — corrected from "nothing was reserved" for this branch,
                    which the RPC's own commit-then-fail path can falsify). Both branches ARE safe on the
                    Idempotency-Key axis — no order was created either way, so a SAME-key replay is correct — which
                    is why the message says "retry", not "get a new Idempotency-Key"; the caller's own reservation,
                    if one was taken, self-expires on that same TTL rather than needing an explicit release. The
                    message names the real next step (a fresh quote) instead of implying an immediate same-request
                    retry will succeed, and `error.details.quote_id` gives a correlation handle for it.
                  - "A transfer attempt for this request may already exist — check GET /v1/orders before retrying" —
                    emitted by two DIFFERENT unclassifiable-outcome branches, both now setting
                    `error.details.quote_id` (no `order_id` to name, but the quote is a real reconcile handle):
                    `mapBridgeExecuteFailure`'s tail when a Bridge failure code proves (or cannot rule out) a row
                    may exist but no `transactionId` came back (`BRIDGE_TRANSFER_RECONCILE_FAILED` /
                    `_INTENT_UNREADABLE` / `_RECOVERY_LOOKUP_FAILED`, BSR-13 2026-09-20); and a throw from
                    `handleBridgeExecuteTransfer` itself caught before that function's OWN `try`/`catch` even
                    starts (its prologue — route-claim open, ownership check, idempotency-key derivation), which
                    can say nothing about whether Step 4's intent insert or Step 5's provider call ran — this one
                    shipped `details: {}` until BSR-13 fixer round 1 (2026-09-21) closed the same gap, since it
                    fires strictly after the pre-Bridge-execute checks and `quoteRowId` is in scope at that catch
                    too. Neither carries `Retry-After`, and neither is side-effect-free (a same-key replay
                    answers `409 idempotency_failed`) — poll `GET /v1/orders` (filter by the request's own
                    attributes) before retrying under any key in both cases.
                  - "Order was created but its id could not be determined — check GET /v1/orders shortly" —
                    `handleBridgeExecuteTransfer` answered success but the response carried no `transactionId`; NO
                    `order_id` in `details` (there is nothing to name), but `error.details.quote_id` IS set
                    (BSR-13 fixer round 1, 2026-09-21 — this branch is reached only once Bridge execute has
                    already succeeded, so a transactions row provably exists); poll `GET /v1/orders` (filter by
                    the request's own attributes), never a specific `{id}`.
                  - "Order was created but could not be read back — check GET /v1/orders shortly" — the id IS known
                    (`transactionId` came back) but this service's own read-back of that row failed; `order_id` IS
                    present in `details`; poll `GET /v1/orders/{id}`.
                  - "Order was created but could not be confirmed — check GET /v1/orders shortly" — `order_id` is
                    present; the row is expected to reach a terminal state on its own; poll `GET /v1/orders/{id}`.
                  - "A compliance attestation for this order is incomplete (see order_id) — do not fund it yet. Check
                    GET /v1/orders for the latest status." — `order_id` is present; the Bridge transfer already
                    committed and is not rejected; poll, but do not fund the order until it resolves.
                  - "An order record exists for this attempt (see order_id); it may not be payable. Do not retry —
                    contact support if it stays pending." — `order_id` is present; this is a DEFINITIVE Bridge
                    rejection that will never reach a terminal state on its own (BSR-20,
                    github.com/swapsapp/swaps/issues/3312) — never retry, and do not poll expecting resolution; a
                    same-key retry answers `409` and a re-quoted retry mints a duplicate provider transfer, which is
                    exactly what this message exists to prevent.
                `conflict` from the same operation's `409` carries `order_id` for the identical reason (a terminal,
                already-existing order that cannot be retried as-is) for three of its causes; the OTHER `409 conflict`
                this operation returns, `requote_required`, never sets `details` even though a row can already be
                visible on `GET /v1/orders` for that case too (BSR-18, not yet closed) — do not assume every `409
                conflict` from this operation carries `order_id`. `permission_error` from the same operation's `403`
                also carries `order_id` when Bridge rejects the selected fiat payment rail AFTER the local order already
                exists — request a new quote and a different payment rail rather than retrying this one; a
                pre-order-creation rail rejection sets no `details`.
    ListMeta:
      type: object
      required:
        - has_more
        - next_cursor
      properties:
        has_more:
          type: boolean
        next_cursor:
          type:
            - string
            - 'null'
          description: >-
            A1-2: required (still nullable) — every producer already emits `null` rather than omitting the field when
            there is no next page, so this only removes a third, unused wire state (absent) a strict client had to
            handle for nothing.
    ObjectBase:
      type: object
      description: Fields present on every first-class object.
      required:
        - id
        - object
        - livemode
        - created_at
      properties:
        id:
          type: string
          description: Prefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
          example: pl_01J9ZK3Q8M2F5A7C9E1G3H5J7K
        object:
          type: string
          example: payment_link
        livemode:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    Account:
      type: object
      description: |
        The caller's currently selected account (D-109). One live account per login is supported
        (`POST /accounts` refuses a second). `GET /account`
        always reads "the selected one" — the account named by `Swaps-Account`, or the caller's
        `default_account_id` — never a lookup by id; the id-prefix convention (RESOURCE-MODEL §0.2)
        still applies so the same shape is reachable from `GET /accounts`.
      required:
        - id
        - display_name
        - email
        - member_since
        - customer_type
        - country
        - language
        - access_state
        - livemode
        - market
        - support_contact
      properties:
        id:
          type: string
          pattern: ^acct_
          description: >-
            The account's id. Never looked up directly by a business key or a session — reached only as "the selected
            account" (`GET /account`) or via `GET /accounts` (mine).
        display_name:
          type: string
        email:
          type: string
          format: email
        member_since:
          type: string
          format: date-time
        customer_type:
          type: string
          description: >-
            `individual` or `business`. Follows the account owner's customer (`customers.get`, `GET /customers/{id}`):
            it is the type Swaps has recorded for that customer, not a live read from our verification partner. Swaps
            sets it whenever a customer of the owner is recorded or changes type, in either direction (`business` when
            any owner has a business customer). Until the owner has a customer, the type the account was opened with.
            Not writable through `PATCH /account`, and `customers.create` does not write it. A test-mode account keeps
            the type it was created with. Not itself a KYB projection (RESOURCE-MODEL §2.4).
        country:
          type:
            - string
            - 'null'
          description: ISO 3166-1 alpha-2. `null` until a source column exists (проверить — public.users has none today).
        language:
          type:
            - string
            - 'null'
          description: >-
            `null` until a source column exists (проверить — public.users has none today). Always `null` for an
            `sk_test_` key (test mode).
        access_state:
          type: string
          description: Reason for a restricted/suspended/blocked state stays internal — never on the wire.
          enum:
            - active
            - restricted
            - suspended
            - blocked
        notification_preferences:
          type: object
          description: >-
            For an `sk_test_` key (test mode) always `{marketing: false, product_tips: true}`, not the owner's values; a
            test-mode dashboard session sees its own.
          required:
            - marketing
            - product_tips
          properties:
            marketing:
              type: boolean
              description: Opt-in. Backs `user_preferences.marketing_consent`.
            product_tips:
              type: boolean
              description: Opt-out — enabled unless explicitly set false. Backs `user_preferences.email_notifications`.
        dashboard_preferences:
          type: object
          description: >-
            Cross-device UI preferences persisted server-side (R18 TA-G9) — not `UI-private`, since prod already syncs
            them. For an `sk_test_` key (test mode) always `{}`, not the owner's values; a test-mode dashboard session
            sees its own.
          properties:
            money_tools_hidden:
              type: array
              items:
                type: string
              description: Money-tools tile ids the holder has hidden from Today.
            readiness_card:
              type: string
              description: Collapse state of the persistent readiness card.
              enum:
                - expanded
                - collapsed
            setup_guide_mode:
              type: string
              enum:
                - expanded
                - minimized
                - launcher
        livemode:
          type: boolean
        support_contact:
          type:
            - object
            - 'null'
          description: >-
            L4-9 — an EXPLICIT opt-in support contact for the payer surfaces (`PaymentSession.merchant_contact` on
            `/pay/<token>`). NEVER derived from `email` above (the login e-mail), KYC, Bridge or settlement data —
            `null` until the holder sets it here. Also `null` for an `sk_test_` key: withheld in test mode, not unset.
          properties:
            email:
              type: string
              format: email
              maxLength: 254
            url:
              type: string
              format: uri
              pattern: ^https://
              maxLength: 512
          additionalProperties: false
        market:
          type: object
          description: >-
            BL-25 (D-PF-9a) — the persona's market for the embedded Buy & sell widget, which previously had no signal to
            resolve country/currency from and fell back to the public-site default regardless of who was signed in. A
            read-only projection over the same static country→currency registry the public-site widget itself defaults
            from (`_shared/services/config.ts`'s `countries.json`, ISO 4217 local currency per ISO 3166-1 alpha-2
            country) — never a live/provider-routed pick, so this carries no money path. Not itself writable. Resolved,
            in order of trust, from `country` above (declared once at `POST /accounts`, validated against this same
            registry there), then the holder's Bridge KYC address country (not published as its own field), then the
            public-site no-signal default. Always present — `source` names which case applied, and a consumer MUST treat
            `source: 'default'` as "no persona signal at all" rather than seed anything from it (it is the exact value
            an anonymous, signed-out visitor gets).
          required:
            - country
            - currency
            - source
          properties:
            country:
              type: string
              description: >-
                ISO 3166-1 alpha-2. The resolved market country — see `market`'s own description for the three-tier
                resolution order; NOT simply a mirror of `country` above (that is only the first, highest-trust tier).
            currency:
              type: string
              description: >-
                ISO 4217. The market country's own local currency from the registry — not a provider/route-aware pick,
                so a corridor this currency cannot actually settle today is still reported honestly; the widget's own
                existing fallback resolution (already exercised for an explicit `?country=` query param) decides what to
                do about that, same as today.
            source:
              type: string
              enum:
                - declared
                - kyc
                - default
              description: >-
                `declared` when the holder's own `country` (above) drove the pick. `kyc` when there was no usable
                `country` but the holder's Bridge KYC address country resolved to a registry entry instead. `default`
                when NEITHER resolved — no persona signal at all, and a consumer must not seed the widget from it (it is
                the identical value an anonymous, signed-out visitor gets).
          additionalProperties: false
        readiness:
          allOf:
            - $ref: '#/components/schemas/AccountReadiness'
          description: >-
            Present only when `?expand=readiness` (or `?expand=readiness,setup_guide`) is requested on `GET /account`
            AND that expansion succeeded (API-PERF-1 M5, fix round 1) — the exact `GET /account/readiness` payload,
            merged in. Absent, never `null`, when not requested; also absent, with a matching entry in `expand_errors`,
            when requested but the expansion itself failed (an unresolvable owner or a Bridge outage) — a failed
            expansion never fails the account read.
        setup_guide:
          allOf:
            - $ref: '#/components/schemas/SetupGuide'
          description: >-
            Present only when `?expand=setup_guide` (or `?expand=readiness,setup_guide`) is requested on `GET /account`
            AND that expansion succeeded (API-PERF-1 M5, fix round 1) — the exact `GET /account/setup_guide` payload,
            merged in. Absent, never `null`, when not requested; also absent, with a matching entry in `expand_errors`,
            when requested but the expansion itself failed.
        expand_errors:
          type: array
          description: >-
            Present only when at least one requested `?expand=` field could not be computed (API-PERF-1 fix round 1).
            The account read itself, and any OTHER requested expansion that DID succeed, are still published — this
            names exactly which field is missing and why, rather than failing the whole response the way the standalone
            `GET /account/readiness`/`GET /account/setup_guide` routes do on the same failure. Absent entirely when
            `?expand=` is unset, or when every requested field succeeded.
          items:
            type: object
            required:
              - field
              - code
            properties:
              field:
                type: string
                enum:
                  - readiness
                  - setup_guide
              code:
                type: string
                description: >-
                  `not_found` (no resolvable account owner), `temporarily_unavailable` (the live Bridge customer-status
                  call failed), or `internal_error` (an unexpected failure inside that one field's own computation) —
                  the same code vocabulary `error.code` uses elsewhere in this API, never a bespoke one.
                enum:
                  - not_found
                  - temporarily_unavailable
                  - internal_error
            additionalProperties: false
        caller:
          type: object
          description: >-
            Who is making this request, on this account: the credential type and, for a session, the caller's membership
            role. Read it before offering an owner/admin-only action (e.g. changing `display_name` with `PATCH
            /account`, API keys, the wallet families) instead of learning it from a `403 role_denied`. It comes from the
            same resolved credential the role gate checks, never a second lookup. Present on `GET /account` and `PATCH
            /account`, and on the one `GET /accounts` item this request's credential resolved to (the selected account).
            Absent, not `null`, on any other account (the other `GET /accounts` items, `POST /accounts`): this request
            never resolved a role there — select that account with `Swaps-Account` and read `GET /account`. Published in
            test and live mode alike.
          required:
            - credential
            - role
          properties:
            credential:
              type: string
              x-swaps-open-enum: true
              enum:
                - dashboard_session
                - business_key
              description: >-
                The credential class, in `x-swaps-caller` vocabulary: `dashboard_session` (a signed-in dashboard user's
                session) or `business_key` (an `sk_live_…`/`sk_test_…` key). May gain a member within `/v1`; treat an
                unknown value as opaque.
            role:
              oneOf:
                - $ref: '#/components/schemas/AccountMemberRole'
                - type: 'null'
              description: >-
                A `dashboard_session`'s membership role on this account — the value the role gate reads. An
                owner/admin-only operation passes that gate exactly when this is `owner` or `admin`, an owner-only one
                (`POST /customers`, `POST /customers/{id}/verification_links`) exactly when it is `owner`; any other
                value, including one this client does not recognize, is refused with `403 role_denied`. The operation's
                scope still applies after the gate (a `member` holds only the `*.read` scopes). Always `null` for a
                `business_key`: a key is the account itself and holds no membership, so it is never role-gated — its
                authority is its `scopes`, and an operation it cannot call answers `403 scope_denied`.
          additionalProperties: false
          example:
            credential: dashboard_session
            role: member
      additionalProperties: false
    AccountMemberRole:
      type: string
      x-swaps-open-enum: true
      description: >-
        A caller's membership role on an account (`account_members.role`). `owner` and `admin` may change the account's
        own shape and mint durable credentials; `member` reads (its scopes are the `*.read` half). No flow assigns
        `admin` today. Response-only and open: a role added within `/v1` (e.g. a team role) is additive — treat a value
        you do not recognize as neither owner nor admin.
      enum:
        - owner
        - admin
        - member
    AccountUpdateRequest:
      type: object
      description: >-
        Partial update. `email`, `country`, `customer_type` and `access_state` are not writable here — email change is
        an identity-plane operation and the rest are derived elsewhere.
      properties:
        display_name:
          type: string
          minLength: 1
          maxLength: 80
          description: >-
            The account's name. 1–80 characters; leading and trailing whitespace is trimmed, and a value that is empty
            after trimming is refused with `400 invalid_request`, `param: display_name`. Only an account owner or admin
            may change it (a member gets `403`). Payment links, the payer page, receipts and invoice emails show the
            profile name of the person the link is recorded under (for a link created through `/v1`, the account's first
            owner), not this account name. On a live account, saving this name also sets it for each owner who belongs
            to no other live account, so their payers see it at once. An owner who belongs to more than one live account
            keeps their current name on all of them until payer names are resolved per account.
        language:
          type: string
        support_contact:
          type:
            - object
            - 'null'
          description: >-
            L4-9 — the opt-in support contact the payer surfaces may show (§0.12 PATCH null-clears convention): omitted
            leaves it unchanged, an object sets it, explicit `null` clears it back to unset. At least one of
            `email`/`url` must be present on a set (never a bare `{}`).
          properties:
            email:
              type: string
              format: email
              maxLength: 254
            url:
              type: string
              format: uri
              pattern: ^https://
              maxLength: 512
          additionalProperties: false
        notification_preferences:
          type: object
          properties:
            marketing:
              type: boolean
            product_tips:
              type: boolean
        dashboard_preferences:
          type: object
          properties:
            money_tools_hidden:
              type: array
              items:
                type: string
            readiness_card:
              type: string
              enum:
                - expanded
                - collapsed
            setup_guide_mode:
              type: string
              enum:
                - expanded
                - minimized
                - launcher
      additionalProperties: false
    AccountCreateRequest:
      type: object
      description: >-
        The body of `POST /accounts`, which is not open today: one account per login is supported, and a login that
        already owns a live account gets `409 account_already_exists`. The contract it will carry: an individual or a
        business account under the same login (D-109), never a sandbox (`POST /accounts/{id}/sandboxes` is D-110, a
        later PR); the caller becomes its sole owner member.
      required:
        - display_name
        - customer_type
      properties:
        display_name:
          type: string
        customer_type:
          type: string
          enum:
            - individual
            - business
        country:
          type: string
          description: >-
            ISO 3166-1 alpha-2. Optional — unset until the holder declares one. Fix round 1 (P2): validated against the
            same `countries.json` registry `market` (on `Account`) reads from — case/whitespace-normalized on write, an
            unrecognised value is a `400 invalid_request` (`param: 'country'`), never silently stored and never able to
            disagree with the resolved `market.country` for the same account.
      additionalProperties: false
    AccountList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Account'
      unevaluatedProperties: false
    AccountReadiness:
      type: object
      description: |
        The single resolved kind for the persistent Today readiness card, re-derivable on every
        mount (RESOURCE-MODEL §2.7; R18 §3). Not a list — one kind wins.
      required:
        - kind
        - title
        - body
      properties:
        kind:
          type: string
          enum:
            - error
            - checking
            - pending_transaction
            - verification
            - accept_terms
            - address
            - first_trade
            - returning
        title:
          type: string
        body:
          type: string
        facts:
          type: array
          items:
            type: string
          description: Short supporting facts rendered under the body copy (e.g. a pending-count sentence).
        next_action:
          type:
            - object
            - 'null'
          properties:
            label:
              type: string
            href:
              type: string
        degraded:
          type: boolean
          description: >-
            Present, and `true`, only when the Bridge customer status this card was built from is the last persisted
            snapshot because the live read failed (`Customer.degraded`). The card then states no verified identity.
            Absent on a live read.
      additionalProperties: false
    SetupGuide:
      type: object
      description: |
        Six sections, eighteen steps (RESOURCE-MODEL §2.7; R18 §3 TA-G7): Overview(1), Wallet(2),
        Payment links(5), Payroll(4), Pay an invoice(4), Crypto processing(2).
      required:
        - sections
        - done
        - total
      properties:
        sections:
          type: array
          items:
            type: object
            required:
              - id
              - title
              - steps
              - done
              - total
            properties:
              id:
                type: string
                enum:
                  - overview
                  - wallet
                  - payment_links
                  - payroll
                  - pay_invoice
                  - crypto_processing
              title:
                type: string
              steps:
                type: array
                items:
                  type: object
                  required:
                    - id
                    - label
                    - done
                  properties:
                    id:
                      type: string
                    label:
                      type: string
                    done:
                      type: boolean
              done:
                type: integer
                minimum: 0
              total:
                type: integer
                minimum: 0
        done:
          type: integer
          minimum: 0
        total:
          type: integer
          minimum: 0
        next_step:
          type:
            - string
            - 'null'
        degraded:
          type: boolean
          description: >-
            Present, and `true`, only when the Bridge customer status this guide was built from is the last persisted
            snapshot because the live read failed (`Customer.degraded`). `verify_identity` and `confirm_eligible` then
            report `done: false` even where the snapshot says verified, and `next_step` never names a step withheld for
            that reason: it is the step a live read of the same status names. Absent on a live read.
      additionalProperties: false
    ActivityRow:
      type: object
      description: >-
        One row of the cross-product read model over the events outbox. Not independently addressable — `object` names
        the underlying resource a client should read for detail.
      required:
        - product
        - object
        - title
        - status
        - status_group
        - amount
        - direction
        - occurred_at
      properties:
        product:
          type: string
          enum:
            - payment_links
            - payouts
            - payroll
            - buy_sell
            - wallet
            - crypto_processing
        object:
          type: object
          description: Pointer to the backing `/v1` resource — read it there for the full projection.
          required:
            - id
            - type
          properties:
            id:
              type: string
            type:
              type: string
              description: The pointed-to resource's `object` discriminator, e.g. `payout`, `order`, `payment_link`.
        title:
          type: string
        status:
          type: string
          description: >-
            The underlying object's own status string, verbatim. Not a shared cross-product vocabulary — collisions
            between products' status words are named in R18 §5 and are not resolved by this field.
        status_group:
          type: string
          description: |
            This resource's own six-bucket set (`draft`, `pending`, `processing`, `completed`, `failed`,
            `returned`) — not a vocabulary shared with payment_links (`open`, `needs_attention`, `paid`, `ended`)
            or with payouts/payroll_runs/orders (`needs_you`, `in_progress`, `done`). Read each of those
            resources' own `status_group` for their set.
          enum:
            - draft
            - pending
            - processing
            - completed
            - failed
            - returned
        amount:
          $ref: '#/components/schemas/Money'
        direction:
          type: string
          enum:
            - in
            - out
        amount_usd_equivalent:
          description: Nullable — absent when no fresh conversion exists. Never inflate the figure by omitting a stale one.
          oneOf:
            - type: 'null'
            - type: object
              required:
                - amount
                - currency
                - decimals
                - fresh_as_of
              properties:
                amount:
                  type: string
                  pattern: ^-?[0-9]+$
                currency:
                  type: string
                decimals:
                  type: integer
                fresh_as_of:
                  type: string
                  format: date-time
        counterparty:
          type:
            - string
            - 'null'
        provider:
          description: Null when this row has no single owning provider (e.g. a payroll run).
          oneOf:
            - $ref: '#/components/schemas/Provider'
            - type: 'null'
        occurred_at:
          type: string
          format: date-time
      additionalProperties: false
    ActivityList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/ActivityRow'
      unevaluatedProperties: false
    ActivityProductStatusBucket:
      type: object
      description: >-
        One (product, status_group) cell of `ActivitySummary.by_product_status` (K8c, §52 C4-D14) — an honest count plus
        a minor-unit sum, never client math. `amount` is `null` when the cell has no rows, when its rows carry more than
        one currency OR the same currency at more than one decimal scale (summing across either would need an FX rate or
        a rescale this endpoint does not fabricate), or when a row's amount could not be parsed: `count` still reflects
        every row in the cell, but a sum that quietly excluded the unparseable row's contribution, or combined
        mismatched scales, would be a wrong number, not an honest one.
      required:
        - count
        - amount
      properties:
        count:
          type: integer
          minimum: 0
        amount:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/Money'
      additionalProperties: false
    ActivityProductStatusTotals:
      type: object
      description: The six `status_group` buckets for one product.
      required:
        - draft
        - pending
        - processing
        - completed
        - failed
        - returned
      properties:
        draft:
          $ref: '#/components/schemas/ActivityProductStatusBucket'
        pending:
          $ref: '#/components/schemas/ActivityProductStatusBucket'
        processing:
          $ref: '#/components/schemas/ActivityProductStatusBucket'
        completed:
          $ref: '#/components/schemas/ActivityProductStatusBucket'
        failed:
          $ref: '#/components/schemas/ActivityProductStatusBucket'
        returned:
          $ref: '#/components/schemas/ActivityProductStatusBucket'
      additionalProperties: false
    ActivitySummary:
      type: object
      description: >-
        Unbounded counts across every row on record, independent of the paginated window `/v1/activity` returns
        (RESOURCE-MODEL §0.12) — a cursor list never carries a total, this does.
      required:
        - total
        - by_date_window
        - by_product
        - by_status_group
        - by_product_status
      properties:
        total:
          type: integer
          minimum: 0
        by_date_window:
          type: object
          properties:
            today:
              type: integer
              minimum: 0
            last_7d:
              type: integer
              minimum: 0
            last_30d:
              type: integer
              minimum: 0
            last_90d:
              type: integer
              minimum: 0
        by_product:
          type: object
          properties:
            payment_links:
              type: integer
              minimum: 0
            payouts:
              type: integer
              minimum: 0
            payroll:
              type: integer
              minimum: 0
            buy_sell:
              type: integer
              minimum: 0
            wallet:
              type: integer
              minimum: 0
            crypto_processing:
              type: integer
              minimum: 0
        by_status_group:
          type: object
          properties:
            draft:
              type: integer
              minimum: 0
            pending:
              type: integer
              minimum: 0
            processing:
              type: integer
              minimum: 0
            completed:
              type: integer
              minimum: 0
            failed:
              type: integer
              minimum: 0
            returned:
              type: integer
              minimum: 0
        by_product_status:
          type: object
          description: >-
            Per-product × lifecycle-status totals for the product hub sub-lines and Today tiles (K8c, §52 C4-D14) — the
            same six products and six status groups as `by_product`/`by_status_group`, crossed. Computed over the same
            capped read as the rest of this resource.
          required:
            - payment_links
            - payouts
            - payroll
            - buy_sell
            - wallet
            - crypto_processing
          properties:
            payment_links:
              $ref: '#/components/schemas/ActivityProductStatusTotals'
            payouts:
              $ref: '#/components/schemas/ActivityProductStatusTotals'
            payroll:
              $ref: '#/components/schemas/ActivityProductStatusTotals'
            buy_sell:
              $ref: '#/components/schemas/ActivityProductStatusTotals'
            wallet:
              $ref: '#/components/schemas/ActivityProductStatusTotals'
            crypto_processing:
              $ref: '#/components/schemas/ActivityProductStatusTotals'
          additionalProperties: false
      additionalProperties: false
    EventType:
      type: string
      description: >
        The catalogue of dotted event names an `Event.type` / a webhook endpoint's `event_types[]` entry may carry

        (RESOURCE-MODEL §3 "publish" rows plus v2 additions; dead types are excluded). This is the CLOSED,

        REQUEST-side form: `webhook_endpoints.create`/`.update`'s `event_types` and `events.list`'s `type` filter

        use it, and an unrecognized or retired name there is `400 invalid_request`. `EventTypeOpen` (below) is the

        same list for RESPONSE fields, where an unrecognized member is an opaque string a client must not fail on —

        the two are one vocabulary read from two sides, not a contradiction: a request is validated against what

        this deployment knows, while a response may come from a deployment newer than the client. The list grows

        within `/v1` without a version cut.


        `x-swaps-event-status` marks every member `live` (written to the `api_events` outbox today) or `catalogued`

        (not written to the outbox: never delivered to a webhook endpoint and never listed by `/v1/events` or

        `/v1/activity` until its outbox allowlist ships; it is accepted in a subscription, and a per-resource event

        list such as `payouts.events.list` or `payroll_runs.events.list` may still show it from the product's own

        ledger). The marking is generated from `EVENT_PAYLOAD_ALLOWLIST`

        (`packages/contracts-api/events.ts`) by `scripts/openapi/normalize.mjs`: the outbox writer drops a type

        whose allowlist is empty, so the allowlist is what decides whether a type can ever reach the outbox. No
        historical

        backfill for any type. Producers of the live families: `payment_link.*`/`payment.*` — the payment-link

        service layer (merchant mutations, the Bridge webhook reducer, the expiry/reminder cron) and the Tempo watcher

        (`payment.underpaid`/`.overpaid`; `payment.unmatched` ONLY for a deposit to a closed, unpaid payment's

        address — a second deposit to an already-paid payment, or a deposit matching no payment, is recorded for

        support and not published on `/v1`; `payment.unmatched` is also emitted once, by the payment-link service

        layer, when a merchant cancels an `underpaid` `crypto_tempo`/`crypto_relay` payment); `payout.*` —

        `appendPayoutEvent` (api-v1 or dashboard/admin actions); `payroll_run.*` — `payroll/lib.ts`'s

        `appendEvent`; `subscription.*` — the crypto subscriptions service; `order.*` — `emitOrderOutboxEvent`;

        `customer.*` — `emitCustomerOutboxEvent`; `test.ping` — `webhook_endpoints.send_test_event`, delivered

        only to the endpoint under test; `webhook_endpoint.disabled` — `api-webhooks-worker` when an endpoint

        auto-disables.
      enum:
        - payment_link.created
        - payment_link.activated
        - payment_link.viewed
        - payment_link.paid
        - payment_link.settled
        - payment_link.partially_paid
        - payment_link.refunded
        - payment_link.cancelled
        - payment_link.expired
        - payment_link.reminder_sent
        - payment_link.reminder_schedule_updated
        - payment.created
        - payment.marked_sent
        - payment.awaiting
        - payment.detected
        - payment.processing
        - payment.paid
        - payment.settled
        - payment.underpaid
        - payment.overpaid
        - payment.unmatched
        - payment.expired
        - subscription.created
        - subscription.paused
        - subscription.resumed
        - subscription.cancelled
        - subscription.invoice_issued
        - subscription.invoice_overdue
        - subscription.invoice_paid
        - payout.created
        - payout.funded
        - payout.awaiting_funds
        - payout.funds_received
        - payout.processing
        - payout.paid
        - payout.settled
        - payout.failed
        - payout.returned
        - payout.cancelled
        - payout.paid_with_shortfall
        - payout.marked_sent
        - payout.replaced
        - payout.created_as_replacement
        - payout.beneficiary_added
        - payout.configuration_committed
        - payroll_run.created
        - payroll_run.approved
        - payroll_run.cancelled
        - payroll_run.funding_instructions_requested
        - payroll_run.funding_instructions_verified
        - payroll_run.funding_instructions_failed
        - payroll_run.funding_instructions_blocked
        - payroll_run.funding_verified
        - payroll_run.underfunded
        - payroll_run.execution_requested
        - payroll_run.execution_started
        - payroll_run.execution_blocked
        - payroll_run.completed
        - payroll_run.partial
        - payroll_run.failed
        - payroll_item.destination_updated
        - payroll_item.destination_changed
        - payroll_item.payout_queued
        - payroll_item.payout_processing
        - payroll_item.payout_paid
        - payroll_item.payout_failed
        - payroll_item.payout_returned
        - payroll_template.created
        - deposit_intent.created
        - deposit_intent.source_detected
        - deposit_intent.bridging_started
        - deposit_intent.settled
        - deposit_intent.failed
        - deposit_intent.expired
        - deposit_intent.recovery_started
        - deposit_intent.manual_recovery_required
        - send_intent.created
        - send_intent.source_submitted
        - send_intent.bridging_started
        - send_intent.settled
        - send_intent.failed
        - send_intent.expired
        - offramp_intent.created
        - offramp_intent.funds_received
        - offramp_intent.payment_submitted
        - offramp_intent.payment_processed
        - offramp_intent.refunded
        - offramp_intent.refund_failed
        - offramp_intent.canceled
        - virtual_account.created
        - virtual_account.deactivated
        - virtual_account.reactivated
        - virtual_account.deposit_received
        - wallet.created
        - conversion.built
        - conversion.submitted
        - conversion.settled
        - conversion.failed
        - customer.verification_state_changed
        - customer.rejected
        - customer.requirements_updated
        - capability.blocked
        - capability.unblocked
        - order.created
        - order.status_changed
        - order.failed
        - order.refunded
        - order.cancelled
        - screening.completed
        - screening.risk_elevated
        - account.access_state_changed
        - address_book.entry_rechecked
        - credit.consumed
        - credit.purchased
        - test.ping
        - webhook_endpoint.disabled
      x-swaps-event-status:
        payment_link.created: live
        payment_link.activated: live
        payment_link.viewed: live
        payment_link.paid: live
        payment_link.settled: live
        payment_link.partially_paid: live
        payment_link.refunded: live
        payment_link.cancelled: live
        payment_link.expired: live
        payment_link.reminder_sent: live
        payment_link.reminder_schedule_updated: live
        payment.created: live
        payment.marked_sent: live
        payment.awaiting: live
        payment.detected: catalogued
        payment.processing: catalogued
        payment.paid: live
        payment.settled: live
        payment.underpaid: live
        payment.overpaid: live
        payment.unmatched: live
        payment.expired: live
        subscription.created: live
        subscription.paused: live
        subscription.resumed: live
        subscription.cancelled: live
        subscription.invoice_issued: live
        subscription.invoice_overdue: live
        subscription.invoice_paid: live
        payout.created: live
        payout.funded: live
        payout.awaiting_funds: catalogued
        payout.funds_received: catalogued
        payout.processing: catalogued
        payout.paid: catalogued
        payout.settled: catalogued
        payout.failed: catalogued
        payout.returned: catalogued
        payout.cancelled: live
        payout.paid_with_shortfall: catalogued
        payout.marked_sent: live
        payout.replaced: catalogued
        payout.created_as_replacement: live
        payout.beneficiary_added: live
        payout.configuration_committed: catalogued
        payroll_run.created: live
        payroll_run.approved: live
        payroll_run.cancelled: live
        payroll_run.funding_instructions_requested: catalogued
        payroll_run.funding_instructions_verified: catalogued
        payroll_run.funding_instructions_failed: catalogued
        payroll_run.funding_instructions_blocked: catalogued
        payroll_run.funding_verified: live
        payroll_run.underfunded: catalogued
        payroll_run.execution_requested: catalogued
        payroll_run.execution_started: live
        payroll_run.execution_blocked: catalogued
        payroll_run.completed: live
        payroll_run.partial: live
        payroll_run.failed: live
        payroll_item.destination_updated: catalogued
        payroll_item.destination_changed: catalogued
        payroll_item.payout_queued: catalogued
        payroll_item.payout_processing: catalogued
        payroll_item.payout_paid: catalogued
        payroll_item.payout_failed: catalogued
        payroll_item.payout_returned: catalogued
        payroll_template.created: catalogued
        deposit_intent.created: catalogued
        deposit_intent.source_detected: catalogued
        deposit_intent.bridging_started: catalogued
        deposit_intent.settled: catalogued
        deposit_intent.failed: catalogued
        deposit_intent.expired: catalogued
        deposit_intent.recovery_started: catalogued
        deposit_intent.manual_recovery_required: catalogued
        send_intent.created: catalogued
        send_intent.source_submitted: catalogued
        send_intent.bridging_started: catalogued
        send_intent.settled: catalogued
        send_intent.failed: catalogued
        send_intent.expired: catalogued
        offramp_intent.created: catalogued
        offramp_intent.funds_received: catalogued
        offramp_intent.payment_submitted: catalogued
        offramp_intent.payment_processed: catalogued
        offramp_intent.refunded: catalogued
        offramp_intent.refund_failed: catalogued
        offramp_intent.canceled: catalogued
        virtual_account.created: catalogued
        virtual_account.deactivated: catalogued
        virtual_account.reactivated: catalogued
        virtual_account.deposit_received: catalogued
        wallet.created: catalogued
        conversion.built: catalogued
        conversion.submitted: catalogued
        conversion.settled: catalogued
        conversion.failed: catalogued
        customer.verification_state_changed: live
        customer.rejected: live
        customer.requirements_updated: live
        capability.blocked: catalogued
        capability.unblocked: catalogued
        order.created: live
        order.status_changed: live
        order.failed: live
        order.refunded: live
        order.cancelled: live
        screening.completed: catalogued
        screening.risk_elevated: catalogued
        account.access_state_changed: catalogued
        address_book.entry_rechecked: catalogued
        credit.consumed: catalogued
        credit.purchased: catalogued
        test.ping: live
        webhook_endpoint.disabled: live
    EventTypeOpen:
      type: string
      x-swaps-open-enum: true
      description: >-
        The exact same catalogue as `EventType`, as the RESPONSE-side form (`Event.type`, `WebhookEvent.type`,
        `WebhookEndpoint.event_types`): an unrecognized member is an opaque string, never a deserialization failure,
        because a delivery may carry a type added after the client was generated. A REQUEST that names a type still
        validates against the closed `EventType` and answers `400 invalid_request` for an unknown name. Kept as its own
        schema so the open-enum marker never reaches a request field. `x-swaps-event-status` is the same generated
        live/catalogued marking as on `EventType`.
      enum:
        - payment_link.created
        - payment_link.activated
        - payment_link.viewed
        - payment_link.paid
        - payment_link.settled
        - payment_link.partially_paid
        - payment_link.refunded
        - payment_link.cancelled
        - payment_link.expired
        - payment_link.reminder_sent
        - payment_link.reminder_schedule_updated
        - payment.created
        - payment.marked_sent
        - payment.awaiting
        - payment.detected
        - payment.processing
        - payment.paid
        - payment.settled
        - payment.underpaid
        - payment.overpaid
        - payment.unmatched
        - payment.expired
        - subscription.created
        - subscription.paused
        - subscription.resumed
        - subscription.cancelled
        - subscription.invoice_issued
        - subscription.invoice_overdue
        - subscription.invoice_paid
        - payout.created
        - payout.funded
        - payout.awaiting_funds
        - payout.funds_received
        - payout.processing
        - payout.paid
        - payout.settled
        - payout.failed
        - payout.returned
        - payout.cancelled
        - payout.paid_with_shortfall
        - payout.marked_sent
        - payout.replaced
        - payout.created_as_replacement
        - payout.beneficiary_added
        - payout.configuration_committed
        - payroll_run.created
        - payroll_run.approved
        - payroll_run.cancelled
        - payroll_run.funding_instructions_requested
        - payroll_run.funding_instructions_verified
        - payroll_run.funding_instructions_failed
        - payroll_run.funding_instructions_blocked
        - payroll_run.funding_verified
        - payroll_run.underfunded
        - payroll_run.execution_requested
        - payroll_run.execution_started
        - payroll_run.execution_blocked
        - payroll_run.completed
        - payroll_run.partial
        - payroll_run.failed
        - payroll_item.destination_updated
        - payroll_item.destination_changed
        - payroll_item.payout_queued
        - payroll_item.payout_processing
        - payroll_item.payout_paid
        - payroll_item.payout_failed
        - payroll_item.payout_returned
        - payroll_template.created
        - deposit_intent.created
        - deposit_intent.source_detected
        - deposit_intent.bridging_started
        - deposit_intent.settled
        - deposit_intent.failed
        - deposit_intent.expired
        - deposit_intent.recovery_started
        - deposit_intent.manual_recovery_required
        - send_intent.created
        - send_intent.source_submitted
        - send_intent.bridging_started
        - send_intent.settled
        - send_intent.failed
        - send_intent.expired
        - offramp_intent.created
        - offramp_intent.funds_received
        - offramp_intent.payment_submitted
        - offramp_intent.payment_processed
        - offramp_intent.refunded
        - offramp_intent.refund_failed
        - offramp_intent.canceled
        - virtual_account.created
        - virtual_account.deactivated
        - virtual_account.reactivated
        - virtual_account.deposit_received
        - wallet.created
        - conversion.built
        - conversion.submitted
        - conversion.settled
        - conversion.failed
        - customer.verification_state_changed
        - customer.rejected
        - customer.requirements_updated
        - capability.blocked
        - capability.unblocked
        - order.created
        - order.status_changed
        - order.failed
        - order.refunded
        - order.cancelled
        - screening.completed
        - screening.risk_elevated
        - account.access_state_changed
        - address_book.entry_rechecked
        - credit.consumed
        - credit.purchased
        - test.ping
        - webhook_endpoint.disabled
      x-swaps-event-status:
        payment_link.created: live
        payment_link.activated: live
        payment_link.viewed: live
        payment_link.paid: live
        payment_link.settled: live
        payment_link.partially_paid: live
        payment_link.refunded: live
        payment_link.cancelled: live
        payment_link.expired: live
        payment_link.reminder_sent: live
        payment_link.reminder_schedule_updated: live
        payment.created: live
        payment.marked_sent: live
        payment.awaiting: live
        payment.detected: catalogued
        payment.processing: catalogued
        payment.paid: live
        payment.settled: live
        payment.underpaid: live
        payment.overpaid: live
        payment.unmatched: live
        payment.expired: live
        subscription.created: live
        subscription.paused: live
        subscription.resumed: live
        subscription.cancelled: live
        subscription.invoice_issued: live
        subscription.invoice_overdue: live
        subscription.invoice_paid: live
        payout.created: live
        payout.funded: live
        payout.awaiting_funds: catalogued
        payout.funds_received: catalogued
        payout.processing: catalogued
        payout.paid: catalogued
        payout.settled: catalogued
        payout.failed: catalogued
        payout.returned: catalogued
        payout.cancelled: live
        payout.paid_with_shortfall: catalogued
        payout.marked_sent: live
        payout.replaced: catalogued
        payout.created_as_replacement: live
        payout.beneficiary_added: live
        payout.configuration_committed: catalogued
        payroll_run.created: live
        payroll_run.approved: live
        payroll_run.cancelled: live
        payroll_run.funding_instructions_requested: catalogued
        payroll_run.funding_instructions_verified: catalogued
        payroll_run.funding_instructions_failed: catalogued
        payroll_run.funding_instructions_blocked: catalogued
        payroll_run.funding_verified: live
        payroll_run.underfunded: catalogued
        payroll_run.execution_requested: catalogued
        payroll_run.execution_started: live
        payroll_run.execution_blocked: catalogued
        payroll_run.completed: live
        payroll_run.partial: live
        payroll_run.failed: live
        payroll_item.destination_updated: catalogued
        payroll_item.destination_changed: catalogued
        payroll_item.payout_queued: catalogued
        payroll_item.payout_processing: catalogued
        payroll_item.payout_paid: catalogued
        payroll_item.payout_failed: catalogued
        payroll_item.payout_returned: catalogued
        payroll_template.created: catalogued
        deposit_intent.created: catalogued
        deposit_intent.source_detected: catalogued
        deposit_intent.bridging_started: catalogued
        deposit_intent.settled: catalogued
        deposit_intent.failed: catalogued
        deposit_intent.expired: catalogued
        deposit_intent.recovery_started: catalogued
        deposit_intent.manual_recovery_required: catalogued
        send_intent.created: catalogued
        send_intent.source_submitted: catalogued
        send_intent.bridging_started: catalogued
        send_intent.settled: catalogued
        send_intent.failed: catalogued
        send_intent.expired: catalogued
        offramp_intent.created: catalogued
        offramp_intent.funds_received: catalogued
        offramp_intent.payment_submitted: catalogued
        offramp_intent.payment_processed: catalogued
        offramp_intent.refunded: catalogued
        offramp_intent.refund_failed: catalogued
        offramp_intent.canceled: catalogued
        virtual_account.created: catalogued
        virtual_account.deactivated: catalogued
        virtual_account.reactivated: catalogued
        virtual_account.deposit_received: catalogued
        wallet.created: catalogued
        conversion.built: catalogued
        conversion.submitted: catalogued
        conversion.settled: catalogued
        conversion.failed: catalogued
        customer.verification_state_changed: live
        customer.rejected: live
        customer.requirements_updated: live
        capability.blocked: catalogued
        capability.unblocked: catalogued
        order.created: live
        order.status_changed: live
        order.failed: live
        order.refunded: live
        order.cancelled: live
        screening.completed: catalogued
        screening.risk_elevated: catalogued
        account.access_state_changed: catalogued
        address_book.entry_rechecked: catalogued
        credit.consumed: catalogued
        credit.purchased: catalogued
        test.ping: live
        webhook_endpoint.disabled: live
    Event:
      description: |
        The one event envelope for every per-object event list in the API: `object` is fixed to `event` and
        `data.object` is required. First-class and `$ref`-able from any fragment — a payment link's, a payout's
        or any other resource's own event list is `Event` items, never a bespoke per-product event schema.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - type
            - data
            - api_version
          properties:
            id:
              type: string
              pattern: ^evt_
            object:
              type: string
              enum:
                - event
            type:
              $ref: '#/components/schemas/EventTypeOpen'
            data:
              $ref: '#/components/schemas/EventData'
            request:
              type:
                - object
                - 'null'
              description: Null for events with no originating API request (a webhook reducer, a cron sweep, a watcher).
              properties:
                id:
                  type:
                    - string
                    - 'null'
                idempotency_key:
                  type:
                    - string
                    - 'null'
            api_version:
              type: string
              format: date
              description: The `Swaps-Version` this event was minted under.
      unevaluatedProperties: false
    EventData:
      type: object
      required:
        - object
      properties:
        object:
          type: object
          description: >-
            The affected resource's own allowlisted projection at the time of the event — never the raw internal row.
            Deliberately THINNER than a `GET` of the same resource: fields that need gateway-only computation (a payment
            link's `payable_rails`/`payable_rail_kinds`, its line `items`) are never recomputed here, so they are absent
            from this projection even where `GET` would carry them — never guessed or backfilled from a stale value.
          additionalProperties: true
        detail:
          type: string
          minLength: 1
          description: >-
            A short, human-readable summary of the event, built server-side from the producer's own record of what
            happened (e.g. `3 rows · USD`, `Jamie Rivera · bank account`) — never a substitute for `data.object`, and
            never more than what the resource's own published projections already disclose, and never an internal
            actor's identity (an account's approving user, an operator) unless that identity is itself a published field
            elsewhere the same caller can already read. Populated per event type by the producer that emits it (K7b
            ships it for `payroll_run.*`/`payroll_item.*`); absent, not an empty string, where no producer has one yet.
      unevaluatedProperties: false
    WebhookEvent:
      description: |
        The body of every outbound webhook delivery (the OpenAPI `webhooks` of this document), signed with
        `Swaps-Signature` (docs/api/WEBHOOKS.md §3) — verify the signature over the raw bytes BEFORE parsing. The
        same event `GET /v1/events/{id}` returns, minus `object` and `request`, which a delivery never carries.
        `api_version` is the receiving endpoint's own pinned `Swaps-Version`, not necessarily the one the event
        was minted under. Delivery is at-least-once and ordering is best-effort: deduplicate on `id`.
      type: object
      required:
        - id
        - type
        - created_at
        - livemode
        - api_version
        - data
      properties:
        id:
          type: string
          pattern: ^evt_
        type:
          $ref: '#/components/schemas/EventTypeOpen'
        created_at:
          type: string
          format: date-time
        livemode:
          type: boolean
        api_version:
          type: string
          format: date
          description: The receiving endpoint's pinned `Swaps-Version`.
        data:
          $ref: '#/components/schemas/EventData'
      additionalProperties: false
    EventList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Event'
      unevaluatedProperties: false
    WebhookEndpoint:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - url
            - event_types
            - enabled
          properties:
            id:
              type: string
              pattern: ^whe_
            object:
              type: string
              enum:
                - webhook_endpoint
            url:
              type: string
              format: uri
              description: >
                Public https only. Rejected at create/update time if it is a `swaps.app` host or a Swaps project's own
                `supabase.co` host, or

                resolves to a private, link-local, loopback or otherwise reserved destination (SSRF hardening); the
                delivery worker re-resolves and re-checks

                the SAME way immediately before every send attempt and never follows a redirect — a 3xx response

                is recorded as a failed delivery attempt, not a success. This narrows, but does not eliminate, a

                DNS-rebinding window: the guard's own resolution and the subsequent `fetch()` are two separate DNS

                lookups a hostile or compromised resolver could answer differently (docs/api/WEBHOOKS.md §7).
            event_types:
              type: array
              items:
                $ref: '#/components/schemas/EventTypeOpen'
              description: >
                Event types this endpoint receives. Empty means "all" (every type this account can ever emit,

                present and future). An unknown or retired name is `400 invalid_request`, at create and update alike

                (validated against the closed `EventType` at that time — this response echo is open only because

                it is an A1-2 response field, not because an unrecognized value can actually appear here). A type

                marked `catalogued` in `EventType`'s `x-swaps-event-status` is accepted but delivers nothing until its
                outbox allowlist ships.
            enabled:
              type: boolean
            disabled_reason:
              type:
                - string
                - 'null'
              enum:
                - manual
                - auto_disabled_repeated_failures
                - null
              description: |
                Set only when `enabled` is false: `manual` (the owner disabled it via update) or
                `auto_disabled_repeated_failures` (K9's own auto-disable after too many consecutive
                fully-exhausted deliveries — see `webhook_deliveries.status`). Null whenever `enabled` is true.
            description:
              type:
                - string
                - 'null'
            secret_prefix:
              type:
                - string
                - 'null'
              description: |
                The signing secret's own non-secret prefix (e.g. `whsec_ab12`) — always present once an endpoint has
                a secret, safe to display anywhere the full secret is not (it never re-derives the full value).
            secret:
              type:
                - string
                - 'null'
              description: |
                The full signing secret, in cleartext. Present ONLY in the response to `create` and
                `rotate_secret` — stored hashed thereafter, never returned again and never re-derivable.
      unevaluatedProperties: false
    WebhookEndpointCreateRequest:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          description: >
            Public https only. A private, link-local, loopback or otherwise reserved destination is rejected,

            and so is any `swaps.app` host or a Swaps project's own `supabase.co` host (the platform never delivers to
            itself).
        event_types:
          type: array
          items:
            $ref: '#/components/schemas/EventType'
          description: |
            Subset of the closed event registry this endpoint receives. Omitted or empty means "all". An unknown
            or retired name is `400 invalid_request`. A type marked `catalogued` in `EventType`'s
            `x-swaps-event-status` is accepted but delivers nothing until its outbox allowlist ships.
        description:
          type: string
      additionalProperties: false
    WebhookEndpointUpdateRequest:
      type: object
      properties:
        url:
          type: string
          format: uri
          description: >
            Public https only. A private, link-local, loopback or otherwise reserved destination is rejected,

            and so is any `swaps.app` host or a Swaps project's own `supabase.co` host (the platform never delivers to
            itself).
        event_types:
          type: array
          items:
            $ref: '#/components/schemas/EventType'
          description: An unknown or retired name is `400 invalid_request`.
        enabled:
          type: boolean
          description: |
            Setting `false` disables the endpoint (`disabled_reason` becomes `manual`) and halts future
            deliveries — past deliveries stay in the log. Setting `true` re-enables it and clears
            `disabled_reason`, including one an auto-disable had set.
        description:
          type: string
      additionalProperties: false
    WebhookEndpointList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/WebhookEndpoint'
      unevaluatedProperties: false
    WebhookDelivery:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - endpoint_id
            - event_id
            - attempt
            - status
          properties:
            id:
              type: string
              pattern: ^whd_
            object:
              type: string
              enum:
                - webhook_delivery
            endpoint_id:
              type: string
              pattern: ^whe_
            event_id:
              type: string
              pattern: ^evt_
            attempt:
              type: integer
              minimum: 0
              description: >-
                How many delivery attempts this row has made so far (the worker's own retry schedule; see
                docs/api/WEBHOOKS.md).
            status:
              type: string
              enum:
                - pending
                - succeeded
                - failed
                - exhausted
              description: |
                `pending`: never yet attempted, due now. `succeeded`: a 2xx response — terminal. `failed`: the most
                recent attempt failed and a further retry is already scheduled at `next_attempt_at` (automatic — no
                action needed). `exhausted`: every scheduled attempt failed and no further retry will happen — replay
                it explicitly with `webhook_deliveries.replay`.
            next_attempt_at:
              type:
                - string
                - 'null'
              format: date-time
            response_status:
              type:
                - integer
                - 'null'
              description: >-
                The HTTP status the endpoint returned, or null if the attempt never got a response (timeout, DNS/SSRF
                refusal, connection error).
            response_ms:
              type:
                - integer
                - 'null'
              description: Round-trip time in milliseconds for the most recent attempt. The response BODY is never stored.
            error:
              type:
                - string
                - 'null'
              description: >-
                A short machine-readable failure reason for the most recent attempt (e.g. `timeout`,
                `connection_refused`, `non_2xx_response`, `url_not_allowed`). Never the endpoint's response body.
            delivered_at:
              type:
                - string
                - 'null'
              format: date-time
              description: Set once, when `status` first becomes `succeeded`.
      unevaluatedProperties: false
    WebhookDeliveryList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/WebhookDelivery'
      unevaluatedProperties: false
    WebhookWaitlistRequest:
      type: object
      description: Minimal email capture ahead of the full webhook-delivery system (RESOURCE-MODEL §1 DEV-G9).
      required:
        - email
      properties:
        email:
          type: string
          format: email
      additionalProperties: false
    CardWaitlistRequest:
      type: object
      description: Write-only waitlist capture. No corresponding read.
      required:
        - email
      properties:
        email:
          type: string
          format: email
      additionalProperties: false
    AddressBookEntry:
      description: |
        A saved payment destination, one of seven rail shapes (R16 `layer-ab-add`). Bank-rail fields
        are masked on every read; the rail-specific field present depends on `rail`. `screening`
        carries the latest `/v1/screenings` projection of `address` on `network` and is populated for
        `rail: crypto` only.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - label
            - rail
          properties:
            id:
              type: string
              pattern: ^adr_
            object:
              type: string
              enum:
                - address_book_entry
            label:
              type: string
            rail:
              type: string
              enum:
                - crypto
                - iban
                - ach
                - faster_payments
                - pix
                - spei
                - swift
            network:
              type:
                - string
                - 'null'
              description: 'rail: crypto only.'
            address:
              type:
                - string
                - 'null'
              description: 'rail: crypto only. Case-preserved verbatim — never lowercased.'
            iban_masked:
              type:
                - string
                - 'null'
              description: 'rail: iban only.'
            bic:
              type:
                - string
                - 'null'
              description: 'rail: iban or swift.'
            routing_number_masked:
              type:
                - string
                - 'null'
              description: 'rail: ach only.'
            account_number_masked:
              type:
                - string
                - 'null'
              description: 'rail: ach, faster_payments or swift.'
            sort_code_masked:
              type:
                - string
                - 'null'
              description: 'rail: faster_payments only.'
            pix_key_type:
              type:
                - string
                - 'null'
              description: 'rail: pix only.'
            pix_key_masked:
              type:
                - string
                - 'null'
              description: 'rail: pix only.'
            clabe_masked:
              type:
                - string
                - 'null'
              description: 'rail: spei only.'
            bank_address:
              type:
                - string
                - 'null'
              description: 'rail: swift only.'
            screening:
              description: >-
                rail: crypto only — the latest screening of `address` on `network` by this account, from
                `screenings.create` or `address_book.recheck`. `network` is matched the way `screenings.create` reads
                `chain` (a chain id or name, case-insensitive), so an entry saved as `Ethereum` carries a screening
                recorded under chain `1`. Null only when no such screening exists: no `/v1` screening of it yet (checks
                made in the v1 dashboard are not carried over, so null does not mean never checked), or screening does
                not cover the entry — a network such as Tempo, or an address that cannot exist on its network — and its
                `address_book.recheck` answers `409 network_not_supported`, never an Ethereum verdict.
              oneOf:
                - $ref: '#/components/schemas/Screening'
                - type: 'null'
            last_checked_at:
              type:
                - string
                - 'null'
              format: date-time
              description: >-
                `screened_at` of `screening`; null exactly when `screening` is null — a check made in the v1 dashboard
                is not carried over.
            beneficiary_type:
              description: '`self` for the holder''s own destination, `third-party` once a beneficiary attestation exists.'
              oneOf:
                - type: 'null'
                - type: string
                  enum:
                    - self
                    - third-party
            beneficiary_subtype:
              description: >-
                Null until a beneficiary attestation exists. Set to `individual`/`legal_entity`/`vasp_customer` by
                `address_book.beneficiary.update`; set to `self_verified`/`self_unverified` by the dashboard's
                proof-of-control flow (not itself exposed on `/v1`).
              oneOf:
                - type: 'null'
                - type: string
                  enum:
                    - individual
                    - legal_entity
                    - vasp_customer
                    - self_verified
                    - self_unverified
            proof_of_control_method:
              description: >-
                Read-only. Null until the destination is verified through the dashboard's proof-of-control flow — that
                verification step is not exposed on `/v1` (out of scope for this operation). `micro_deposit` is
                published because a live v1 writer already uses it, even though today's DB CHECK constraint has not been
                widened to accept it (a separate v1 bug) — publishing it now costs nothing, no consumers exist yet.
              oneOf:
                - type: 'null'
                - type: string
                  enum:
                    - signed_message
                    - micro_tx
                    - screenshot
                    - micro_deposit
                    - other
            proof_of_control_verified_at:
              type:
                - string
                - 'null'
              format: date-time
              description: Read-only. Non-null rows are what the address-book hub counts as "verified".
            proof_of_control_artifact_url:
              type:
                - string
                - 'null'
              description: Read-only. A Supabase Storage object key in a private bucket — not a directly fetchable public URL.
            travel_rule_required:
              type: boolean
              description: True once a transfer through this destination triggered EU TFR / MiCA / FATF R.16 evaluation.
            travel_rule_last_attested_at:
              type:
                - string
                - 'null'
              format: date-time
              description: >-
                Null until a transfer through this destination has been Travel-Rule-attested. Stamped only by the
                transfer-time attestation (`attestTransfer`, mid-transfer) — never written by any `/v1` operation,
                including `address_book.beneficiary.update`.
            counterparty_name:
              type:
                - string
                - 'null'
              description: >-
                The saved beneficiary's name. Null until a beneficiary attestation exists. Set by
                `address_book.beneficiary.update`. No pattern/length constraint on this READ shape, deliberately — so a
                row already stored before a request-side constraint existed can never fail the whole list/get response,
                the same failure mode finding #5 flagged for `proof_of_control_method`. The constraint lives on the
                request schema (`TravelRuleAttestationCreateRequest`) instead.
            counterparty_country:
              type:
                - string
                - 'null'
              description: ISO 3166-1 alpha-2, uppercased on write. Null until a beneficiary attestation exists.
            counterparty_vasp_name:
              type:
                - string
                - 'null'
              description: The counterparty VASP's name. Only set when `beneficiary_subtype` is `vasp_customer`.
            counterparty_vasp_jurisdiction:
              type:
                - string
                - 'null'
              description: ISO 3166-1 alpha-2, uppercased on write. Only set when `beneficiary_subtype` is `vasp_customer`.
      unevaluatedProperties: false
    AddressBookEntryCreateRequest:
      description: |
        One of seven fully-specified rail shapes, a real discriminated union on `rail` — not a flat bag of
        optional fields. Bank-rail details cross the boundary once here; `/v1` never echoes them back unmasked
        on a read (the read shape is `AddressBookEntry`).
      oneOf:
        - $ref: '#/components/schemas/AddressBookEntryCreateRequestCrypto'
        - $ref: '#/components/schemas/AddressBookEntryCreateRequestIban'
        - $ref: '#/components/schemas/AddressBookEntryCreateRequestAch'
        - $ref: '#/components/schemas/AddressBookEntryCreateRequestFasterPayments'
        - $ref: '#/components/schemas/AddressBookEntryCreateRequestPix'
        - $ref: '#/components/schemas/AddressBookEntryCreateRequestSpei'
        - $ref: '#/components/schemas/AddressBookEntryCreateRequestSwift'
      discriminator:
        propertyName: rail
        mapping:
          crypto: '#/components/schemas/AddressBookEntryCreateRequestCrypto'
          iban: '#/components/schemas/AddressBookEntryCreateRequestIban'
          ach: '#/components/schemas/AddressBookEntryCreateRequestAch'
          faster_payments: '#/components/schemas/AddressBookEntryCreateRequestFasterPayments'
          pix: '#/components/schemas/AddressBookEntryCreateRequestPix'
          spei: '#/components/schemas/AddressBookEntryCreateRequestSpei'
          swift: '#/components/schemas/AddressBookEntryCreateRequestSwift'
    AddressBookEntryCreateRequestCrypto:
      type: object
      description: 'rail: crypto.'
      required:
        - label
        - rail
        - network
        - address
      properties:
        label:
          type: string
        rail:
          type: string
          enum:
            - crypto
        network:
          type: string
          description: >-
            Saved as sent; any network can be saved. Screening covers only the networks `ScreeningCreateRequest.chain`
            lists — an entry on any other reads `screening: null` and its `address_book.recheck` answers `409
            network_not_supported`.
        address:
          type: string
          description: Case-preserved verbatim — never lowercase a BTC, Tron or Solana address.
      additionalProperties: false
    AddressBookEntryCreateRequestIban:
      type: object
      description: 'rail: iban.'
      required:
        - label
        - rail
        - account_holder
        - iban
        - bic
      properties:
        label:
          type: string
        rail:
          type: string
          enum:
            - iban
        iban:
          type: string
        bic:
          type: string
        account_holder:
          type: string
          minLength: 1
          description: >-
            Legal name of the account owner, as the bank holds it (a person's full name or a company's registered name).
            A payment link settles to this destination under this name. Blank after trimming is refused. Stored trimmed
            and not returned on a read.
      additionalProperties: false
    AddressBookEntryCreateRequestAch:
      type: object
      description: 'rail: ach.'
      required:
        - label
        - rail
        - account_holder
        - account_number
        - routing_number
        - account_type
      properties:
        label:
          type: string
        rail:
          type: string
          enum:
            - ach
        account_number:
          type: string
        routing_number:
          type: string
        account_type:
          type: string
          enum:
            - checking
            - savings
        account_holder:
          type: string
          minLength: 1
          description: >-
            Legal name of the account owner, as the bank holds it (a person's full name or a company's registered name).
            A payment link settles to this destination under this name. Blank after trimming is refused. Stored trimmed
            and not returned on a read.
      additionalProperties: false
    AddressBookEntryCreateRequestFasterPayments:
      type: object
      description: 'rail: faster_payments.'
      required:
        - label
        - rail
        - account_holder
        - sort_code
        - account_number
      properties:
        label:
          type: string
        rail:
          type: string
          enum:
            - faster_payments
        sort_code:
          type: string
        account_number:
          type: string
        account_holder:
          type: string
          minLength: 1
          description: >-
            Legal name of the account owner, as the bank holds it (a person's full name or a company's registered name).
            A payment link settles to this destination under this name. Blank after trimming is refused. Stored trimmed
            and not returned on a read.
      additionalProperties: false
    AddressBookEntryCreateRequestPix:
      type: object
      description: 'rail: pix.'
      required:
        - label
        - rail
        - account_holder
        - pix_key
        - pix_key_type
      properties:
        label:
          type: string
        rail:
          type: string
          enum:
            - pix
        pix_key:
          type: string
        pix_key_type:
          type: string
        account_holder:
          type: string
          minLength: 1
          description: >-
            Legal name of the account owner, as the bank holds it (a person's full name or a company's registered name).
            A payment link settles to this destination under this name. Blank after trimming is refused. Stored trimmed
            and not returned on a read.
      additionalProperties: false
    AddressBookEntryCreateRequestSpei:
      type: object
      description: 'rail: spei.'
      required:
        - label
        - rail
        - account_holder
        - clabe
      properties:
        label:
          type: string
        rail:
          type: string
          enum:
            - spei
        clabe:
          type: string
        account_holder:
          type: string
          minLength: 1
          description: >-
            Legal name of the account owner, as the bank holds it (a person's full name or a company's registered name).
            A payment link settles to this destination under this name. Blank after trimming is refused. Stored trimmed
            and not returned on a read.
      additionalProperties: false
    AddressBookEntryCreateRequestSwift:
      type: object
      description: 'rail: swift.'
      required:
        - label
        - rail
        - account_holder
        - swift_bic
        - account_number
        - bank_address
      properties:
        label:
          type: string
        rail:
          type: string
          enum:
            - swift
        swift_bic:
          type: string
        account_number:
          type: string
        bank_address:
          type: string
        account_holder:
          type: string
          minLength: 1
          description: >-
            Legal name of the account owner, as the bank holds it (a person's full name or a company's registered name).
            SWIFT destinations are not a payment-link settlement destination today: `activate` answers `422
            settlement_rail_unsupported`; use iban, ach, faster_payments, pix or spei. Blank after trimming is refused.
            Stored trimmed and not returned on a read.
      additionalProperties: false
    AddressBookEntryUpdateRequest:
      type: object
      description: Label only — every rail field is immutable after creation (remove and re-add to change a destination).
      required:
        - label
      properties:
        label:
          type: string
      additionalProperties: false
    TravelRuleAttestationCreateRequest:
      type: object
      description: >
        Updates the Travel Rule beneficiary/counterparty details for a saved destination — the same fields the
        dashboard's Manage tab writes (`CounterpartyForm`). Sets `beneficiary_type` to `third-party`; never writes
        `travel_rule_last_attested_at` (stamped only by the transfer-time attestation). `counterparty_*` are readable on
        the entry afterward — not write-only.
      required:
        - beneficiary_subtype
        - counterparty_name
      properties:
        beneficiary_subtype:
          type: string
          enum:
            - individual
            - legal_entity
            - vasp_customer
        counterparty_name:
          type: string
          minLength: 1
          maxLength: 256
        counterparty_country:
          type: string
          pattern: ^[A-Za-z]{2}$
          description: ISO 3166-1 alpha-2, either case (uppercased on write).
        counterparty_vasp_name:
          type: string
          maxLength: 256
          description: Required when `beneficiary_subtype` is `vasp_customer`; refused blank, exactly like the dashboard form.
        counterparty_vasp_jurisdiction:
          type: string
          pattern: ^[A-Za-z]{2}$
          description: >-
            ISO 3166-1 alpha-2, either case (uppercased on write). Only meaningful when beneficiary_subtype is
            vasp_customer.
      additionalProperties: false
    AddressBookList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/AddressBookEntry'
      unevaluatedProperties: false
    Screening:
      description: |
        A risk signal, never a verdict: `action` is a suggestion, not an authorization, and the
        audit row backing this object stores an address hash, never the address itself.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - address
            - chain
            - risk_score
            - risk_level
            - verdict
            - action
            - cached
            - screened_at
          properties:
            id:
              type: string
              pattern: ^scr_
            object:
              type: string
              enum:
                - screening
            address:
              type: string
              description: Case-preserved verbatim.
            chain:
              type: string
            risk_score:
              type: integer
              minimum: 0
              maximum: 100
            risk_level:
              type: string
              enum:
                - low
                - medium
                - high
                - critical
                - unknown
            verdict:
              type: string
              description: Derived, coarser than `risk_level`. Still a signal, never a block decision.
              enum:
                - clear
                - caution
                - risky
                - no_clear_verdict
                - not_checked
            flags:
              type: array
              items:
                type: string
            details:
              type: object
              description: Free-form, source-shaped detail — never re-typed per source.
              additionalProperties: true
            confidence:
              type:
                - number
                - 'null'
              minimum: 0
              maximum: 1
            coverage:
              type: array
              items:
                type: string
              description: The sources actually checked, so a client can see what was not.
            action:
              type: string
              description: A suggestion the caller may act on — never an authorization by itself.
              enum:
                - allow
                - offer_report
                - block
            provider_version:
              type: string
            cached:
              type: boolean
            screened_at:
              type: string
              format: date-time
      unevaluatedProperties: false
    ScreeningCreateRequest:
      type: object
      required:
        - address
        - chain
      properties:
        address:
          type: string
          description: Case-preserved verbatim — never lowercase a BTC, Tron or Solana address.
        chain:
          type: string
          description: >-
            The network to screen on, by chain id or name, case-insensitive: Ethereum (`1`, `eth`), BNB Chain (`56`,
            `bsc`), Polygon (`137`), Arbitrum (`42161`), Optimism (`10`), Base (`8453`), Avalanche (`43114`), `bitcoin`,
            `tron`, `solana`, `xrp` (`XRP Ledger`), `ton`, `cardano`, `cosmos`. Any other value — blank, `tempo`,
            `litecoin` — whatever the address, and an address that cannot exist on the named network (an EVM address on
            `bitcoin`, a Bitcoin address on `tron`) are refused `409 network_not_supported`; nothing is ever screened as
            Ethereum by default. An address is judged by the named network's own address format; on `solana` that is any
            base58 string of 32–44 characters.
      additionalProperties: false
    ScreeningList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Screening'
      unevaluatedProperties: false
    ScreeningReport:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - counterparties
            - evidence_pdf
            - cached
          properties:
            id:
              type: string
              pattern: ^rpt_
            object:
              type: string
              enum:
                - screening_report
            counterparties:
              type: array
              items:
                type: object
                properties:
                  address:
                    type: string
                  label:
                    type:
                      - string
                      - 'null'
                  risk_level:
                    type: string
                    enum:
                      - low
                      - medium
                      - high
                      - critical
                      - unknown
            evidence_pdf:
              type: object
              required:
                - available
              properties:
                available:
                  type: boolean
            cached:
              type: boolean
              description: True when this report was served from the 30-day cache rather than spending a fresh credit.
      unevaluatedProperties: false
    ScreeningReportList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/ScreeningReport'
      unevaluatedProperties: false
    TraceRequest:
      type: object
      required:
        - hash
        - chain
      properties:
        hash:
          type: string
          description: >-
            The origin transaction hash to trace forward from (the incident transfer, e.g. the transaction that sent
            funds to a scammer) — a trace always starts from a specific transaction, never a bare address, since an
            address alone has no single "flow" to follow.
        chain:
          type: string
      additionalProperties: false
    Trace:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - hops
            - counterparties
          properties:
            id:
              type: string
              pattern: ^trc_
            object:
              type: string
              enum:
                - trace
            hops:
              type: array
              items:
                type: object
                properties:
                  address:
                    type: string
                  chain:
                    type: string
                  amount:
                    oneOf:
                      - $ref: '#/components/schemas/Money'
                      - type: 'null'
                  direction:
                    type:
                      - string
                      - 'null'
                    enum:
                      - in
                      - out
                      - null
            counterparties:
              type: array
              items:
                type: object
                properties:
                  address:
                    type: string
                  label:
                    type:
                      - string
                      - 'null'
                  risk_level:
                    type: string
                    enum:
                      - low
                      - medium
                      - high
                      - critical
                      - unknown
      unevaluatedProperties: false
    ChainTransaction:
      type: object
      description: >-
        One on-chain transaction, read by `{chain, hash}` — not a stored, independently addressable object, so it
        carries no `id`.
      required:
        - chain
        - hash
        - amounts
        - transfers
        - counterparties
      properties:
        chain:
          type: string
        hash:
          type: string
        amounts:
          type: array
          items:
            $ref: '#/components/schemas/Money'
        transfers:
          type: array
          items:
            type: object
            properties:
              from:
                type: string
              to:
                type: string
              amount:
                $ref: '#/components/schemas/Money'
              asset:
                type: string
              direction:
                type:
                  - string
                  - 'null'
                enum:
                  - in
                  - out
                  - null
        counterparties:
          type: array
          description: A short risk brief on each side of the transaction.
          items:
            type: object
            properties:
              address:
                type: string
              label:
                type:
                  - string
                  - 'null'
              risk_level:
                type: string
                enum:
                  - low
                  - medium
                  - high
                  - critical
                  - unknown
      additionalProperties: false
    Credits:
      type: object
      required:
        - free_remaining
        - paid_balance
        - total_checks
      properties:
        free_remaining:
          type: integer
          minimum: 0
        paid_balance:
          type: integer
          minimum: 0
        total_checks:
          type: integer
          minimum: 0
      additionalProperties: false
    CreditEvent:
      type: object
      description: One ledger row. Not independently addressable — carries no `id`, only a list.
      required:
        - type
        - amount
        - balance_after
        - created_at
      properties:
        type:
          type: string
          enum:
            - consume_free
            - consume_paid
            - cache_hit
            - insufficient
            - purchase
            - refund
            - admin_grant
        amount:
          type: integer
          description: Signed credit delta — negative for a consumption, positive for a purchase, refund or grant.
        address:
          type:
            - string
            - 'null'
          description: The screened address this event relates to, when one exists.
        chain:
          type:
            - string
            - 'null'
        balance_after:
          type: integer
        created_at:
          type: string
          format: date-time
      additionalProperties: false
    CreditEventList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/CreditEvent'
            events_total:
              type: integer
              minimum: 0
              description: >-
                Lifetime, exact row count of every `credit_events` entry for this account, across all `type` values —
                never capped by `limit`/`cursor` pagination and never limited to the page currently returned in `data`.
                Omitted (not `null`, not `0`) on the rare failure of the underlying count query — a caller must treat a
                missing field as "unknown right now", not as zero.


                Displayed side by side with `GET /v1/credits`'s own `total_checks` as "N events · M reports" — never
                summed. The two counters are not on the same basis and are not reconcilable against each other:
                `events_total` is an append-only row count (it includes the `refund` row itself, alongside the
                `consume_free`/`consume_paid` row it refunds), while `total_checks` is a net counter that is incremented
                on consume and DECREMENTED, clamped at zero, on refund. Summing them double-counts every refunded check,
                and the two drift further apart with every refund an account accumulates.
      unevaluatedProperties: false
    CreditCheckoutRequest:
      type: object
      required:
        - pack
      properties:
        pack:
          type: integer
          enum:
            - 5
            - 10
            - 20
      additionalProperties: false
    CreditCheckout:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          description: A Stripe Checkout session url — redirect the holder there to complete the purchase.
      unevaluatedProperties: false
    ApiKey:
      description: |
        Dashboard session caller only — a key can never mint another key. Blocked on D-11
        (`api_keys` has no owner column an authorized read could key on) until that lands.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - name
            - key_prefix
            - last_four
            - scopes
            - plan
            - status
            - default_swaps_version
          properties:
            id:
              type: string
              pattern: ^key_
            object:
              type: string
              enum:
                - api_key
            name:
              type: string
            key_prefix:
              type: string
              description: '`sk_live_` or `sk_test_`, matching `livemode`.'
            last_four:
              type: string
            default_swaps_version:
              type: string
              format: date
              description: |
                The `Swaps-Version` this key pins requests to when the caller sends no `Swaps-Version` header
                (API-CANON §3, RESOURCE-MODEL §0.6). A request that does send the header overrides this default
                for that call only; this field is unaffected.
            scopes:
              type: array
              description: >-
                A key created before the `screenings.*` rename may still list the legacy
                `screening.read`/`screening.write`.
              items:
                type: string
                pattern: ^[a-z_]+\.(read|write)$
                description: '`<resource>.<read|write>`.'
            plan:
              type: object
              required:
                - name
                - per_minute
                - per_day
              properties:
                name:
                  type: string
                  enum:
                    - free
                    - pro
                    - enterprise
                per_minute:
                  type: integer
                per_day:
                  type: integer
            allowed_ips:
              type: array
              items:
                type: string
            expires_at:
              type:
                - string
                - 'null'
              format: date-time
            last_used_at:
              type:
                - string
                - 'null'
              format: date-time
            revoked_at:
              type:
                - string
                - 'null'
              format: date-time
            status:
              type: string
              enum:
                - active
                - revoked
                - expired
      unevaluatedProperties: false
    ApiKeyCreateRequest:
      type: object
      required:
        - name
        - scopes
        - livemode
      properties:
        name:
          type: string
        scopes:
          type: array
          minItems: 1
          description: >-
            `<resource>.<read|write>`. The legacy `screening.read`/`screening.write` are deprecated aliases, accepted
            until the next `Swaps-Version` date and stored as `screenings.read`/`screenings.write`.
          items:
            type: string
            pattern: ^[a-z_]+\.(read|write)$
          x-swaps-deprecated-alias:
            - value: screening.read
              replacement: screenings.read
              accepted_until: next Swaps-Version date
            - value: screening.write
              replacement: screenings.write
              accepted_until: next Swaps-Version date
        livemode:
          type: boolean
        default_swaps_version:
          type: string
          format: date
          description: Optional; defaults to the current `Swaps-Version` when omitted.
        allowed_ips:
          type: array
          items:
            type: string
        expires_at:
          type: string
          format: date-time
      additionalProperties: false
    ApiKeyCreated:
      description: Returned once, at creation and again on `roll` — the plaintext secret is never shown or retrievable again.
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          required:
            - secret
          properties:
            secret:
              type:
                - string
                - 'null'
              description: |
                The full `sk_live_…` / `sk_test_…` value, in cleartext. Present only in the FIRST live response to
                `create`/`roll` — stored hashed thereafter, never returned again and never re-derivable. A replayed
                Idempotency-Key against the same request gets the same body back with this field redacted to null,
                never the cleartext a second time (`AuthenticatedRoute.secretFields`, the same mechanism K9
                introduced for `webhook_endpoints.create`/`.rotate_secret`).
      unevaluatedProperties: false
    ApiKeyList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/ApiKey'
      unevaluatedProperties: false
    ApiKeyUsage:
      type: object
      description: Today-only — no trend history exists yet (RESOURCE-MODEL DEV-G3).
      required:
        - requests_today
        - ok_today
        - errors_today
      properties:
        requests_today:
          type: integer
          minimum: 0
        ok_today:
          type: integer
          minimum: 0
        errors_today:
          type: integer
          minimum: 0
        last_request_at:
          type:
            - string
            - 'null'
          format: date-time
      additionalProperties: false
    RequestLog:
      description: >-
        Raw per-call HTTP telemetry for one of the caller's own keys — distinct from the product-lifecycle `/v1/events`
        catalogue.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - request_id
            - operation
            - status_code
            - ok
            - latency_ms
            - ip
          properties:
            id:
              type: string
              pattern: ^req_
            object:
              type: string
              enum:
                - request_log
            request_id:
              type: string
            operation:
              type: string
              description: The `operationId` this call resolved to, e.g. `screenings.create`.
            status_code:
              type: integer
            ok:
              type: boolean
            latency_ms:
              type: integer
              minimum: 0
            ip:
              type: string
            error_code:
              type:
                - string
                - 'null'
      unevaluatedProperties: false
    RequestLogList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/RequestLog'
      unevaluatedProperties: false
    PaymentLinkItem:
      type: object
      description: >-
        A line item on a payment link (`payment_link_items`). Quantity and unit price are locked once set; `unit` is a
        late, design-forward addition (RESOURCE-MODEL v2 amendment, R11 PL-G4).
      properties:
        product_id:
          type:
            - string
            - 'null'
          pattern: ^prd_
        name:
          type: string
        description:
          type:
            - string
            - 'null'
        quantity:
          type: integer
          minimum: 1
        unit_price:
          $ref: '#/components/schemas/MoneyNonNegative'
        unit:
          type:
            - string
            - 'null'
          enum:
            - item
            - hour
            - day
            - project
            - null
          description: >-
            Optional unit of measure. Design-forward (RESOURCE-MODEL v2 amendment, R11 PL-G4) — not a confirmed backend
            column.
        sort_order:
          type: integer
        metadata:
          type: object
          additionalProperties: true
      additionalProperties: false
    PaymentLink:
      description: >-
        A request for money — the merchant's own view (RESOURCE-MODEL §2.1). Distinct from the payer-facing projection
        at `GET /v1/payment_sessions/{token}`, which never carries these same field names verbatim.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - status
            - amount
            - title
            - memo
            - invoice_number
            - reference_id
            - client_id
            - direction
            - expected_payer_type
            - payable_rail_kinds
            - reminder_schedule
            - expires_at
            - payer_email
            - settlement_kind
            - settlement_asset
            - settlement_destination
            - short_code
            - url
            - short_url
            - viewed_at
            - paid_at
            - settled_at
          properties:
            id:
              type: string
              pattern: ^pl_
            object:
              type: string
              enum:
                - payment_link
            status:
              type: string
              enum:
                - draft
                - active
                - viewed
                - processing
                - paid
                - settled
                - expired
                - cancelled
                - refunded
              description: >-
                `refunded` reads "Refunded" to the merchant and "Returned" to the payer on the same underlying value
                (R11 §3) — one enum member, two audience labels.
            title:
              type:
                - string
                - 'null'
            memo:
              type:
                - string
                - 'null'
              description: >-
                Labelled "Memo" on Payment Links merchant screens; the crypto-processing surfaces label this same field
                "Description" (R19 CP-G15) — one field, two display labels, never two schema fields.
            invoice_number:
              type:
                - string
                - 'null'
            reference_id:
              type:
                - string
                - 'null'
              readOnly: true
              description: >-
                A short, non-technical, server-minted id in `CP-<n>` form (e.g. `CP-1042`), unique per merchant. Set by
                the server at creation, never accepted as input. Distinct from `invoice_number` above, which is an
                optional, merchant-typed reference that may repeat or be null, and from `id` (the `pl_`-prefixed
                identifier), whose raw form is not meant to be shown to a payer or merchant as a reference number.
                Present on every payment link, not only crypto-processing ones.
            amount:
              $ref: '#/components/schemas/Money'
            client_id:
              type:
                - string
                - 'null'
              pattern: ^cli_
            direction:
              type:
                - string
                - 'null'
              readOnly: true
              description: >-
                System-set at activation, never an input (RESOURCE-MODEL §2.1 invariants). Enum values are not published
                in RESOURCE-MODEL; left open rather than invented.
            expected_payer_type:
              type: string
              enum:
                - any
                - business
                - individual
              default: any
            allowed_rails:
              type: array
              items:
                type: string
                x-swaps-open-enum: true
                enum:
                  - ach
                  - wire
                  - fednow
                  - sepa
                  - faster_payments
                  - pix
                  - spei
                  - crypto_tempo
                  - crypto_bridge
                  - crypto_relay
              description: >-
                `crypto_relay` (R19 X2) is a payer rail behind the `crypto_relay_rail` flag (off in production), never a
                stored restriction. A `settlement_kind: crypto_only` link offers `crypto_tempo`, plus `crypto_relay`
                where its gate admits it (the same on `payable_rails` and in the payer session): that flag and an
                admitted route (`founder_accepted` per founder §52.34, or `proven`; see K13-CRYPTO-RELAY-ROUTES.md).
                A1-2: open on THIS response field only — an unrecognized rail here is an opaque string, never a
                deserialization failure. The same vocabulary on `PaymentLinkCreateRequest`/`PaymentLinkUpdateRequest`
                stays closed: a merchant choosing an unknown rail is a real request error, not a fact to tolerate.
            payable_rails:
              type: array
              readOnly: true
              items:
                type: string
                x-swaps-open-enum: true
                enum:
                  - ach
                  - wire
                  - fednow
                  - sepa
                  - faster_payments
                  - pix
                  - spei
                  - crypto_tempo
                  - crypto_bridge
                  - crypto_relay
              description: >-
                The rails the payer is offered now (one exception below), recomputed on every read; not a snapshot
                (RESOURCE-MODEL §2.1 v2 amendment). A later read can differ from an earlier one when a rail flag, a
                Relay route or the Relay cap changes. `crypto_relay` is listed exactly when the payer session of this
                link offers it (the same gate: the `crypto_relay_rail` flag for this merchant, the per-invoice cap, an
                admitted route for the link's Tempo settlement network, a USD link). If that gate cannot be read,
                `crypto_relay` is left out and the read still succeeds, where the payer session answers `503
                temporarily_unavailable`. Exception: an individual (`direction: p2p`) merchant whose Bridge fiat pay-in
                is not active yet is listed with its bank rails, although the payer session withholds them (C4-D23;
                tracked in #3977). Open-enum like `allowed_rails` above (queue item #3560) — the same
                forward-compatibility reasoning applies to a computed field.
            payable_rail_kinds:
              type: object
              readOnly: true
              description: >-
                Computed grouping of `payable_rails` into the three UI kinds (RESOURCE-MODEL §2.1 v2 amendment; R11
                PL-G5).
              properties:
                bank:
                  type: boolean
                crypto:
                  type: boolean
                card:
                  type: boolean
              additionalProperties: false
            accepted_rail_kinds:
              type: array
              items:
                type: string
                enum:
                  - bank
                  - crypto
                  - card
              description: >-
                A `crypto_only` settlement suppresses every fiat rail, so this reads `[crypto]` (RESOURCE-MODEL §2.1 v2
                amendment; R19 §2.1).
            expires_at:
              type:
                - string
                - 'null'
              format: date-time
            payer_email:
              type:
                - string
                - 'null'
              format: email
            reminder_schedule:
              $ref: '#/components/schemas/ReminderSchedule'
            settlement_kind:
              type:
                - string
                - 'null'
              readOnly: true
              enum:
                - bridge
                - crypto_only
                - null
              description: >-
                System-set at activation, never an input (RESOURCE-MODEL §2.1 invariants; RESOURCE-MODEL §2.1 v2
                amendment). Null on a draft that has not activated yet.
            settlement_asset:
              readOnly: true
              description: >-
                BL-39 (C4-D27) — the token identity frozen from `settlement_snapshot` at ACTIVATION, never a live chain
                read and never derived from a rate. `null` on a draft (no snapshot yet) or a non-Tempo settlement —
                Bridge bank/crypto destinations have no single payer-facing "token" this field describes. Carries no
                amount of its own.
              oneOf:
                - $ref: '#/components/schemas/SettlementAsset'
                - type: 'null'
            settlement_destination:
              readOnly: true
              description: >-
                BL-50 — the saved `/v1/address_book` entry `create`/`update` set as where this link's funds land. `null`
                until chosen. Read from the SAME reference `activate` freezes into `settlement_snapshot` (never cleared
                by activation), so it keeps reading the live destination after the link goes live too — distinct from
                `settlement_asset` above, which is the token identity frozen at that one moment.
              oneOf:
                - $ref: '#/components/schemas/SettlementDestinationView'
                - type: 'null'
            items:
              type: array
              items:
                $ref: '#/components/schemas/PaymentLinkItem'
            short_code:
              type: string
              pattern: ^[0-9bcdfghjkmnpqrstvwxyz]{7}$
              readOnly: true
              description: >-
                C5-SHORT-LINK — the link's short public code (e.g. `7fq3k2c`): 7 characters of lowercase Crockford
                base32 without the vowels `a`/`e` (so also no `i`, `l`, `o`, `u`). Minted by the server from a
                cryptographic random source when the link is created, for every link (draft or live, live or test mode),
                never accepted as input, never derived from ids, amounts, e-mails or names, and never changed
                afterwards. Unique across the project's links. It resolves to the payer session through `GET
                /v1/payment_sessions/by_code/{short_code}`, so treat it like `url`: share it with the payer only.
            url:
              type:
                - string
                - 'null'
              format: uri
            short_url:
              type:
                - string
                - 'null'
              format: uri
              readOnly: true
              description: >-
                C5-SHORT-LINK — `<payer host>/p/<short_code>` (e.g. `https://swaps.app/p/7fq3k2c`), on the same
                per-project payer host as `url` and under the same rule: `null` exactly when `url` is `null`. Not yet a
                working payer link: it resolves only once the `/p/:code` page is live on that host, and only while the
                `/v1` API is enabled; share `url` until then.
            viewed_at:
              type:
                - string
                - 'null'
              format: date-time
              readOnly: true
              description: RESOURCE-MODEL §2.1 v2 amendment, R11 PL-G1.
            paid_at:
              type:
                - string
                - 'null'
              format: date-time
              readOnly: true
            settled_at:
              type:
                - string
                - 'null'
              format: date-time
              readOnly: true
            partial_payment:
              type:
                - object
                - 'null'
              readOnly: true
              description: >-
                Set only while `status` is `processing` and a payment arrived short of `amount` (§8.4). `received`
                reports the single MOST RECENT short payment recorded for this link's CURRENT attempt — one deposit's
                figure, not a running total of everything the link has received — and is `null` when that deposit's
                settled amount could not be confirmed (shown to the merchant as "needs review"). `null` while
                `processing` with nothing recorded, and again once the link leaves `processing` for a later full payment
                or a cancellation. A no-funds failure on an attempt moves the link to `viewed` and clears this field to
                `null`; it does NOT reopen the link to `processing`. The flag CAN reappear, but only when a LATER
                attempt creates a new transfer and puts the link back into `processing` — if that new attempt also
                under-pays, its own event is what reappears here, never an earlier attempt's superseded figure. The key
                itself may be absent when the underlying signal could not be read — that is not the same as `null`.
              properties:
                received:
                  description: Null when the settled amount could not be confirmed.
                  oneOf:
                    - $ref: '#/components/schemas/Money'
                    - type: 'null'
                expected:
                  $ref: '#/components/schemas/Money'
              required:
                - received
                - expected
              additionalProperties: false
      unevaluatedProperties: false
    PaymentLinkList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/PaymentLink'
            summary:
              $ref: '#/components/schemas/PaymentLinkListSummary'
      unevaluatedProperties: false
    PaymentLinkListSummary:
      type: object
      x-swaps-since: '2026-09-25'
      description: >-
        Present only with `?expand=summary` (LIST-SUMMARY-1). Computed server-side over every link matching the
        request's `status_group`, `q`, `client_id` and `settlement_kind` (never `cursor`/`limit`), in one grouped query.
        `by_status_group` uses the `status_group` buckets exactly; they overlap (`viewed`/`processing` are both `open`
        and `needs_attention`), so they do not add up to `total`.
      required:
        - total
        - by_status_group
        - collected
      properties:
        total:
          type: integer
          minimum: 0
        by_status_group:
          type: object
          required:
            - open
            - needs_attention
            - paid
            - ended
          properties:
            open:
              type: integer
              minimum: 0
            needs_attention:
              type: integer
              minimum: 0
            paid:
              type: integer
              minimum: 0
            ended:
              type: integer
              minimum: 0
          additionalProperties: false
        collected:
          type: array
          description: >-
            The amount of every matching `paid` or `settled` link, summed exactly per currency (minor units, same
            `Money` rules as `PaymentLink.amount`), sorted by currency. Empty when none. The link's requested amount,
            not a settlement ledger.
          items:
            $ref: '#/components/schemas/Money'
      additionalProperties: false
    SettlementDestination:
      type: object
      description: >-
        A REQUEST-side settlement destination reference (BL-50) — `create`/`update`'s own input shape, always naming a
        real, owned `/v1/address_book` entry (bank or crypto, RESOURCE-MODEL §2.4); `address_book_id` is
        ownership-checked server-side on every write and again at `activate` — a foreign or unknown id answers the
        identical `404 not_found` either way (RESOURCE-MODEL §0.7), never a distinguishing error. See {@link
        SettlementDestinationView} for the RESPONSE shape, which additionally allows `address_book_id: null` (a
        wallet-via-bridge destination has no address_book row) — the two never disagree about the id format, only about
        whether a client can ever legitimately send a null one (it cannot: there is nothing to create or replace with
        "no destination", only `settlement_destination: null` at the top level clears the field, per
        `PaymentLinkUpdateRequest.settlement_destination`'s own three-state convention).
      required:
        - address_book_id
      properties:
        address_book_id:
          type: string
          pattern: ^adr_
      additionalProperties: false
    SettlementDestinationView:
      type: object
      description: >-
        Where an activated link's funds land, as PUBLISHED on `PaymentLink`/`PaymentLinkReceipt` — a REFERENCE to
        something the account already owns, never a raw address or bank detail on the wire (BL-50). Usually a saved
        `/v1/address_book` entry, same `address_book_id` {@link SettlementDestination} (the REQUEST shape) accepts.
        Fixer round (§52.33, PL-TEMPO-BRIDGE-4-T, findings #2/#8) — `address_book_id` is `null` for a "settle to my
        Swaps Wallet" link routed through Bridge (`payment_links_tempo_via_bridge`): that destination is the account's
        OWN wallet, resolved server-side, with no `address_book` row behind it, but `provider` (below) still publishes
        the FX-leg disclosure for it exactly like an address-book Tempo destination does. An object rather than a bare
        string so a future destination kind can be added without a breaking change.
      required:
        - address_book_id
      properties:
        address_book_id:
          type:
            - string
            - 'null'
          pattern: ^adr_
        provider:
          description: >-
            PL-TEMPO-BRIDGE-1-T (§52.33, independent money review — the FX-leg P1); mechanism fixed by
            PL-TEMPO-BRIDGE-2-T (Bridge external accounts are fiat-only — sandbox proof: `400 invalid_parameters` on
            `account_type: 'crypto'`). Present ONLY once activation has materialized a Bridge COLLECTION account for
            this destination (a non-USD link's Tempo wallet settling through Bridge instead of the non-custodial
            `crypto_tempo` splitter) — absent for every other destination (a plain Tempo splitter address, a Bridge bank
            rail, or a draft with no snapshot yet). `status: 'created'` is the only value this can ever carry: a Bridge
            REFUSAL of the collection account (`settlement_destination_provider_refused`) fails the `activate` call
            itself before anything is persisted, so a link can never be read back with any other status here. Carries no
            exchange rate or fee — that data exists only once a real transfer settles; this object reports the
            destination Bridge holds, never a quote. `last4` is the settlement address's own last 4 characters (the
            collection template's `to_address`), never an account id — there is no external account.
          oneOf:
            - type: object
              required:
                - id
                - status
                - network
                - last4
              properties:
                id:
                  type: string
                  enum:
                    - bridge
                status:
                  type: string
                  enum:
                    - created
                network:
                  type: string
                  enum:
                    - tempo
                  description: >-
                    Bridge's own chain slug for this collection account's destination (`payment_rail: 'tempo'`) —
                    distinct from `SettlementAsset.network`, which labels the non-custodial splitter's token instead.
                last4:
                  type: string
                  description: The settlement address's own last 4 characters. Never the full address.
              additionalProperties: false
            - type: 'null'
      additionalProperties: false
    PaymentLinkCreateRequest:
      type: object
      description: >-
        Creates a `draft`. Nothing is charged and no rail goes live until `activate` (D-15). `reminder_schedule.sent[]`
        is server-owned — this body has no `reminder_schedule` field, and any caller-supplied value on that path is
        stripped by the router before the create action runs (MON-13). `settlement_destination` may be set here or left
        for a later `update` — either way `activate` refuses `400 settlement_destination_required` until one is chosen.
      required:
        - amount
      properties:
        title:
          type: string
          description: >-
            Optional — a blank title is treated as absent. When absent, it is derived server-side: `invoice_number` (as
            `Invoice <invoice_number>`) if set, else the first line item's name (or, when unnamed, the name of the
            product it resolves to), with `+N more` appended for additional items. Only when none of those can supply a
            value does create answer `400 invalid_request` naming `title`. A derived title longer than 200 characters is
            truncated with an ellipsis (L4-10).
        memo:
          type: string
        invoice_number:
          type: string
        amount:
          $ref: '#/components/schemas/MoneyPositive'
        client_id:
          type: string
          pattern: ^cli_
        expected_payer_type:
          type: string
          enum:
            - any
            - business
            - individual
          default: any
        allowed_rails:
          type: array
          items:
            type: string
            enum:
              - ach
              - wire
              - fednow
              - sepa
              - faster_payments
              - pix
              - spei
              - crypto_tempo
              - crypto_bridge
              - crypto_relay
          description: >-
            Rails to offer the payer on this link. Use `accepted_rail_kinds` to draft a crypto-only invoice — setting
            rails here alone does not select `settlement_kind`.
        accepted_rail_kinds:
          type: array
          items:
            type: string
            enum:
              - bank
              - crypto
              - card
          description: >-
            Set to `[crypto]` for a link settling to your OWN Swaps Wallet (R19 CP-G2) — there is no separate
            `settlement_kind` input (RESOURCE-MODEL §2.1 invariants: `settlement_kind` is system-set, never an input).
            On a `USD` request this routes the later `activate` call onto the Bridge-less `crypto_tempo` splitter
            branch, gated by `payment_links_crypto_only`. On any OTHER currency (§52.33, PL-TEMPO-BRIDGE-4-T, fixer
            round findings #5/#11) it instead routes `activate` onto the SAME Bridge collection path (`payment_rail:
            tempo` + the account's own wallet address) an address-book Tempo destination uses, gated by
            `payment_links_tempo_via_bridge` ALONE — the SAME flag `capabilities.get`'s
            `settlement_currencies[currency].tempo_wallet` reads.
        settlement_destination:
          $ref: '#/components/schemas/SettlementDestination'
          description: >-
            BL-50 — the saved destination `activate` will freeze into `settlement_snapshot`. Naming this alongside
            `accepted_rail_kinds: [crypto]` (a Swaps-Wallet intent) on the SAME create request is refused `400
            settlement_conflicts_with_destination` — reachable only while this account is admitted for that currency
            (`payment_links_crypto_only` for `USD`, `payment_links_tempo_via_bridge` otherwise); when it is not,
            `accepted_rail_kinds: [crypto]` is itself refused first (`409 capability_unavailable` on `USD`, `422
            tempo_via_bridge_not_enabled` otherwise), so the 400 never fires. `update` does not refuse the combination:
            setting a destination there RETIRES an earlier `accepted_rail_kinds: [crypto]` intent instead — see
            `PaymentLinkUpdateRequest.settlement_destination`.
        expires_at:
          type: string
          format: date-time
        payer_email:
          type: string
          format: email
        items:
          type: array
          items:
            $ref: '#/components/schemas/PaymentLinkItem'
      additionalProperties: false
    PaymentLinkUpdateRequest:
      type: object
      description: >-
        Draft-only (RESOURCE-MODEL §2.1) — refused once the link has left `draft`. The reminder cadence has its own
        sub-resource and is not part of this body; `reminder_schedule.sent[]` is server-owned, and any caller value on
        that path is stripped by the router before this action runs (MON-13).
      properties:
        title:
          type: string
          description: >-
            A blank value is refused `400 invalid_request` naming `title` — clearing it is not supported; omit the key
            to leave the stored title unchanged (L4-10).
        memo:
          type: string
        invoice_number:
          type: string
        amount:
          $ref: '#/components/schemas/MoneyPositive'
        client_id:
          type: string
          pattern: ^cli_
        expected_payer_type:
          type: string
          enum:
            - any
            - business
            - individual
        allowed_rails:
          type:
            - array
            - 'null'
          items:
            type: string
            enum:
              - ach
              - wire
              - fednow
              - sepa
              - faster_payments
              - pix
              - spei
              - crypto_tempo
              - crypto_bridge
              - crypto_relay
          description: >-
            Accepted as a partial write mid-draft (R11 PL-G6). Three states, and they are distinct: omit the field to
            leave the restriction as it is, send `null` to REMOVE it (the link then offers every rail available for its
            currency), send an array to replace it. An empty array is not a way to clear it — it is a `400`, because a
            link restricted to no rail cannot be paid. A restriction that leaves the link with no payable rail at all is
            refused `400 allowed_rails_invalid_for_currency` (and `409` at activation), carrying the rails that would
            work in `error.details.offerable_rails`. It narrows what the link can be paid on and never widens it: a rail
            the merchant is not endorsed for stays unavailable whether or not it is named here.
        settlement_destination:
          description: >-
            BL-50 — three-state, same convention as `allowed_rails` above: omit to leave the stored destination as it
            is, send `null` to CLEAR it, send `{address_book_id}` to set or replace it. An id you do not own answers
            `404 not_found`, identical to an unknown one (RESOURCE-MODEL §0.7). Setting a destination here RETIRES an
            earlier `accepted_rail_kinds: [crypto]` (Swaps-Wallet) intent recorded at create — the response stops
            reporting `accepted_rail_kinds` and the link settles to this destination instead; the newer choice always
            wins. `400 settlement_conflicts_with_destination` is refused only by `create`, for naming both in the SAME
            request — `update` never throws it.
          oneOf:
            - $ref: '#/components/schemas/SettlementDestination'
            - type: 'null'
        expires_at:
          type: string
          format: date-time
        payer_email:
          type: string
          format: email
        items:
          type: array
          items:
            $ref: '#/components/schemas/PaymentLinkItem'
      additionalProperties: false
    PaymentLinkActivateRequest:
      type: object
      description: >-
        The money boundary (D-15). `attestation_accepted` is the merchant's own compliance attestation, written back as
        evidence with an ip hash and the caller's auth user id — an agent must never set this on the merchant's behalf,
        which is why this operation is REST-only and excluded from MCP.
      required:
        - attestation_accepted
      properties:
        attestation_accepted:
          type: boolean
          description: Must be `true`; any other value is refused.
      additionalProperties: false
    PaymentLinkReceipt:
      type: object
      description: >-
        The merchant's receipt for a collected link (RESOURCE-MODEL §2.1 v2 amendment: "mirrors the payout receipt").
        Every value is read from the link row and the ONE payment attempt that completed it — never from a status
        string, never estimated. Payment Links has no beneficiary and no multi-provider estimate, so the payout-only
        `beneficiary{}` and `estimates{}` are absent. Shape per A1 D-101 (confirmed amounts, masked settlement
        destination, provider reference). `fee`, `deducted_fee` and `net_amount` are the same values `GET
        /v1/payments/{payment_id}` returns for the completing payment, so the two surfaces never disagree.
      required:
        - payment_link_id
        - payment_id
        - title
        - invoice_number
        - amount
        - payment_rail
        - amount_received
        - fee
        - deducted_fee
        - net_amount
        - settlement_kind
        - settlement_destination
        - provider_reference
        - onchain_tx_hash
        - paid_at
        - settled_at
        - note
      properties:
        payment_link_id:
          type: string
          pattern: ^pl_
        payment_id:
          type: string
          pattern: ^pay_
          description: The payment (attempt) that completed the link — `GET /v1/payments/{id}` returns it.
        title:
          type: string
        invoice_number:
          type:
            - string
            - 'null'
        amount:
          description: What the link asked for, in its own currency — the ask, not an observation.
          allOf:
            - $ref: '#/components/schemas/Money'
        payment_rail:
          type: string
          description: The rail the completing payment used (a `Payment.payment_rail` value).
        recipient_amount:
          description: >-
            What the merchant received, provider-confirmed. OMITTED today, never `null` and never the invoice amount:
            the received figure is recorded on the link's `paid` event (checked against the invoice within a 1%
            tolerance) but is not yet published as a provider-confirmed amount. `net_amount` is the server arithmetic,
            not this confirmation.
          allOf:
            - $ref: '#/components/schemas/Money'
            - type: object
              properties:
                confirmed:
                  type: boolean
        amount_received:
          description: >-
            The observed on-chain deposit for a `crypto_tempo` payment, in the token that arrived (same value as
            `Payment.amount_received`). `null` on every other rail.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        provider_reference:
          type:
            - string
            - 'null'
          description: >-
            The provider's reference for a provider-routed payment — the transfer id, or the deposit id for a payment
            collected on a collection account (virtual account); `null` for `crypto_tempo`.
        fee:
          description: >-
            The 1% Swaps fee, payer-borne — same value as `Payment.fee` for `payment_id` (exact server arithmetic on
            `crypto_tempo`; `null` on rails where the payment projection publishes none). It is why a `crypto_tempo`
            `amount_received` exceeds `amount`.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        deducted_fee:
          description: >-
            The Swaps fee deducted from what arrived on a Bridge-routed rail — same value as `Payment.deducted_fee` for
            `payment_id` (the provider receipt's figure); `null` on `crypto_tempo`/`crypto_relay`, when the payer paid
            in another currency than the invoice, and while no consistent receipt is recorded.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        net_amount:
          description: >-
            What the merchant receives — same value as `Payment.net_amount` for `payment_id`: the invoice amount on
            `crypto_tempo`/`crypto_relay` once settled; on a Bridge-routed rail the provider receipt's figure (what
            arrived minus `deducted_fee`, in the invoice currency, before conversion), `null` without one; `null` before
            settlement.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        settlement_kind:
          type:
            - string
            - 'null'
          enum:
            - bridge
            - crypto_only
            - null
          description: Same value as `PaymentLink.settlement_kind`.
        settlement_destination:
          description: >-
            Where the funds land, masked as a reference — same value as `PaymentLink.settlement_destination`; never a
            raw address or bank detail.
          oneOf:
            - $ref: '#/components/schemas/SettlementDestinationView'
            - type: 'null'
        onchain_tx_hash:
          type:
            - string
            - 'null'
        paid_at:
          type: string
          format: date-time
        settled_at:
          type:
            - string
            - 'null'
          format: date-time
          description: '`null` while the link is `paid` and not yet `settled`.'
        note:
          type:
            - string
            - 'null'
          description: The link's own memo.
      unevaluatedProperties: false
    ReminderSchedule:
      type: object
      description: >-
        Editable while the link is `draft`, `active` or `viewed` (RESOURCE-MODEL §2.1). R11 PL-G3 flags a design screen
        that disables editing on `viewed` — not ratified as a rule change, so the contract keeps `viewed` editable.
      properties:
        status:
          type: string
          enum:
            - 'off'
            - 'on'
        offsets_days:
          type: array
          items:
            type: integer
          description: Caller-owned.
        sent:
          type: array
          readOnly: true
          items:
            type: integer
          description: Server-owned (RESOURCE-MODEL §2.1). A caller-supplied value here is ignored, not merged.
        next_reminder_at:
          type:
            - string
            - 'null'
          format: date-time
          readOnly: true
          description: Computed convenience field (RESOURCE-MODEL §2.1 v2 amendment; R11 PL-G17).
      unevaluatedProperties: false
    ReminderScheduleUpdateRequest:
      type: object
      description: >-
        Sets the caller-owned cadence. `sent[]` and `next_reminder_at` are server-owned and never accepted here; use
        `disable` to turn reminders off rather than sending an empty schedule.
      required:
        - offsets_days
      properties:
        offsets_days:
          type: array
          items:
            type: integer
      additionalProperties: false
    Client:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - display_name
          properties:
            id:
              type: string
              pattern: ^cli_
            object:
              type: string
              enum:
                - client
            display_name:
              type: string
            email:
              type:
                - string
                - 'null'
              format: email
            company:
              type:
                - string
                - 'null'
            country:
              type:
                - string
                - 'null'
              description: >-
                Free text today. R11 PL-G13 is an open ruling on whether this narrows to a closed enum — left as free
                text pending that ruling.
            role:
              type:
                - string
                - 'null'
            notes:
              type:
                - string
                - 'null'
            metadata:
              type: object
              additionalProperties: true
            archived_at:
              type:
                - string
                - 'null'
              format: date-time
              readOnly: true
      unevaluatedProperties: false
    ClientCreateRequest:
      type: object
      required:
        - display_name
      properties:
        display_name:
          type: string
        email:
          type: string
          format: email
        company:
          type: string
        country:
          type: string
        notes:
          type: string
        metadata:
          type: object
          additionalProperties: true
      additionalProperties: false
    ClientUpdateRequest:
      type: object
      description: >-
        `null` clears an optional field to `NULL` on the row (C4-D8, the same convention as `allowed_rails: null` in the
        payment-link Settlement wizard, §51.13); an omitted key leaves the stored value unchanged. An empty string
        (`""`) is not the same as `null` — for `email` it fails the format check with `400 invalid_request`; for the
        other free-text fields it is trimmed and treated as `null` (unchanged pre-K3c behaviour). `display_name` stays a
        plain, non-nullable string when sent — it can be changed but never cleared.
      properties:
        display_name:
          type: string
        email:
          type:
            - string
            - 'null'
          format: email
          description: '`null` clears the client''s email. An empty string fails the format check (`400`).'
        company:
          type:
            - string
            - 'null'
          description: '`null` clears the client''s company name.'
        country:
          type:
            - string
            - 'null'
          description: '`null` clears the client''s country.'
        notes:
          type:
            - string
            - 'null'
          description: '`null` clears the client''s notes.'
        metadata:
          type: object
          additionalProperties: true
      additionalProperties: false
    ClientList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Client'
      unevaluatedProperties: false
    Product:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - name
            - unit_price
          properties:
            id:
              type: string
              pattern: ^prd_
            object:
              type: string
              enum:
                - product
            name:
              type: string
            description:
              type:
                - string
                - 'null'
            unit_price:
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
              description: >-
                `null` for a product saved with no fixed price (the merchant fills the amount in per-invoice at create
                time) — `pl_products.unit_price` is a nullable column; K3 correction, the v1 draft required this and
                500'd on a real row.
            currency:
              type: string
              description: >-
                ISO 4217 code or stablecoin symbol, carried alongside `unit_price` (RESOURCE-MODEL §2.1 lists both
                `unit_price{}` and `currency` verbatim as sibling fields).
            metadata:
              type: object
              additionalProperties: true
            archived_at:
              type:
                - string
                - 'null'
              format: date-time
              readOnly: true
      unevaluatedProperties: false
    ProductCreateRequest:
      type: object
      required:
        - name
        - unit_price
        - currency
      properties:
        name:
          type: string
        unit_price:
          $ref: '#/components/schemas/MoneyPositive'
        currency:
          type: string
        description:
          type: string
        metadata:
          type: object
          additionalProperties: true
      additionalProperties: false
    ProductUpdateRequest:
      type: object
      description: >-
        `null` on `description` clears it to `NULL` (C4-D8, same convention as `ClientUpdateRequest` and `allowed_rails:
        null`, §51.13); an omitted key leaves it unchanged. `name`, `unit_price` and `currency` are not part of this
        convention — they identify or price the product, not describe it, and stay non-nullable when sent.
      properties:
        name:
          type: string
        unit_price:
          $ref: '#/components/schemas/MoneyPositive'
        currency:
          type: string
        description:
          type:
            - string
            - 'null'
          description: >-
            `null` clears the product's description. An empty string is trimmed and treated as `null` (unchanged pre-K3c
            behaviour, no format validator on this field).
        metadata:
          type: object
          additionalProperties: true
      additionalProperties: false
    ProductList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Product'
      unevaluatedProperties: false
    SettlementAsset:
      type: object
      description: >-
        BL-39 (C4-D27, phase-V parity CPD-2/CPD-3) — the settlement token identity, frozen once from
        `settlement_snapshot` at link ACTIVATION and never recomputed per attempt or from a live chain read. Shared by
        `Payment.settlement_asset` and `PaymentLink.settlement_asset` so a crypto-processing screen has exactly ONE
        token name to print for a given link, instead of guessing it independently from `deposit_instructions`, a
        hardcoded literal, or a raw internal currency code (the source of CPD-3's three-names-on-one-screen defect).
        Carries no amount — `amount_expected`/`fee`/`amount_received` (`Money`) remain the only figures; this only
        labels their existing numerals with the token actually sent.
      required:
        - symbol
        - decimals
        - network
      properties:
        symbol:
          type: string
          description: Canonical display ticker for the frozen network — `USDC.e` on Tempo mainnet, `pathUSD` on Moderato.
          example: USDC.e
        decimals:
          type: integer
          minimum: 0
          maximum: 18
          example: 6
        network:
          type: string
          enum:
            - tempo
          description: >-
            The settlement rail, not the concrete Tempo network — Moderato vs Mainnet is `DepositInstructions.chain_id`,
            never re-derived from this label.
      additionalProperties: false
    PaymentStatus:
      type: string
      description: >-
        Cross-product pay-in lifecycle (RESOURCE-MODEL §2.1 v2 amendment, 12 states). `processing` is still planned
        (Bridge-rail only, R19 X4). `underpaid` / `overpaid` / `unmatched` are AVAILABLE (CP-T3, §52.37 item 4 —
        RESOURCE-MODEL CP-G5's producer): the Tempo watcher (`crypto_tempo`/`crypto_relay`) writes them directly onto
        `payment_link_attempts.status` — a deposit short of the frozen total is `underpaid` (stays live: a later top-up
        can still complete it, settling `paid` with the SUM of every leg received, never just the last one); a match
        ABOVE the total settles the merchant's exact invoice as before and is `overpaid`, `surplus` published (paid to
        the Swaps fee wallet together with the fee, never auto-refunded — see `Payment.surplus`); a deposit to a closed,
        unpaid payment's address (expired or abandoned) makes that payment `unmatched`, `amount_received` = what was
        observed on it; a merchant cancel of an `underpaid` payment makes it `unmatched` too, never `abandoned` — the
        money stays held and its running total stays `amount_received`; `Payment.unmatched_reason` names which of these
        two made it (`deposit_after_close` / `partial_before_cancel`); a further deposit to an `unmatched` or
        already-paid payment's address never changes its status and is recorded for support, not published on `/v1`; a
        leg in a different accepted token than an `underpaid` payment's running total is never summed into it and is
        recorded for support; a deposit matching no payment at all is recorded for support. Events: `payment.underpaid`
        / `payment.overpaid` are live on TWO independent producers — the Bridge pay-in reducer (below) AND, as of CP-T3,
        the Tempo watcher, each firing at most once per payment on its own rail. The Bridge pay-in reducer emits one per
        payment when Bridge reports the received amount in the link's own currency and it falls outside ±1 % of the link
        amount; `data.object` carries both figures as `amount_received` and `amount_expected` (`Money`, link currency
        for the Bridge producer; the observed Tempo stablecoin for the watcher's own producer — crypto_tempo has no FX
        leg to convert either figure into). No verdict is published for a pay-in in another currency or on an
        FX-estimated figure. On the Bridge rails ONLY, `payment.paid` (and `payment.settled`) also fire for a short
        payment and come first, and `data.object.status` keeps reading `paid`/`settled` while the link stays
        `processing`: a `payment.underpaid` on the same `pay_` id overrides them — do not fulfil on `payment.paid`
        alone. On crypto_tempo/crypto_relay this never happens: an underpaid deposit never reaches `funds_received` at
        all until it is topped up. `payment.marked_sent` is live — the payer's non-authoritative "I have sent it" on
        /pay, `status` still `awaiting`. Still catalogued, with the missing observation: `payment.detected` — Bridge
        delivers no pre-arrival state on the rails Payment links use (`funds_scheduled` is ACH-only, collection accounts
        are GBP/EUR) and the Tempo watcher settles on first sight; `payment.processing` — Bridge's `payment_submitted`
        follows `funds_received` (already `paid`) and `in_review` is only recorded on the link timeline, so no pre-paid
        processing step is observed. `payment.unmatched` is live (CP-T3, crypto_tempo/crypto_relay) once per payment,
        ONLY for a deposit to a closed, unpaid payment's address or a merchant cancel of an `underpaid` payment, its
        `data.object.unmatched_reason` naming which; a further deposit to an unmatched or paid payment and a deposit
        matching no payment are recorded for support and never published on `/v1`; a Bridge collection-account deposit
        with no match is still held in the operator ledger with no Payment to carry it (that half stays unproduced).
        A1-2 fixer round 1 (finding #2): this is the CLOSED, request-side form — `payments.list`'s `status` query filter
        uses it directly, and an unrecognized value there is a real `400`. `PaymentStatusOpen` (below) is the exact same
        list, response-side only (`Payment.status`, `PaymentAttemptView.status`, `PaymentLink.latest_attempt_status`);
        round 1 marked this schema directly instead, which leaked the open behaviour onto the query filter too
        (CONVENTIONS.md: mark the specific field, never the vocabulary in general).
      enum:
        - awaiting
        - detected
        - processing
        - paid
        - settled
        - underpaid
        - overpaid
        - unmatched
        - expired
        - returned
        - failed
        - abandoned
    PaymentStatusOpen:
      type: string
      x-swaps-open-enum: true
      description: >-
        The exact same 12-state lifecycle as `PaymentStatus` above, kept as its own schema so the open-enum marker never
        reaches the `payments.list` `status` query filter (A1-2 fixer round 1, finding #2). Response-only — an
        unrecognized member here is an opaque string, never a deserialization failure. See `PaymentStatus` above for the
        state descriptions.
      enum:
        - awaiting
        - detected
        - processing
        - paid
        - settled
        - underpaid
        - overpaid
        - unmatched
        - expired
        - returned
        - failed
        - abandoned
    PaymentUnmatchedReason:
      type: string
      x-swaps-open-enum: true
      description: >-
        Why a payment is `unmatched` (CP-T4-T, crypto_tempo/crypto_relay): one code per way a payment becomes unmatched.
        `partial_before_cancel` — the payer had already sent part of the amount (the payment was `underpaid`) when the
        merchant cancelled the link; that partial amount is `amount_received`. `deposit_after_close` — money was first
        observed at the payment's settlement address after the payment had closed without being paid (for example it
        expired, or the link was cancelled before any money was observed); what arrived is `amount_received`. It may
        have been sent shortly before the close: the watcher polls, and a `crypto_relay` payment can still be bridging —
        this is the observation time, not the chain time. Either way the funds are held: nothing is released or refunded
        automatically, support handles them (CP-G6). Response-only and open: a value added within `/v1` is additive —
        treat one you do not recognise like `null` (neutral copy).
      enum:
        - partial_before_cancel
        - deposit_after_close
    PaymentSessionAmountVerdict:
      type: string
      x-swaps-open-enum: true
      description: >-
        PAY-VERDICT-2-T — the amount verdict a payer session publishes (`PaymentSession.amount_verdict`). `exact`: the
        total due arrived, no more and no less. `overpaid`: more than the total arrived; the merchant still received
        exactly the invoice and the surplus is paid to the Swaps fee wallet together with the fee (see
        `Payment.surplus`). `underpaid`: less than the total arrived and the request is not complete. `unmatched`: money
        arrived on a payment that had already closed without being paid (see `Payment.unmatched_reason`) and is held.
        Response-only and open: a value added within `/v1` is additive — treat one you do not recognise like `null`.
      enum:
        - exact
        - underpaid
        - overpaid
        - unmatched
    PaymentScreening:
      type: object
      description: >-
        RESOURCE-MODEL §2.1 v2 amendment. `not_screened` is a legitimate value, not an absent field (R19 CP-G13) — a
        payment that exists but was never run through the guard action reads `not_screened`, distinct from the field
        being missing.
      properties:
        status:
          type: string
          enum:
            - not_screened
            - screened
        level:
          type:
            - string
            - 'null'
          enum:
            - low
            - medium
            - high
            - critical
            - unknown
            - null
        flags:
          type: array
          items:
            type: string
      unevaluatedProperties: false
    Payment:
      description: >-
        A cross-product pay-in — a payment-link attempt or a crypto-processing attempt (RESOURCE-MODEL §2.1 v2
        amendment, promoted to a top-level resource; R19 §2.2). No create and no refund: only a payer session creates
        one, and Swaps never initiates a refund (RESOURCE-MODEL §0.10).
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - status
            - payment_link_id
            - subscription_id
            - source_currency
            - source_chain
            - source_asset
            - source_address
            - deposit_address
            - deposit_instructions
            - bank_deposit_instructions
            - payer_type
            - payer_marked_sent_at
            - return_reason
            - settlement_tx_hash
            - onchain_tx_hash
            - settlement_asset
            - amount_expected
            - amount_received
            - fee
            - deducted_fee
            - net_amount
            - surplus
            - amount_missing
            - unmatched_reason
            - screening
            - expires_at
          properties:
            id:
              type: string
              pattern: ^pay_
            object:
              type: string
              enum:
                - payment
            payment_link_id:
              type:
                - string
                - 'null'
              pattern: ^pl_
            subscription_id:
              type:
                - string
                - 'null'
              pattern: ^sub_
              description: >-
                Set when this pay-in settles a subscription invoice rather than a standalone link (RESOURCE-MODEL §2.1
                v2 amendment — `/v1/payments` filters on `payment_link_id`, `subscription_id`, `settlement`).
            status:
              $ref: '#/components/schemas/PaymentStatusOpen'
            payment_rail:
              type: string
              enum:
                - ach
                - wire
                - fednow
                - sepa
                - faster_payments
                - pix
                - spei
                - crypto_tempo
                - crypto_bridge
                - crypto_relay
              description: >-
                `crypto_relay` (R19 X2): the payer sends USDC on another EVM network to a Relay deposit address whose
                recipient is this attempt's splitter; live behind the `crypto_relay_rail` flag, off in production.
            source_currency:
              type:
                - string
                - 'null'
            source_chain:
              type:
                - string
                - 'null'
              enum:
                - tempo
                - base
                - ethereum
                - polygon
                - arbitrum
                - optimism
                - solana
                - null
              description: >-
                The one chain field (CMP-8) — the v2 crypto-extension's `source_network` duplicate is removed; this
                carries the `payment_sessions.network` vocabulary for every crypto rail.
            source_asset:
              type:
                - string
                - 'null'
            source_address:
              type:
                - string
                - 'null'
              description: Masked (RESOURCE-MODEL §2.1 v2 amendment).
            deposit_address:
              type:
                - string
                - 'null'
              description: The splitter address, case-preserved verbatim — never lowercased (AGENTS.md address-case rule).
            deposit_instructions:
              description: >-
                Where to send crypto for this attempt (MON-7, CMP-5) — present only for a crypto rail; null on a bank
                rail (`bank_deposit_instructions` below is set instead). An `underpaid` `crypto_tempo` payment with a
                recorded running total names only the token that total counts, or is `null` when that token cannot be
                told apart.
              oneOf:
                - $ref: '#/components/schemas/DepositInstructions'
                - type: 'null'
            bank_deposit_instructions:
              description: >-
                The Bridge-issued bank transfer target for this attempt (K3b) — present only for one of the seven bank
                rails (`ach`, `wire`, `fednow`, `sepa`, `faster_payments`, `pix`, `spei`); null on a crypto rail
                (`deposit_instructions` above is set instead). Exactly one of the two is ever non-null. See
                `BankDepositInstructions.holder_kind` for who the beneficiary actually is on this attempt.
              oneOf:
                - $ref: '#/components/schemas/BankDepositInstructions'
                - type: 'null'
            payer_type:
              type:
                - string
                - 'null'
              enum:
                - business
                - individual
                - null
            payer_marked_sent_at:
              type:
                - string
                - 'null'
              format: date-time
              description: A payer self-report, never authoritative on its own.
            return_reason:
              type:
                - string
                - 'null'
              description: Raw code from the returns dictionary (R11 §3); the client renders the display label.
            settlement_tx_hash:
              type:
                - string
                - 'null'
            onchain_tx_hash:
              type:
                - string
                - 'null'
              description: Crypto rails only (RESOURCE-MODEL §2.1 v2 amendment; R11 PL-G7).
            settlement_asset:
              readOnly: true
              description: >-
                BL-39 (C4-D27) — this attempt's parent link's token identity, frozen from `settlement_snapshot` at
                ACTIVATION (never a live chain read, never derived from a rate, never recomputed per attempt). `null`
                for anything that is not a Tempo crypto-only settlement.
              oneOf:
                - $ref: '#/components/schemas/SettlementAsset'
                - type: 'null'
            amount_expected:
              description: >-
                The ask — what this attempt requires the payer to send, fixed at creation and unaffected by what has
                actually arrived (MON-8; RESOURCE-MODEL §2.1 v2 amendment; R11 PL-G2).
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            amount_received:
              description: >-
                The observation — what has actually been detected on-chain or at the rail so far. Null before
                `status=detected`; once populated it never goes back to null (MON-8). On `unmatched`
                (crypto_tempo/crypto_relay): everything observed up to the moment the payment became unmatched; later
                deposits to its address are recorded for support only.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            fee:
              description: >-
                The Swaps fee on `crypto_tempo`/`crypto_relay`, payer-borne on top of the invoice, at the settlement
                token's scale (the deposit instructions' figure; `amount_expected` includes it); `null` on every other
                rail, Bridge-routed rails included — the fee those rails deduct from what arrives is `deducted_fee`.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            deducted_fee:
              description: >-
                The Swaps fee deducted from what arrived on a Bridge-routed rail (bank rails, `crypto_bridge`) — the
                provider receipt's developer fee, recorded when the payment settled, in the invoice currency, rounded up
                to the currency's scale; the merchant bears it (`net_amount` is what arrived minus it). `null` before
                the payment is settled (and again if it is later returned); on `crypto_tempo`/`crypto_relay` (their fee
                is `fee`, paid on top); when the payer paid in another currency than the invoice (a receipt is never
                converted — the USDC source of `crypto_bridge` included); and while no consistent receipt is recorded
                (none sent, terms that do not add up, a provider exchange or gas fee on top). The configured rate is
                `Capabilities.deducted_fee`.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            net_amount:
              description: >-
                What the merchant receives, once the payment is settled; `null` before. On `crypto_tempo`/`crypto_relay`
                the invoice amount (the fee is paid on top). On a Bridge-routed rail only the provider receipt's figure
                — what arrived minus `deducted_fee`, in the invoice currency, before any conversion to the settlement
                asset, rounded down to the currency's scale — and `null` whenever `deducted_fee` is `null` for a receipt
                reason; never copied from the invoice there.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            surplus:
              description: >-
                Present only when `status=overpaid` (RESOURCE-MODEL §2.1 v2 amendment; live, CP-T3/CP-R4 — the Tempo
                watcher's own producer, crypto_tempo/crypto_relay only). `amount_received` minus the frozen total the
                deposit instructions quoted, denominated in the observed stablecoin. Paid to the Swaps fee wallet
                together with the fee when the payment settles; returned to the sending address, minus the network fee,
                only on request through support (manual, from the fee wallet; in force after the legal sign-off on
                CP-G6, founder §52.60 54A); never auto-refunded. The merchant's own release is untouched: they still
                receive exactly their invoice regardless of this figure.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            amount_missing:
              description: >-
                Present only when `status=underpaid` (live, CP-T3/CP-R4 — crypto_tempo/crypto_relay only): the frozen
                total minus everything observed toward this attempt SO FAR (every deposit leg summed, not just the
                latest one). A later top-up that completes the payment clears this back to `null` and settles `paid`
                with the sum. `null` rather than a zero or negative figure, or one across two token scales.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            unmatched_reason:
              description: >-
                Present only when `status=unmatched` (CP-T4-T): which of the two paths made the payment unmatched — see
                `PaymentUnmatchedReason`. `null` on every other status, and on a payment that became `unmatched` before
                this field existed (no reason was recorded: show neutral copy, never a guessed reason).
              oneOf:
                - $ref: '#/components/schemas/PaymentUnmatchedReason'
                - type: 'null'
            screening:
              $ref: '#/components/schemas/PaymentScreening'
            expires_at:
              type:
                - string
                - 'null'
              format: date-time
              description: >-
                `crypto_relay`: the send-by time of its Relay quote (the `quote_expires_at` of its deposit
                instructions). `null` on every other rail today.
      unevaluatedProperties: false
    PaymentList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Payment'
            summary:
              $ref: '#/components/schemas/PaymentListSummary'
      unevaluatedProperties: false
    PaymentListSummary:
      type: object
      x-swaps-since: '2026-09-25'
      description: >-
        Present only with `?expand=summary` (LIST-SUMMARY-1). Computed over every pay-in matching the request's
        `payment_link_id`, `subscription_id`, `settlement` and `status` (never `cursor`/`limit`). `by_status` has one
        key per `PaymentStatus` value; a status this version does not know counts in `total` only.
      required:
        - total
        - needs_attention
        - by_status
      properties:
        total:
          type: integer
          minimum: 0
        needs_attention:
          type: integer
          minimum: 0
          description: >-
            `underpaid` + `overpaid` + `unmatched`, server-computed over the same rows the list already loaded (C-2,
            §52.37 item 4). Live for `crypto_tempo`/`crypto_relay` pay-ins (CP-T3 — the Tempo watcher is the producer of
            all three statuses; `unmatched` can also come from a merchant cancel of an `underpaid` payment); a
            Bridge-rail short/over payment is still visible only through the `payment.underpaid`/`.overpaid` EVENTS (its
            `Payment.status` stays `paid`/`settled` per `PaymentStatus`'s own note), so a nonzero count here is not the
            complete set of every pay-in a merchant might want to review — subscribe to those events too.
        by_status:
          type: object
          required:
            - awaiting
            - detected
            - processing
            - paid
            - settled
            - underpaid
            - overpaid
            - unmatched
            - expired
            - returned
            - failed
            - abandoned
          properties:
            awaiting:
              type: integer
              minimum: 0
            detected:
              type: integer
              minimum: 0
            processing:
              type: integer
              minimum: 0
            paid:
              type: integer
              minimum: 0
            settled:
              type: integer
              minimum: 0
            underpaid:
              type: integer
              minimum: 0
            overpaid:
              type: integer
              minimum: 0
            unmatched:
              type: integer
              minimum: 0
            expired:
              type: integer
              minimum: 0
            returned:
              type: integer
              minimum: 0
            failed:
              type: integer
              minimum: 0
            abandoned:
              type: integer
              minimum: 0
          additionalProperties: false
      additionalProperties: false
    PaymentEventList:
      description: >
        A view of `/v1/events` scoped to one payment (RESOURCE-MODEL §2.1 — mirrors `PaymentLinkEventList`/
        `PayoutEventList` exactly, BL-33). Items share the global event envelope: `type` is one of the `payment.*` event
        names in RESOURCE-MODEL §3 (`payment.created`, `.awaiting`, `.paid`, `.settled`, `.expired`), and `data.object`
        carries this payment's own allowlisted projection (`buildOutboxPaymentAttemptEventData` — no
        `deposit_instructions`, no payer PII, no Bridge transfer id).
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Event'
      unevaluatedProperties: false
    PaymentSessionPayment:
      type: object
      description: >-
        The payer's projection of a payment attempt (SEC-2) — returned by every `payment_sessions/*` operation that
        starts or reads a payment. Never carries `screening`, `source_address`, or any merchant-only field; the full
        object is served only at `/v1/payments*` as `Payment`.
      required:
        - id
        - object
        - status
        - created_at
      properties:
        id:
          type: string
          pattern: ^pay_
        object:
          type: string
          enum:
            - payment
        status:
          $ref: '#/components/schemas/PaymentStatusOpen'
        payment_rail:
          type: string
          enum:
            - ach
            - wire
            - fednow
            - sepa
            - faster_payments
            - pix
            - spei
            - crypto_tempo
            - crypto_bridge
            - crypto_relay
          description: >-
            `crypto_relay` (R19 X2): the payer sends USDC on another EVM network to a Relay deposit address whose
            recipient is this attempt's splitter; live behind the `crypto_relay_rail` flag, off in production.
        source_chain:
          type:
            - string
            - 'null'
          enum:
            - tempo
            - base
            - ethereum
            - polygon
            - arbitrum
            - optimism
            - solana
            - null
        stablecoin:
          type:
            - string
            - 'null'
          enum:
            - USDC.e
            - pathUSD
            - USDT0
            - USD1
            - cUSD
            - USDC
            - null
          description: >-
            The stablecoin the payer sends. `USDC` (native Circle USDC on `source_chain`) on a `crypto_relay` attempt;
            `null` on every other rail today.
        source_asset:
          type:
            - string
            - 'null'
        deposit_instructions:
          description: >-
            Where to send crypto for this attempt (MON-7, CMP-5) — present only for a crypto rail; null on a bank rail
            (`bank_deposit_instructions` below is set instead). An `underpaid` `crypto_tempo` payment with a recorded
            running total names only the token that total counts (as `PaymentSession.underpaid_payment` does), or is
            `null` when that token cannot be told apart.
          oneOf:
            - $ref: '#/components/schemas/DepositInstructions'
            - type: 'null'
        bank_deposit_instructions:
          description: >-
            The Bridge-issued bank transfer target for this attempt (K3b) — present only for one of the seven bank
            rails; null on a crypto rail (`deposit_instructions` above is set instead). Exactly one of the two is ever
            non-null. See `BankDepositInstructions.holder_kind` for who the beneficiary actually is.
          oneOf:
            - $ref: '#/components/schemas/BankDepositInstructions'
            - type: 'null'
        amount_expected:
          description: The ask — what this attempt requires the payer to send (MON-8).
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        amount_received:
          description: The observation — what has actually been detected so far. Null before `status=detected` (MON-8).
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        surplus:
          description: >-
            Same value and gating as `Payment.surplus`: present only when `status=overpaid` (crypto_tempo/crypto_relay),
            `amount_received` minus the frozen total, in the observed stablecoin. Paid to the Swaps fee wallet together
            with the fee when the payment settles; returned to the sending address, minus the network fee, only on
            request through support (manual, from the fee wallet; in force after the legal sign-off on CP-G6, founder
            §52.60 54A); never auto-refunded.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        amount_missing:
          description: >-
            Same value and gating as `Payment.amount_missing`: present only when `status=underpaid`
            (crypto_tempo/crypto_relay), the frozen total minus every deposit leg observed so far. The same figure as
            `PaymentSession.underpaid_payment.amount_missing` for this payment.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        unmatched_reason:
          description: >-
            Same value and gating as `Payment.unmatched_reason`: present only when `status=unmatched`, which of the two
            paths made it (`PaymentUnmatchedReason`); `null` otherwise and on a payment made unmatched before the field
            existed.
          oneOf:
            - $ref: '#/components/schemas/PaymentUnmatchedReason'
            - type: 'null'
        fee:
          description: >-
            The Swaps fee on `crypto_tempo`/`crypto_relay`, payer-borne on top of the invoice, at the settlement token's
            scale (the deposit instructions' figure; `amount_expected` includes it); `null` on every other rail,
            Bridge-routed rails included.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        expires_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            `crypto_relay`: the send-by time of its Relay quote (the `quote_expires_at` of its deposit instructions).
            `null` on every other rail today.
        watcher_state:
          $ref: '#/components/schemas/PaymentWatcherState'
        watcher_reason:
          $ref: '#/components/schemas/PaymentWatcherReason'
        settlement_tx_hash:
          type:
            - string
            - 'null'
        payer_marked_sent_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            The payer's own non-authoritative "I've sent it" self-report (`POST .../mark_sent`) — never settlement,
            `status` stays the sole source of truth (RESOURCE-MODEL §3, `authoritative:false`).
        created_at:
          type: string
          format: date-time
      unevaluatedProperties: false
    PaymentLinkEventList:
      description: >-
        A view of `/v1/events` scoped to one link (RESOURCE-MODEL §2.1, proposed). Shares the global event envelope
        (CMP-7) — no bespoke per-link event shape.
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Event'
      unevaluatedProperties: false
    Subscription:
      description: >-
        Version 1 scheduled invoices — a crypto invoice reissued on a schedule, never an authorised pull (RESOURCE-MODEL
        §2.6; R19 §2.3). Dark-flag (PRD-CP-001 slice X5) — gated behind `api_v1.subscriptions`, off in production
        pending legal sign-off (G-8, MiCA/crypto-processing).
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - status
            - kind
            - name
            - amount
            - interval
          properties:
            id:
              type: string
              pattern: ^sub_
            object:
              type: string
              enum:
                - subscription
            kind:
              type: string
              enum:
                - scheduled_invoices
                - authorised_pull
              description: >-
                Only `scheduled_invoices` is buildable for v1. `authorised_pull` names the v2 shape (`SwapsSubscription`
                contract) so the collision cannot be claimed by accident — it stays behind the founder-gated third-party
                audit (PRD §11.6) and has no producer.
            status:
              type: string
              enum:
                - active
                - paused
                - cancelled
              description: Design-proposed (R19 §2.3) — PRD-CP-001 does not itself define a status enum.
            overdue:
              type: boolean
              readOnly: true
              description: >-
                NB-2 — derived: this subscription has at least one invoice that is `open` and past its `due_at`. Never
                stored, computed fresh from the invoice table at read time (one query per page, never N+1) — the same
                zero-grace rule `SubscriptionInvoice.overdue` uses. `status` itself never flips to reflect a missed
                payment (D-K13-6); this is the field that says so instead. Present on every `/v1` read of this object
                (`list`, `get`, `create`, `pause`, `resume`, `cancel`) and ABSENT from `subscription.*` event and
                webhook payloads, which describe the transition that happened rather than a live invoice poll — read
                `subscriptions.get` for the current value.
            name:
              type: string
            amount:
              $ref: '#/components/schemas/Money'
            interval:
              type: string
              enum:
                - monthly
                - quarterly
                - yearly
            first_due_at:
              type: string
              format: date-time
            next_due_at:
              type:
                - string
                - 'null'
              format: date-time
            client_id:
              type:
                - string
                - 'null'
              pattern: ^cli_
            settlement:
              type: object
              additionalProperties: true
              description: >-
                The merchant's settlement destination — always the Swaps Wallet for v1 (RESOURCE-MODEL §2.6). Sub-shape
                not further enumerated there.
            reference:
              type: string
              description: '`SUB-00nn` (RESOURCE-MODEL §2.6).'
            paused_at:
              type:
                - string
                - 'null'
              format: date-time
              readOnly: true
              description: >-
                C-55 (§52.37 item 4) — the instant `pause` set it; `resume` clears it back to `null`. `null` on an
                `active` or `cancelled` subscription, or one never paused. Present on every `/v1` read (`list`, `get`,
                `create`, `pause`, `resume`, `cancel`) — the same producer/consumer pairing as `overdue` above.
            reminder_schedule:
              $ref: '#/components/schemas/ReminderSchedule'
              description: Stored but inert — no delivery exists on the crypto rail yet (R19 CP-G10).
      unevaluatedProperties: false
    SubscriptionCreateRequest:
      type: object
      description: >-
        Dark-flag (X5). `attestation_accepted` mirrors the payment-link activation gate — R19 §1 shows the creation
        wizard behind the same attestation checkbox.
      required:
        - name
        - amount
        - interval
        - client_id
        - attestation_accepted
      properties:
        name:
          type: string
        amount:
          $ref: '#/components/schemas/MoneyPositive'
        interval:
          type: string
          enum:
            - monthly
            - quarterly
            - yearly
        first_due_at:
          type: string
          format: date-time
        client_id:
          type: string
          pattern: ^cli_
        attestation_accepted:
          type: boolean
      additionalProperties: false
    SubscriptionList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Subscription'
            summary:
              $ref: '#/components/schemas/SubscriptionListSummary'
      unevaluatedProperties: false
    SubscriptionListSummary:
      type: object
      x-swaps-since: '2026-09-25'
      description: >-
        Present only with `?expand=summary` (LIST-SUMMARY-1). Computed over every subscription of this account matching
        the request's `status` (never `cursor`/`limit`), in one grouped query.
      required:
        - total
        - active
        - paused
        - next_due
      properties:
        total:
          type: integer
          minimum: 0
        active:
          type: integer
          minimum: 0
        paused:
          type: integer
          minimum: 0
        next_due:
          description: >-
            The earliest `next_due_at` among the matching `active` subscriptions (ties broken by id); `null` when none
            is active or none has a next due date.
          oneOf:
            - type: 'null'
            - type: object
              required:
                - at
                - subscription_id
              properties:
                at:
                  type: string
                  format: date-time
                subscription_id:
                  type: string
                  pattern: ^sub_
              additionalProperties: false
      additionalProperties: false
    SubscriptionInvoice:
      description: >-
        One child invoice of a subscription — issued on its due date, never earlier (RESOURCE-MODEL §2.6; R19 §2.3). No
        create: the emitter is a cron, not a caller.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - subscription_id
            - sequence
            - due_at
            - status
          properties:
            id:
              type: string
              pattern: ^inv_
            object:
              type: string
              enum:
                - subscription_invoice
            subscription_id:
              type: string
              pattern: ^sub_
            sequence:
              type: integer
              description: 1, 2, 3… (Invoice 1, Invoice 2…).
            due_at:
              type: string
              format: date-time
            issued_at:
              type:
                - string
                - 'null'
              format: date-time
              description: Set on the due date, never earlier.
            payment_link_id:
              type:
                - string
                - 'null'
              pattern: ^pl_
              description: Nullable until issued.
            status:
              type: string
              enum:
                - not_issued
                - open
                - paid
            overdue:
              type: boolean
              readOnly: true
              description: >-
                Derived: past due and unpaid. Never stored (RESOURCE-MODEL §2.6) — a clock skew cannot desync it from
                the invoice's real status.
      unevaluatedProperties: false
    SubscriptionInvoiceList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/SubscriptionInvoice'
      unevaluatedProperties: false
    PaymentSession:
      type: object
      description: >-
        The payer's projection of a payment link (RESOURCE-MODEL §2.1; §2.1 invariants). Never carries
        `merchant_user_id`, `settlement_snapshot`, `bridge_*`, `payer_ip_hash`, the merchant's login e-mail, a payment
        id (not a secret from a link holder, see `pending_payment`), or any payer's data — the projection is the schema,
        not a filter applied afterwards. The one set of deposit instructions it carries is `pending_payment` (amended
        2026-09-29, PAY-WAIT-1-T): the receiving side of the link's live payment, never the payer's side, never another
        payment's; for a live `crypto_tempo` payment that arrived short it is `underpaid_payment` instead (PAY-WAIT-2-T,
        2026-09-29), and at most one of the two is non-null. `client_display_name` (§52 C4-D5) is the one exception to
        "merchant-internal stays withheld": it is the payer's OWN display name as the merchant's client of record, not
        merchant data. `merchant_contact` (L4-9) is a second, narrower exception: an EXPLICIT opt-in the merchant sets
        for this purpose (`Account.support_contact`) — never the login e-mail, never derived from KYC/Bridge/settlement
        data, `null` until set — SEC-2 governs what is withheld from the payer and is amended to say so explicitly.
      required:
        - merchant_display_name
        - amount
        - status
        - pending_payment
        - underpaid_payment
        - unmatched_reason
      properties:
        merchant_display_name:
          type: string
        client_display_name:
          type:
            - string
            - 'null'
          description: >-
            The payer's own name as saved on the merchant's client record for this link (§52 C4-D5) — e.g. "Marin & Co."
            rendered as "Website deposit · Marin & Co." `null` when the link has no client of record attached. Distinct
            from `merchant_display_name` (whose money this is) and from `title` (the merchant's free-text label for the
            link).
        title:
          type:
            - string
            - 'null'
        memo:
          type:
            - string
            - 'null'
          description: Rendered as "Description" on the payer surfaces (R19 CP-G15).
        invoice_number:
          type:
            - string
            - 'null'
        items:
          type: array
          items:
            $ref: '#/components/schemas/PaymentLinkItem'
        amount:
          $ref: '#/components/schemas/Money'
        expected_payer_type:
          type: string
          enum:
            - any
            - business
            - individual
        rails:
          type: array
          description: >-
            Exactly the rails `POST .../payments` will actually accept for this link today (K3b: bank rails are back,
            now that `bank_deposit_instructions` exists — the P1-4b gap this note used to describe is closed).
            `crypto_relay` is listed only while the `crypto_relay_rail` flag admits the merchant (off in production),
            the link settles on Tempo mainnet in USD, the invoice is within the rail cap and at least one source network
            has an admitted Relay route (`founder_accepted` per founder §52.34, or `proven`; see
            K13-CRYPTO-RELAY-ROUTES.md); its `networks` names those networks.
          items:
            type: object
            properties:
              rail:
                type: string
                enum:
                  - ach
                  - wire
                  - fednow
                  - sepa
                  - faster_payments
                  - pix
                  - spei
                  - crypto_tempo
                  - crypto_bridge
                  - crypto_relay
              eta_seconds:
                type:
                  - integer
                  - 'null'
                description: >-
                  Added in the v2 amendment. Previously nulled before reaching the client for want of localized copy
                  (R11 PL-G9) — now published as a raw value.
              networks:
                type: array
                description: >-
                  `crypto_relay` only — the source networks with an admitted Relay route (`founder_accepted` per founder
                  §52.34, or `proven`; see K13-CRYPTO-RELAY-ROUTES.md) into Tempo USDC.e. `POST .../payments` refuses
                  any other `network` for this rail.
                items:
                  type: string
                  enum:
                    - base
                    - ethereum
                    - polygon
                    - arbitrum
                    - optimism
        unavailable_rails:
          type: array
          description: >-
            PL-UNAVAIL-RAILS — rails this link could offer in principle (its currency's rails, the merchant's enabled
            rails, narrowed by `allowed_rails`, plus both crypto rails) that THIS session may not pick, so a payer page
            can show them muted with an honest reason instead of hiding them. Computed from the same eligibility inputs
            as `rails[]` and never overlaps it; `POST .../payments` refuses every rail listed here with
            `rail_not_allowed`. Payer-dependent refusals (the individual-payer caps, which depend on the declared
            `payer_type`) are not listed: those rails stay in `rails[]` and are refused at payment time.
          items:
            $ref: '#/components/schemas/PaymentSessionUnavailableRail'
        status:
          type: string
          enum:
            - active
            - viewed
            - processing
            - paid
            - settled
            - expired
            - cancelled
            - refunded
          description: >-
            The live link status published verbatim, plus `refunded` (v2 amendment). `link_not_payable`, `link_expired`
            and `not_found` are errors, never states.
        expires_at:
          type:
            - string
            - 'null'
          format: date-time
        latest_attempt_status:
          oneOf:
            - $ref: '#/components/schemas/PaymentStatusOpen'
            - type: 'null'
        amount_verdict:
          description: >-
            PAY-VERDICT-2-T (#3922) — ONE amount verdict for this link, from the payment that settled it or holds its
            money, never from the newest payment: a newer payment on another rail never shadows it
            (`latest_attempt_status` is unchanged and still reads the newest payment). Swaps derives it from the amounts
            it recorded itself; never compute one from `amount_received` and `amount_expected`. crypto_tempo /
            crypto_relay: `exact` when the watcher observed exactly the total due (the invoice plus the 1% fee) and no
            other payment of this link holds money; `overpaid` when it observed more (stored together with the paid
            state, never a second write); `underpaid` while the running total is short; `unmatched` for money that
            reached a closed, unpaid payment. Bridge rails: `underpaid` only while Swaps holds a short payment open with
            its received amount recorded for that payment; a Bridge payment Swaps settled as paid reads `null`, never
            `exact`: the only received figure kept for it is an audit note compared without the 1% tolerance and
            possibly converted at an FX rate, not a verdict (its `payment.overpaid` / `payment.underpaid` events stay
            the Bridge signal). `null` whenever no verdict is provable: nothing has arrived; the observed amount was not
            recorded, or is still being recorded; more than one payment of the link completed; with no completed
            payment, payments that hold money under different verdicts; `exact` while any other money for this link is
            held (on another payment, on another payment's Relay leg, or a further deposit to this one). A settling
            payment's `overpaid` / `underpaid` is published even when another payment also holds money; only `exact`
            requires that none does. `null` never means `exact`.
          oneOf:
            - $ref: '#/components/schemas/PaymentSessionAmountVerdict'
            - type: 'null'
        unmatched_reason:
          description: >-
            Why held money is held, when amount_verdict is unmatched: the server's own classification. Clients draw it;
            they never derive order from other fields. PAY-HELD-REASON-1-T: the `unmatched_reason` of the payments
            behind that verdict (`PaymentUnmatchedReason`, the same value `GET .../payments/{payment_id}` publishes for
            such a payment): `partial_before_cancel` when part of the payment arrived before the request was cancelled,
            `deposit_after_close` when money was first observed after the payment closed unpaid. `null` whenever
            `amount_verdict` is not `unmatched`; when a payment behind it became `unmatched` before reasons were
            recorded; when the payments behind it carry different reasons; when the reason is `partial_before_cancel`
            but other money for this link is held beside the payment (for example a top-up sent after the cancel); and
            for a stored value this contract does not know (withheld and reported, never published). Show neutral copy
            for `null` and for a value you do not recognise. Only the code: never an amount, a payment id or a time.
            Always present, never omitted.
          oneOf:
            - $ref: '#/components/schemas/PaymentUnmatchedReason'
            - type: 'null'
        pending_payment:
          type:
            - object
            - 'null'
          description: >-
            PAY-WAIT-1-T (founder P1, 2026-09-29) — where to send the money while this link's live payment waits for it,
            so a payer who reopens the link in another browser, an in-app browser or a private window still sees the
            payment method they chose until its status changes. Present only while `status` is `processing`, the link
            has not expired, and its latest payment is `awaiting`, `detected` or `processing` with deposit instructions
            that are still usable (a `crypto_relay` address only until its send-by time and only while Relay has seen
            nothing; a memo-matched bank slip only with its reference). `null` in every other case: no payment, a
            failed, abandoned, expired or returned payment, a verdict (`underpaid`, `overpaid`, `unmatched`), a deposit
            Swaps already observed on that payment and is still recording (PAY-VERDICT-2-T), another payment's deposit
            still being recorded (a deposit Swaps observed on another open payment of this link may complete it, so the
            payer is never asked for money while it is being recorded; PAY-WAIT-3-T), money already on another payment
            of this link (`underpaid`, `unmatched`, completed, or a `crypto_relay` payment whose Relay leg saw funds), a
            paid, closed or expired link. Every payment link is a single-payment request (the first completed payment
            closes it to new payments; a subscription issues one link per invoice), so these are the receiving
            instructions of the ONE request the link is for: Swaps' or the provider's receiving account, address and
            reference plus the link's own amount, exactly what `GET .../payments/{payment_id}` shows for that payment.
            An exact, on-time payment with them completes that same request, whoever sends it. `crypto_relay`: send
            exactly `deposit_instructions.amount` on `deposit_instructions.chain` before `expires_at`; any Relay refund
            goes to the refund address given when this payment was started, which is not shown here. Never carries the
            payment id, the payer's e-mail, name, type, state, IP, source or refund address, or any provider id. The id
            is not a secret from a link holder, though: the same-rail `POST .../payments` replay returns it on every
            rail except `crypto_relay`, so the link token is the only capability for `mark_sent` and `receipt_email`.
            Always present (`null` or the object), never omitted. A short `crypto_tempo` payment the payer can top up
            reads `null` here and is described by `underpaid_payment`.
          required:
            - rail
            - status
            - deposit_instructions
            - bank_deposit_instructions
            - expires_at
            - created_at
            - payer_marked_sent
          properties:
            rail:
              type: string
              enum:
                - ach
                - wire
                - fednow
                - sepa
                - faster_payments
                - pix
                - spei
                - crypto_tempo
                - crypto_bridge
                - crypto_relay
            status:
              type: string
              enum:
                - awaiting
                - detected
                - processing
              description: The live payment's `PaymentStatus`; only these three ever appear here.
            deposit_instructions:
              description: >-
                A crypto rail's receiving instructions, the same projection as
                `PaymentSessionPayment.deposit_instructions` (`crypto_tempo`: the splitter address, chain id, token and
                the total including the 1% Swaps fee; `crypto_relay`: the Relay deposit address, source network and
                exact amount, never the refund address; `crypto_bridge`: the provider deposit address and estimated
                amount). `null` on a bank rail. `crypto_relay`: send exactly `amount` on `chain` before `expires_at`;
                any Relay refund goes to the refund address given when this payment was started, which is not shown
                here.
              oneOf:
                - $ref: '#/components/schemas/DepositInstructions'
                - type: 'null'
            bank_deposit_instructions:
              description: >-
                A bank rail's receiving account and the reference the payer must use, the same projection as
                `PaymentSessionPayment.bank_deposit_instructions`. `null` on a crypto rail. Exactly one of the two is
                non-null.
              oneOf:
                - $ref: '#/components/schemas/BankDepositInstructions'
                - type: 'null'
            expires_at:
              type:
                - string
                - 'null'
              format: date-time
              description: >-
                `crypto_relay`: the send-by time of its Relay quote (`pending_payment` is `null` once it passes). `null`
                on every other rail, whose instructions do not expire on their own.
            created_at:
              type: string
              format: date-time
              description: When the payer chose this payment method.
            payer_marked_sent:
              type: boolean
              description: >-
                `true` once `POST .../mark_sent` recorded it: the payer reported sending; show the instructions as
                reference, not as a call to pay. Only the flag, never when.
          additionalProperties: false
        underpaid_payment:
          type:
            - object
            - 'null'
          description: >-
            PAY-WAIT-2-T (founder ruling §52.53 2A, 2026-09-29) — how much is still owed and where to send it when this
            link's live `crypto_tempo` payment arrived short, so the payer can top up the same payment from any browser.
            Present only while `status` is `processing`, the link has not expired, its latest payment is a
            `crypto_tempo` payment in `underpaid` whose running total Swaps recorded, no deposit to it is still being
            recorded, no other payment's deposit is being recorded (a deposit Swaps observed on another open payment of
            this link may complete it; PAY-WAIT-3-T), and no other payment of this link holds money (`underpaid`,
            `unmatched`, completed, or a `crypto_relay` leg that saw funds). `null` in every other case, including:
            `crypto_relay` (its Relay deposit address is issued per quote, so there is no top-up path; `amount_verdict`
            and the payment's own read say it is short), a bank rail or `crypto_bridge`, a cancelled link (its short
            payment becomes `unmatched`), a paid, closed or expired link, and a recorded total from which Swaps cannot
            prove one positive remainder in one token (withheld and reported, never published as a guess). While this is
            set `pending_payment` is `null`. A top-up that reaches the total settles the payment `paid` with the sum.
            Never carries the payment id or any payer data (e-mail, name, type, state, IP, source address).
          required:
            - rail
            - amount_missing
            - amount_received
            - deposit_instructions
            - created_at
          properties:
            rail:
              type: string
              enum:
                - crypto_tempo
            amount_missing:
              description: >-
                What is still owed: the total due (the invoice plus the 1% Swaps fee, the figure the payment is judged
                against) minus `amount_received`, exact, in the same token and scale as `amount_received`; always
                greater than zero. The same figure `GET .../payments/{payment_id}` publishes as `amount_missing` for
                this payment, and `deposit_instructions.amount` here.
              allOf:
                - $ref: '#/components/schemas/Money'
            amount_received:
              description: >-
                The running total Swaps recorded for this payment, in the token of its first counted deposit. A deposit
                in another accepted token is never added to it (it is held for support), so it is not in this figure.
              allOf:
                - $ref: '#/components/schemas/Money'
            deposit_instructions:
              description: >-
                What to send now: the same receiving address and chain id as while the payment was awaiting, narrowed to
                the ONE token the running total counts (`asset` and `token_address` name that token only; a top-up in
                any other token is not added to the total). `amount` equals `amount_missing` and is exact
                (`amount_is_estimate: false`); `amount_out`, `bridge_fee` and `gross_up`, which describe the whole
                payment, are absent.
              allOf:
                - $ref: '#/components/schemas/DepositInstructions'
            created_at:
              type: string
              format: date-time
              description: When the payer chose this payment method.
          additionalProperties: false
        network:
          type:
            - string
            - 'null'
          enum:
            - tempo
            - base
            - ethereum
            - polygon
            - arbitrum
            - optimism
            - solana
            - null
          description: >-
            The source network of this link's latest `crypto_relay` payment (CP-X2). Present only once a `crypto_relay`
            payment exists, which only the `crypto_relay_rail` flag (off in production) allows; absent otherwise.
            `solana` stays dark: `POST .../payments` answers `409 network_not_supported` for it.
        stablecoin:
          type:
            - string
            - 'null'
          enum:
            - USDC.e
            - pathUSD
            - USDT0
            - USD1
            - cUSD
            - USDC
            - null
          description: The stablecoin the payer sends on `network` — `USDC` for `crypto_relay`. Present exactly when `network` is.
        deposit_instructions_by_chain:
          type: array
          description: >-
            The Relay deposit for `network` (CP-X2; R19 §2.4; MON-7, CMP-5), present exactly when `network` is. Send
            `amount` of `asset` on `chain` to `address` (case-preserved verbatim) — never to the splitter — before
            `quote_expires_at`. The quote is a strict exact-output order: `amount_is_estimate` is false, the splitter
            receives exactly `amount_out` (the invoice plus the 1% Swaps fee), `bridge_fee` is Relay's part of `amount`,
            and `provider: relay` states that a third party moves the funds. Relay refunds a failed or mismatched
            deposit only to the refund address the payer gave; Swaps holds no key on this path. An empty array means the
            address is not payable (the payment or link is closed, Relay has already seen a transfer, or the send-by
            time passed) or the stored quote could not be read; `watcher_state` says which. It is also empty while
            another payment of the link holds money or has a deposit being recorded; `watcher_state` then stays
            `address_issued` and `pending_payment` is null.
          items:
            $ref: '#/components/schemas/DepositInstructions'
        one_tap:
          type: object
          properties:
            available:
              type: boolean
        watcher_state:
          $ref: '#/components/schemas/PaymentWatcherState'
        watcher_reason:
          $ref: '#/components/schemas/PaymentWatcherReason'
        merchant_contact:
          type:
            - object
            - 'null'
          description: >-
            L4-9 — display-only, so a terminal screen's "contact the merchant" line can be actionable. Populated ONLY
            from the merchant's own opt-in `Account.support_contact`; `null` when the merchant never set one. NEVER the
            merchant's login e-mail, a KYC field, a Bridge record or any settlement/id data.
          properties:
            email:
              type: string
              format: email
              maxLength: 254
            url:
              type: string
              format: uri
              pattern: ^https://
              maxLength: 512
          additionalProperties: false
      unevaluatedProperties: false
    PaymentSessionCodeResolution:
      type: object
      description: >-
        C5-SHORT-LINK — what a payment link's `short_code` resolves to: the payer session token and the payer page
        `url`. Carries nothing else; read the session itself with `GET /v1/payment_sessions/{token}`.
      required:
        - token
        - url
      properties:
        token:
          type: string
          pattern: ^plk_
          description: 'The plk_ capability token — a secret, never an id: never logged, never echoed.'
        url:
          type: string
          format: uri
          pattern: ^https://
          description: The payer page for `token` on this project's payer host (the same value as `PaymentLink.url`).
      additionalProperties: false
    PaymentSessionUnavailableRail:
      type: object
      required:
        - rail
        - reason_code
      properties:
        rail:
          type: string
          enum:
            - ach
            - wire
            - fednow
            - sepa
            - faster_payments
            - pix
            - spei
            - crypto_tempo
            - crypto_bridge
            - crypto_relay
        reason_code:
          type: string
          enum:
            - merchant_fiat_payin_pending
            - settlement_requires_crypto
            - merchant_individual_rail_blocked
            - merchant_rail_not_enabled
            - link_currency_unsupported
            - amount_below_rail_minimum
            - rail_disabled
            - rail_temporarily_unavailable
            - rail_not_offered
            - settlement_kind_mismatch
            - merchant_not_enrolled
            - amount_above_rail_maximum
          description: >-
            Closed set. `merchant_fiat_payin_pending`: the merchant is an individual whose bank pay-in is not active yet
            (the same `details.reason` `POST .../payments` returns). `settlement_requires_crypto`: the link settles to a
            bank account and there is no bank-to-bank route, so only a crypto pay-in can settle it; it takes precedence
            over `merchant_fiat_payin_pending`. `merchant_individual_rail_blocked`: this rail is never payable to an
            individual merchant (pix, faster_payments). `merchant_rail_not_enabled`: the merchant's account is not
            enabled for this rail. `link_currency_unsupported`: the rail cannot settle an invoice in this currency
            (`crypto_tempo` is USD only). `amount_below_rail_minimum`: the invoice is under the rail's minimum.
            `rail_disabled`: Swaps has switched the rail off for this link (an operator switch, not a merchant choice).
            `settlement_kind_mismatch`: the link's settlement destination cannot receive this crypto rail
            (`crypto_tempo` needs a Tempo address; `crypto_bridge` needs a Tempo address or a supported bank off-ramp
            account, so a link settling to an Ethereum address gets neither; `crypto_bridge` is also never offered on a
            link that settles to the merchant's own Swaps Wallet, `crypto_only`). `merchant_not_enrolled`: Swaps has not
            yet opened `crypto_bridge` for this merchant (a Swaps rollout gate, not a merchant setting, so do not word
            it as the merchant's choice). `crypto_tempo` and `crypto_bridge` are always in exactly one of `rails[]` and
            this list; `crypto_relay` is in exactly one of them only while the `crypto_relay_rail` flag admits the
            merchant, and in neither otherwise. For `crypto_relay`, `rail_not_offered` means no source network has an
            admitted Relay route (`founder_accepted` per founder §52.34, or `proven`) into this link's settlement
            network, and `amount_above_rail_maximum` means the invoice is above the rail's per-payment cap.
            `rail_temporarily_unavailable`: eligibility could not be verified right now (for example a missing FX rate),
            so the rail fails closed. `rail_not_offered`: no more specific reason applies. New values are added only in
            a documented contract change.
      additionalProperties: false
    PaymentSessionSelectRailRequest:
      type: object
      description: >-
        Selects a rail and, for a crypto rail, the network and stablecoin (RESOURCE-MODEL §2.1 v2 amendment:
        `select_rail` extended with network + stablecoin). The payer's consent checkbox is a client-side gate, not a
        field here. `payer_type` is required on every rail — it is the §5.1 payer-gate declaration (business vs.
        individual); the three USDC-stablecoin rails (`crypto_tempo`, `crypto_bridge`, `crypto_relay`) still take the
        field but carry no fiat ceiling, so it is accepted and ignored for the cap check on those. `source_chain`/
        `source_asset` are required, and `source_address` optional, when `rail` is `crypto_bridge` (K3 correction to the
        v1 draft of this schema, which omitted every payer-gate and cross-chain field the handler actually requires).
        `network` and `refund_address` are required when `rail` is `crypto_relay` (CP-X2, behind the `crypto_relay_rail`
        flag): one open payment per link — the same network and refund address get it back, anything else is `409
        payment_in_progress` until Relay can no longer fill it and either saw no transfer or refunded it with the refund
        transaction recorded. Refusals: `400 refund_address_required`; `409 network_not_supported` for a network outside
        the five Relay sources; `503 relay_route_unavailable` when the network has no admitted route (`founder_accepted`
        per founder §52.34, or `proven`), Relay cannot quote, the Tempo leg is down or the link closes too soon
        (`details.reason`, `details.network`); `502 relay_quote_shortfall` when Relay's quote does not deliver exactly
        the amount due to the splitter. Every refusal records nothing.
      required:
        - rail
        - payer_type
      properties:
        rail:
          type: string
          enum:
            - ach
            - wire
            - fednow
            - sepa
            - faster_payments
            - pix
            - spei
            - crypto_tempo
            - crypto_bridge
            - crypto_relay
        payer_type:
          type: string
          enum:
            - business
            - individual
          description: >-
            The payer's own declaration (§5.1) — decides which fiat-rail cap applies. Not the merchant's
            `expected_payer_type` restriction.
        payer_state:
          type: string
          description: >-
            Two-letter US state code — only consulted for an individual payer on a US ACH/wire/FedNow rail against an
            individual (p2p) merchant's US-residency gate.
        payer_email:
          type: string
          format: email
          description: >-
            Optional receipt email set at select-rail time — a payer may also set or correct it afterward via `POST
            .../receipt_email`.
        network:
          type: string
          enum:
            - tempo
            - base
            - ethereum
            - polygon
            - arbitrum
            - optimism
            - solana
          description: >-
            Required when `rail` is `crypto_relay`: the network the payer sends USDC from (`base`, `ethereum`,
            `polygon`, `arbitrum`, `optimism`; `tempo` and `solana` answer `409 network_not_supported`). Accepted and
            ignored on the other rails today.
        stablecoin:
          type: string
          enum:
            - USDC.e
            - pathUSD
            - USDT0
            - USD1
            - cUSD
            - USDC
          description: >-
            Optional. `crypto_relay` accepts only `USDC` (anything else is `400 invalid_request`, `param: stablecoin`);
            accepted and ignored on the other rails today.
        refund_address:
          type: string
          pattern: ^0x[0-9a-fA-F]{40}$
          description: >-
            Required when `rail` is `crypto_relay`: the payer's own address on `network`, kept case-verbatim. Relay
            returns a failed, short or excess deposit only here. Never a Swaps address — the Swaps fee wallet, the
            merchant's settlement address and the zero address are refused (`400 invalid_request`, `param:
            refund_address`).
        source_chain:
          type: string
          enum:
            - base
            - ethereum
            - polygon
            - arbitrum
            - optimism
          description: >-
            Required when `rail` is `crypto_bridge` — the payer's own chain, distinct from `network` (the merchant's
            settlement network).
        source_address:
          type: string
          description: >-
            Optional when `rail` is `crypto_bridge` — the payer's sending wallet, kept case-verbatim. Bridge accepts a
            deposit from any sender when omitted.
        source_asset:
          type: string
          enum:
            - USDC
          description: Required when `rail` is `crypto_bridge` — Phase 1 accepts USDC only.
      additionalProperties: false
    PaymentWatcherState:
      type:
        - string
        - 'null'
      enum:
        - address_issued
        - source_seen
        - confirming
        - matching
        - releasing
        - delivered
        - recovering
        - recovery_required
        - bridge_failed
        - expired
        - null
      description: >-
        Narrator sub-state (R19 CP-G11), produced for `crypto_relay` payments (CP-X2, behind `crypto_relay_rail`);
        `null` on every other rail today. From the Relay deposit intent and the payment: `address_issued` (deposit
        address issued, nothing seen), `source_seen` (the payer's transfer seen on the source network), `confirming`
        (Relay is bridging it), `matching` (Relay reported the fill on Tempo; the splitter watcher is matching it),
        `delivered` (the splitter watcher observed the deposit and released it — the only state that goes with `paid`).
        `releasing` has no observable producer yet. Problem states always carry `watcher_reason` and never mean paid:
        `recovering` / `recovery_required` (a deposit on the wrong network, recovered through Relay or needing support;
        `recovery_required` with `payment_closed`: funds reached or are on their way to the splitter of a payment that
        is already closed, so support must return them), `bridge_failed` (Relay refunded the order to the payer's refund
        address, or failed it; a failed order may return nothing and needs support), `expired` (the send-by time passed
        with nothing seen — do not send to this address). A transfer Relay saw before the send-by time stays
        `confirming`.
    PaymentWatcherReason:
      type:
        - string
        - 'null'
      enum:
        - wrong_network_deposit
        - provider_refund_or_failure
        - quote_expired
        - bridge_delivered_less
        - payment_closed
        - null
      description: >-
        Why `watcher_state` is a problem state; `null` otherwise. `bridge_delivered_less` goes with `matching` when
        Relay reported delivering less than the amount due: the splitter holds the funds and releases nothing until the
        full amount is there (the underpaid rule). `payment_closed` goes with `recovery_required`: the payment was
        closed (link expired, cancelled or paid another way) while its funds were in flight.
    PaymentSessionReceiptEmailRequest:
      type: object
      description: >-
        Attaches a receipt email to one payment attempt on this session. `attempt_id` is required (K3 correction — the
        v1 draft of this schema omitted it, but the handler scopes the write to one attempt id, not "the link"; a payer
        with more than one live attempt — the product allows up to five — would otherwise have no way to say which one).
      required:
        - attempt_id
        - email
      properties:
        attempt_id:
          type: string
          pattern: ^pay_
        email:
          type: string
          format: email
      additionalProperties: false
    PaymentSessionMarkSentRequest:
      type: object
      description: >-
        Identifies which payment attempt the payer is confirming they sent (K3 addition — `mark_sent` had no request
        body in the v1 draft at all; the handler scopes the CAS update to one attempt id).
      required:
        - attempt_id
      properties:
        attempt_id:
          type: string
          pattern: ^pay_
      additionalProperties: false
    PaymentSessionPayWithWalletResponse:
      type: object
      description: >-
        Unsigned steps only (RESOURCE-MODEL §0.10, §2.1 v2 amendment `pay_with_wallet`). Mirrors the shape already
        established for the wallet's own unsigned operations (`send_intents.send_instructions`, `conversions.steps[]`,
        RESOURCE-MODEL §2.3) — nothing here can sign or move funds; the holder's passkey is the only signer.
      properties:
        steps:
          type: array
          items:
            type: object
            properties:
              kind:
                type: string
                description: E.g. `transfer` — a single unsigned call to the splitter address.
              call:
                type: object
                properties:
                  to:
                    type: string
                  data:
                    type: string
                  value:
                    type: string
        request_id:
          type: string
        expires_at:
          type: string
          format: date-time
      unevaluatedProperties: false
    PayoutStatus:
      type: string
      x-swaps-open-enum: true
      description: >
        The 11 payout states (RESOURCE-MODEL §2.2). `paid` is not terminal — the ladder's final rung stays active until
        `settled`. `settled` is the recipient paid the invoice exactly, or above it by at most 0.05 % of the invoice
        plus 10 minor units (25 for MXN) — the funding buffer's over-delivery; `settled_amount` is what was actually
        paid. `paid_with_shortfall` is terminal: it produces no receipt, no success notification, and never becomes
        `settled`. A1-2: open — an unrecognized member is an opaque string, never a deserialization failure.
      enum:
        - draft
        - awaiting_funds
        - funds_received
        - processing
        - paid
        - paid_with_shortfall
        - settled
        - failed
        - returned
        - expired
        - cancelled
    PayoutFailureCode:
      type: string
      description: >
        Bridge terminal-state failure code (RESOURCE-MODEL §2.2, v2 amendments — "`failure_code` as a published enum").
        The exhaustive list of Bridge terminal codes is not present in the files this fragment was generated from; only
        `returned_no_account` is directly evidenced (`docs/api/consumers/R12-pay-invoice.md` §1 `pi-returned`, §4). Left
        open as a free string rather than a closed, invented enum — see `x-swaps-enum-source`.
      x-swaps-enum-source: RESOURCE-MODEL §2.2 D-77
      example: returned_no_account
    PayoutCapabilitySnapshot:
      type: object
      description: >
        **v1 reality (K4c, §52 C4-D12):** `eta_seconds`/`minimum` are resolved from the LIVE corridor catalog at read
        time, keyed by the payout's own stored `capability_id`; `version` is the capability-contract version stamped at
        creation, not re-resolved — both are presentation-only facts (no SLA, no legal claim) that are safe to re-derive
        live. `sender_display`/`legal_entity_name` are the OPPOSITE: FROZEN at payout creation (or replacement — a
        `payouts.replace` draft gets its own fresh resolution too, never a copy of the payout it replaces) and read back
        from the row, never re-derived from the live catalog. A payout created on or after this snapshot's migration
        always carries one — `sender_display` is set even when the resolver could not identify a specific sender (the
        literal value `unknown`, never omitted in that case). A payout created BEFORE the migration carries no snapshot
        at all: `sender_display` is omitted entirely and `legal_entity_name` is `null`, exactly as this endpoint behaved
        before the freeze existed — nothing proves what that payout was actually sent as, so nothing is invented for it.
        The freeze exists precisely because the catalog's answer for a corridor can change for reasons that have nothing
        to do with one specific payout (e.g. Bridge moved `eur_sepa` from `bridge` to `customer` on 2026-09-02, D-111):
        a live-resolved value here would let a payout created before that date wrongly claim "customer" on a statement
        it never actually carried that name on. Resolution reads the provider's account record AS CACHED AT THE MOMENT
        OF FREEZE — not a live probe — so a rename on Bridge's side after creation never changes an already-frozen
        payout's answer; a client rendering `bridge` should pair it with a tip that the statement name changes once the
        account is renamed. A client that wants the corridor's CURRENT terms (for a payout not yet created) calls `GET
        /v1/capabilities?product=payouts` instead. Consumer contract: prefer this snapshot first and fall back to a live
        capability read ONLY when it is absent (a legacy pre-freeze payout) — never the reverse.
      required:
        - eta_seconds
        - minimum
        - version
      properties:
        sender_display:
          type: string
          description: >
            How the sender appeared on the recipient's bank statement, frozen at the moment this payout was created.
            Omitted only for a payout created before the creation-time freeze existed (no column value to publish) —
            present, and possibly `unknown`, on every payout created since.
          enum:
            - bridge
            - customer
            - payment_partner
            - unknown
        legal_entity_name:
          type:
            - string
            - 'null'
          description: >
            The specific legal-entity/account-holder name frozen alongside `sender_display`, verbatim from the
            customer's Bridge virtual account record AS CACHED AT THE MOMENT OF FREEZE — populated ONLY for `eur_sepa`
            (the one corridor whose `customer` category genuinely reflects the receiving-account record; `usd_wire` also
            defaults to `customer` in the catalog, but that is an ACTIVE Bridge configuration Swaps applies at funding
            time, not an account fact, so it never gets this per-account refinement and always reads `null` here), and
            only when a Bridge customer id is already resolvable for the payer and every activated account THAT Bridge
            customer holds for EUR agrees on the name on file. Null in every other case: `sender_display` itself omitted
            (a pre-freeze payout), `sender_display` is `bridge`/`payment_partner`/`unknown`, the corridor is not
            `eur_sepa`, no Bridge customer id was resolvable yet, no activated account existed to resolve against, or
            more than one activated account disagreed on the name — the resolver never guesses between them.
            `packages/config/payoutCapabilities.ts` itself still carries no legal-entity-name field (PI-G6's catalog
            half remains open); this field is a per-PAYOUT fact sourced from the account record, not a per-corridor
            catalog constant.
        eta_seconds:
          type: integer
          minimum: 0
          description: >
            Typical time to arrival for this corridor, in seconds. Presentation-only — it carries no provider SLA and
            must be labelled accordingly wherever it is shown.
        minimum:
          $ref: '#/components/schemas/Money'
        version:
          type: string
          description: Capability-contract version this snapshot was resolved from.
      additionalProperties: false
    PayoutBeneficiary:
      type: object
      description: >
        The masked beneficiary projection returned on `payouts` and `receipt`. Raw bank details cross the boundary once,
        at `POST /v1/payouts/{id}/beneficiary`, and are held only by the provider from then on — this is the only shape
        ever read back (RESOURCE-MODEL §2.2 invariants). Note the output field is `beneficiary_owner_type`, not the
        request's `account_owner_type` — the two are named differently on purpose.
      required:
        - account_holder
        - account_tail
        - bank_country
        - beneficiary_owner_type
      properties:
        account_holder:
          type: string
          description: The bank account holder's name, as supplied on the beneficiary request.
        account_tail:
          type: string
          description: The last few digits of the destination account or rail identifier, masked.
          example: ••••4821
        bank_country:
          type: string
          description: ISO country code of the destination bank.
          example: DE
        beneficiary_owner_type:
          type: string
          enum:
            - individual
            - business
        bank_name:
          type: string
          description: >
            Name of the destination bank, as supplied on the beneficiary request. Present only when one was supplied (US
            bank rails `ach` and `wire`); absent, never empty, otherwise.
      additionalProperties: false
    PayoutBeneficiaryAddress:
      type: object
      description: >
        The beneficiary's address (`docs/api/consumers/R12-pay-invoice.md` §2 PI-G11). `state_region` is collected only
        for US rails, and is OPTIONAL there as well as everywhere else — the `required` list below omits it; a US
        beneficiary with no state supplied is not rejected for that alone. (Corrected 2026-09-07: the schema, the
        route's own `optionalAddress` and the approved `pi-recipient-usd` screen already agreed on this; this
        description previously said "required" and was the one outlier — confirm with Bridge whether that was ever
        actually enforced before tightening either side.)
      required:
        - street
        - city
        - postal_code
      properties:
        street:
          type: string
        city:
          type: string
        postal_code:
          type: string
        state_region:
          type:
            - string
            - 'null'
          description: US rails only; null for every other rail.
      additionalProperties: false
    PayoutBeneficiaryIban:
      type: object
      description: 'IBAN rail beneficiary (`rail: sepa`).'
      required:
        - rail
        - account_owner_type
        - account_holder
        - address
        - iban
        - bic
      properties:
        rail:
          type: string
          enum:
            - sepa
        account_owner_type:
          type: string
          enum:
            - individual
            - business
          description: Who owns the destination bank account.
        account_holder:
          type: string
          description: Full name on the destination bank account.
        address:
          $ref: '#/components/schemas/PayoutBeneficiaryAddress'
        iban:
          type: string
        bic:
          type: string
      additionalProperties: false
    PayoutBeneficiaryUsBank:
      type: object
      description: 'US account+routing rail beneficiary (`rail: ach` or `rail: wire`).'
      required:
        - rail
        - account_owner_type
        - account_holder
        - address
        - account_number
        - routing_number
        - account_type
      properties:
        rail:
          type: string
          enum:
            - ach
            - wire
        account_owner_type:
          type: string
          enum:
            - individual
            - business
          description: Who owns the destination bank account.
        account_holder:
          type: string
          description: Full name on the destination bank account.
        address:
          $ref: '#/components/schemas/PayoutBeneficiaryAddress'
        account_number:
          type: string
        routing_number:
          type: string
        account_type:
          type: string
          enum:
            - checking
            - savings
        bank_name:
          type: string
          minLength: 1
          maxLength: 128
          description: Name of the destination bank. Optional; some banks need it to be identified.
      additionalProperties: false
    PayoutBeneficiaryUkBank:
      type: object
      description: 'UK sort-code rail beneficiary (`rail: faster_payments`).'
      required:
        - rail
        - account_owner_type
        - account_holder
        - address
        - sort_code
        - account_number
      properties:
        rail:
          type: string
          enum:
            - faster_payments
        account_owner_type:
          type: string
          enum:
            - individual
            - business
          description: Who owns the destination bank account.
        account_holder:
          type: string
          description: Full name on the destination bank account.
        address:
          $ref: '#/components/schemas/PayoutBeneficiaryAddress'
        sort_code:
          type: string
        account_number:
          type: string
      additionalProperties: false
    PayoutBeneficiaryPix:
      type: object
      description: 'Brazil PIX rail beneficiary (`rail: pix`).'
      required:
        - rail
        - account_owner_type
        - account_holder
        - address
        - pix_key
        - pix_key_type
      properties:
        rail:
          type: string
          enum:
            - pix
        account_owner_type:
          type: string
          enum:
            - individual
            - business
          description: Who owns the destination bank account.
        account_holder:
          type: string
          description: Full name on the destination bank account.
        address:
          $ref: '#/components/schemas/PayoutBeneficiaryAddress'
        pix_key:
          type: string
        pix_key_type:
          type: string
          description: >-
            The PIX key's own kind (e.g. email, phone, CPF/CNPJ, random) — not further enumerated in RESOURCE-MODEL
            §2.2.
      additionalProperties: false
    PayoutBeneficiaryClabe:
      type: object
      description: 'Mexico CLABE rail beneficiary (`rail: spei`).'
      required:
        - rail
        - account_owner_type
        - account_holder
        - address
        - clabe
      properties:
        rail:
          type: string
          enum:
            - spei
        account_owner_type:
          type: string
          enum:
            - individual
            - business
          description: Who owns the destination bank account.
        account_holder:
          type: string
          description: Full name on the destination bank account.
        address:
          $ref: '#/components/schemas/PayoutBeneficiaryAddress'
        clabe:
          type: string
      additionalProperties: false
    PayoutBeneficiaryRequest:
      description: >
        Adds the beneficiary to a `draft` payout — once, ever (RESOURCE-MODEL §2.2). Exactly one fully-specified rail
        shape must be supplied, chosen by `rail`: `sepa` (IBAN+BIC), `ach`/`wire` (US account+routing+`account_type`),
        `faster_payments` (UK sort code + account number), `pix` (a PIX key), or `spei` (a CLABE)
        (`docs/api/consumers/R12-pay-invoice.md` §1 `pi-recipient*`). Money boundary — REST only, never exposed as an
        MCP tool argument, because most MCP clients log tool arguments verbatim (D-6). When the provider rejects the
        details, the answer is `422 beneficiary_invalid`; its `error.param` names the request field (for example
        `bank_name`, `routing_number`, `address.postal_code`) only when exactly one field can be named, and is absent
        otherwise.
      oneOf:
        - $ref: '#/components/schemas/PayoutBeneficiaryIban'
        - $ref: '#/components/schemas/PayoutBeneficiaryUsBank'
        - $ref: '#/components/schemas/PayoutBeneficiaryUkBank'
        - $ref: '#/components/schemas/PayoutBeneficiaryPix'
        - $ref: '#/components/schemas/PayoutBeneficiaryClabe'
      discriminator:
        propertyName: rail
        mapping:
          sepa: '#/components/schemas/PayoutBeneficiaryIban'
          ach: '#/components/schemas/PayoutBeneficiaryUsBank'
          wire: '#/components/schemas/PayoutBeneficiaryUsBank'
          faster_payments: '#/components/schemas/PayoutBeneficiaryUkBank'
          pix: '#/components/schemas/PayoutBeneficiaryPix'
          spei: '#/components/schemas/PayoutBeneficiaryClabe'
    Payout:
      description: >
        A fiat payout funded with crypto (RESOURCE-MODEL §2.2, "Pay an invoice"). No `expires_at` (written by nothing
        today, always null — cut per D-8) and no `reconciliation_status` (stays internal; surfaces here only as the
        derived `needs_attention` boolean).
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - object
            - status
            - invoice_amount
            - corridor_id
            - fiat_rail
            - source_chain
            - source_chain_chosen
            - source_asset
            - payer_type
            - fee_bps
            - notify_recipient
            - capability_snapshot
            - needs_attention
          properties:
            id:
              type: string
              pattern: ^po_
            object:
              type: string
              enum:
                - payout
            status:
              $ref: '#/components/schemas/PayoutStatus'
            invoice_amount:
              $ref: '#/components/schemas/Money'
              description: The invoice amount the recipient is owed, in fiat.
            corridor_id:
              type: string
              description: >
                The corridor this payout settles through, as listed by `GET /v1/capabilities?product=payouts`. Known
                values today (RESOURCE-MODEL §3 vocabulary): `usd_ach`, `usd_wire`, `eur_sepa`, `gbp_faster_payments`,
                `brl_pix`, `mxn_spei`, `cop_co_bank_transfer` (lifecycle `blocked` — never actually payable).
              example: eur_sepa
            fiat_rail:
              type: string
              description: The settlement rail underlying `corridor_id`.
              enum:
                - ach
                - wire
                - sepa
                - faster_payments
                - pix
                - spei
            source_chain:
              type: string
              description: >
                The chain the payer funds from. Read it together with `source_chain_chosen`: while that is false, no
                chain has been chosen yet and this is only the default (`base`), never a choice. The stored `payout.*`
                event payload (`GET /v1/events`, the SSE stream, webhooks, `GET /v1/activity`) carries the stored chain
                instead: `null` for a draft with no chain, and no `source_chain_chosen`.
              enum:
                - base
                - ethereum
                - polygon
                - arbitrum
                - optimism
            source_chain_chosen:
              type: boolean
              description: >
                True once a chain is fixed for this payout: sent as `source_chain` at create, or fixed at funding (`POST
                /v1/payouts/{id}/funding_instructions`, locked once the provider transfer exists). False on a draft
                created without `source_chain`; `source_chain` then shows the default.
            source_asset:
              type: string
              description: The stablecoin the payer funds with. Only USDC is live today.
              example: USDC
            payer_type:
              type: string
              enum:
                - individual
                - business
              description: >
                Set by Swaps at creation, never from the request: `business` only when the account the payout was
                created under and the payer's provider customer were both business. The individual per-payout limit is
                checked at funding against the account's type at that moment and the fresh provider customer, not
                against this stored value. A distinct axis from `beneficiary.beneficiary_owner_type`.
            fee_bps:
              type: integer
              description: >
                The Swaps fee, in basis points, on the gross source amount. Today always 100 (1%). A stored value that
                disagrees with the current fee contract is refused at fund (RESOURCE-MODEL §2.2 invariants).
              example: 100
            settled_amount:
              description: >
                The confirmed amount actually settled to the recipient — the invoice, or up to 0.05 % of the invoice
                plus 10 minor units (25 for MXN) above it when the funding buffer over-delivers. Null until `settled`.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            provider_paid_amount:
              description: >
                The confirmed amount the provider actually paid out. Set alongside `invoice_shortfall_amount` on
                `paid_with_shortfall`; null otherwise.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            invoice_shortfall_amount:
              description: >
                `invoice_amount` minus `provider_paid_amount` on a `paid_with_shortfall` payout. Null on every other
                status.
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
            note:
              type:
                - string
                - 'null'
              maxLength: 280
              description: Free-text note the payer attached at creation.
            recipient_email:
              type:
                - string
                - 'null'
              format: email
              description: >
                The recipient's email, when the payer chose to notify them (RESOURCE-MODEL §2.2 v2 amendments — added
                create + read).
            notify_recipient:
              type: boolean
              default: false
              description: Whether `recipient_email` is emailed a payment confirmation once the payout lands.
            capability_snapshot:
              $ref: '#/components/schemas/PayoutCapabilitySnapshot'
            beneficiary:
              description: The masked beneficiary, once added. Null on a `draft` with no beneficiary yet.
              oneOf:
                - $ref: '#/components/schemas/PayoutBeneficiary'
                - type: 'null'
            failure_code:
              description: >
                Set on `failed` and `returned`; also set to `missing_return_policy` while Bridge holds the deposit with
                no address to return it to (`processing`, `needs_attention`).
              oneOf:
                - $ref: '#/components/schemas/PayoutFailureCode'
                - type: 'null'
            needs_attention:
              type: boolean
              description: >
                Router-derived from the internal `reconciliation_status` signal, which stays internal (RESOURCE-MODEL
                D-8, resolved). True renders as "Checking settlement" over whatever `status` currently reads, most
                commonly over `paid`.
            funded_at:
              type:
                - string
                - 'null'
              format: date-time
              description: When the funding provider transfer was created. Null before funding.
            paid_at:
              type:
                - string
                - 'null'
              format: date-time
              description: When the provider confirmed payout to the recipient's bank.
            settled_at:
              type:
                - string
                - 'null'
              format: date-time
              description: When the payout reached its terminal `settled` state.
      unevaluatedProperties: false
    PayoutCreateRequest:
      type: object
      description: >
        Creates a `draft` payout. Nothing is charged and no money moves — funding is a separate, later step (`POST
        /v1/payouts/{id}/funding_instructions`).
      required:
        - invoice_amount
        - corridor_id
      properties:
        invoice_amount:
          $ref: '#/components/schemas/MoneyPositive'
        corridor_id:
          type: string
          description: >
            One of `GET /v1/capabilities?product=payouts` corridors[].id. Refused with a 409 `capability_unavailable` if
            the corridor is not currently executable.
          example: eur_sepa
        source_chain:
          type: string
          enum:
            - base
            - ethereum
            - polygon
            - arbitrum
            - optimism
          description: >
            Optional. The USDC chain the payer funds from, when already chosen; it must be one of the corridor's
            `source_chains` (otherwise `409 capability_unavailable`, nothing created). Leave it out until the payer
            picks: the draft then has no chain (`source_chain_chosen: false`) and the chain is given when funding.
        payer_type:
          type: string
          enum:
            - individual
            - business
          description: >
            Optional. Swaps derives the payer type from the account the request acts for (`Swaps-Account`, or the key's
            account) and the payer's provider customer: `business` only when both are business. Leave it out. An equal
            value is accepted; a different one is refused with `422 payer_type_mismatch` (`details.expected`,
            `details.received`) and nothing is created.
        note:
          type: string
          maxLength: 280
        recipient_email:
          type: string
          format: email
          description: Required if `notify_recipient` is true.
        notify_recipient:
          type: boolean
          default: false
          description: Email `recipient_email` a payment confirmation once, when the payment lands.
      additionalProperties: false
    PayoutList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Payout'
      unevaluatedProperties: false
    PayoutFundingInstructions:
      type: object
      description: >
        The money boundary: what to send, and where, to fund a payout's provider transfer. The first call creates the
        transfer and freezes the fee: from that moment `fee_estimate`/`gross_estimate` are the figures the transfer was
        actually created against (`payouts.developer_fee`/`gross_amount`), and a later call returns THOSE, not a fresh
        computation against the current `fee_bps` — a re-quote would contradict a transfer that already exists. Before
        funding, with no committed pair yet, they are computed live. Every later call returns the same address
        (idempotent by design, not only by `Idempotency-Key`). No `expires_at` — written by nothing today, cut per D-8.
      required:
        - deposit_address
        - amount
        - buffer
        - amount_is_estimate
        - fee_estimate
        - gross_estimate
        - chain
        - deposit_message
        - transfer_reference
        - refund_address
      properties:
        deposit_address:
          type: string
          description: |
            Case-preserved verbatim — never lower-cased, never re-cased. Reproduce it exactly as returned.
        amount:
          $ref: '#/components/schemas/Money'
          description: >
            The source amount to send, in `source_asset`: the provider's quote rounded UP to the cent plus a buffer of
            at most 0.05 % (`buffer`), so the recipient receives the full invoice. A receipt above the invoice within
            that buffer still settles. Always an estimate, never an exact total — the rate can move before the funds
            arrive.
        buffer:
          description: >
            The part of `amount` that is the buffer (at most 0.05 % of the quote rounded up to the cent), published so a
            screen can state it without computing it. Null on a funding attempt created before the buffer existed.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        fee_estimate:
          $ref: '#/components/schemas/Money'
          description: >
            The estimated Swaps fee on this transfer. Computed server-side as `gross = amount × 10000 / (10000 − bps)`;
            clients never recompute.
        gross_estimate:
          $ref: '#/components/schemas/Money'
          description: >
            The estimated gross source amount before the Swaps fee. Computed server-side as `gross = amount × 10000 /
            (10000 − bps)`; clients never recompute.
        amount_is_estimate:
          type: boolean
          enum:
            - true
          description: Always `true` — the source amount is never an exact total (RESOURCE-MODEL §2.2 invariants).
        chain:
          type: string
          enum:
            - base
            - ethereum
            - polygon
            - arbitrum
            - optimism
        deposit_message:
          type:
            - string
            - 'null'
          description: A memo/tag to attach to the transfer, when the chain requires one; null otherwise.
        transfer_reference:
          type:
            - string
            - 'null'
          description: A reference the merchant can quote when tracing the fiat leg.
        refund_address:
          type:
            - string
            - 'null'
          description: >
            The address on `chain` where Bridge returns the USDC if this transfer can't be completed, recorded only
            after Bridge accepted it as the transfer's return address (EIP-55 checksummed). `null` when none was set:
            Swaps then has no refund address on file, and a deposit Bridge cannot deliver may wait in
            `missing_return_policy` (the payout reads `processing` with `needs_attention`).
        sandbox:
          type: boolean
          enum:
            - true
          description: >
            Present, and `true`, only on a test-mode (`livemode: false`) response: `deposit_address` is a
            provider-sandbox address on `chain`, not a real one. Never send real funds to it. Absent on every live
            response.
        funding_source:
          type: string
          enum:
            - swaps_wallet
          description: >
            Present only when this attempt is funded from the payer's Swaps wallet («Wallet balance»), on every read and
            fund of the attempt whatever the request body; absent means the payer sends USDC to `deposit_address` from
            any wallet («Another wallet»). While present, do not send from another wallet.
        wallet_send_intent:
          $ref: '#/components/schemas/SendIntent'
          description: >
            With `funding_source: swaps_wallet`: the unsigned send from the payer's own Tempo wallet to exactly
            `deposit_address` on `chain`. Its `amount` is what leaves the wallet (every cost of the leg included),
            `amount_out` what Relay guarantees to deliver. Sign `send_instructions.steps[]` with the wallet passkey only
            in the session `payouts.fund` handed it to (one session per send, never re-handed), then record the hash
            from that session through `POST /v1/wallet/send_intents/{id}/source_tx`. Returned only to a dashboard
            session; a business key sees `funding_source` alone.
      additionalProperties: false
    PayoutFundRequest:
      type: object
      description: >
        Optional body of `payouts.fund` (DS-14 «Pay with»). Omit it for the implicit fund. `source_chain` and
        `funding_source: swaps_wallet` are mutually exclusive — for wallet funding the server chooses the chain.
        `refund_address` is for «Another wallet» only.
      properties:
        funding_source:
          type: string
          enum:
            - external_wallet
            - swaps_wallet
          description: '`external_wallet` (default): send USDC from any wallet. `swaps_wallet`: fund from Wallet balance.'
        source_chain:
          type: string
          enum:
            - base
            - ethereum
            - polygon
            - arbitrum
            - optimism
          description: >
            The USDC chain to fund from; only before a provider transfer exists (`409 payout_source_chain_locked`
            afterwards). Required for a payout created without `source_chain` (`400 payout_source_chain_missing`
            otherwise, nothing changed), unless `funding_source` is `swaps_wallet`.
        refund_address:
          type: string
          description: >
            Where Bridge returns the USDC if this transfer can't be completed. An address on `source_chain` that you
            control. Not accepted with `funding_source: swaps_wallet`. EVM only (`0x` and 40 hex characters), never the
            zero address; a mixed-case address must carry a valid EIP-55 checksum, and the address is stored and sent in
            its EIP-55 form. Omit it to leave the current one unchanged; once set it can be changed only while the
            transfer awaits funds, never removed.
      additionalProperties: false
    PayoutWalletFundingQuote:
      type: object
      description: >
        Whether «Wallet balance» can fund this payout and what it would cost. Every Money is USDC-denominated with 6
        decimals: `required_delivery` in USDC on `source_chain`, the rest in USDC.e on Tempo. Null figures whenever
        `available` is false.
      required:
        - funding_source
        - available
        - reason
        - source_chain
        - required_delivery
        - wallet_send_estimate
        - leg_cost
        - wallet_balance
        - covers
        - short_by
        - expires_at
      properties:
        funding_source:
          type: string
          enum:
            - swaps_wallet
        available:
          type: boolean
        reason:
          type:
            - string
            - 'null'
          enum:
            - flag_off
            - wallet_not_provisioned
            - wallet_paused
            - route_unavailable
            - test_mode
            - marked_sent
            - null
          description: >
            Why Wallet balance is unavailable; null when `available` is true. `marked_sent`: the payout is already
            marked sent with no recorded wallet send.
        source_chain:
          type:
            - string
            - 'null'
          enum:
            - base
            - arbitrum
            - ethereum
            - null
          description: The chain the server would fund from (first of base, arbitrum, ethereum open to both sides).
        required_delivery:
          description: >
            What must arrive at the deposit address: the funding estimate rounded up to the cent plus a buffer of at
            most 5 bps.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        wallet_send_estimate:
          description: What leaves the wallet, the Relay leg's own cost included (no Swaps fee on the leg).
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        leg_cost:
          description: '`wallet_send_estimate` minus `required_delivery` — the cross-network leg, published, never hidden.'
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        wallet_balance:
          description: The wallet's spendable USDC.e right now.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        covers:
          type: boolean
          description: '`wallet_balance` ≥ `wallet_send_estimate`; false whenever `available` is false.'
        short_by:
          description: How much more USDC.e the wallet needs; null when it covers.
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        expires_at:
          type:
            - string
            - 'null'
          format: date-time
          description: When to re-read this quote (the prepared send's own expiry once one exists).
      additionalProperties: false
    PayoutReceipt:
      type: object
      description: >
        Exists only once a payout has settled — a payout paid with a shortfall is terminal and never produces one
        (RESOURCE-MODEL §2.2 invariants).
      required:
        - payout_id
        - recipient_amount
        - actual_source
        - fee
        - gross
        - provider_reference
        - settled_at
        - beneficiary
      properties:
        payout_id:
          type: string
          pattern: ^po_
        recipient_amount:
          type: object
          description: The confirmed amount the recipient actually received — never an estimate on a receipt.
          required:
            - amount
            - confirmed
          properties:
            amount:
              $ref: '#/components/schemas/Money'
            confirmed:
              type: boolean
              enum:
                - true
          additionalProperties: false
        actual_source:
          $ref: '#/components/schemas/Money'
          description: The confirmed amount actually sent from the source chain.
        fee:
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
          description: >
            The Swaps fee actually charged on this transfer, read from provider-confirmed settlement state — never an
            estimate, on a receipt or anywhere else. **`null` unless observed**, and today it is always `null`: the only
            fee figures a payout carries are `computePayoutFee`'s pre-funding projections, written at funding commit,
            and that function's own contract is that it produces an estimate, not realized revenue — the settlement
            webhook remains the SSOT. Publishing a projection here would state as observed something nothing observed.
            Those projections are published under `estimates` below instead. This field becomes non-null the day a
            settlement-confirmed fee is recorded in normalized state, and not before (SEC-D / P1-10).
        gross:
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
          description: >
            The gross source amount actually charged, before the Swaps fee — read from provider-confirmed settlement
            state, never an estimate. **`null` unless observed**, under exactly the same reasoning as `fee` above: the
            pre-funding projection (`gross = amount × 10000 / (10000 − bps)`) is a computation, not an observation, and
            is published as `estimates.gross_estimate`.
        estimates:
          type: object
          description: >
            The estimates shown before settlement, kept for the record — never authoritative. Present whenever the
            payout carries the fee projection written at funding commit; `source_estimate` is omitted when no source
            projection was stored for this payout.
          required:
            - fee_estimate
            - gross_estimate
          properties:
            fee_estimate:
              $ref: '#/components/schemas/Money'
            gross_estimate:
              $ref: '#/components/schemas/Money'
            source_estimate:
              $ref: '#/components/schemas/Money'
          additionalProperties: false
        provider_reference:
          type: string
          example: brg_7f3a2c48e1
        settled_at:
          type: string
          format: date-time
        beneficiary:
          $ref: '#/components/schemas/PayoutBeneficiary'
        note:
          type:
            - string
            - 'null'
          maxLength: 280
      additionalProperties: false
    PayoutAttempt:
      description: One funding attempt on a payout — mirrors the payment-links `payments` shape.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - object
            - status
            - source_chain
            - source_currency
          properties:
            id:
              type: string
              pattern: ^poa_
            object:
              type: string
              enum:
                - payout_attempt
            status:
              type: string
              enum:
                - awaiting_funds
                - funds_received
                - payment_processed
                - returned
                - failed
                - abandoned
                - expired
            source_chain:
              type: string
              enum:
                - base
                - ethereum
                - polygon
                - arbitrum
                - optimism
            source_currency:
              type: string
            source_tx_hash:
              type:
                - string
                - 'null'
            refund_address:
              type:
                - string
                - 'null'
              description: |
                The return address Bridge accepted for this attempt's transfer (EIP-55); null when none was set.
      unevaluatedProperties: false
    PayoutAttemptList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/PayoutAttempt'
      unevaluatedProperties: false
    PayoutEventList:
      description: >
        This payout's own event history — the only timestamp source for the 4-step tracker ladder (RESOURCE-MODEL §2.2
        v2 amendments). Items share the global event envelope and `data.object` carries this payout's own allowlisted
        projection. Rows are read from the payout's own ledger (`payout_events`), not from the `api_events` outbox, and
        every row with a public counterpart is projected to its `payout.*` member of `EventType` — including the members
        `x-swaps-event-status` marks `catalogued` (never delivered to a webhook and never listed by `/v1/events` yet).
        An internal-only row with no public counterpart is skipped.
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Event'
      unevaluatedProperties: false
    PayoutEligibility:
      type: object
      description: |
        The holder's readiness to create and fund payouts, per corridor (RESOURCE-MODEL §2.2). Not paginated.
      required:
        - kyc
        - corridors
        - capability_contract
      properties:
        kyc:
          type: object
          required:
            - status
          properties:
            status:
              type: string
              description: The holder's KYC/KYB verification status (mirrors `customers`).
          additionalProperties: false
        corridors:
          type: array
          items:
            type: object
            required:
              - currency
              - fiat_rail
              - status
            properties:
              currency:
                type: string
                description: >-
                  ISO 4217, uppercase (`EUR`, never `eur`) — the same casing as `Money.currency` and
                  `Capabilities.corridors[].currency`, so a client can join this array to the capabilities catalogue on
                  `(currency, fiat_rail)` without normalizing case on either side.
                example: EUR
              fiat_rail:
                type: string
                enum:
                  - ach
                  - wire
                  - sepa
                  - faster_payments
                  - pix
                  - spei
              status:
                type: string
                enum:
                  - available
                  - action_required
                  - in_review
                  - gathering_no_path
                  - not_started
                  - not_enabled
              blocker:
                description: >
                  Set when `status` is not `available`. `in_review` and `gathering_no_path` are kept visually distinct
                  even though both are muted, no-color states — never conflate them.
                oneOf:
                  - type: 'null'
                  - type: string
                    enum:
                      - kyc
                      - address
                      - tos
                      - source_of_funds
                      - additional_details
                      - endorsement
                      - capability
            additionalProperties: false
        capability_contract:
          type: object
          description: >
            The same fee/corridor contract as `GET /v1/capabilities?product=payouts` — not decomposed further here; read
            that resource for the full shape.
          additionalProperties: true
      additionalProperties: false
    PayrollRunStatus:
      type: string
      x-swaps-open-enum: true
      description: |
        A1-2: open — an unrecognized member is an opaque string, never a deserialization failure.

        The run's lifecycle. `draft` and `approved` are DB-legal but not reachable through a
        `/v1` action of this caller's own: `runs_create` writes `reviewed` directly and
        `runs_approve` writes `funding_pending` directly, so neither state is ever produced by
        this API — only an internal ops webhook (or a seeded row) can leave one in `draft` or
        `approved`. Both are published in this enum regardless (G decision 2026-09-14):
        `GET /v1/payroll_runs` reads real rows, and a caller-unreachable status is still a
        status a real row can carry — omitting it 500'd the entire list the moment one such row
        existed, rather than 200ing every row but that one. `funded`: an inbound funding event
        was observed — it does not mean the run is fully funded; read `funding_state`,
        `funding_confirmed_amount` and `needs_attention`. Execution is refused for a `funded` run
        whose `funding_state` is not `funded` or `overfunded`.
      enum:
        - draft
        - reviewed
        - approved
        - funding_pending
        - funded
        - executing
        - completed
        - partial
        - failed
        - cancelled
    PayrollRunItemStatus:
      type: string
      description: >-
        The item's payout lifecycle inside its run (R14-payroll §4). No item-level `cancelled` exists — cancelling a run
        leaves its items wherever they were.
      enum:
        - needs_destination
        - ready
        - queued
        - processing
        - paid
        - failed
        - returned
    PayrollBlockerCode:
      type: string
      x-swaps-open-enum: true
      description: |
        Additive reason codes blocking approval, funding or execution, reconciling the two live
        dialects behind `BLOCKER_CODE_ALIASES` (R14-payroll §4, PR-G20): a **cached** check (4
        codes, drawn on the Today hero and the readiness gate before a live Bridge round trip) and
        a **fresh** check (7 codes, drawn in full on the readiness gate — `pr-gate`). `tos_not_accepted`
        is the one code both dialects share. New codes are appended, never renumbered or removed.
        `run_has_no_rows` (PR-APPROVE-ROWS) is a run-state rung, not a payer check: the run has
        zero rows, so `approve` and `fund` refuse it with `409 run_has_no_rows`.
        A1-2: open — an unrecognized member is an opaque string, never a deserialization failure.
      enum:
        - bridge_customer_missing
        - bridge_customer_type_unsupported
        - verification_not_approved
        - tos_not_accepted
        - payer_customer_missing
        - kyc_not_approved
        - missing_address_data
        - requirements_outstanding
        - no_base_endorsement
        - no_settlement_endorsement
        - run_has_no_rows
    PayrollRun:
      type: object
      description: |
        A pay run: a batch of payments to recipients, funded once by the employer and executed as
        one unit. The fee is 1% of the gross amount, **employer-funded on top** of what recipients
        receive — a Bridge developer fee, an absolute amount, never a percentage (do not compute it
        as a percentage the way the `payouts` fee is computed). Caps are $25,000 per run, $10,000
        per item and 500 rows, enforced at both `create` and `execute`. `funded_at` marks that a
        funding event was observed — never that the run is *fully* funded. `funding_state` is the
        amount-aware verdict, and it, not `status`, is what `execute` reads.

        `funding_amount` (D-PF-8, BL-24) is the GROSS total the employer funds and always was —
        unchanged in meaning. `funding_fee` and `funding_net_amount` are additive: `funding_fee`
        is the 1% Swaps fee baked into `funding_amount` (`0` for every run created while the fee
        is dark-launched off), and `funding_net_amount` is the aggregate of the run's recipient
        amounts — what the run funds them for, not a settlement receipt: a `partial`/`failed` run's
        `failed`/`returned` items are still counted, so this is what recipients were scheduled to
        receive, not necessarily what settled. `funding_amount == funding_fee + funding_net_amount`
        always holds. A client renders this split from these three server figures — never
        re-derives a fee by applying a percentage to `funding_amount` itself, which is exactly how
        a client would go stale the moment the fee bps or the fee-on/off state changed server-side
        without a client release.

        `funding_fee`/`funding_net_amount` are published on this REST projection only (`GET
        /v1/payroll_runs`, `GET /v1/payroll_runs/{id}`, `GET /v1/payroll_runs/{id}/events`). The
        `payroll_run.*` event object delivered via `GET /v1/events`, the SSE stream and webhooks
        carries `funding_amount` alone — a consumer of the outbox feed that needs the split
        re-reads the run over REST rather than re-deriving a fee client-side.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - status
            - title
            - currency
            - pay_period_start
            - pay_period_end
            - funding_method
            - recipient_summary
            - funding_state
            - needs_attention
          properties:
            id:
              type: string
              pattern: ^pr_
              example: pr_01J9ZK3Q8M2F5A7C9E1G3H5J7K
            object:
              type: string
              enum:
                - payroll_run
              example: payroll_run
            status:
              $ref: '#/components/schemas/PayrollRunStatus'
            title:
              type: string
            currency:
              type: string
              description: ISO 4217 code the run pays in.
            pay_period_start:
              type:
                - string
                - 'null'
              format: date
              description: >-
                `payroll_runs.pay_period_start` is a NULLABLE DB column — the internal create path (`optionalDate`)
                writes `null` whenever the caller omits it, and most real rows do (prod: 59 of 74 rows, 21 of 25
                employers, have a null pay period on at least one side). `respondValidated`'s `.strict()` parse used to
                require a string here, so that dominant row shape 500'd the entire list the same way the missing
                `draft`/`approved` enum values did (BL-15) — the key stays present (never omitted), the value is null
                when the run has no pay period on record.
            pay_period_end:
              type:
                - string
                - 'null'
              format: date
              description: Nullable for the same reason as `pay_period_start` — see that field.
            memo:
              type:
                - string
                - 'null'
            funding_amount:
              $ref: '#/components/schemas/Money'
            funding_fee:
              $ref: '#/components/schemas/Money'
            funding_net_amount:
              $ref: '#/components/schemas/Money'
            funding_confirmed_amount:
              anyOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
              description: >-
                The inbound amount the funding classifier attributed to this run and compared against `funding_amount`.
                Denominated in the currency the money actually arrived in: the run's own currency on the bank rail,
                `USDC` on the crypto rail, where the requirement itself was converted before it was compared. It is
                never restated into another currency. Null means no amount was ever confirmed — and a run with a null
                value here cannot execute.
            funding_state:
              type: string
              enum:
                - unfunded
                - partially_funded
                - funded
                - overfunded
              description: >-
                The funding classifier's verdict, independent of `status`. `unfunded`: nothing attributable arrived.
                `partially_funded`: money arrived and is short of `funding_amount` — `payroll_runs.execute` answers `409
                payroll_run_underfunded` naming the shortfall. `funded`: the confirmed amount matches. `overfunded`:
                more arrived than was asked for; the run may execute and the surplus is recorded.
            needs_attention:
              type: boolean
              description: |
                Derived from `funding_state`: true when money was observed but the amount is short, is in surplus, or
                was never confirmed at all. Advisory — the refusal that actually protects the money is on `execute`.
            funding_method:
              type: string
              enum:
                - fiat
                - crypto
              description: |
                How the employer funds the run: `fiat` by bank transfer to a Bridge virtual account,
                `crypto` by sending USDC to the employer's Bridge payroll wallet. `crypto` is offered
                per currency by `GET /v1/capabilities?product=payroll`
                (`funding_currencies[].crypto_funding`); while crypto funding is switched off it
                answers `409 capability_unavailable` on `create`, `approve` and the funding call — it
                is never silently substituted with a fiat instruction.
            funding_instructions:
              anyOf:
                - $ref: '#/components/schemas/PayrollFundingInstructions'
                - type: 'null'
              description: Present once a funding attempt has been requested; null before that.
            recipient_summary:
              type: object
              description: Counts over this run's items, by destination readiness.
              required:
                - ready
                - waiting
                - total
              properties:
                ready:
                  type: integer
                waiting:
                  type: integer
                total:
                  type: integer
            approved_at:
              type:
                - string
                - 'null'
              format: date-time
            funded_at:
              type:
                - string
                - 'null'
              format: date-time
              description: A funding event was observed for this run — not proof the run is fully funded.
            completed_at:
              type:
                - string
                - 'null'
              format: date-time
      unevaluatedProperties: false
    PayrollRunCreateRequest:
      type: object
      description: |
        Drafts a run. `runs_create` writes `reviewed` directly — there is no `draft` state on
        `/v1`. Refused with `422` when a cap is exceeded (run total over $25,000, an item over
        $10,000, or more than 500 rows) — the same caps are re-checked at `execute`.
      required:
        - title
        - currency
        - pay_period_start
        - pay_period_end
        - funding_method
        - items
      properties:
        title:
          type: string
        currency:
          type: string
        pay_period_start:
          type: string
          format: date
        pay_period_end:
          type: string
          format: date
        memo:
          type: string
        funding_method:
          type: string
          enum:
            - fiat
            - crypto
          description: >-
            Offer `crypto` only when `GET /v1/capabilities?product=payroll` shows `crypto_funding.available` for this
            currency. `create` refuses it with `409 capability_unavailable` only while crypto funding is switched off
            (`crypto_funding.reason: crypto_funding_not_enabled`). For `bridge_funding_currency_unsupported` or
            `verification_required` the run is accepted and cannot be funded: `approve` refuses it or its funding
            instructions read `blocked`.
        items:
          type: array
          minItems: 1
          maxItems: 500
          items:
            type: object
            required:
              - recipient
              - amount
            properties:
              recipient:
                type: object
                description: Identifying details only — the destination is added later by the recipient, through their own session.
                required:
                  - display_name
                  - email
                properties:
                  display_name:
                    type: string
                  email:
                    type: string
                    format: email
                  country:
                    type: string
                  worker_type:
                    type: string
              amount:
                $ref: '#/components/schemas/MoneyPositive'
      additionalProperties: false
    PayrollRunList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/PayrollRun'
            summary:
              $ref: '#/components/schemas/PayrollRunListSummary'
      unevaluatedProperties: false
    PayrollRunListSummary:
      type: object
      x-swaps-since: '2026-09-25'
      description: >-
        Present only with `?expand=summary` (LIST-SUMMARY-1). Computed over every run matching the request's
        `status_group` (never `cursor`/`limit`). The three counts are the Payroll hub's own sections, NOT the
        `status_group` buckets: `needs_action` = draft, approved, reviewed, funding_pending, funded, partial, failed;
        `in_progress` = executing; `completed` = completed, cancelled. A status this version does not know counts in
        `total` only.
      required:
        - total
        - needs_action
        - in_progress
        - completed
      properties:
        total:
          type: integer
          minimum: 0
        needs_action:
          type: integer
          minimum: 0
        in_progress:
          type: integer
          minimum: 0
        completed:
          type: integer
          minimum: 0
      additionalProperties: false
    PayrollRunItem:
      type: object
      description: >-
        One recipient's line inside a run — a masked destination projection, never the raw bank or wallet detail
        (mirrors the `payouts` beneficiary projection).
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - run_id
            - recipient_snapshot
            - destination_status
            - status
            - amount
          properties:
            id:
              type: string
              pattern: ^pri_
              example: pri_01J9ZK3Q8M2F5A7C9E1G3H5J7K
            object:
              type: string
              enum:
                - payroll_run_item
            run_id:
              type: string
              pattern: ^pr_
            recipient_snapshot:
              type: object
              description: Frozen at run creation — does not track later edits to the recipient record.
              properties:
                display_name:
                  type: string
                email:
                  type: string
                  format: email
                country:
                  type: string
                worker_type:
                  type: string
            destination_snapshot:
              anyOf:
                - type: object
                  description: >-
                    Masked — rail, country, bank name (where present) and the last 4 characters only. Never the raw
                    account or wallet detail.
                  properties:
                    rail:
                      type: string
                      description: >-
                        The bank destination's settlement rail (UK Faster Payments is `faster_payments` here). For a
                        crypto destination this instead carries the chain name — the same masked field is shared by both
                        kinds.
                    country:
                      type: string
                    bank_name:
                      type: string
                    last4:
                      type: string
                  additionalProperties: false
                - type: 'null'
              description: Null while `destination_status` is `missing`.
            destination_status:
              type: string
              enum:
                - missing
                - ready
            destination_changed_after_approval:
              type: boolean
              description: |
                Planned — no producer exists yet (`payroll_run_items` carries no such column and
                `payroll_events` mints no such event today); a caller must not read this as a live
                signal until a producer ships. Always `false` until then.
            status:
              $ref: '#/components/schemas/PayrollRunItemStatus'
            amount:
              $ref: '#/components/schemas/Money'
            paid_at:
              type:
                - string
                - 'null'
              format: date-time
      unevaluatedProperties: false
    PayrollRunItemList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/PayrollRunItem'
      unevaluatedProperties: false
    PayrollRunItemLinkReissue:
      type: object
      description: >-
        The result of `payroll_runs.items.reissue_link` — rotating and re-sending one recipient's destination-request
        link. Inside the handler's own short idempotent-reissue window, the SAME still-usable link is returned and no
        new mail goes out for that call; `email.sent` always reflects whether THIS call sent mail, never a prior call's
        outcome. `email.reason` is always one of the members below — a safe CLASS token, never the raw provider body,
        HTTP status or exception message the transport actually reported (that full detail stays in `payroll_events`,
        off the wire). `send_returned_null` means the transport genuinely reported no reason at all, never a stand-in
        for one that was withheld. `superseded_by_concurrent_reissue` means a second, overlapping call rotated the link
        again before this one could confirm its own send.
      required:
        - object
        - item
        - link
        - email
      properties:
        object:
          type: string
          enum:
            - payroll_run_item_link
        item:
          $ref: '#/components/schemas/PayrollRunItem'
        link:
          type:
            - object
            - 'null'
          description: >-
            The employer's own copy of the recipient's onboarding link. The token appears ONLY inside `url`, never as a
            bare field, and only to this run's own employer (the same trust boundary the operation description states).
            Null on an Idempotent-Replayed response — the token is shown once, never re-served from the idempotency
            store (`AuthenticatedRoute.secretFields`, the same mechanism `api_keys`' own `secret` uses).
          required:
            - url
            - expires_at
          properties:
            url:
              type: string
              format: uri
            expires_at:
              type:
                - string
                - 'null'
              format: date-time
          additionalProperties: false
        email:
          type: object
          required:
            - sent
          properties:
            sent:
              type: boolean
            message_id:
              type: string
            reason:
              type: string
              enum:
                - recipient_email_missing
                - reissue_skipped_link_still_valid
                - resend_api_key_missing
                - suppressed_by_preference
                - suppressed_bounce_complaint
                - suppressed_reserved_tld
                - suppressed_test_mode
                - output_lint_failed
                - resend_non_2xx
                - resend_invalid_response
                - send_exception
                - send_failed
                - send_returned_null
                - superseded_by_concurrent_reissue
              x-swaps-open-enum: true
          additionalProperties: false
      unevaluatedProperties: false
    PayrollRecipient:
      type: object
      description: >-
        A payee referenced by one or more runs. Created only as a byproduct of `create` — there is no direct create,
        update or archive on `/v1`; the one write is `adopt_pending_destination`, which saves the destination in
        `pending_destination` as the default. `email` is genuinely optional: existing rows can be email-less — from data
        that predates this field's own validation, or from the legacy dashboard-v1 action handler
        (`payroll/actions.ts`'s own `optionalEmail`) — even though the public `POST /v1/payroll_runs` itself always
        requires `recipient.email` and accepts no destination input of its own (`PayrollRunCreateRequest`; a `/v1`
        caller cannot currently create an email-less row this way — Codex review finding, round 12, correcting an
        earlier description here that implied it could). The sibling `PayrollRunItem.recipient_snapshot.email` and
        `PayrollTemplate.items_snapshot[].recipient.email` already model the identical optionality for the identical
        field on those resources (Codex review finding, round 7 — `email` was required here only, out of step with every
        other resource carrying the same field, and the list route's `.strict()` validation of every returned row meant
        one such legitimately-email-less recipient failed the ENTIRE list response with a 500, not just its own row).
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - display_name
            - destination_status
          properties:
            id:
              type: string
              pattern: ^prcp_
              example: prcp_01J9ZK3Q8M2F5A7C9E1G3H5J7K
            object:
              type: string
              enum:
                - payroll_recipient
            display_name:
              type: string
            email:
              type: string
              format: email
            country:
              type: string
            worker_type:
              type: string
            destination_kind:
              type:
                - string
                - 'null'
              enum:
                - bank
                - crypto
                - null
            destination_status:
              type: string
              enum:
                - missing
                - ready
            destination_snapshot:
              anyOf:
                - type: object
                  description: >-
                    Masked — rail, country, bank name (where present) and the last 4 characters only. Never the raw
                    account or wallet detail. Identical shape to `PayrollRunItem.destination_snapshot`.
                  properties:
                    rail:
                      type: string
                      description: >-
                        The bank destination's settlement rail (UK Faster Payments is `faster_payments` here). For a
                        crypto destination this instead carries the chain name — the same masked field is shared by both
                        kinds.
                    country:
                      type: string
                    bank_name:
                      type: string
                    last4:
                      type: string
                  additionalProperties: false
                - type: 'null'
              description: >-
                Non-null only once the employer has adopted a recipient-supplied destination onto the roster
                (`payroll_recipients.adopt_pending_destination`) — for where a specific payment actually lands, read
                `payroll_run_items.destination_snapshot` instead; `null` for every recipient with no adopted
                destination, including rows created before this field existed, which are never backfilled.
            pending_destination:
              anyOf:
                - type: object
                  description: >-
                    A destination the recipient saved through their own payout link that is not their default yet. It
                    already pays the run whose link they used; new runs copy it only after
                    `payroll_recipients.adopt_pending_destination`. Masked like `destination_snapshot`.
                  required:
                    - destination_kind
                    - destination
                    - submitted_at
                  properties:
                    destination_kind:
                      type:
                        - string
                        - 'null'
                      enum:
                        - bank
                        - crypto
                        - null
                    asset:
                      type: string
                      description: The wallet's asset symbol (for example `USDC`). Crypto destinations only.
                    destination:
                      anyOf:
                        - type: object
                          description: >-
                            Masked — rail (the chain for a wallet), country, bank name (where present) and the last 4
                            characters only. Never the raw account or wallet detail. Same shape as
                            `destination_snapshot`.
                          properties:
                            rail:
                              type: string
                            country:
                              type: string
                            bank_name:
                              type: string
                            last4:
                              type: string
                          additionalProperties: false
                        - type: 'null'
                    submitted_at:
                      type:
                        - string
                        - 'null'
                      format: date-time
                      description: >-
                        When the recipient saved it. Send this value back as `expected_submitted_at` to adopt exactly
                        this destination.
                  additionalProperties: false
                - type: 'null'
              description: >-
                `null` when nothing is waiting. Independent of `destination_status`: a recipient with a ready default
                can also have a newer destination waiting.
      unevaluatedProperties: false
    PayrollRecipientAdoptPendingDestinationRequest:
      type: object
      description: Binds the adoption to the destination the caller showed.
      required:
        - expected_submitted_at
      properties:
        expected_submitted_at:
          type: string
          format: date-time
          description: >-
            The `pending_destination.submitted_at` value you read. If the recipient saved another destination since, the
            call answers `409 pending_destination_changed` and the default is unchanged.
      additionalProperties: false
    PayrollRecipientDestinationAdoption:
      type: object
      description: >-
        The result of `payroll_recipients.adopt_pending_destination`. `recipient` carries the new default
        (`destination_status: ready`, `pending_destination: null`). `updated_run_items` are the recipient's rows in
        unapproved runs (`draft`, `reviewed`) that were waiting for a destination and now use this one — one run can
        appear more than once. `skipped_run_items` are rows that keep asking the recipient because this destination
        cannot pay that run's currency.
      required:
        - object
        - recipient
        - updated_run_items
        - skipped_run_items
      properties:
        object:
          type: string
          enum:
            - payroll_recipient_destination_adoption
        recipient:
          $ref: '#/components/schemas/PayrollRecipient'
        updated_run_items:
          type: array
          items:
            type: object
            required:
              - id
              - run_id
            properties:
              id:
                type: string
                pattern: ^pri_
              run_id:
                type: string
                pattern: ^pr_
            additionalProperties: false
        skipped_run_items:
          type: array
          items:
            type: object
            required:
              - id
              - run_id
              - code
            properties:
              id:
                type: string
                pattern: ^pri_
              run_id:
                type: string
                pattern: ^pr_
              code:
                type: string
                description: >-
                  Why the row kept asking the recipient. `destination_currency_mismatch` — the bank account's currency
                  or rail does not match the run's currency. `destination_currency_unsupported` — wallet payouts are not
                  available for the run's currency. `destination_rail_unavailable` — wallet payouts are switched off.
                  `destination_not_usable` — any other reason this destination cannot pay the row.
                enum:
                  - destination_currency_mismatch
                  - destination_currency_unsupported
                  - destination_rail_unavailable
                  - destination_not_usable
                x-swaps-open-enum: true
            additionalProperties: false
      unevaluatedProperties: false
    PayrollRecipientList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/PayrollRecipient'
      unevaluatedProperties: false
    PayrollTemplate:
      type: object
      description: |
        A saved snapshot of a completed run's items, for reuse when drafting a future run. There is
        no "start a run from a template" operation — nothing in the backend consumes
        `items_snapshot` back into a new run yet, so publishing that op would document a capability
        that does not exist (R14-payroll §3, PR-G13).
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - name
            - currency
            - items_snapshot
            - source_run_id
          properties:
            id:
              type: string
              pattern: ^prt_
              example: prt_01J9ZK3Q8M2F5A7C9E1G3H5J7K
            object:
              type: string
              enum:
                - payroll_template
            name:
              type: string
            currency:
              type: string
            items_snapshot:
              type: array
              description: The source run's items at the moment the template was saved.
              items:
                type: object
                properties:
                  recipient:
                    type: object
                    properties:
                      display_name:
                        type: string
                      email:
                        type: string
                        format: email
                      country:
                        type: string
                      worker_type:
                        type: string
                  amount:
                    $ref: '#/components/schemas/Money'
            source_run_id:
              type: string
              pattern: ^pr_
      unevaluatedProperties: false
    PayrollTemplateCreateRequest:
      type: object
      description: Saves the given run's current items as a reusable template.
      required:
        - source_run_id
      properties:
        source_run_id:
          type: string
          pattern: ^pr_
        name:
          type: string
          description: Defaults server-side to "<run title> template" when omitted.
      additionalProperties: false
    PayrollTemplateList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/PayrollTemplate'
      unevaluatedProperties: false
    PayrollFundingInstructions:
      type: object
      description: |
        How to fund a run, and whether that funding has landed. Not a first-class object — it has
        no id, and `get`/`create-or-refresh` both return this shape for the owning run. Detection
        runs on a five-minute poll, not a webhook, so `verified` can lag a real bank transfer by up
        to that window. `verified` means the instructions are ready to act on, and stays `verified`
        (with the same instructions) after money is observed — read the run's `status` and
        `funding_state` for whether it has been funded. A crypto deposit that could not be priced
        reads `failed` with `failure_reason: fx_unconverted` and `retryable: true`; the next
        funding call prices it again.
      required:
        - status
        - run_reference
        - retryable
      properties:
        status:
          type: string
          enum:
            - preparing
            - verified
            - failed
            - blocked
            - pending
        funding_route_type:
          type: string
          description: >-
            The rail family behind these instructions (bank or crypto), driving which of `bank`/`crypto` below is
            populated. A run whose `funding_method` is `crypto` reads `crypto_deposit_to_wallet` in every state before
            `verified`.
        run_reference:
          type: string
          description: '`PAYROLL-` plus the run id, stripped of non-alphanumerics, first 12 characters, upper-cased.'
          example: PAYROLL-3F2A9C1B4D0E
        bank:
          anyOf:
            - $ref: '#/components/schemas/BankDepositInstructions'
            - type: 'null'
          description: |
            Present when `funding_route_type` is `bank`. `beneficiary_name` carries what was previously
            `beneficiary`; `rail` is the single settlement rail for this instruction (the run's own
            `run_reference` above stays on the parent object, not nested here).
        crypto:
          anyOf:
            - $ref: '#/components/schemas/DepositInstructions'
            - type: 'null'
          description: |
            Present when `funding_route_type` is `crypto_deposit_to_wallet` and `status` is
            `verified`: the employer's Bridge payroll wallet — `address` (case-preserved, send to it
            verbatim), `chain` (the wallet's chain, e.g. `solana`), `asset` (`USDC`) and `amount`, the run's
            `funding_amount` (fee included) converted to USDC when the instructions were prepared.
            Send the whole `amount` in ONE transfer of `asset` on `chain`: deposits on this route are
            not summed — a short deposit leaves the run `partially_funded`, and a top-up does not
            fund it (contact support). A later funding call without a method switch returns this
            same amount; it is never re-priced under a deposit already sent. This whole object is
            null (never a zero-valued object) when the amount cannot be priced; `status` then reads
            `failed` with `failure_reason: fx_unconverted`. This route answers `capability_unavailable` on
            `create`/`approve`/`refresh` while crypto funding is switched off — it is never returned
            as a silent substitute for the bank shape, and the bank shape is never returned as a
            silent substitute for it.
        failure_reason:
          type:
            - string
            - 'null'
          description: >-
            Normalized only — never the raw Bridge or provider error text. A snake_case code (for example
            `bridge_wallet_address_unavailable`); `provider_error` when the provider failed with text that is not
            published.
        retryable:
          type: boolean
      additionalProperties: false
    PayrollReadiness:
      type: object
      description: |
        A dry read of the payer-compliance gate `approve`, `create-or-refresh funding` and
        `execute` each check FIRST (Bridge-customer verification, KYC, ToS, rail endorsement), so a
        caller can show why THAT gate is blocked before attempting a mutation. `blockers` is empty
        exactly when `ready` is `true`, but `ready: true` here does not promise the mutation
        succeeds — a state-specific check this preview does not run (destinations, funding, rail,
        caps) can still refuse it separately, with its own error code.
      required:
        - ready
        - blockers
      properties:
        ready:
          type: boolean
        blockers:
          type: array
          items:
            $ref: '#/components/schemas/PayrollBlockerCode'
      additionalProperties: false
    PayrollAttempt:
      type: object
      description: One provider-side attempt to pay a single run item.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - run_item_id
            - status
          properties:
            id:
              type: string
              pattern: ^pra_
              example: pra_01J9ZK3Q8M2F5A7C9E1G3H5J7K
            object:
              type: string
              enum:
                - payroll_attempt
            run_item_id:
              type: string
              pattern: ^pri_
            provider_id:
              type: string
            rail_type:
              type: string
              x-swaps-open-enum: true
              description: >-
                A provider-scoped rail identifier, not a `Rail` value: `bank_bridge` (bank payout via Bridge),
                `bridge_crypto_wallet` (crypto-wallet payout via Bridge) or `tempo_wallet` (Swaps Tempo wallet). Open —
                a new provider or transport adds a value. A provider-neutral vocabulary is an A1-10 (naming freeze)
                follow-up.
              enum:
                - bank_bridge
                - bridge_crypto_wallet
                - tempo_wallet
            provider_reference:
              type:
                - string
                - 'null'
            status:
              type: string
              enum:
                - queued
                - processing
                - paid
                - failed
                - returned
            normalized_error_code:
              type:
                - string
                - 'null'
            normalized_error_message:
              type:
                - string
                - 'null'
              description: Humanised — never raw engineering vocabulary.
      unevaluatedProperties: false
    PayrollAttemptList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/PayrollAttempt'
      unevaluatedProperties: false
    PayrollEventList:
      description: >-
        One run's own event history. Rows are read from the payroll ledger (`payroll_events`), not from the `api_events`
        outbox, and every row with a public counterpart is projected to its `payroll_run.*`, `payroll_item.*` or
        `payroll_template.*` member of `EventType` — including the members `x-swaps-event-status` marks `catalogued`
        (never delivered to a webhook and never listed by `/v1/events` yet). An internal-only row with no public
        counterpart is skipped.
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Event'
      unevaluatedProperties: false
    PayrollRecipientSession:
      type: object
      description: |
        The recipient's own payer-facing view of one run item, resolved from the capability token
        in the path — never from an identity. Reading it has no side effect: unlike a payment
        session, nothing here advances on a mere `GET`.
      required:
        - run_title
        - amount
        - for_name
        - status
        - can_update_destination
        - employer_display_name
        - capabilities
      properties:
        run_title:
          type: string
        amount:
          $ref: '#/components/schemas/Money'
        for_name:
          type: string
        status:
          type: string
          enum:
            - needs_destination
            - ready
            - locked
          description: >-
            Humanised item state. `locked` covers every status past `ready` — the page shows one line, never a
            per-status detail.
        can_update_destination:
          type: boolean
          description: >-
            Whether `payroll_recipient_sessions.destination.set` will currently accept a submission from this token.
            `status` alone stopped predicting this once a `ready` item's parent run could also lock it: a `ready` item
            reads `can_update_destination: false` once the run has moved past `draft`/`reviewed` (`approved` onward),
            and a `needs_destination` item reads `false` once the run is `cancelled`/`completed`/`failed` — nothing will
            ever be approved against it. Check this before rendering the destination form rather than inferring it from
            `status`.
        employer_display_name:
          type: string
          description: The employer's real business name, not an internal handle.
        rotated_token:
          type:
            - string
            - 'null'
          description: >-
            Only present on `payroll_recipient_sessions.destination.set`, and only for the holder that just used the old
            token. Changing a destination retires the token that did it and mints a replacement, so a leaked copy of a
            payslip link cannot redirect the same payout twice. Use this value for every subsequent call; the token in
            the path you just called is dead.
        capabilities:
          type: object
          required:
            - bank
            - crypto_external_wallet
          properties:
            bank:
              type: object
              required:
                - enabled
              properties:
                enabled:
                  type: boolean
            crypto_external_wallet:
              type: object
              required:
                - enabled
              properties:
                enabled:
                  type: boolean
                networks:
                  type: array
                  items:
                    type: string
                reason:
                  type:
                    - string
                    - 'null'
                  description: Set when `enabled` is false — the live reason this rail is closed.
      additionalProperties: false
    PayrollRecipientDestinationRequest:
      type: object
      description: |
        Sets where this run item's payment lands. Bank fields mirror the `payouts` beneficiary
        per-rail matrix — exactly one rail set for the chosen country. The crypto branch is closed
        behind a flag today and answers `capability_unavailable` regardless of the fields supplied.
      required:
        - destination_kind
      properties:
        destination_kind:
          type: string
          enum:
            - bank
            - crypto
        bank:
          type: object
          description: >-
            Required when `destination_kind` is `bank`. Supply exactly the rail's own field set — today only SEPA (IBAN
            + BIC) is reachable end to end. `bank_name` and `address` are also required for every reachable bank rail
            (the server's own `normalizePayrollBankDestinationInput` refuses `destination_bank_name_required` /
            `destination_beneficiary_address_required` without them, the same beneficiary-address requirement the
            authenticated `payouts` beneficiary shape already carries) — this pair of fields was missing from the wire
            schema at K7 ship time, which made the ONLY reachable rail (SEPA) always 400 (code-fix prerequisite, found
            while building the public recipient page, R6).
          properties:
            account_holder:
              type: string
            iban:
              type: string
            bic:
              type: string
            account_number:
              type: string
            routing_number:
              type: string
            sort_code:
              type: string
            pix_key:
              type: string
            clabe:
              type: string
            bank_name:
              type: string
              description: The recipient's bank name — required alongside `address` for every bank rail.
            address:
              type: object
              description: >-
                The beneficiary's own mailing address — required for every bank rail (never guessed from
                `iban`/`clabe`/…). `state` is optional: the internal normalizer (`normalizePayrollBankDestinationInput`)
                reads and forwards it to Bridge when present but never requires it, unlike the other four fields.
              required:
                - street_line_1
                - city
                - postal_code
                - country
              properties:
                street_line_1:
                  type: string
                city:
                  type: string
                state:
                  type: string
                  description: State / province — optional, forwarded to Bridge when supplied.
                postal_code:
                  type: string
                country:
                  type: string
                  description: ISO 3166-1 alpha-2 or alpha-3 country code.
              additionalProperties: false
        crypto:
          type: object
          description: Required when `destination_kind` is `crypto`.
          required:
            - network
            - address
          properties:
            network:
              type: string
            address:
              type: string
              description: Case-preserved — never lower-cased.
      additionalProperties: false
    QuoteRequest:
      type: object
      description: >
        Price a route before ever creating an order. Field names mirror `bestQuoteRequestSchema`
        (`packages/contracts-exchange/schemas.ts`); `from_asset`/`to_asset` already name the currency or chain asset, so
        no separate `fiat_currency` field is published. `from_amount`/`to_amount` are plain decimal strings in that
        asset's major units, not `Money` objects — the zod contract's `fromAmount`/`toAmount` take a bare decimal
        string, never a `Money`-shaped minor-unit integer.
      required:
        - side
        - from_asset
        - to_asset
      properties:
        side:
          type: string
          enum:
            - buy
            - sell
            - swap
          description: >-
            Direction of the trade. `swap` is crypto-to-crypto; `search` exists in the underlying contract but is never
            a public input (RESOURCE-MODEL §2.4, R15 §3).
        from_asset:
          type: string
          description: Source asset symbol or ISO 4217 fiat code (`bestQuoteRequestSchema.fromAsset`).
        to_asset:
          type: string
          description: Destination asset symbol or ISO 4217 fiat code (`bestQuoteRequestSchema.toAsset`).
        from_amount:
          type: string
          pattern: ^[0-9]{1,15}(\.[0-9]{1,18})?$
          maxLength: 34
          description: >-
            Amount to spend, as a decimal string in `from_asset`'s major units — must be strictly positive; the router
            rejects zero and validates it against the route's resolved minimum (`bestQuoteRequestSchema.fromAmount`).
            Exactly one of `from_amount` / `to_amount` is given; the router quotes the other side. Bounded by digit
            count, not a flat character cap (SEC/BSR-5, buy/sell v-fix review 2026-09-17; widened fixer round 1,
            2026-09-19) — this string previously had no length cap at all: `from_amount "99999999999999999999"` (20
            digits) reached a live Bridge `backend_native` offer and echoed back as `final_in: "9999999999999999999900"`
            minor units. A flat `maxLength: 18` closed that but was itself too narrow: prod `quote_attempts` (last 30
            days, widget-originated but pricing the SAME shape an `/v1` destination-amount caller sends) has real rows
            up to 20 characters, e.g. `0.03853376279604058` (19) and `0.004231894472103885` (20) — full-precision crypto
            amounts an ERC-20 caller pricing by destination amount routinely sends. The pattern instead bounds the
            INTEGER part to 15 digits — one above the widest documented per-currency ceiling
            (`CURRENCY_MAX_AMOUNT_MAJOR_UNITS.COP`, `packages/config/amountLimits.ts`, 14 integer digits at 0 decimals)
            — and the FRACTIONAL part to 18 digits, comfortably covering every real value above while still rejecting
            the original 20-digit integer abuse case. This is a contract-layer SHAPE bound only, not a value ceiling:
            the per-offer/per-route VALUE limit (`resolved_limits.max`, further clamped by a config-owned risk ceiling)
            is enforced separately in `createBridgeNativeOrder` (`supabase/functions/api-v1/routes/orders.ts`) — a
            15-digit integer string can still spell an amount far above a route's own limit without tripping a
            pattern/length check alone. Separately, for `side: 'buy'` only, `POST /v1/quotes` also refuses (`422
            amount_invalid`) a `from_amount` whose fractional part carries more precision than `from_asset`'s OWN scale
            (BSR-8, `getPrecision` — `packages/config/amountLimits.ts`'s `CURRENCY_PRECISION`, the canonical
            fiat-decimals table `.claude/rules/money.md` names for every amount limit in this codebase) — e.g.
            `"20.999"` for a 2-decimal fiat currency like EUR — a shape this pattern deliberately still admits (18
            fractional digits, for a real 18-decimal crypto amount) but that no fiat rail can move: LIVE evidence (dev)
            showed a Bridge `backend_native` offer round that exact value to EUR 21.00 internally while sealing the raw
            "20.999" verbatim into the deposit instructions, instructing the payer to send an amount SEPA cannot carry.
            A value with harmless trailing zeros beyond the scale (`"20.990"`) is unaffected, as is a real 3-decimal
            Gulf dinar (`"1.001"` KWD/BHD/OMR/JOD — `CURRENCY_PRECISION` lists these at 3, not the naive 2-decimal guess
            an earlier cut of this fix used). This scale check applies to `from_amount` only, never `to_amount` (see
            that field's own description for why), and to `side: 'buy'` only, never `sell`/`swap`: `sell`'s `from_asset`
            is the crypto being sold and `swap` is crypto-to-crypto, neither a fiat amount `CURRENCY_PRECISION` has an
            opinion on — an unlisted ERC-20 (e.g. DAI, 18 decimals) checked against `getPrecision`'s own 2-decimal
            fallback would be misjudged and refused for a perfectly real amount.
        to_amount:
          type: string
          pattern: ^[0-9]{1,15}(\.[0-9]{1,18})?$
          maxLength: 34
          description: >-
            Amount to receive, as a decimal string in `to_asset`'s major units — must be strictly positive, when the
            caller is pricing by destination amount instead of source amount (`bestQuoteRequestSchema.toAmount`).
            Bounded the same way as `from_amount` (SEC/BSR-5, widened fixer round 1) — see that field's description for
            why. Deliberately NOT held to `from_amount`'s BSR-8 per-asset scale check: `from_amount` (or the offer's own
            `final_in` derived from it, when priced in receive-mode) is the one figure this gateway ever seals into a
            live payer-facing deposit instruction, and `to_amount` is not — no route this API exposes today seals a raw
            `to_amount` into anything a payer must send or a rail must move. A caller pricing BY destination amount
            routinely sends the full on-chain precision of what it wants to receive (prod `quote_attempts`: `to_amount:
            "0.004231894472103885"`, 18 fractional digits, for `to_asset: "USDC"`, a 6-decimal token) — the SmartRouter
            still prices that correctly into a from-leg the provider itself scales properly, so there is no
            verbatim-sealing hazard on this side to close.
        country:
          type: string
          pattern: ^[A-Z]{2}$
          description: >-
            ISO 3166-1 alpha-2 country of the payer, when known — narrows the payment-method fan-out
            (`bestQuoteRequestSchema.country`). A well-formed but sanctioned or unrecognized code is refused with `409
            capability_unavailable`, not accepted and quoted (BSR-6, SEC-02) — checked against the same catalog `GET
            /v1/capabilities?product=buy_sell` validates `country` against.
        payment_method:
          type: string
          description: >-
            Preferred payment method id, when known (`bestQuoteRequestSchema.paymentMethod`). Omit to let the router
            rank every connected method.
        from_network:
          type: string
          description: Source chain, for a crypto leg (`bestQuoteRequestSchema.fromNetwork`).
        to_network:
          type: string
          description: >-
            Destination chain, for a crypto leg (`bestQuoteRequestSchema.toNetwork`). Optional here, but a Bridge-native
            `orders.create` (`handoff_mode: backend_native`) requires the WINNING offer's own
            `execution_context.to_network` to be set — a quote priced without it cannot become a Bridge-native order
            (`409 capability_unavailable`). Set it explicitly for any asset with more than one public chain.
      additionalProperties: false
    Provider:
      type: object
      description: >
        A provider's identity and, where the caller is pricing or reviewing capabilities, its registry-published limits
        — reused verbatim on `Quote.provider`, `Order.provider` and `QuoteProviderResult` (D-26, D-56).
        `Capabilities.providers[]` (`product=buy_sell`) uses `CapabilityProvider` below — a flat schema that duplicates
        these fields rather than an `allOf` extension of this one — so its own `status` vocabulary never collides with
        `QuoteProviderResult.status`, and so composing a second closed schema on top of this closed one never produces
        an unsatisfiable combined schema (K6b — see `CapabilityProvider`'s own description).
      required:
        - id
        - display_name
      properties:
        id:
          type: string
          description: >-
            Stable registry id (e.g. `bridge`, `paybis`, `transak`) — not a `/v1` prefixed object id (RESOURCE-MODEL
            §0.2 lists no provider prefix).
        display_name:
          type: string
        logo_url:
          type: string
          format: uri
          description: >-
            Served from Swaps' own CDN, never recoloured (D-26, brand-asset invariant) — `https://swaps.app/providers/
            {id}.svg`, the same first-attempt asset path the exchange widget's own `ProviderLogo` component requests
            (K6b).
        kyc_type:
          type: string
          description: >-
            The provider's KYC posture from `PROVIDER_REGISTRY` (D-56) — e.g. whether it verifies before or after the
            first trade. No fixed enum is published upstream; treat as an opaque label.
        min:
          $ref: '#/components/schemas/Money'
          description: This provider's minimum for the route, from the registry (D-56).
        max:
          $ref: '#/components/schemas/Money'
          description: This provider's maximum for the route, from the registry (D-56).
        description:
          type: string
          description: >-
            Short, public-safe copy for this provider, from `PROVIDER_REGISTRY[].description` (K6b) — never the internal
            `notes` field (operational status, env var names, commercial terms). Omitted, not guessed, for a provider
            with no vetted public description yet.
      additionalProperties: false
    CapabilityProvider:
      type: object
      description: >
        A `Capabilities.providers[]` (`product=buy_sell`) row — every `Provider` field, plus the operational state
        colouring the exchange checkout needs to grey out or hide a degraded/disabled provider row without a second
        round-trip (K6b, §52 C4-D14). A FLAT schema that duplicates `Provider`'s own properties rather than an `allOf`
        extension of it: `Provider` is published closed (`additionalProperties: false`) for its own standalone uses
        (`Quote.provider`, `Order.provider`, `QuoteProviderResult`), and composing two independently-closed schemas via
        `allOf` is unsatisfiable under JSON Schema — each branch's own `additionalProperties: false` only recognises
        that branch's own `properties`, so `Provider`'s branch would reject `status` and this schema's own `status`-only
        branch would reject `id`/`display_name` (Codex review, PR #2989). `status` is a different vocabulary than
        `QuoteProviderResult.status` and must never collide with it.
      required:
        - id
        - display_name
        - status
      properties:
        id:
          type: string
          description: >-
            Stable registry id (e.g. `bridge`, `paybis`, `transak`) — not a `/v1` prefixed object id (RESOURCE-MODEL
            §0.2 lists no provider prefix).
        display_name:
          type: string
        logo_url:
          type: string
          format: uri
          description: >-
            Served from Swaps' own CDN, never recoloured (D-26, brand-asset invariant) — `https://swaps.app/providers/
            {id}.svg`, the same first-attempt asset path the exchange widget's own `ProviderLogo` component requests.
            Present only when that SVG is actually checked in under `public/providers/` — a provider without one yet is
            omitted here rather than advertising a URL that 404s for an external client with no monogram fallback of its
            own (K6b P2 fix, Codex review PR #2989).
        kyc_type:
          type: string
          description: >-
            The provider's KYC posture from `PROVIDER_REGISTRY` (D-56) — e.g. whether it verifies before or after the
            first trade. No fixed enum is published upstream; treat as an opaque label.
        min:
          $ref: '#/components/schemas/Money'
          description: This provider's minimum for the route, from the registry (D-56).
        max:
          $ref: '#/components/schemas/Money'
          description: This provider's maximum for the route, from the registry (D-56).
        description:
          type: string
          description: >-
            Short, public-safe copy for this provider, from `PROVIDER_REGISTRY[].description` (K6b) — never the internal
            `notes` field (operational status, env var names, commercial terms). Omitted, not guessed, for a provider
            with no vetted public description yet.
        status:
          type: string
          enum:
            - available
            - degraded
            - disabled
          description: >-
            Derived from THREE signals, combined, never the static one alone: this provider's `RAIL_CAPABILITIES_
            REGISTRY` row `lifecycle` (`public` -> `available` (live), `preview` -> `degraded` (real code, not fully
            live — dark-flagged or cohort-gated), `blocked` -> `disabled`) — the SAME registry `buildBuySellProviders()`
            already reads — overridden to `disabled` whenever EITHER of the two sanctioned runtime kill switches the
            quote route's own eligibility check reads is tripped right now: `provider_config.enabled = false` (global
            switch), or every `provider_directions` row for this provider is `enabled = false` (per-direction switch — a
            provider with no `provider_directions` rows at all is unaffected). A read failure on either check 503s the
            whole response rather than guessing (Codex review, PR #2989 rounds 2-4: a provider an operator had
            runtime-disabled via either switch otherwise still reported `available` here).
      additionalProperties: false
    QuoteProviderResult:
      description: >
        One terminal outcome per provider observed in this call (D-21, BS-G2), ordered by provider ID. A usable quote on
        any payment method wins over that provider's sibling-method failures.
      allOf:
        - $ref: '#/components/schemas/Provider'
        - type: object
          required:
            - status
          properties:
            status:
              type: string
              enum:
                - quoted
                - quoting
                - no_offer
                - paused
                - error
              description: >
                `quoted` = this provider returned a priced offer; `quoting` is reserved for future streaming and is
                never returned by synchronous POST /quotes; `no_offer` = it answered with nothing executable; `paused` =
                disabled server-side; `error` = it failed to answer in time. Never collapse `no_offer` into `error`.
            offer:
              type: object
              description: >-
                Present only when `status` is `quoted` — this provider's own priced offer, same shape as the fields
                `Quote` carries for the winning route (`quoteOfferSchema`). `payment_method` alone stays optional
                (absent means unknown) — every other field here is a money/pricing fact the server never quotes a
                provider without (BL-35 follow-up, DEV-2).
              required:
                - offer_id
                - rate
                - final_out
                - total_fees
                - expires_at
              properties:
                offer_id:
                  type: string
                  description: Opaque identifier of this provider's selectable offer; preserve it verbatim.
                payment_method:
                  type: string
                  pattern: ^[a-z][a-z0-9_]*$
                  description: >-
                    Canonical method priced for this offer. Preserve it with offer_id; never infer it from the provider
                    or another offer. Absent means unknown.
                rate:
                  type: string
                  description: >-
                    Decimal string, `from_asset` per `to_asset` (BSR-9) — the server's own `final_in`'s decimal value ÷
                    `final_out`'s decimal value for THIS offer, computed BEFORE either is rounded into the published
                    `Money` fields below (when `final_in` is absent it equals the request's `from_amount`), in the SAME
                    two request currencies, computed identically for every offer in this response regardless of provider
                    or side (never a provider's own internal pricing field, whose basis is not uniform). Because `rate`
                    is derived pre-rounding, `rate` × `final_out.amount` (scaled by `final_out.decimals`) can differ
                    from `final_in.amount` in the last significant digit(s) when an asset's published `Money.decimals`
                    is smaller than the server's own internal precision — do not treat that identity as exact.
                    Informational only — NEVER compare offers by `rate` (rounding/precision differ by asset). Compare by
                    `final_out` for a `from_amount` request (every offer targets a different `final_out` for the same
                    pay amount). For a `to_amount` request, compare by `final_in` only among offers whose `final_out`
                    equals the requested `to_amount` (lower `final_in` is better there) — an offer whose `final_out`
                    differs is not comparable this way; not every provider honors an exact-output target.
                final_out:
                  $ref: '#/components/schemas/Money'
                final_in:
                  $ref: '#/components/schemas/Money'
                total_fees:
                  $ref: '#/components/schemas/QuoteFees'
                expires_at:
                  type: string
                  format: date-time
                eta_seconds:
                  type: integer
                resolved_limits:
                  $ref: '#/components/schemas/QuoteResolvedLimits'
              additionalProperties: false
            reason:
              type: string
              description: >-
                Present only when `status` is `no_offer`, `paused` or `error` — the dead-end reason code (never a raw
                provider error string).
      additionalProperties: false
    QuoteFees:
      type: object
      description: >
        Fee breakdown, field names from `quoteFeesSchema` (`packages/contracts-exchange/schemas.ts`). Each leg converts
        to a minor-unit `Money` object at the `/v1` boundary per RESOURCE-MODEL §0.3; `network_currency` (the zod
        schema's optional currency-of-the-network-fee field) is folded into `network.currency` rather than published
        twice.
      required:
        - provider
        - network
        - total
      properties:
        provider:
          $ref: '#/components/schemas/Money'
          description: The provider's own fee (`quoteFeesSchema.provider`).
        network:
          $ref: '#/components/schemas/Money'
          description: On-chain/network fee (`quoteFeesSchema.network`).
        total:
          $ref: '#/components/schemas/Money'
          description: Sum of every fee line (`quoteFeesSchema.total`).
        fx:
          $ref: '#/components/schemas/Money'
          description: FX conversion fee, when the route crosses currencies (`quoteFeesSchema.fx`, optional).
      additionalProperties: false
    QuoteResolvedLimits:
      type: object
      description: >
        Field names from `resolvedLimitsSchema`. `min`/`max` share one `currency` rather than each carrying their own
        `Money` object, matching the zod source's flat shape verbatim — RESOURCE-MODEL §2.4 lists this field as
        `resolved_limits{min,max,currency,source}`, not as two nested `Money` objects. A1-4 — this schema is ALSO
        `Capabilities.defaults.resolved_limits`' shape (a producer outside this item's file ownership); `min`/`max`
        gained a `pattern` here rather than becoming `Money` objects, so that other producer's existing plain-decimal
        output is not broken by a shape change it was never part of fixing.
      required:
        - min
        - max
        - currency
        - source
      properties:
        min:
          type: string
          pattern: ^[0-9]+(\.[0-9]+)?$
          description: Decimal amount in `currency`'s major units (`resolvedLimitsSchema.min`).
        max:
          type: string
          pattern: ^[0-9]+(\.[0-9]+)?$
          description: Decimal amount in `currency`'s major units (`resolvedLimitsSchema.max`).
        currency:
          type: string
        source:
          type: string
          description: Human-readable provenance, e.g. `"Global fallback · converted from USD"` (`resolvedLimitsSchema.source`).
      additionalProperties: false
    QuoteExecutionContext:
      type: object
      description: >-
        The non-PII request facts used to produce this offer. It binds the offer to the caller's exact route intent
        (assets, amount side, optional country, payment method and networks); it is descriptive only and does not
        authorize execution. Order creation must revalidate the quote, offer_id and all risk controls.
      required:
        - side
        - from_asset
        - to_asset
        - amount_mode
      properties:
        side:
          type: string
          enum:
            - buy
            - sell
            - swap
        from_asset:
          type: string
        to_asset:
          type: string
        amount_mode:
          type: string
          enum:
            - from
            - to
          description: Which side of the requested route amount was supplied.
        from_amount:
          type: string
          description: Requested source amount in the source asset's major units, when supplied.
        to_amount:
          type: string
          description: Requested destination amount in the destination asset's major units, when supplied.
        country:
          type: string
          description: Country supplied for route eligibility, when supplied. This is not proof of residency.
        payment_method:
          type: string
          description: Payment method supplied as a caller preference, when supplied.
        from_network:
          type: string
          description: Source crypto network supplied for the route, when supplied.
        to_network:
          type: string
          description: Destination crypto network supplied for the route, when supplied.
      additionalProperties: false
    QuoteOffer:
      type: object
      description: >-
        One fully priced, selectable offer from the same provider fan-out as the winning route. The list is ordered by
        the server's price-first ranking. Preserve the provider, offer_id and payment_method together; they identify the
        exact route facts that must be revalidated before execution. Provider payloads, redirect URLs and identity
        metadata are intentionally excluded.
      required:
        - provider
        - offer_id
        - rate
        - final_out
        - total_fees
        - expires_at
        - checkout_readiness
        - execution_context
      properties:
        provider:
          $ref: '#/components/schemas/Provider'
        offer_id:
          type: string
          description: Opaque provider offer identifier; preserve it verbatim.
        payment_method:
          type: string
          pattern: ^[a-z][a-z0-9_]*$
          description: Canonical payment method priced for this offer, when the provider returned one. Never infer it.
        rate:
          type: string
          description: >-
            Decimal string, `from_asset` per `to_asset` (BSR-9) — the server's own `final_in`'s decimal value ÷
            `final_out`'s decimal value for THIS offer, computed BEFORE either is rounded into the published `Money`
            fields below (when `final_in` is absent it equals the request's `from_amount`), in the SAME two request
            currencies, computed identically for every offer in this list regardless of provider or side (never a
            provider's own internal pricing field, whose basis is not uniform). Because `rate` is derived pre-rounding,
            `rate` × `final_out.amount` (scaled by `final_out.decimals`) can differ from `final_in.amount` in the last
            significant digit(s) when an asset's published `Money.decimals` is smaller than the server's own internal
            precision — do not treat that identity as exact. Informational only — NEVER compare offers by `rate`
            (rounding/precision differ by asset). Compare by `final_out` for a `from_amount` request (every offer
            targets a different `final_out` for the same pay amount). For a `to_amount` request, compare by `final_in`
            only among offers whose `final_out` equals the requested `to_amount` (lower `final_in` is better there) — an
            offer whose `final_out` differs is not comparable this way; not every provider honors an exact-output
            target.
        final_out:
          $ref: '#/components/schemas/Money'
        final_in:
          $ref: '#/components/schemas/Money'
        total_fees:
          $ref: '#/components/schemas/QuoteFees'
        expires_at:
          type: string
          format: date-time
          description: This offer's expiry; re-quote after it expires.
        checkout_readiness:
          type: object
          required:
            - policy_version
            - handoff_mode
            - quote_expires_at
            - checkout_fresh_until
            - safety_window_ms
          properties:
            policy_version:
              type: string
              enum:
                - checkout-readiness-v1
            handoff_mode:
              type: string
              enum:
                - deferred_request
                - hosted_session
                - signed_url
                - backend_native
            quote_expires_at:
              type: string
              format: date-time
            checkout_fresh_until:
              type: string
              format: date-time
            safety_window_ms:
              type: integer
              minimum: 0
          additionalProperties: false
        resolved_limits:
          $ref: '#/components/schemas/QuoteResolvedLimits'
        eta_seconds:
          type: integer
          minimum: 0
        execution_context:
          $ref: '#/components/schemas/QuoteExecutionContext'
      additionalProperties: false
    Quote:
      description: >
        The best executable route across every connected provider, plus the fan-out that produced it. `POST /v1/quotes`
        is the only writer; there is no update.
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - side
            - provider
            - rate
            - final_out
            - total_fees
            - expires_at
            - checkout_readiness
            - quote_status
          properties:
            id:
              type: string
              pattern: ^qt_
              example: qt_01J9ZK3Q8M2F5A7C9E1G3H5J7K
            object:
              type: string
              enum:
                - quote
            side:
              type: string
              enum:
                - buy
                - sell
                - swap
            offer_id:
              type: string
              description: Opaque identifier of the winning offer; preserve it verbatim for selection.
            payment_method:
              type: string
              pattern: ^[a-z][a-z0-9_]*$
              description: >-
                Canonical method priced for the winning offer. This is requested funding intent, not proof of the
                eventual received settlement rail. Absent means unknown.
            provider:
              $ref: '#/components/schemas/Provider'
              description: The winning route's provider (D-26).
            rate:
              type: string
              description: >-
                Decimal string, `from_asset` per `to_asset` (BSR-9) — the server's own `final_in`'s decimal value ÷
                `final_out`'s decimal value for the winning offer, computed BEFORE either is rounded into the published
                `Money` fields below (when `final_in` is absent it equals the request's `from_amount`), in the SAME two
                request currencies, computed identically for every offer in this response regardless of provider or side
                (never a provider's own internal pricing field — Bridge's and Coinbase's own rate math disagree on this
                basis by side, which is exactly the bug BSR-9 closed: `docs/api/CHANGELOG.md`). Because `rate` is
                derived pre-rounding, `rate` × `final_out.amount` (scaled by `final_out.decimals`) can differ from
                `final_in.amount` in the last significant digit(s) when an asset's published `Money.decimals` is smaller
                than the server's own internal precision — do not treat that identity as exact. Informational only —
                NEVER compare offers by `rate` (rounding/precision differ by asset). Compare by `final_out` for a
                `from_amount` request (every offer targets a different `final_out` for the same pay amount). For a
                `to_amount` request, compare by `final_in` only among offers whose `final_out` equals the requested
                `to_amount` (lower `final_in` is better there) — an offer whose `final_out` differs is not comparable
                this way; not every provider honors an exact-output target.
            final_out:
              $ref: '#/components/schemas/Money'
              description: What the caller receives (`bestQuoteResponseSchema.finalOut`).
            final_in:
              $ref: '#/components/schemas/Money'
              description: What the caller pays, when it differs from the request amount (`bestQuoteResponseSchema.finalIn`).
            total_fees:
              $ref: '#/components/schemas/QuoteFees'
            expires_at:
              type: string
              format: date-time
              description: Quotes expire — re-quote rather than reusing a stale one.
            checkout_readiness:
              type: object
              description: >-
                Field names from `quoteOfferSchema.checkoutReadiness` — tells the caller how the eventual order will
                complete.
              required:
                - handoff_mode
                - quote_expires_at
                - checkout_fresh_until
              properties:
                policy_version:
                  type: string
                  enum:
                    - checkout-readiness-v1
                handoff_mode:
                  type: string
                  enum:
                    - deferred_request
                    - hosted_session
                    - signed_url
                    - backend_native
                  description: >-
                    `backend_native` completes without a redirect (Bridge-native); the other three hand off to a hosted
                    provider (R15 `buysell-checkout.html`).
                quote_expires_at:
                  type: string
                  format: date-time
                checkout_fresh_until:
                  type: string
                  format: date-time
                safety_window_ms:
                  type: integer
                  minimum: 0
              additionalProperties: false
            resolved_limits:
              $ref: '#/components/schemas/QuoteResolvedLimits'
            eta_seconds:
              type: integer
            kyc_level:
              type: string
              description: '`quoteOfferSchema.kycLevel` — the identity tier this route requires, when known.'
            route_facts:
              type: object
              description: >-
                Field names from `routeFactsSchema` — comparison context for a poor-value or no-KYC-eligible
                alternative.
              properties:
                best_price:
                  type: object
                  properties:
                    payment_method:
                      type: string
                    pay_amount:
                      type: string
                    receive_amount:
                      type: string
                    rate:
                      type: string
                      description: >-
                        Decimal string, `from_asset` per `to_asset` (BSR-9) — fixer round 1 (Codex review, P3): the SAME
                        value as the winning offer's own `rate` (`Quote.rate`; see that field's description, including
                        its `to_amount`-mode caveat), never a second, independent derivation from
                        `pay_amount`/`receive_amount`. Those two fields describe this request's best-price context and
                        are not always the winning offer's own `final_in`/`final_out` (`receive_amount` is the REQUESTED
                        amount, not necessarily the offer's realized one, in `to_amount` mode) — reusing one rate keeps
                        a single winning offer from publishing two disagreeing numbers in the same response.
                        Informational only — never compare offers by `rate`.
                    limit:
                      type: object
                      description: >-
                        A1-4 — `max` is `Money` (was a bare float sharing a sibling `currency` — `root.yaml`'s own
                        "Floats never appear on a money path"); the currency now lives on `Money` itself.
                      properties:
                        max:
                          $ref: '#/components/schemas/Money'
                        source:
                          type: string
                      additionalProperties: false
                  additionalProperties: false
                no_kyc_eligible:
                  type:
                    - object
                    - 'null'
                  properties:
                    payment_method:
                      type: string
                    receive_amount:
                      type: string
                    max_spend:
                      type: object
                      description: A1-4 — `amount` is `Money` (was a bare float sharing a sibling `currency`).
                      properties:
                        amount:
                          $ref: '#/components/schemas/Money'
                        scope:
                          type: string
                          enum:
                            - annual
                            - order
                      additionalProperties: false
                    warning:
                      type: string
                  additionalProperties: false
                limit_warning:
                  type: object
                  description: A1-4 — `max` is `Money` (was a bare float sharing a sibling `currency`).
                  properties:
                    max:
                      $ref: '#/components/schemas/Money'
                    tone:
                      type: string
                      enum:
                        - neutral
                        - warning
                  additionalProperties: false
              additionalProperties: false
            quote_status:
              type: string
              enum:
                - ready
                - indicative
              description: >-
                `indicative` came from a warm cache without running the live fan-out — re-quote live before acting on it
                once older than the safety window (`bestQuoteResponseSchema.quoteStatus`).
            indicative_as_of:
              type: string
              format: date-time
              description: When the indicative price was actually fetched — never "now" (`bestQuoteResponseSchema.indicativeAsOf`).
            offers:
              type: array
              description: >-
                Every fully priced offer from the same call, including sibling payment methods and providers. This
                additive list is the priced method surface; `providers[]` remains the terminal one-row-per-provider
                diagnostic summary for backward compatibility. An offer omitted from this list was not safely priced and
                must not be inferred from capabilities — including when the reason is this gateway being unable to
                verify that offer's own currency/asset decimal scale (fixer round 1, 2026-09-22), a money reason, not a
                rate one. If `payment_method` is absent, the provider did not return a canonical method and the client
                must not present that entry as a selectable named method.
              items:
                $ref: '#/components/schemas/QuoteOffer'
            providers:
              type: array
              description: >
                Terminal outcomes from this call's existing provider offers and diagnostics (D-21, BS-G2). No additional
                provider requests are made to populate these rows. Reasons are public codes; raw diagnostics and
                provider payloads are never exposed.
              items:
                $ref: '#/components/schemas/QuoteProviderResult'
            price_change_threshold_bps:
              type: integer
              description: The basis-point move that triggers a re-quote prompt on the client (today a server constant, 50) — D-27.
          additionalProperties: false
      unevaluatedProperties: false
    OrderCreateRequest:
      type: object
      description: >
        Collapses the browser-shaped `redirect_intent_create` → `redirect_init` → `redirect_execute` chain into one
        idempotent create for an authenticated caller (D-20, BS-G1). The public-token payer redirect is a separate
        surface and is out of this file's scope.
      required:
        - quote_id
      properties:
        quote_id:
          type: string
          pattern: ^qt_
          description: A `Quote.id` from a prior `POST /v1/quotes`.
        offer_id:
          type: string
          description: >-
            Selects a specific `QuoteProviderResult` offer instead of the winning route, when the caller reviewed the
            fan-out and picked a different provider.
        external_account_id:
          type: string
          pattern: ^ba_
          description: >-
            REQUIRED for a Bridge-native sell (`execution_context.side: sell` on a `backend_native` offer): the saved
            bank destination the fiat payout settles to, from `GET /v1/wallet/external_accounts`. It must belong to this
            account's own Bridge customer, and its currency and rail must match the quote's `to_asset` and the offer's
            payout rail — a mismatch is refused, never reinterpreted. Never accepted on a `buy` or `swap`.
        wallet_address:
          type: string
          description: >-
            Crypto destination for a `buy`. NOT accepted on a Bridge-native `sell` (`400 invalid_request`, `param:
            wallet_address`): Bridge matches the incoming deposit from ANY sending wallet, so a declared source address
            would be a promise the server cannot keep. Send from any wallet you control to the address in the created
            order's `deposit_instructions`.
        email:
          type: string
          format: email
          description: >-
            F-5: ignored. No path reads this field — a hosted checkout's identity is built from this account's own
            verified email (`public.users`), never from the request body, and the internal action this route calls has
            no email parameter to forward one to. Ignore any value sent here; do not build client logic on it being
            used. Kept on the wire in case a future provider path needs a caller-suggested address, not because one
            exists today.
      additionalProperties: false
    Order:
      description: >
        A buy, sell or swap in flight or settled. `status` publishes `transactions.status` verbatim. Read `money_state`
        and `money_status` together when `money_state` is present: it says whether funds were ever captured,
        `money_status` carries refund truth (D-10, D-22). `money_state` is published only when the Paybis/Bridge
        failure-classification pipeline has actually classified this row — ABSENT, never a guessed
        `captured`/`never_authorized`, otherwise (BL-38, #3086).
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - status
            - side
          properties:
            id:
              type: string
              pattern: ^ord_
              example: ord_01J9ZK3Q8M2F5A7C9E1G3H5J7K
            object:
              type: string
              enum:
                - order
            status:
              type: string
              x-swaps-open-enum: true
              enum:
                - pending
                - initiated
                - awaiting_payment
                - awaiting_funds
                - processing
                - funds_received
                - payment_submitted
                - in_review
                - refund_in_flight
                - completed
                - payment_processed
                - failed
                - cancelled
                - abandoned
                - refunded
                - expired
                - undeliverable
                - returned
                - refund_failed
              description: >-
                A1-2: open — a new `transactions.status` value is a real, growing set (see K6/BL-36 history below); an
                unrecognized member is an opaque string, never a deserialization failure. `transactions.status` verbatim
                (D-22) — `transactions/recovery.ts`'s `ACTIVE_STATUSES` + `TERMINAL_STATUSES` + `UNCLASSIFIED_STATUSES`
                vocabulary (19 values), widened from the seven the approved R15 §3 screens draw on (K6 fix: the original
                enum only covered the seven approved terminal/active screens and rejected every other real
                `transactions.status` value as a contract violation). BL-36 (2026-09-16) adds `initiated` (3 prod rows)
                and `abandoned` (1293 prod rows) — the DB `status_check` constraint has allowed both since
                `20260708201001_transactions_status_add_refunded.sql`, but the vocabulary here and in `recovery.ts`
                never caught up: `orders.get` 500ed on either status (`respondValidated` rejects a `status` the enum
                does not list) and a page of `orders.list` containing one failed the same way whenever that row also
                carried a `quote_id`. `abandoned` joins `TERMINAL_STATUSES`; `initiated` is admitted here for contract
                purposes only and is deliberately left in `recovery.ts`'s `UNCLASSIFIED_STATUSES` — prod shows every
                live `initiated` row already carries an on-chain `tx_hash` (one is `metadata.lifecycle_stage:
                'completed'`), so it is a stranded post-broadcast swap the DEX reconciler never picked up, not a
                start-of-lifecycle "no money moved" state; see `recovery.ts`'s comment for the full writer/reconciler
                trace. Never conflate with `failure_kind` — the failed screen's "Status code" must read `failure_kind`,
                not this field (BS-G9).
            side:
              type: string
              enum:
                - buy
                - sell
                - swap
            quote_id:
              type: string
              pattern: ^qt_
              description: >-
                An opaque reference to the quote this order came from, when one exists. OPTIONAL — ABSENT, never a
                fabricated id, for an order that has no producer for it (a live write-path gap on some provider-native
                orders, or a swap that never went through a quote at all; see the API changelog for current prod scope).
                NOT resolvable via `GET /v1/quotes/{id}` for a historical order: this value can reference
                `quote_attempts.rid`, a different table than the one that endpoint reads, while an order created through
                `POST /v1/orders` carries a `GET /v1/quotes/{id}`-resolvable id instead.
            rate:
              type: string
              description: >-
                Decimal string, `from_asset` per `to_asset`, the SAME basis as `QuoteOffer.rate` (BL-28, BS-10; fixer
                round 2, P1, independent review 2026-09-20 — round 1 published this on Bridge's own native, opposite
                basis, which collided with sibling PR BSR-9/#3357's provider-agnostic wire contract). BSR-10 gives this
                a live producer for a Bridge-native `buy` order: read `rate_basis`, below, to know which of two figures
                this is — never assume one from this field's mere presence. The planned read-time source
                (`transactions.quote_id` -> `quotes.id` -> `quotes.offers`) is a dead join in production —
                `transactions.quote_id` has referenced `quote_attempts.rid`, not `quotes.id`, since
                `20260210100000_fix_transactions_quote_fk.sql`, and `quote_attempts` carries no rate/offers column.
                BL-48 re-examined this for `orders.create`'s Bridge-native path (BL-47), which prices via `api_quotes`
                (a genuinely joinable row, unlike `quotes`) — still ruled out as a read-time source, since `api_quotes`
                is quote-cache infrastructure with its own TTL, and reading it back from `orders.get`/`.list` days after
                settlement, for a row a future cleanup job may have already pruned, would make this field's presence
                flicker on a purge schedule having nothing to do with the order itself. BSR-10 uses a WRITE-time source
                instead: `createBridgeNativeOrder`'s execute-transfer call opens a server-sealed route claim and
                `bridge-core.ts` derives the persisted rate from THAT claim, inverted onto this wire basis (never from a
                request-body value), onto the `transactions` row itself at handoff (`baseMetadata`) — durable
                transaction history, never a re-derived read, and never a caller-supplied figure. Once settlement writes
                the observed `to_amount` (`bridge-webhook/index.ts`'s `transfer.payment_processed` handler) AND Bridge's
                own transfer receipt proves the actual settled source amount, this field is RECOMPUTED as that
                receipt-proven source amount divided by the observed `to_amount` — a provider can legitimately re-price
                between quote and arrival for some corridors, so the settled figure is never assumed equal to the quoted
                one even when they agree; `rate_basis` flips to `at_arrival` in the same response. A row whose receipt
                never proves that source amount, or that reaches a terminal status without ever settling, publishes
                neither `rate` nor `rate_basis` rather than a stale or unproven figure. Still absent for every other
                order — a Sell/Swap order, a non-Bridge provider, or a Bridge buy this fix's write-time source never
                reached (a row created before this deploy) — never guessed.
            rate_basis:
              type: string
              enum:
                - quoted
                - at_arrival
              description: >-
                Qualifies `rate` (above; both regimes share the same `from_asset` per `to_asset` basis). `quoted`: the
                taken offer's quote-time price, published only while the order is still active — the persisted estimate,
                never the final word; a terminal row without a receipt-proven settled figure publishes neither `rate`
                nor `rate_basis` (fixer round 3, P2, independent review 2026-09-20 — this paragraph had drifted to say
                the opposite of what `buildOrderRateProjection` actually gates on; `rate`'s own description above, and
                RESOURCE-MODEL.md §2.4, already stated the gate correctly). `at_arrival`: the REALIZED rate, Bridge's
                own receipt-proven settled source amount divided by the observed `amounts.receive` once its transfer
                receipt lands — never assumed equal to the quoted figure, since a provider can legitimately re-price
                between quote and arrival for some corridors (Bridge's own "rate fixed on arrival" settlement semantics
                this field exists to name structurally, replacing a copy caveat with a field). Published alongside
                `rate` for a Bridge-native `buy` order (BSR-10); still absent alongside it for every other order, for
                the identical reason.
            provider:
              $ref: '#/components/schemas/Provider'
            payment_method:
              type: string
              description: >-
                The payment method originally requested for this order (`transactions.payment_method`, e.g.
                `sepa`/`card`/`ach_push`), verbatim — BL-48. Not restricted to a fiat leg: a Bridge buy funded from a
                crypto balance can carry a crypto method here too (e.g. `bitcoin`/`ethereum`) — prod carries 184 such
                rows. Absent when the row predates this column or the value was never set. Distinct from `payment_rail`:
                this is the REQUEST, not necessarily what settled — see that field.
            payment_rail:
              type: string
              description: >-
                Bridge's own OBSERVED settlement rail for a Bridge order (`transactions.payment_rail`, BL-48) — starts
                equal to `payment_method` at creation and is overwritten once `bridge-webhook/index.ts`'s
                `buildBridgeTransferUpdatePayload` resolves one (from `transfer.created` onward, not only once settled),
                so the two can genuinely diverge. Published ONLY for `provider.id: bridge` orders (fix-pack, independent
                review): the same column carries Transak's own private rail vocabulary (`order.paymentOptionId`, e.g.
                `credit_debit_card`) for a Transak order, which is not Bridge's vocabulary and disagreed with
                `payment_method` on the method itself on 448 prod rows — publishing it under this Bridge-documented
                field for another provider would misrepresent what the customer paid with. Never inferred — published
                verbatim from the column, or absent when it was never set or the provider is not Bridge.
            amounts:
              type: object
              description: >
                The checkout's own money lines (R15 §4: "You pay" / "You receive"). The exact sub-field set is not yet
                spelled out beyond this pairing in RESOURCE-MODEL — flagged for confirmation rather than invented
                further. Fixer round 1 (2026-09-22) — either leg is WITHHELD, never published at an unverified decimal
                scale, when this gateway cannot verify that leg's currency/asset — see `docs/api/CHANGELOG.md`'s entry
                of the same date. This rule is identical on every `order.*` event's own `amounts` (`GET /v1/events`, the
                SSE stream, `GET /v1/activity`) — each leg withholds independently there too, and `amounts` is absent
                from the event only when NEITHER leg resolved, never when just one could not be verified — see
                `docs/api/CHANGELOG.md`'s L5-FIX-event-money-parity fixer round 1 entry (2026-09-22).
              properties:
                pay:
                  $ref: '#/components/schemas/Money'
                receive:
                  $ref: '#/components/schemas/Money'
                  description: >-
                    The `transactions.to_amount` column, published VERBATIM for ANY provider/side that writes it
                    (provider-agnostic, unchanged by BSR-10 — fixer round 1, P2, independent review 2026-09-20 corrected
                    a prior draft of this text that wrongly claimed this figure was new and Bridge-buy-only; prod
                    already publishes it for Paybis, Transak, OKX, Bridge-sell and Moonpay orders). Whether this figure
                    is itself SETTLED is provider-specific, not guaranteed by this field alone (fixer round 3, P2,
                    independent review 2026-09-20 — Transak writes `to_amount` on every synced observation, not only
                    `completed`, so a `processing` Transak order can publish an unsettled provider figure here with no
                    `rate_basis` alongside it): only a Bridge-native buy's `rate_basis` qualifies this value at all
                    (`quoted` names it an unsettled estimate; its absence here means no route-specific caveat is known,
                    not that the figure is proven settled). What BSR-10 actually adds is the PENDING case: before
                    `to_amount` is written (`bridge-webhook/index.ts`'s `transfer.payment_processed` handler,
                    Bridge-native `buy` only), `createBridgeNativeOrder` persists the taken offer's own quote-time
                    `final_out` at handoff (`bridge-core.ts`'s `baseMetadata`, derived from the sealed route claim —
                    never a caller-supplied value), and this field publishes that ESTIMATE instead — see `rate_basis`,
                    above, which names which of the two this value is — until the settled `to_amount` lands and takes
                    over unconditionally, or the order reaches a terminal status without ever settling, at which point
                    neither figure is published. No other order path persists a quote-time estimate here; every other
                    order either shows the settled `to_amount` or nothing.
                fee:
                  $ref: '#/components/schemas/Money'
                  description: >-
                    Declared since BL-19; BL-28/BS-10 investigated populating it off the same linked-quote offer as
                    `rate`, above, and found no live source (see `rate`'s description) — left reserved at the time.
                    BL-48 gives it a real, narrow producer: a Bridge-native `buy` order's own transfer receipt (Bridge's
                    observed `developer_fee`, netted from the transfer's source amount — the buy's fiat `from_asset`
                    leg, matching this field's own currency contract below) — published ONLY when `status: completed`
                    (fix-pack, independent review). Bridge writes this receipt at `transfer.created`, before any money
                    has moved — prod carries a fee figure on the majority of `awaiting_payment`, `cancelled` and
                    `refunded` Bridge buys too, all a request-time QUOTED figure Bridge echoes back, not an observation
                    of what was actually charged, so `completed` is the gate, not merely "the receipt is non-empty".
                    Still ABSENT for every other case — a Sell/Swap order, a non-Bridge provider, or a Bridge buy that
                    has not reached `completed` — never guessed. Denominated in this order's fiat leg (`from_asset` on a
                    buy, `to_asset` on a sell) — never on a `swap` order, which has no fiat leg to denominate it in.
                    This is a single total, not a provider/network breakdown — Bridge exposes no on-chain network-fee
                    figure this codebase reads anywhere; a breakdown is real follow-up work; a Sell's fee is left
                    unpopulated because Bridge nets it from the crypto leg, not `to_asset`, and publishing it here would
                    misrepresent the currency.
              additionalProperties: false
            money_state:
              type: string
              enum:
                - never_authorized
                - hold_placed
                - captured
              description: >-
                OPTIONAL — absent when this row has never been classified (BL-38, #3086): the writer is the
                Paybis/Bridge failure-classification pipeline, gated on `failed`/`cancelled` Paybis rows only, so most
                rows never receive one. When present, read it together with `money_status`: `money_state` says whether
                funds were ever captured, `money_status` carries refund truth. A row can read `status: completed` while
                `money_state` reads `never_authorized` (D-10). Never infer a money state from `status` alone, and never
                treat its absence as `never_authorized` — that is exactly the fabricated-default bug this field's
                optionality fixes.
            money_status:
              type: string
              enum:
                - not_charged
                - unknown
                - refund_in_progress
                - refund_completed
              description: >-
                Drives the "Money:" line on the terminal screens (R15 §3). Every bank rail resolves to `unknown`; only a
                card rail is ever positively `not_charged`.
            failure_kind:
              type: string
              enum:
                - session_expired
                - declined
                - rejected
                - refunded
                - verification_failed
                - risk_declined
                - generic_failed
              description: >-
                The seven normalized failure kinds (R15 BS-G9, prod-inventory §1.4). This is the field the failed
                screen's "Status code" must read from — never `status`.
            failure_reason:
              type: string
              description: Safe, human-readable copy for `failure_kind` — never a raw provider error string.
            return_reason:
              type: string
              description: >-
                Why a settled leg was sent back, as the provider itself worded it (e.g. `Receiver Name Mismatch`, `Too
                Many Recent Transactions`) — the returned leg is a fiat pay-in on a buy, and either the crypto deposit
                or the fiat payout on a Bridge-native sell (L1-5). Present only on an order whose money actually arrived
                and was then returned — distinct from `failure_reason`, which is OUR safe copy for a `failure_kind`.
                Same field and same contract as `payments.return_reason` on a payment link, so one integration handles
                both. Provider vocabulary is open-ended and NOT an enum: branch on `failure_kind`/`status`, and treat
                this as display and support text.
            next_action:
              type: object
              description: >-
                What the caller must do next before this order can advance, when anything is required of them at all.
                Present ONLY while a hosted-provider checkout is open and reachable: the order is in an active status, a
                hand-off URL exists for it, that URL has not expired, and its host is one this API recognises. ABSENT —
                never null, never a stale URL — the moment any of those stops being true, including on a Bridge-native
                order (which completes in place and publishes `bank_deposit_instructions` or `deposit_instructions`
                instead). A caller that receives no `next_action` on a hosted order whose `status` is still active must
                request a new quote; there is no way to re-mint a checkout session for an order that already has one.
                L1-6b (contract only, no producer yet): every route stays silent on this field until L1-6c/d build the
                hosted-checkout branch and its resume. Published by `orders.create`'s `201` and `GET /v1/orders/{id}`
                only — `GET /v1/orders` never carries it, for any row.
              required:
                - type
                - checkout_url
                - expires_at
              properties:
                type:
                  type: string
                  enum:
                    - redirect
                  x-swaps-open-enum: true
                  description: >-
                    `redirect`: open `checkout_url` in a browser and let the provider complete the payment. This enum
                    may gain members within `/v1`; treat an unknown value as opaque and do not fail deserialization.
                checkout_url:
                  type: string
                  format: uri
                  description: >-
                    An `https` URL on a provider host this API recognises, minted server-side for THIS order. Never
                    caller-influenced, never reused across orders. Open it; do not parse it, store it past `expires_at`,
                    or send it to anyone but the person paying.
                expires_at:
                  type: string
                  format: date-time
                  description: >-
                    When this URL stops being usable. Past it the order stays readable but the checkout is gone: the
                    field disappears from `GET /v1/orders/{id}` and a replayed `POST /v1/orders` response may still
                    carry the expired value it was stored with — check this field, do not assume a replay is fresh.
              additionalProperties: false
            offer_id:
              type: string
              description: >-
                The offer actually executed for this order (`transactions.offer_id`), verbatim. Normally the `offer_id`
                the caller sent, or the quote's winning offer when none was sent; it can differ when the selected offer
                was not executable at order time and the server fell through to the next-ranked one — see
                `offer_selection`. Absent on an order created before this field existed or by a path that records no
                offer.
            offer_selection:
              type: object
              description: >-
                Why THIS offer ran, when that is not simply "the one you asked for". Published on the `201` from
                `orders.create` only — it is a record of a creation-time decision, and a later `GET` of the same order
                republishes `offer_id` but not this object. Absent when the executed offer is the one the request
                selected.
              required:
                - executed_offer_id
                - reason
              properties:
                executed_offer_id:
                  type: string
                skipped:
                  type: array
                  items:
                    type: object
                    required:
                      - offer_id
                      - provider_id
                      - reason
                    properties:
                      offer_id:
                        type: string
                      provider_id:
                        type: string
                      reason:
                        type: string
                        enum:
                          - provider_paused
                          - provider_not_supported
                          - capability_unavailable
                          - offer_expired
                        x-swaps-open-enum: true
                    additionalProperties: false
                reason:
                  type: string
                  enum:
                    - next_ranked_eligible_offer
                  x-swaps-open-enum: true
              additionalProperties: false
            deposit_instructions:
              $ref: '#/components/schemas/DepositInstructions'
              description: >
                Present on a self-custody sell that needs crypto sent to a provider-issued address before payout — the
                shared `DepositInstructions` component, same shape as `wallet.deposit_intents.deposit_instructions`
                (D-24, BS-G7).
            bank_deposit_instructions:
              $ref: '#/components/schemas/BankDepositInstructions'
              description: >
                Present on a Buy order awaiting a bank-rail payment — the deposit slip (IBAN/account number,
                beneficiary, reference) the payer sends fiat to (BL-19). Reuses the shared `BankDepositInstructions`
                component verbatim rather than a second same-named shape, so this never drifts from the identical
                projection `payment_sessions`/`payroll` funding already publish. UNLIKE `payout_destination` below, this
                is deliberately the full, unmasked provider payload (D-24): the payer must have the real IBAN to
                actually send funds to it. Bound by the same D-24 rule as every `BankDepositInstructions` consumer —
                never logged, never placed in a URL or query string.
            payout_destination:
              type: object
              description: >
                Present on a buy, sell or swap once its settlement destination is known (the buy crypto branch, BL-48,
                added below). The bank branch is masked the same way as `payouts.beneficiary`'s output projection —
                never the full account number, never a public link or token (D-24); a sell order never itself becomes a
                `payout` object. The crypto branch (`chain`/`address`/`tx_hash`, BL-19; buy, BL-48) is exclusive with
                the bank branch: a self-custody payout is on-chain and has no account number to mask, so `address` is
                published verbatim (BS-G7's own `deposit_instructions.address` precedent). `tx_hash` on the buy branch
                is read from the SAME Bridge transfer receipt (`bridge_data`'s `receipt.destination_tx_hash`) the
                `amounts.fee` producer reads (fix-pack, independent review — 66 of 70 `completed` Bridge buys carry
                one); absent before the transfer settles or on an older payload shape that predates the field, never
                guessed.
              properties:
                account_holder:
                  type: string
                account_tail:
                  type: string
                bank_country:
                  type: string
                beneficiary_owner_type:
                  type: string
                chain:
                  type: string
                  enum:
                    - tempo
                    - base
                    - ethereum
                    - polygon
                    - arbitrum
                    - optimism
                    - solana
                  description: >-
                    The settlement chain for a crypto payout (Sell/Swap, BL-19; Buy, BL-48) — the same `chain`
                    vocabulary as `DepositInstructions.chain`, never a second `network` spelling of the same fact
                    (CMP-8's own lesson: a `source_network` duplicate of `chain` was removed from `payment-links`, not
                    reintroduced here). Present only when the settlement chain can be resolved to this exact enum from a
                    known chain id — an unrecognised chain id still publishes `address`/`tx_hash` with `chain` simply
                    absent, never guessed.
                address:
                  type: string
                  description: >-
                    The on-chain settlement address funds actually landed at — case-preserved verbatim (AGENTS.md
                    address-case rule).
                tx_hash:
                  type: string
                  description: The on-chain settlement transaction hash, when the payout provider has confirmed one.
              additionalProperties: false
          additionalProperties: false
      unevaluatedProperties: false
    OrderList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Order'
      unevaluatedProperties: false
    OrderEventList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Event'
      unevaluatedProperties: false
    CapabilityCorridor:
      type: object
      description: >
        One payout corridor's public and (when authenticated) entitlement-bearing facts (RESOURCE-MODEL §2.2, v2
        amendments; D-48).
      required:
        - id
        - currency
        - fiat_rail
        - lifecycle
        - public_claim_status
        - status
      properties:
        id:
          type: string
        currency:
          type: string
        fiat_rail:
          type: string
        minimum:
          $ref: '#/components/schemas/Money'
        max_amount:
          $ref: '#/components/schemas/Money'
          description: >-
            The Travel-Rule ceiling for this corridor (§2.2 v2 amendments). On `crypto_relay`, the per-invoice cap: a
            payer is refused above it, and `payment_links.activate` refuses a link it would leave with no payable rail
            (`422 no_payable_rail`).
        source_chains:
          type: array
          items:
            type: string
        lifecycle:
          type: string
          enum:
            - public
            - preview
            - blocked
        public_claim_status:
          type: string
          enum:
            - live
            - unverified
            - planned
        sender_display:
          type: string
        legal_entity_name:
          type: string
          description: >-
            Present only where the settling entity differs from the account holder (e.g. a EUR SEPA IBAN held by the
            provider's EEA entity).
        eta_seconds:
          type: integer
        executable:
          type: boolean
          description: >-
            Availability, never authorization — the server re-checks at create and at the money boundary (RESOURCE-MODEL
            §0.10).
        blocked_reason:
          type: string
          enum:
            - lifecycle_blocked
            - minimum_unconfigured
            - rail_not_enabled
            - route_unavailable
            - payin_fiat_pending
            - collection_account_uses_currency
            - relay_requires_tempo_mainnet
          x-swaps-open-enum: true
          description: >-
            A machine-readable code for a corridor whose `status` alone does not say enough — read it per `status`,
            never generically. For a `payouts` corridor, `handlePayoutsEligibility`'s own blocker classification
            (`lifecycle_blocked`, `minimum_unconfigured`, `rail_not_enabled`, `route_unavailable`). For a `wallet_bank`
            `wallet_bank_virtual_account` corridor (receive by bank, W-R23), `rail_not_enabled` with `status:
            not_enabled` means the rail is not launched for this account (the `wallet_bank_virtual_account_enabled` flag
            does not admit it) — no verification step opens it, so `action` is `none`. `route_unavailable` on that same
            corridor means the flag admits the account but its active Tempo wallet is not on Tempo mainnet (Bridge
            settles the deposit as USDC there) — on a staging project whose Bridge runs in its sandbox, not on that
            project's own test network; `POST /wallet/virtual_accounts` refuses the identical wallet with `409
            network_not_supported`, so this is never published alongside an `available` a create could not honour.
            `collection_account_uses_currency` (W-R33) on that same corridor, with `status: not_enabled` and `action:
            none`: a Payment links collection account already receives this currency into this account's Tempo wallet,
            no activated wallet-funding account does (the create would reuse that one), and the provider hands the
            collection account back instead of issuing a wallet-funding one — `POST /wallet/virtual_accounts` refuses
            the same currency with `409 conflict` before any provider call. It outranks the verification rungs (no
            verification step frees the slot) but never `rail_not_enabled` or `route_unavailable`. `payin_fiat_pending`:
            a fiat pay-in corridor (`payment_links`' own `kind: 'bank'` rows and a launched
            `wallet_bank_virtual_account`; never `payroll`) of an `individual` account whose Bridge fiat pay-in
            activation (`bridge_customers.capabilities.payin_fiat`) is not exactly `active` even though KYC and
            endorsement already cleared — a separate Bridge step (C4-D23, `docs/capabilities/CAPABILITIES-CANON.md`
            §1.8) `status` alone cannot distinguish from any other `gathering_no_path` cause. This is the SAME
            underlying condition `rail_not_allowed`'s `details.reason` publishes as `merchant_fiat_payin_pending`
            (`root.yaml`) at the payer's execution-time error boundary — same fact, two scopes: this field describes the
            account's OWN capability resource, that one describes a payer's refused attempt against a merchant's link.
            `relay_requires_tempo_mainnet` (RELAY-ENV-1-T, P-72), only on the `payment_links` `crypto_relay` corridor,
            with `status: not_enabled` and `source_chains: []`: Relay routes are admitted (`founder_accepted` or
            `proven`) only into Tempo mainnet, and this deployment settles Payment links on a Tempo test network (dev
            and staging), or its Tempo network could not be resolved, so the payer page never offers Relay here whatever
            the flags say. It outranks `rail_not_enabled`; `max_amount` is still published while the cap is set.
            Production (Tempo mainnet) reads it only if its Tempo network cannot be resolved. Absent for every corridor
            this does not apply to. Open enum: a client tolerates a value it does not know.
        status:
          type: string
          enum:
            - available
            - action_required
            - in_review
            - gathering_no_path
            - not_started
            - not_enabled
          description: >-
            The corridor's eligibility state for this account. `in_review` and `gathering_no_path` are never the same
            answer (V-G1, D-48).
        review_category:
          type: string
          enum:
            - genuinely_reviewing
            - actionable
            - gathering_no_path
          description: Whether the rail row gets an action button at all (`RailReviewCategory`, R17 §3).
        display_label:
          type: string
          description: >-
            The one machine-readable label every client renders, so `in_review` and `gathering_no_path` never collapse
            to the same text again (D-48).
        detail:
          type:
            - string
            - 'null'
          description: >-
            The human sentence for `status`'s current value (K6b, §52 C4-D14) — one fixed sentence per
            `CorridorEligibilityStatus`, never a per-reason variant, so it never claims more than `status` itself
            already promises. One exception: a `wallet_bank_virtual_account` corridor with `blocked_reason:
            rail_not_enabled` reads `Receive by bank is not launched yet.` (the rail is off for everyone it does not
            admit, not for this account in particular); with `blocked_reason: collection_account_uses_currency` it reads
            `A Payment links account already receives <CURRENCY> for this wallet.`
        action:
          type:
            - object
            - 'null'
          description: >-
            What the caller can do about this corridor right now (K6b, §52 C4-D14), derived from `status` and, when a
            richer signal exists, overridden by it: for a `payment_links`/`payroll`/`wallet_bank` corridor the
            resolver's own `next_action`, for a `payouts` corridor `handlePayoutsEligibility`'s own `blocker`
            (`kyc`/`endorsement` -> `verify`; `address`/`tos`/`source_of_funds`/`additional_details` ->
            `provide_details`; `capability` -> `none`). Never invented: a corridor with no such signal named falls back
            to the coarse `status` default alone (Codex review, PR #2989).
          required:
            - kind
            - href
          properties:
            kind:
              type: string
              enum:
                - provide_details
                - verify
                - none
            href:
              type:
                - string
                - 'null'
              format: uri
              description: >-
                Always `null` today — no verification-link minting is wired into this route (`POST
                /v1/customers/{id}/verification_links` is the separate call that mints one).
          additionalProperties: false
        requested_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            Reserved for when this corridor's endorsement was requested (K6b, §52 C4-D14) — always absent today. Bridge
            stores no per-endorsement request timestamp anywhere in this codebase
            (`bridge_customers.bridge_data.endorsements[]` carries `name`/`status`/`requirements` only), and
            `bridge_customers.updated_at` bumps on ANY write to the customer (KYC, ToS, an unrelated endorsement), so it
            is not a safe per-endorsement proxy — publishing it would misreport an old request as freshly made whenever
            something else on the account changed (Codex review, PR #2989). Left in the schema, nullable, so a future PR
            can populate it once a real per-endorsement timestamp is persisted, rather than never having a field to
            fill.
        crypto_only:
          $ref: '#/components/schemas/CapabilityCryptoOnly'
      additionalProperties: false
    CapabilityCryptoOnly:
      type: object
      x-swaps-since: '2026-09-25'
      description: >-
        CP-T2 (CP-G16, D-63) — present only on the `crypto_tempo` corridor of `product=payment_links`. Whether THIS
        account can activate a crypto-only link (settlement to its own Swaps Wallet, K13 §4) right now, judged by the
        same account-level gates `payment_links.activate` enforces, in the same order: the `payment_links_crypto_only`
        flag for this merchant, the account's own status, the `crypto_tempo` rail and its fee wallet, and the account
        owner's active Tempo wallet on a Tempo network. No Bridge customer, KYC or endorsement is read or required. The
        corridor's own `status` keeps its meaning (the rail is on for this deployment, which a saved Tempo address also
        uses); `crypto_only` refines it and is never `available` while that `status` is not. Link-level checks (USD
        currency, attestation, rail restriction) still run at activation. Availability only, never authorization.
      required:
        - status
        - display_label
        - requires_bridge
      properties:
        status:
          type: string
          enum:
            - available
            - action_required
            - not_enabled
          description: >-
            The corridor vocabulary: `available`, `action_required` (only `wallet_not_found` — the account can create
            its Swaps Wallet), `not_enabled` (every other reason; nothing the caller can do in this API).
        blocked_reason:
          type: string
          enum:
            - crypto_only_not_enabled
            - account_inactive
            - rail_not_enabled
            - wallet_not_found
            - settlement_destination_unsupported
          x-swaps-open-enum: true
          description: >-
            The first closed gate, absent when `status` is `available`. `crypto_only_not_enabled`: the
            `payment_links_crypto_only` flag does not admit this merchant. `account_inactive`: the account is not
            `active`. `rail_not_enabled`: the `crypto_tempo` rail is off or has no fee wallet. `wallet_not_found`: the
            account owner has no active Tempo wallet (activation answers `409 wallet_not_found`).
            `settlement_destination_unsupported`: that wallet does not resolve to a Tempo network this account can
            settle on (activation answers `422 invalid_request`, or `422 settlement_rail_unsupported` for a
            tempo-mainnet wallet on a non-livemode account).
        display_label:
          type: string
          description: The D-48 label for `status`, the same map every corridor uses.
        requires_bridge:
          type: boolean
          enum:
            - false
          description: Always `false` — the crypto-only path never needs a Bridge customer (K13 §4 step 7).
      additionalProperties: false
    CapabilityPayrollCryptoFunding:
      type: object
      x-swaps-since: '2026-09-25'
      description: >-
        PAYROLL-CRYPTO-1 — whether a pay run in this currency can be funded by sending USDC to the employer's Bridge
        payroll wallet (`POST /v1/payroll_runs` with `funding_method: crypto`), the second funding method production
        offers next to the bank transfer. `available` reads the deployment's crypto-funding switch first, then the same
        per-currency facts as `funding_account_available` (a Bridge funding rail for the currency and this account's
        verification for it): the shared funding service checks the currency's funding rail before it prepares a crypto
        deposit. `asset` and `chain` are present only when `available` is true; they are the wallet the deposit address
        is on — send only that asset on that chain. The exact amount, fee included, is published on the run's
        `funding_instructions.crypto` once funding is requested; send it in ONE transfer: deposits on this route are not
        summed, and a top-up after a short deposit does not fund the run. Availability only, never authorization:
        `create`, `approve` and the funding call re-check the same switch. An entry without this object reads as not
        available.
      required:
        - available
      properties:
        available:
          type: boolean
        reason:
          type: string
          enum:
            - crypto_funding_not_enabled
            - bridge_funding_currency_unsupported
            - verification_required
          x-swaps-open-enum: true
          description: >-
            Present only when `available` is false; the first closed gate. `crypto_funding_not_enabled`: crypto funding
            is switched off for payroll (a run asking for it answers `409 capability_unavailable`).
            `bridge_funding_currency_unsupported` and `verification_required` mean what they mean on the parent
            `funding_currencies[]` entry.
        asset:
          type: string
          description: The asset to send, e.g. `USDC`.
          example: USDC
        chain:
          type: string
          description: The chain the payroll wallet's deposit address is on, e.g. `solana`.
          example: solana
      additionalProperties: false
    CapabilityTempoWalletSettlement:
      type: object
      x-swaps-since: '2026-09-27'
      description: >-
        §52.33 (PL-TEMPO-BRIDGE-1-T, founder decisions 27.09; mechanism fixed by PL-TEMPO-BRIDGE-2-T; the flag mode
        fixed by PL-TEMPO-BRIDGE-3-T) — whether a payment link in THIS currency can settle to the merchant's Tempo
        wallet, and which rail carries the money. `via: splitter` is the existing non-custodial `crypto_tempo` rail (USD
        only, 1:1, no FX — BL-37 stays for USD links). `via: bridge` is the Bridge-settled real-FX path
        (`payment_links_tempo_via_bridge`, platform-wide or a per-merchant allowlist): Bridge holds a collection account
        whose destination carries `payment_rail: 'tempo'` + the address (never a crypto external account — Bridge
        external accounts are fiat-only) and converts this currency to USDC on Tempo, fee visible. ORDER IS LAW (founder
        ruling): the Bridge sandbox proof, then the founder's own production €5 test — run as a per-merchant allowlist
        entry, the SAME mechanism `crypto_relay_rail` uses — then the wizard option becomes selectable for everyone
        (platform-wide) — this field can read `available: true, via: bridge` before the wizard exposes it, because
        availability here is a server fact, not a UI gate. Availability only, never authorization —
        `payment_links.activate` re-checks the same flag mode and currency, for the SAME merchant, at write time.
      required:
        - available
        - via
      properties:
        available:
          type: boolean
        via:
          type: string
          enum:
            - splitter
            - bridge
        reason:
          type: string
          enum:
            - tempo_via_bridge_not_enabled
            - verification_required
            - tempo_rail_not_enabled
            - wallet_not_provisioned
            - wallet_network_not_supported
          x-swaps-open-enum: true
          description: >-
            Present only when `available` is false. `tempo_via_bridge_not_enabled`: the `payment_links_tempo_via_bridge`
            flag does not admit THIS merchant — off entirely (the seeded default), or a per-merchant allowlist that does
            not list this account's `public.users.id`. `verification_required`: the flag admits this merchant
            (platform-wide, or this merchant is allowlisted) but this account's own bank corridor for the currency
            (`corridors[]` above, same currency) has not itself resolved to `available` — Bridge requires that same
            endorsement to execute the FX leg. `tempo_rail_not_enabled` (`via: splitter`, USD only): the non-custodial
            `crypto_tempo` rail's own kill switch is off (fixer round, findings #5/#11) — never fabricate `available:
            true` for a rail nobody can actually use. `wallet_not_provisioned`/`wallet_network_not_supported` (`via:
            bridge` only): the flag admits this merchant, but the account either has no active Tempo wallet on file, or
            its wallet is not on THIS project's Tempo network (`tempo-mainnet` in production) — the SAME two gates
            `payment_links.activate`'s wallet-via-bridge branch checks, read from the SAME `wallet_entities` row, so
            this field and that write path can never disagree about which merchants can actually activate.
      additionalProperties: false
    Capabilities:
      type: object
      description: >
        What this account can execute right now, rail by rail — or, unauthenticated, the public claims projection with
        no entitlement, route id, flag or preview row (RESOURCE-MODEL §2.4). One resource, five `product` projections
        (D-23, D-34).
      required:
        - version
      properties:
        version:
          type: string
        fee:
          type: object
          description: >
            Shape varies by `product`: payouts publishes `{kind, bps, applies_to}`; payment_links publishes `{kind, bps,
            applies_to, rails, rounding, rounding_decimals}`; payroll publishes `{bps, shape, enabled}`
            (employer-funded-on-top, never a percentage deducted from the recipient — §2.5). All keys are optional here
            to cover every shape.


            **payment_links** — the Swaps fee on the splitter rails named in `rails` (`crypto_tempo`, `crypto_relay`),
            the same constant the server charges: `applies_to: payer` — added on top of the invoice and paid by the
            payer; the merchant receives the full invoice. For an invoice of `amount`, the payer's deposit instructions
            charge `fee = floor(amount × 10^rounding_decimals × bps / 10000) / 10^rounding_decimals` (a USD invoice with
            at most two decimals is never rounded); `Payment.fee` and `Payment.amount_expected` on these rails publish
            the same figures at that scale. It applies to crypto subscriptions too: every subscription invoice is a
            crypto-only payment link paid on these rails. A rail not listed in `rails` carries no fee claim from this
            object (no rate, no bearer); the fee on a Bridge-routed rail (bank rails, `crypto_bridge`) is deducted from
            what the merchant receives and is published separately as `deducted_fee`. Provider fees (Relay relay/gas,
            Bridge network/processing) are not this fee and are quoted per payment. Present whatever the rails' current
            availability (`corridors[]`).
          properties:
            kind:
              type: string
              description: e.g. `percentage`.
            bps:
              type: integer
            applies_to:
              type: string
            rails:
              type: array
              items:
                type: string
              description: >-
                `product=payment_links` only — the rails (`corridors[].fiat_rail`) this fee is true for. A rail not
                listed carries no fee claim from this object.
            rounding:
              type: string
              enum:
                - floor
              x-swaps-open-enum: true
              description: >-
                `product=payment_links` only — how the charged fee is rounded: `floor` (down, never an overcharge) at
                `rounding_decimals`.
            rounding_decimals:
              type: integer
              description: >-
                `product=payment_links` only — the scale the fee is computed and floored at: the settlement token's
                decimals (6 on every Tempo stablecoin).
            shape:
              type: string
              enum:
                - employer_funded_on_top
              description: Payroll only (§2.5) — the fee is added on top of the run, never deducted from a recipient.
            enabled:
              type: boolean
          additionalProperties: false
        deducted_fee:
          type: object
          description: >
            `product=payment_links` only — the Swaps fee on the Bridge-routed rails named in `rails` (bank rails and
            `crypto_bridge`): `applies_to: merchant` — the payer pays exactly the invoice and the provider deducts this
            percentage from what arrives, so the merchant receives what arrived minus it (founder ruling «Bank: 1% is
            deducted from what you receive»). This is the rate configured now; the figure a payment was charged, to the
            cent and rounded up, is that payment's own `Payment.deducted_fee`, read from the provider receipt (`null`
            there when the payer paid in another currency than the invoice — `crypto_bridge`'s USDC source included — or
            no consistent receipt was recorded). Omitted when no fee is configured.
          required:
            - kind
            - bps
            - applies_to
            - rails
          properties:
            kind:
              type: string
              description: e.g. `percentage`.
            bps:
              type: integer
              description: The configured rate in basis points (100 = 1%).
            applies_to:
              type: string
              enum:
                - merchant
              x-swaps-open-enum: true
            rails:
              type: array
              items:
                type: string
              description: The rails (`corridors[].fiat_rail` / `Payment.payment_rail`) the deduction applies to.
          additionalProperties: false
        corridors:
          type: array
          items:
            $ref: '#/components/schemas/CapabilityCorridor'
        funding_sources:
          type: array
          description: >
            `product=payouts` only — the «Pay with» sources for this caller: `external_wallet` (USDC from any wallet,
            always available) and `swaps_wallet` («Wallet balance», `coming_soon` while its kill switch is off;
            `unavailable` to a business key and in test mode).
          items:
            type: object
            required:
              - id
              - status
            properties:
              id:
                type: string
                enum:
                  - external_wallet
                  - swaps_wallet
              status:
                type: string
                enum:
                  - available
                  - coming_soon
                  - unavailable
              reason:
                type: string
                enum:
                  - flag_off
                  - wallet_not_provisioned
                  - wallet_paused
                  - route_unavailable
                  - unverifiable
                  - test_mode
            additionalProperties: false
        providers:
          type: array
          items:
            $ref: '#/components/schemas/CapabilityProvider'
        limits:
          type: object
          properties:
            per_payout:
              type: object
              properties:
                individual:
                  $ref: '#/components/schemas/Money'
                business:
                  $ref: '#/components/schemas/Money'
              additionalProperties: false
          additionalProperties: false
        defaults:
          type: object
          description: '`product=buy_sell` only — the resolved first-load defaults (`exchange_bootstrap`, D-23, BS-G4).'
          properties:
            country:
              type: string
            default_fiat:
              type: string
            default_asset:
              type: string
            primary_method:
              type:
                - string
                - 'null'
              description: The preferred payment method from the same fresh route snapshot, or null when no usable route exists.
            selectable_methods:
              type: array
              items:
                type: string
            families:
              type: array
              items:
                type: string
            fallback_fiats:
              type: array
              items:
                type: string
            resolved_limits:
              $ref: '#/components/schemas/QuoteResolvedLimits'
          additionalProperties: false
        coverage:
          type: array
          description: '`product=buy_sell` only — the country × method × provider availability grid (D-23, BS-G5).'
          items:
            type: object
            required:
              - country
              - fiat
              - direction
              - method
              - provider
              - state
            properties:
              country:
                type: string
              fiat:
                type: string
                description: Fiat currency considered for this coverage cell.
              direction:
                type: string
                enum:
                  - buy
              method:
                type: string
              provider:
                type: string
              state:
                type: string
                enum:
                  - selectable_background
                  - selectable_on_selection
                  - blocked
                  - drift
                description: A representation contract, not provider health (`buyCoverageContract`, BUY-SELL-CANON §4).
              reason:
                type: string
                x-swaps-open-enum: true
                description: 'A1-2: open — an unrecognized member is an opaque string, never a deserialization failure.'
                enum:
                  - policy_blocked
                  - provider_disabled
                  - country_unsupported
                  - coverage_unconfirmed
                  - method_unsupported
                  - currency_unsupported
                  - direction_unsupported
                  - route_disabled
                  - provider_unavailable
                  - ELIGIBLE_NOT_SELECTABLE
                  - SELECTABLE_WITHOUT_PROVIDER
                  - QUOTE_ON_SELECTION_NOT_SELECTABLE
                  - SANCTIONED_ROUTE_SELECTABLE
            additionalProperties: false
        currencies:
          type: array
          description: '`product=wallet_bank` only — the currency → endorsement → status map (§2.3 v2 amendments).'
          items:
            type: object
            required:
              - currency
              - endorsement
              - status
            properties:
              currency:
                type: string
              endorsement:
                type: string
                description: The Bridge endorsement backing this currency (e.g. `base`, `sepa`, `spei`).
              status:
                type: string
              minimum:
                $ref: '#/components/schemas/Money'
            additionalProperties: false
        funding_currencies:
          type: array
          description: >-
            `product=payroll` only (BL-60, C4-D37) — for each payroll fiat currency (the SAME set
            `supabase/functions/payroll/lib.ts`'s `FIAT_CURRENCIES` accepts as a run currency), whether Bridge can issue
            a FUNDING account for it today, with a reason when it cannot. Additive: the run-currency picker keeps every
            fiat currency (the founder rejected removing COP — a LIVE `bank_bridge`/`co_bank_transfer` payout corridor,
            see `corridors` above — over dropping it just because it has no Bridge funding rail yet) and now reads this
            array to warn honestly up front instead of the gap surfacing only after a run is created.
            `funding_account_available` is the bank-transfer method; `crypto_funding` (PAYROLL-CRYPTO-1) is the USDC
            method for the same currency, and its absence means the USDC method is not available.
          items:
            type: object
            required:
              - currency
              - funding_account_available
            properties:
              currency:
                type: string
              funding_account_available:
                type: boolean
              reason:
                type: string
                enum:
                  - bridge_funding_currency_unsupported
                  - verification_required
                description: >-
                  Present only when `funding_account_available` is `false`. `bridge_funding_currency_unsupported` is the
                  SAME blocker `payroll/lib.ts`'s `provisionPayrollFundingInstructions` already returns when it cannot
                  resolve a Bridge funding rail for a recognized payroll fiat currency (COP today).
                  `verification_required` (fix round 1, findings #1/#3) means a Bridge funding rail exists for this
                  currency but THIS account's own `bank_bridge` corridor for it (`corridors[]` above, same currency) has
                  not itself resolved to `available` yet — an account-level fact, never a static, caller-independent
                  one.
              crypto_funding:
                $ref: '#/components/schemas/CapabilityPayrollCryptoFunding'
            additionalProperties: false
        settlement_currencies:
          type: array
          description: >-
            `product=payment_links` only (§52.33, PL-TEMPO-BRIDGE-1-T) — for each link fiat currency (the same
            currencies the bank corridors above cover), whether a link in that currency can settle its Tempo-wallet
            destination and through which rail. An entry without `tempo_wallet.available: true` reads as not available
            for that currency today.
          items:
            type: object
            required:
              - currency
              - tempo_wallet
            properties:
              currency:
                type: string
              tempo_wallet:
                $ref: '#/components/schemas/CapabilityTempoWalletSettlement'
            additionalProperties: false
        ceiling:
          $ref: '#/components/schemas/Money'
          description: '`product=wallet_bank` only — the V1 ceiling ($3,000, §2.3 v2 amendments).'
        degraded:
          type: boolean
          description: >
            `product=buy_sell` only — true when this read is not backed by a fresh route snapshot: the upstream hop
            (best-quote) was unreachable or itself had no fresh route-truth lease (NB-5). `providers[]` is still a live,
            independent read; `defaults`/`coverage` are OMITTED — never an empty grid or a fabricated "nothing is
            available" claim, and never a permanent 503 for a condition that cannot change.
        degraded_reason:
          type: string
          enum:
            - upstream_unreachable
            - snapshot_unavailable
            - unavailable_in_test_mode
          description: >
            Present only when `degraded` is true. Reserved: `unavailable_in_test_mode` is never emitted since A4-FIX-6;
            a test-mode caller gets 503.
      additionalProperties: false
    MonthlyVolumeBand:
      type: string
      enum:
        - under_5k
        - 5k_to_20k
        - 20k_to_50k
        - 50k_plus
      description: >-
        An enum id, never a raw number — closes the label/midpoint disagreement bug class named in V-G14 (D-53).
        RESERVED (L3-2, 2026-09-21): not referenced by `eligibility.get` today — that operation is public/
        unauthenticated only (D-16 forbids this field on any anonymous path), so this enum is kept, unreferenced, for
        the authenticated `eligibility` variant a future item builds once the router's optional-auth route class exists
        (see `paths/platform.yaml`'s `eligibility.get` description).
    Eligibility:
      type: object
      description: >
        The compliance-navigator engine's own answer (RESOURCE-MODEL §2.4), published by the PUBLIC, unauthenticated
        `eligibility.get` (L3-2). `country`/`customer_type` echo the normalized request; every other axis
        (`monthly_volume_band`, `purpose`, `regulated_activities`) is a KYB questionnaire that never takes an anonymous
        path (D-16) and is not part of this response.
      required:
        - country
        - customer_type
        - products
        - disclaimers
      properties:
        country:
          type: string
          description: The normalized (upper-cased) `country` the caller passed.
        customer_type:
          type: string
          enum:
            - individual
            - business
        disclaimers:
          type: array
          items:
            type: string
          description: >-
            Machine-readable disclaimer codes (fix round 1, P2) — mirrors `resolveEligibility`'s own `disclaimerKeys`
            (`src/lib/eligibility/engine.ts`), stripped of the `complianceNav.disclaimer.` i18n prefix. `not_advice` is
            present on every response; `approximate` when any product's `confidence` is `approximate`; `eea` for an EEA
            country; `sanctioned`/`unknown_country` replace the other three for those two hard-block branches (fix round
            2, P3 corrected the spelling — round 1 shipped the reversed `country_unknown`, which is actually the
            SEPARATE `reasons[]` code this same branch publishes; `disclaimers[]` and `reasons[]` are deliberately
            different vocabularies and this was their one accidental collision). Never omitted — an empty array would
            itself be a false "no caveats" claim on the operation this `dark-flag` legal sign-off exists to gate.
        products:
          type: array
          items:
            type: object
            required:
              - id
              - status
              - confidence
              - required_customer_type
              - requires_bridge_verification
              - blockers
              - documents
              - reasons
              - notes
            properties:
              id:
                type: string
                enum:
                  - exchange
                  - wallet
                  - payment_links
                  - payouts
                  - payroll
                description: >-
                  The compliance-navigator engine's own five products (CAPABILITIES-CANON §1.5) — corrected from a
                  copy-paste of the unrelated `/v1/capabilities` product enum (`payment_links|payouts|payroll|
                  buy_sell|wallet_bank`), which answers a different, authenticated-caller question (L3-2 fix).
              status:
                type: string
                enum:
                  - likely_available
                  - conditional
                  - unavailable
                  - unknown
                description: >-
                  The engine's own four statuses, verbatim — never collapse `likely_available` and `conditional` into
                  one `available` (RESOURCE-MODEL §2.4).
              confidence:
                type: string
                enum:
                  - exact
                  - approximate
                  - unknown
                description: >-
                  How much this verdict can be trusted — `exact` mirrors a hard rule in code, `approximate` a client-
                  visible mirror of a backend-owned truth (e.g. EEA membership), `unknown` insufficient data. Corrected
                  from a numeric 0-1 stub (L3-2 fix) — the engine's own vocabulary is this 3-value enum
                  (`src/lib/eligibility/types.ts`'s `Confidence`), never a probability.
              required_customer_type:
                type: string
                enum:
                  - individual
                  - business
                  - either
                description: >-
                  Added the missing `either` value (L3-2 fix) — every product but `payment_links` accepts either
                  customer type; the stub previously could not express that.
              requires_bridge_verification:
                type: boolean
                description: >-
                  Added fix round 1 (P1) as `requires_verification`, renamed fix round 2 (P2) — mirrors the
                  compliance-navigator engine's own `requiresBridge` (`src/lib/eligibility/products.ts`'s
                  `REQUIRES_BRIDGE`): `true` for `payment_links`, `payouts` and `payroll` (every product gated on a
                  Bridge customer + endorsement the caller has not necessarily earned yet), `false` for `wallet`
                  (non-custodial, no KYC) and `exchange`. Before this field, a Bridge-gated product at `status:
                  likely_available` with an empty `blockers`/`reasons` read byte-identical to `wallet`, the one product
                  the engine documents as its true unconditional "yes" — this flag (together with the
                  `bridge_customer_missing` reason code those three products now lead `reasons[]` with) is the
                  machine-readable signal that distinguishes them. The bare name `requires_verification` over-claimed
                  for `exchange`: its `false` means only "no BRIDGE customer needed" — the canon engine still pairs that
                  `false` with a `complianceNav.note.fiat_provider_kyc` note (a channel this API does not publish),
                  because the fiat on/off-ramp runs the CARD PROVIDER's own KYC; only the DEX path is truly KYC-free.
                  Renamed so the field says precisely the one fact it can back — whether a Bridge customer/endorsement
                  is the gate — and nothing wider.
              blockers:
                type: array
                items:
                  type: object
                  required:
                    - code
                    - confidence
                  properties:
                    code:
                      type: string
                      description: >-
                        A machine-readable blocker code (e.g. `business_account_required`) — never prose; a caller
                        supplies its own copy by code.
                    confidence:
                      type: string
                      enum:
                        - exact
                        - approximate
                        - unknown
                  additionalProperties: false
                description: >-
                  Corrected from a bare string array (L3-2 fix) — a blocker's own confidence must travel with it,
                  exactly like a product's.
              documents:
                type: array
                items:
                  type: object
                  required:
                    - slug
                    - method_hint
                    - display_label
                  properties:
                    slug:
                      type: string
                    method_hint:
                      type: string
                      enum:
                        - form
                        - hosted_kyc
                        - tos
                        - info
                      description: >-
                        The SAME resolution vocabulary `customers.get`'s `requirements_due[].resolution` already
                        publishes (RESOURCE-MODEL §2.4 v2 amendments) — one vocabulary across both resources, never a
                        second UI-facing set of ids (settles V-G13/D-53: publish the machine value, keep the display
                        string in `display_label`, no free-text `copy` field).
                    display_label:
                      type: string
                      description: A short, factual, plain-English label (e.g. "Government-issued ID") — never marketing copy.
                  additionalProperties: false
              limits:
                type: object
                description: >-
                  Populated ONLY when a real, numeric SSOT backs it (today: `payouts`' per-currency corridor minimum,
                  and only for a country the corridor's rail actually covers — see `reasons`' `no_local_payout_rail`).
                  Every other product OMITS this field entirely rather than publish an approximate or fabricated figure
                  — omitted, never null (RESOURCE-MODEL's own "omitted, never null, never fabricated" convention). `min`
                  is a `Money` object, not a bare number (fix round 1, P1 — corrected from a scale-less major-unit
                  float, the one float on an otherwise Money-typed `/v1` surface; `currency` is dropped as a redundant
                  sibling field now that `Money` itself carries it). There is no `max` (fix round 2, P3 dropped it — no
                  producer here ever set one; publishing it would have documented "a knob nothing can ever turn," the
                  exact shape this operation's own path description already argues against for the three dropped
                  authenticated-only query parameters). Re-add `max` in the PR that first has a real per-corridor
                  maximum to publish.
                required:
                  - confidence
                properties:
                  min:
                    $ref: '#/components/schemas/Money'
                  confidence:
                    type: string
                    enum:
                      - exact
                      - approximate
                      - unknown
                additionalProperties: false
              reasons:
                type: array
                items:
                  type: string
                description: >-
                  Machine-readable reason codes — never prose. `country_sanctioned`/`country_unknown` uniformly replace
                  every other code for those two hard-block branches. `bridge_customer_missing` (fix round 1, P1) leads
                  `reasons[]` for every product whose `requires_bridge_verification` is `true`
                  (`payment_links`/`payouts`/`payroll`), regardless of country or customer type — the caller has not
                  created a Bridge customer yet, which is the CTA, not a `blockers[]` entry. `eea_verification_required`
                  marks an EEA-country downgrade on those same three products (also published as a `blockers[]` entry at
                  the same `approximate` confidence — fix round 1, P2) AND, since fix round 2 (P3), on `exchange` too:
                  round 1 gave `exchange` its own invented code (`eea_asset_restrictions`) on the theory that its EEA
                  fact is a MiCA asset restriction (USDT/USDB), not an account-verification one — but the canon engine's
                  own `resolveExchange` (`src/lib/eligibility/products.ts`) does not emit a matching code either; it
                  pushes this SAME `eea_verification_required` and carries the MiCA-specific fact separately, in its own
                  `noteKeys` — the same channel this operation's own `notes[]` below now mirrors. `exchange` shares the
                  reason code for canon parity; `notes[]` is what tells the two facts (a MiCA asset restriction vs. a
                  missing Bridge customer, `requires_bridge_verification: false` for this product) apart.
                  `no_local_payout_rail` marks a `payouts` corridor whose currency exists but whose rail does not cover
                  the requested country (e.g. Zimbabwe uses USD but has no US-domestic ACH/wire rail — fix round 1, P1;
                  Monaco/San Marino/Vatican City are EUR-currency SEPA members this code no longer wrongly applies to as
                  of fix round 2, P2 — see `paths/platform.yaml`).
              notes:
                type: array
                items:
                  type: string
                description: >-
                  Machine-readable note codes — a SECOND vocabulary from `reasons[]`, for a fact that isn't a
                  status/blocker reason. Mirrors the compliance-navigator engine's own `noteKeys`
                  (`src/lib/eligibility/products.ts`), stripped of the `complianceNav.note.` prefix exactly as
                  `disclaimers[]` strips `complianceNav.disclaimer.`. Never omitted — empty for every product with none
                  today. `exchange` always carries `dex_no_kyc`/`fiat_provider_kyc` (the DEX-vs-provider-KYC split its
                  `requires_bridge_verification: false` alone cannot express) and adds `eea_assets` for an EEA country
                  (the MiCA asset-restriction fact `reasons[]`' shared `eea_verification_required` code cannot, on its
                  own, distinguish from the other three products' real Bridge-verification gate).
            additionalProperties: false
      additionalProperties: false
    Customer:
      description: >
        Verification status only — never a document, a submitted KYC form value, a bank detail or a beneficial owner's
        name (RESOURCE-MODEL §2.4, R17 PII rule).
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          required:
            - customer_type
            - verification_state
          properties:
            id:
              type: string
              pattern: ^cus_
              example: cus_01J9ZK3Q8M2F5A7C9E1G3H5J7K
            object:
              type: string
              enum:
                - customer
            customer_type:
              type: string
              enum:
                - individual
                - business
            verification_state:
              type: string
              x-swaps-open-enum: true
              enum:
                - not_started
                - pending
                - under_review
                - approved
                - rejected
                - stale_mapping
                - action_required
              description: >-
                Swaps' own verification-state vocabulary (R17 §3): `not_started` (verification not yet begun), `pending`
                (submitted, awaiting review), `under_review` (a review is actively in progress), `approved` (cleared),
                `rejected` (declined — see `rejection_outcome`/`rejection_guidance`), `stale_mapping` (our record of
                this customer needs an operator to repair it before verification can continue), `action_required` (a
                `requirements_due[]` entry is blocking progress). Normalized to these seven values today, but the field
                is marked forward-compatible (`x-swaps-open-enum`) — treat an unrecognized member as opaque rather than
                a deserialization failure.
            requirements_outstanding:
              type: integer
              description: Count backing `requirements_due[]`.
            requirements_due:
              type: array
              items:
                type: object
                required:
                  - slug
                  - resolution
                properties:
                  slug:
                    type: string
                    description: >-
                      An identifier for this requirement, in our verification partner's own vocabulary today — not yet a
                      closed Swaps enum. Route on `resolution` below, render `display_label` for copy; never show this
                      raw slug to a user (D-53, V-G2).
                  resolution:
                    type: string
                    enum:
                      - form
                      - hosted_kyc
                      - tos
                      - info
                    description: >-
                      Swaps' own resolution vocabulary for this requirement (D-53, V-G2): `form` (fill it in inside
                      Swaps), `hosted_kyc` (finish it on a hosted verification link), `tos` (accept terms), `info`
                      (informational only, no action to take). Publishes the BEHAVIOR a client must reproduce, never the
                      raw `slug` above.
                  display_label:
                    type: string
                  due_at:
                    type: string
                    format: date-time
                    description: >-
                      The verification deadline, when one applies (D-53, V-G12) — e.g. an EEA re-verification window.
                      Not read live on this request, so it can trail a `requirements_due[]` set that changed very
                      recently. Never present for a non-verification slug (e.g. `external_account`), once the account's
                      KYC is already approved, or once the deadline itself has passed.
                additionalProperties: false
            endorsements:
              type: array
              items:
                type: string
              description: >-
                The payment rails this customer is cleared for (R17 §3) — Swaps' own rail codes: `base` (the
                crypto/stablecoin base rail every customer needs first), `sepa` (EUR via SEPA), `spei` (MXN via SPEI),
                `pix` (BRL via Pix), `faster_payments` (GBP via Faster Payments), `cop` (COP via bank transfer) — the
                array is open: a rail code outside this list is possible and must be ignored, never treated as an error.
            endorsement_statuses:
              type: array
              description: >-
                Per-rail status — one entry per rail for which the upstream snapshot carries a per-rail status object,
                including rails not yet approved; it can be empty even when `endorsements[]` is not. Each item names its
                own rail in `endorsement`; never pair by index with `endorsements[]`, which lists only the approved
                subset and can be shorter or differently ordered.
              items:
                type: object
                properties:
                  endorsement:
                    type: string
                  status:
                    type: string
                additionalProperties: false
            next_step:
              type: string
              description: The single next step that unblocks verification (source text for `get_verification_status`).
            tos_status:
              type: string
              description: >-
                Terms-of-service acceptance status, as our verification partner reports it today — not yet normalized
                onto a closed Swaps enum (unlike `verification_state` above); treat any non-`approved` value as "not yet
                accepted" rather than enumerating every possible string.
            kyc_status:
              type: string
              description: >-
                KYC/identity-verification status, as our verification partner reports it today — already folded into
                `verification_state` above for the canonical read; not yet normalized onto its own closed Swaps enum.
            available_actions:
              type: array
              items:
                type: string
            stale:
              type: boolean
              description: Restart onboarding — survives to the wire, never collapsed into another state.
            degraded:
              type: boolean
              description: Cached — we could not reach the provider. Survives to the wire, never collapsed.
            rejection_outcome:
              type: string
              description: >-
                An outcome enum converted server-side from `developer_reason` — never the raw provider reason
                (RESOURCE-MODEL §2.4). Known values include `verification_unavailable` and `terminal`; the full set is
                not yet published upstream.
            rejection_guidance:
              type: array
              items:
                type: string
              description: Bounded, safe-copy only — never `developer_reason` passthrough (V-G3).
            compliance_review_state:
              type: string
            associated_persons_count:
              type: integer
              description: A count, never a name — the only UBO fact on the base object (V-G4).
          additionalProperties: false
      unevaluatedProperties: false
    CustomerCreateRequest:
      description: >
        Start verification for THIS account's own holder — never a customer-of-a-customer (RESOURCE-MODEL D-3). Never a
        name, email, date of birth, address, document or tax id, with ONE exception: the server resolves the caller's
        account owner internally and reads its own email, and — for `customer_type: individual` — its own name, when a
        real first+last pair is already on file, never a merchant/brand name or the email local part. **Round-2
        architect ruling (2026-09-21)**: when no trustworthy name is on file, verification starts WITHOUT one — our
        verification partner's hosted KYC reads the legal name off the identity document itself. This closes the round-1
        `409 customer_name_required` dead end (fix-pack, independent review, P1), which is removed. `legal_name` below
        is the one field this body may carry: a registered company name is not personal data.
      type: object
      required:
        - customer_type
      properties:
        customer_type:
          type: string
          enum:
            - individual
            - business
          description: >-
            Which kind of verification to start: a natural person, or a business. Never written to the account:
            `Account.customer_type` follows the type Swaps records for the customer, which is this one for a new
            customer and the customer's own type when our verification partner already holds a customer for the
            account's owner (the `201` body's `customer_type` shows it).
        country:
          type: string
          pattern: ^[A-Z]{2}$
          description: >-
            ISO 3166-1 alpha-2, when known — validated against the SAME market registry `POST /v1/accounts` validates it
            against (`400 invalid_request`, `param: country`, for a well-formed but unrecognised code — fix-pack,
            independent review, P2), then persisted ONLY when the account has no country on file yet; a country the
            account already declared is never silently overwritten. Does not yet select a payment rail for this customer
            — a future PR can map country to a rail once product confirms which one.
        legal_name:
          type: string
          minLength: 1
          maxLength: 200
          description: >-
            `customer_type: business` ONLY — the registered company name, trimmed, forwarded to our verification partner
            as the entity's own legal name. Not personal data (round-2 architect ruling, 2026-09-21): a business's
            registered name is fine to accept from the caller, unlike a natural person's legal name, which this endpoint
            never accepts from any request body. `400 invalid_request` (`param: legal_name`) when `customer_type` is
            `individual`. Omitted, a business customer is created without a name on file — same treatment an individual
            with no name on file gets.
      additionalProperties: false
    CustomerVerificationLinkRequest:
      type: object
      required:
        - kind
        - return_to
      properties:
        kind:
          type: string
          enum:
            - tos
            - kyc
            - business_questionnaire
            - business_ubo
            - remediation
          description: >-
            One polymorphic mint across every hosted verification hand-off (D-52, V-G17): `tos` (terms of service),
            `kyc` (identity/business verification), `business_questionnaire` (compliance questionnaire), `business_ubo`
            (beneficial-owner check), `remediation` (a requested fix to a prior submission).
        return_to:
          type: string
          format: uri
          description: >-
            Required, and validated same-origin (`https://swaps.app`/`https://www.swaps.app`) — but NOT YET threaded
            into the underlying hosted hand-off, so the dead end where every live hand-off lands on `/confirm` instead
            of the hub (D-52, V-G18, prod P-B3) is not yet closed. This field is accepted and checked, not silently
            ignored, but is not yet "honoured" in the sense of actually steering the redirect; that wiring is tracked as
            a follow-up.
      additionalProperties: false
    CustomerVerificationLink:
      type: object
      required:
        - url
        - expires_at
      properties:
        url:
          type: string
          format: uri
        expires_at:
          type: string
          format: date-time
      additionalProperties: false
    AssociatedPerson:
      type: object
      description: >
        Name-free by policy (D-49) for a business key or a third-party agent; `name` is published only for the holder's
        own delegated session.
      required:
        - label
      properties:
        label:
          type: string
          description: >-
            `"Owner 1"`, `"Owner 2"`, … — never a raw internal object id, never a name (CAPABILITIES-CANON owner-task
            rule).
        name:
          type:
            - string
            - 'null'
          description: >-
            Present only when the caller is the holder's own delegated session (`dashboard_session`); always null for a
            business key or a third-party agent.
        ownership_percent:
          type: number
          minimum: 0
          maximum: 100
          description: >-
            Omitted, never defaulted to 0, when our verification partner's raw per-person payload does not carry a
            recognized ownership field. A client must render the absence honestly (e.g. "—"), never as an observed 0% —
            the contract trap issues #3086/#3088 name (a server default is not an observation).
        status:
          type: string
          description: >-
            Per-owner verification status. Not yet given a fixed Swaps enum — optional and omitted until a confirmed
            per-person status field exists upstream; today no source in this codebase reads one (only
            `associated_persons.length` is read). A follow-up derives it from the same endorsement `missing[]`
            object-scoped bundles the Verification Hub already reads, once a live upstream response confirms the shape.
      additionalProperties: false
    AssociatedPersonList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/AssociatedPerson'
      unevaluatedProperties: false
    ComplianceProfileRequest:
      type: object
      description: >
        Every field below is Swaps' own compliance-profile vocabulary — every field optional, only the ones present are
        submitted. Enum fields must match the published list for that field EXACTLY — an unrecognized value is `400`,
        never silently dropped or coerced. The three free-text fields (`account_purpose_other`, `business_description`,
        `source_of_funds_description`) publish a `maxLength: 1024`, and a value past it is `400` by the SAME
        generated-schema gate this route validates every request against — never silent truncation. One exception
        remains within the cap: a whitespace-only free-text value is trimmed and dropped entirely (never stored as an
        empty string) rather than rejected.
      properties:
        account_purpose:
          type: string
          enum:
            - charitable_donations
            - ecommerce_retail_payments
            - investment_purposes
            - operating_a_company
            - other
            - payments_to_friends_or_family_abroad
            - payroll
            - personal_or_living_expenses
            - protect_wealth
            - purchase_goods_and_services
            - receive_payment_for_freelancing
            - receive_payments_for_goods_and_services
            - receive_salary
            - tax_optimization
            - third_party_money_transmission
            - treasury_management
          description: >-
            The union of the individual (11) and business (13) `account_purpose` values (16 unique — some overlap).
            Which of these are valid is further restricted by the customer's own `customer_type`; a
            mismatched-but-listed value still gets `400`.
        account_purpose_other:
          type: string
          maxLength: 1024
          description: >-
            Free text — only meaningful (and only read) when `account_purpose` is `other`. `400` past 1024 characters
            (never silently truncated) — see this schema's own description.
        source_of_funds:
          type: string
          enum:
            - business_loans
            - company_funds
            - ecommerce_reseller
            - gambling_proceeds
            - gifts
            - government_benefits
            - grants
            - inheritance
            - inter_company_funds
            - investment_proceeds
            - investments_loans
            - legal_settlement
            - owners_capital
            - pension_retirement
            - salary
            - sale_of_assets
            - sale_of_assets_real_estate
            - sales_of_goods_and_services
            - savings
            - someone_elses_funds
            - third_party_funds
            - treasury_reserves
          description: >-
            The union of the individual (12) and business (11) `source_of_funds` enum values (22 unique — one overlap,
            `pension_retirement`).
        source_of_funds_description:
          type: string
          maxLength: 1024
          description: Free text. `400` past 1024 characters (never silently truncated) — see this schema's own description.
        employment_status:
          type: string
          enum:
            - employed
            - homemaker
            - retired
            - self_employed
            - student
            - unemployed
          description: Individual only — current employment status, Swaps' own six-value vocabulary.
        expected_monthly_payments_usd:
          type: string
          enum:
            - '0_4999'
            - '5000_9999'
            - '10000_49999'
            - 50000_plus
          description: Expected monthly payment volume through Swaps, banded in USD.
        business_description:
          type: string
          maxLength: 1024
          description: >-
            Free text — the business's own description of what it does. `400` past 1024 characters (never silently
            truncated) — see this schema's own description.
        business_type:
          type: string
          enum:
            - cooperative
            - corporation
            - llc
            - other
            - partnership
            - sole_prop
            - trust
          description: Business only — legal structure, Swaps' own seven-value vocabulary.
        business_industry:
          type: string
          pattern: ^[0-9]{6}$
          description: >-
            A single 2022 NAICS six-digit code identifying the business's industry — a public US federal classification
            standard, not a Swaps- or partner-specific list. Too large to enumerate here, so only the `^[0-9]{6}$` shape
            is checked at this layer; the closed set of valid codes is enforced server-side, never free text.
        estimated_annual_revenue_usd:
          type: string
          enum:
            - '0_99999'
            - '100000_999999'
            - '1000000_9999999'
            - '10000000_49999999'
            - '50000000_249999999'
            - 250000000_plus
          description: Business only — estimated annual revenue, banded in USD.
        high_risk_activities:
          type: array
          items:
            type: string
            enum:
              - adult_entertainment
              - gambling
              - hold_client_funds
              - investment_services
              - lending_banking
              - marijuana_or_related_services
              - money_services
              - nicotine_tobacco_or_related_services
              - operate_foreign_exchange_virtual_currencies_brokerage_otc
              - pharmaceuticals
              - precious_metals_precious_stones_jewelry
              - safe_deposit_box_rentals
              - third_party_payment_processing
              - weapons_firearms_and_explosives
              - none_of_the_above
          description: Zero or more of 15 regulated-activity values (includes `none_of_the_above`).
      additionalProperties: false
    ComplianceProfileResult:
      type: object
      description: >-
        Write-only confirmation — deliberately does NOT echo any submitted field back (V-G5, V-G15): the caller's own
        form already holds what it just typed, and re-displaying a "previous answer" on next open would contradict the
        screen's own promise that Swaps does not store or show it.
      required:
        - accepted
      properties:
        accepted:
          type: boolean
          enum:
            - true
      additionalProperties: false
    CustomerEventList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Event'
      unevaluatedProperties: false
    CustomerList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Customer'
      unevaluatedProperties: false
    Wallet:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          description: >
            The holder's non-custodial Tempo wallet (RESOURCE-MODEL §2.3). Nothing here can sign or move funds — the
            passkey on the holder's own device is the only signer (RESOURCE-MODEL §0.10).
          required:
            - address
            - chain
            - network
            - status
          properties:
            id:
              type: string
              pattern: ^wlt_
              description: Prefixed id (`wlt_`). See RESOURCE-MODEL §0.2.
            object:
              type: string
              enum:
                - wallet
            address:
              type: string
              description: Checksum-cased Tempo address. Never lower-cased on read or write.
            chain:
              type: string
              description: The chain this wallet lives on. Today always Tempo.
            network:
              type: string
              x-swaps-open-enum: true
              enum:
                - tempo-mainnet
                - tempo-moderato
              description: >
                The Tempo network this wallet lives on, from the wallet record. `tempo-moderato` is Tempo's test
                network: what it holds is test money (pathUSD), never real funds. A project serves only wallets on its
                own network, so every wallet one environment returns carries the same value, and it is the same value
                `WalletBalances.network` reports. Use it to pick the token a balance, send or signature is about; never
                infer the network from the environment's name or a client build setting. `chain` stays `tempo`.
            status:
              type: string
              enum:
                - active
              description: RESOURCE-MODEL §2.3 names only one live state.
            label:
              type:
                - string
                - 'null'
          additionalProperties: false
      unevaluatedProperties: false
    WalletCreateRequest:
      type: object
      description: >
        Registers or re-syncs the wallet the client already derived from a passkey credential — the WebAuthn ceremony
        itself (challenge + credential storage) has no `/v1` operation and runs client-side against the passkey
        key-manager directly (RESOURCE-MODEL §0.10: "Sign in with passkey" never touches `/v1`). The server
        independently re-derives the address from the stored credential's public key and rejects a mismatch (400) —
        `address` is a cross-check, never trusted alone.
      required:
        - address
      properties:
        address:
          type: string
          description: The Tempo address the client derived from its passkey credential. Case-preserved verbatim.
        credential_id:
          type: string
          description: >-
            The WebAuthn credential id already registered via the passkey key-manager, when more than one exists for
            this holder.
        label:
          type: string
      additionalProperties: false
    WalletList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Wallet'
      unevaluatedProperties: false
    WalletBalances:
      type: object
      description: >
        Scoped to the caller's own wallets only (RESOURCE-MODEL §2.3 D-9) — authenticating does not authorize reading
        another holder's address. `usd_total` excludes tokens whose decimals are not 6 rather than inflating the figure;
        those are named in `skipped_non_usd` instead.
      required:
        - chain_id
        - network
        - balances
        - usd_total
        - skipped_non_usd
      properties:
        chain_id:
          type: string
          description: The chain this balance snapshot was read from. Today always Tempo.
        network:
          type: string
          x-swaps-open-enum: true
          enum:
            - tempo-mainnet
            - tempo-moderato
          description: >
            The Tempo network these balances were read on: the caller's wallet network (`Wallet.network`), or, when the
            caller has no wallet yet, the network this environment serves. `tempo-moderato` is Tempo's test network, so
            its balances (pathUSD) are test money. The primary token is USDC.e on `tempo-mainnet` and pathUSD on
            `tempo-moderato`; a client decides which balance can pay from this field, never from its own build.
        balances:
          type: array
          items:
            type: object
            required:
              - token
              - decimals
              - amount
              - symbol
            properties:
              token:
                type: string
                description: The token's contract or ledger identifier.
              decimals:
                type: integer
                minimum: 0
                maximum: 18
                description: The token's own decimal precision (USDC.e is 6).
              amount:
                $ref: '#/components/schemas/Money'
              symbol:
                type: string
                example: USDC.e
            additionalProperties: false
        usd_total:
          $ref: '#/components/schemas/Money'
        skipped_non_usd:
          type: array
          items:
            type: string
          description: >
            Token symbols excluded from `usd_total` because their decimals are not 6 — named so a client never reads the
            total as complete.
      additionalProperties: false
    WalletTransactionList:
      type: object
      description: >
        A bounded recent window of on-chain transfers for the caller's own wallet — never full history (RESOURCE-MODEL
        §2.3). `degraded: true` with an empty `transfers[]` means the chain read failed, and must never be read as "no
        activity" or a zero balance.
      required:
        - chain_id
        - transfers
        - degraded
      properties:
        chain_id:
          type: string
        transfers:
          type: array
          items:
            type: object
            description: One on-chain transfer row.
            required:
              - from
              - to
              - amount
              - symbol
              - direction
              - block_number
              - timestamp
              - origin
            properties:
              from:
                type: string
              to:
                type: string
              amount:
                $ref: '#/components/schemas/Money'
              symbol:
                type: string
              direction:
                type: string
                enum:
                  - in
                  - out
                description: Relative to the caller's own wallet.
              block_number:
                type: integer
              timestamp:
                type: string
                format: date-time
                description: >
                  Required on every row (RESOURCE-MODEL v2 fixes the v1 draft's `timestamp?` — R13-wallet §2 W-G8 — so
                  the hub's calendar-day spend chart can bucket every transfer).
              counterparty_label:
                type:
                  - string
                  - 'null'
                description: >
                  The caller's own `address_book` label for the counterparty address of this row (`to` on an `out`
                  transfer, `from` on an `in` one) — §52 C4-D14 / K5c, closing W1 PR #2960's open ruling #1. `null` when
                  the caller has no saved entry for that address on a Tempo network (`address_book.chain` matching
                  `TEMPO_NETWORKS` — `tempo`/`tempo-mainnet`/`tempo-moderato`, `_shared/services/tempo_settlement.ts`).
                  Never a fabricated name.
              network:
                type:
                  - string
                  - 'null'
                description: >
                  The network this transfer happened on (§52 C4-D14 / K5c). Always `"tempo"` today — the same public
                  name `Wallet.chain` uses, never the internal `tempo-mainnet`/`tempo-moderato` split (WALLET-CANON §1:
                  "the chain is never shown to the user").
              origin:
                description: >-
                  Where this incoming transfer came from, when Swaps' own records name it (W-R34). A bank deposit is
                  this same one row, never a second row. `null` on every other transfer, on every `out` transfer, and
                  when the origin cannot be named for this caller or read right now — never a guess. Always present.
                oneOf:
                  - $ref: '#/components/schemas/WalletTransactionOrigin'
                  - type: 'null'
            additionalProperties: false
        degraded:
          type: boolean
        degraded_reason:
          type:
            - string
            - 'null'
      additionalProperties: false
    WalletTransaction:
      type: object
      description: One on-chain transaction, looked up by hash (`GET /v1/wallet/transactions/{hash}`).
      required:
        - hash
        - chain_id
        - status
      properties:
        hash:
          type: string
        chain_id:
          type: string
        status:
          type: string
          enum:
            - success
            - reverted
            - pending
            - not_found
          description: >
            `not_found` is a 200 body naming the lookup result, not itself an HTTP 404 — RESOURCE-MODEL §2.3 names it as
            a state of this resource.
        from:
          type: string
        to:
          type: string
        amount:
          $ref: '#/components/schemas/Money'
          description: >
            Present only on a transaction the wallet did not send whose decoded TIP-20 `Transfer` logs of the accepted
            tokens hold exactly one transfer touching the wallet; absent on a transaction the wallet sent.
        symbol:
          type: string
          description: The accepted Swaps-dollar token of `amount`; present exactly when `amount` is.
        direction:
          type: string
          enum:
            - in
            - out
        block_number:
          type: integer
        timestamp:
          type:
            - string
            - 'null'
          format: date-time
        counterparty_label:
          type:
            - string
            - 'null'
          description: >
            The caller's own `address_book` label for the counterparty address — same `address_book` lookup as
            `WalletTransactionList.transfers[].counterparty_label` (§52 C4-D14 / K5c). On a transaction the wallet did
            not send, the counterparty is the decoded TIP-20 Transfer log's sender (`in`) or recipient (`out`), as on
            the list, and `null` when several transfers touch the wallet. On a transaction the wallet sent it is the
            receipt-level `to`, which for every TIP-20 send is the TOKEN CONTRACT, not the recipient
            (`WalletSendModal.tsx`) — so that `out` lookup is `null` for essentially every real send (Codex review of
            #2988, P2: fail-closed, never a wrong guess). `null` when unsaved or when there is no counterparty address;
            absent only when `status: not_found`.
        network:
          type:
            - string
            - 'null'
          description: >
            The network this transaction happened on — same convention as `WalletTransactionList.transfers[].network`
            (§52 C4-D14 / K5c). Absent when `status: not_found` (no transaction to place on a network).
        origin:
          description: >-
            Same as `WalletTransactionList.transfers[].origin`, for a mined (`success`) `in` transaction; `null` on
            every other one. Absent only when `status: not_found`.
          oneOf:
            - $ref: '#/components/schemas/WalletTransactionOrigin'
            - type: 'null'
      additionalProperties: false
    WalletTransactionOrigin:
      type: object
      description: >-
        The recorded origin of an incoming wallet transfer (W-R34). `kind: bank_deposit`: the transfer delivered a bank
        deposit made into one of the caller's own wallet-funding virtual accounts (`GET /v1/wallet/virtual_accounts`) —
        matched on the delivery transaction hash that account's provider events recorded, never inferred from the
        sender, the amount or the timing. Read only for a bearer caller who may read that account (owner or admin);
        `null` for a business key. A Payment links collection account is never a wallet transfer's origin.
      required:
        - kind
        - virtual_account_id
        - currency
        - rail
      properties:
        kind:
          type: string
          x-swaps-open-enum: true
          enum:
            - bank_deposit
        virtual_account_id:
          type: string
          description: The `VirtualAccount.id` (`va_…`) the deposit was made into. Never a provider id.
        currency:
          type: string
          description: The currency the bank deposit was made in, uppercase — the account's `destination.currency` (e.g. `USD`).
        rail:
          type: string
          x-swaps-open-enum: true
          enum:
            - ach
            - sepa
            - spei
            - faster_payments
            - pix
            - wire
            - bre_b
          description: >-
            The bank rail the account receives on — the same value and spelling as that account's `destination.rail`
            (`ach` for a USD account). Open enum: a client tolerates a value it does not know.
      additionalProperties: false
      examples:
        - kind: bank_deposit
          virtual_account_id: va_3f0c9a52-6d1e-4b8a-9c3e-2a7b5d1e8f40
          currency: USD
          rail: ach
    WalletRouteStatus:
      type: string
      description: >
        The 4-value funding-route status, exact match confirmed by R13-wallet §3 — it backs both `deposit_routes` and
        `send_routes`. A cross-network route is `unsupported` on a deployment whose wallets live on a Tempo test
        network; one that would otherwise be open is `temporarily_unavailable` while the server cannot confirm which
        Tempo network it serves.
      enum:
        - available
        - coming_soon
        - unsupported
        - temporarily_unavailable
    DepositRoute:
      type: object
      description: A candidate cross-network path onto the holder's Tempo wallet (RESOURCE-MODEL §2.3).
      required:
        - id
        - source_chain
        - source_asset
        - target_network
        - target_asset
        - status
        - enabled
      properties:
        id:
          type: string
        source_chain:
          type: string
        source_asset:
          type: string
        target_network:
          type: string
        target_asset:
          type: string
        status:
          $ref: '#/components/schemas/WalletRouteStatus'
        enabled:
          type: boolean
          description: >
            `true` only when the server opens this route now; never on a Tempo test network, whatever the route's proof
            says.
        proof_status:
          type: string
        estimated_time:
          type: string
          description: A human-readable ETA hint for this route.
        fee_hint:
          type: string
          description: A human-readable fee hint for this route — not a computed quote.
        message:
          type:
            - string
            - 'null'
          description: >
            A short server-computed line about this route's `status`; on a Tempo test network every route is
            `unsupported` and this names that network as the reason.
      additionalProperties: false
    DepositRouteList:
      type: object
      description: >
        A1-6: a plain `{data}` catalogue, not a cursor list — the operation declares no `limit`/`cursor` (the full
        candidate-route catalogue is always returned) and previously composed `ListMeta`, publishing `has_more`/
        `next_cursor` the handler always hardcoded `false`/`null` with no way to ever page. Dropped rather than wired
        up: this catalogue is small and bounded by the source-chain set, not account data.
      required:
        - data
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/DepositRoute'
      additionalProperties: false
    DepositQuoteRequest:
      type: object
      description: >
        Nothing is written on Swaps' side and no `deposit_intent` is created — but Relay itself still allocates a fresh,
        funds-accepting deposit address per call (the response never returns it), so this is NOT provider-side
        side-effect-free: `Idempotency-Key` is required for the same reason it is on `deposit_intents.create`.
      required:
        - source_chain
        - source_asset
        - amount
      properties:
        source_chain:
          type: string
        source_asset:
          type: string
        amount:
          $ref: '#/components/schemas/MoneyPositive'
      additionalProperties: false
    DepositQuote:
      type: object
      description: >
        Dark-flag resource (R13-wallet §2 W-G1) — a live "you send X, receive ~Y" preview for one candidate network,
        ahead of minting a real deposit intent.
      required:
        - amount_out
        - haircut_pct
      properties:
        amount_out:
          $ref: '#/components/schemas/Money'
        haircut_pct:
          type: string
          description: >
            Decimal string, percent (e.g. `"0.15"` means 0.15%), never a float — mirrors `developer_fee_percent`'s
            representation elsewhere in this file.
      additionalProperties: false
    DepositIntentStatus:
      type: string
      description: >
        9 values (7 base + 2 recovery), confirmed exact by R13-wallet §2 W-G11 against the DB CHECK constraint —
        RESOURCE-MODEL §2.3's v1 draft omitted `cancelled`.
      enum:
        - awaiting_deposit
        - source_seen
        - bridging
        - settled
        - expired
        - failed
        - cancelled
        - recovering
        - manual_recovery_required
    DepositIntent:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          description: >
            A one-time, amount-bound, cross-network deposit address (RESOURCE-MODEL §2.3). `Idempotency-Key` is
            mandatory on create — a repeat POST would otherwise mint a second address and a second provider quote (D-2).
          required:
            - status
            - source_chain
            - source_asset
            - target_chain
            - target_asset
            - deposit_instructions
            - expires_at
            - timeline
          properties:
            id:
              type: string
              pattern: ^din_
              description: Prefixed id (`din_`). See RESOURCE-MODEL §0.2.
            object:
              type: string
              enum:
                - deposit_intent
            status:
              $ref: '#/components/schemas/DepositIntentStatus'
            source_chain:
              type: string
            source_asset:
              type: string
            target_chain:
              type: string
            target_asset:
              type: string
            deposit_instructions:
              $ref: '#/components/schemas/DepositInstructions'
              description: Single-use, amount-bound — minted once per intent (RESOURCE-MODEL §2.3 D-2). Case-preserved verbatim.
            source_tx_hash:
              type:
                - string
                - 'null'
            target_tx_hash:
              type:
                - string
                - 'null'
            fail_reason:
              type:
                - string
                - 'null'
              description: >
                Set ONLY when the intent reached `failed` — §52 C4-D14 / K5c. Mirrors the already-recorded
                `wallet_deposit_intents.fail_reason` column (added 20260625150000, populated by the wallet-funding
                watcher). An OPAQUE, provider-defined (Relay) string: the raw `failReason` Relay attached to the request
                (e.g. `SLIPPAGE` on a tiny-deposit auto-refund) when it sent one, otherwise the Relay status word itself
                (`refund`/`refunded`/`failure`/`failed`) as a fallback so this is never blank. The watcher maps ALL FOUR
                of those Relay states to the SAME `failed` intent status — this field does NOT encode whether Relay
                auto-refunded the sender or hard-failed the request, and no vocabulary is guaranteed stable; do not
                pattern-match a specific string to infer "refunded" (Codex review of #2988, P2). `null` in every other
                state, including `expired`, `cancelled` and `manual_recovery_required` — the watcher only ever writes it
                while setting `status: 'failed'`.
            expires_at:
              type: string
              format: date-time
            timeline:
              type: array
              description: Ordered transition history for this intent.
              items:
                type: object
                required:
                  - state
                  - occurred_at
                properties:
                  state:
                    type: string
                  occurred_at:
                    type: string
                    format: date-time
                additionalProperties: false
          additionalProperties: false
      unevaluatedProperties: false
    DepositIntentCreateRequest:
      type: object
      required:
        - source_chain
        - source_asset
        - amount
      properties:
        source_chain:
          type: string
        source_asset:
          type: string
        amount:
          $ref: '#/components/schemas/MoneyPositive'
      additionalProperties: false
    DepositIntentList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/DepositIntent'
      unevaluatedProperties: false
    SendRoute:
      type: object
      description: A candidate cross-network path off the holder's Tempo wallet (RESOURCE-MODEL §2.3).
      required:
        - id
        - kind
        - source_chain
        - source_asset
        - source_token_address
        - destination_chain
        - destination_asset
        - status
        - enabled
        - intent_required
        - message
      properties:
        id:
          type: string
        kind:
          type: string
        source_chain:
          type: string
        source_asset:
          type: string
        source_token_address:
          type: string
          description: >
            The source-leg TIP-20 contract address on Tempo. A `direct_tempo` send signs a
            `transferWithMemo(address,uint256,bytes32)` call against this address client-side (a plain send uses the
            32-byte zero memo), since that route never touches `/v1` at all; a `cross_chain` send never needs it
            directly, but it is exposed uniformly rather than splitting the schema by `kind`. This is always the live
            mainnet contract address, independent of any test-mode credential used to call this operation — route
            discovery does not vary by livemode. Returned exactly as stored, never lower-cased by the server — though
            for an EVM contract address this is a cosmetic guarantee, not a money-safety one: unlike BTC/Tron/Solana
            addresses, an EVM address is case-insensitive at the protocol level (EIP-55 checksum casing is a display
            convention, not part of the address).
        destination_chain:
          type: string
        destination_asset:
          type: string
        status:
          $ref: '#/components/schemas/WalletRouteStatus'
        enabled:
          type: boolean
          description: >
            `true` only when the server opens this route for the caller now; a `cross_chain` route is never enabled on a
            Tempo test network.
        intent_required:
          type: boolean
          description: >
            `false` for a direct same-chain Tempo send, which never touches `/v1` at all (RESOURCE-MODEL §0.10,
            R13-wallet §0) — `true` for every cross-network send, which must go through `send_intents`.
        estimated_time:
          type:
            - string
            - 'null'
          description: >
            A human-readable ETA hint for this route. `null` for every route in the current catalogue — no entry in
            `wallet-send/handler.ts`'s static route table populates it yet, and no approved design surface reads it
            today (`docs/api/consumers/R13-wallet.md` §4 sources every send-flow ETA elsewhere: a client-side chain-gas
            estimate for `direct_tempo`, Relay `/quote/v2` for `cross_chain`). Published nullable, not omitted, and
            always present, unlike `DepositRoute.estimated_time` (a non-nullable string the deposit projection omits
            when unset) — reserved for a future route (e.g. a provider-quoted cross-chain ETA) that can set it.
        fee_hint:
          type:
            - string
            - 'null'
          description: >
            A human-readable fee hint for this route — not a computed quote. `null` for every route today, for the same
            reason as `estimated_time` above; not currently read by any approved surface. Published nullable and always
            present, unlike `DepositRoute.fee_hint` (non-nullable, omitted when unset).
        message:
          type: string
          description: >
            A short per-route status string the server always computes — e.g. why a `coming_soon` cross-chain route
            isn't live yet, or that a route is non-custodial. Published for parity with `DepositRoute.message` (same
            shape and purpose) and populated on every route without exception, so it is required and never `null` here
            (`DepositRoute.message` stays nullable+optional for its own producer, which can omit it). No approved design
            surface reads this field today — `docs/api/consumers/R13-wallet.md` §1 Group S and the approved
            `wallet-send.html` design both render disabled cross-network rows from a fixed client-side sub-label plus a
            `status`/`enabled`-driven "Behind flag" tag, not this string — and the text itself is server-generated
            English, not routed through the dashboard's i18n shards, so a screen must not render it verbatim in a
            localized surface. Exposed as a diagnostic/parity field now so a future screen (or a non-dashboard API
            caller) has it without a second contract change. On a Tempo test network a `cross_chain` route is
            `unsupported` and this names that network as the reason.
      additionalProperties: false
    SendRouteList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/SendRoute'
      unevaluatedProperties: false
    SendIntentStatus:
      type: string
      description: |
        8 values, confirmed exact by R13-wallet §2 W-G11 — RESOURCE-MODEL §2.3's v1 draft omitted `cancelled`.
        `awaiting_signature`: a payout-funding send `payouts.fund` handed to one session, not yet recorded.
      enum:
        - created
        - awaiting_signature
        - source_submitted
        - bridging
        - settled
        - expired
        - failed
        - cancelled
    SendIntent:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          description: >
            A cross-network send off the holder's Tempo wallet. The API never signs (RESOURCE-MODEL §0.10):
            `send_instructions.steps[]` are unsigned calls the holder's own passkey signs; a direct same-chain Tempo
            send never creates one of these at all. Sign only a send that `send_intents.create` or `payouts.fund` handed
            to your session: reading one (`get`, `list`, a payout's funding instructions) never hands it out, and a
            payout-funding send's `source_tx.set` is accepted only from the session it was handed to. When that record
            never lands, the server records the source transaction itself from the routing provider's request, once the
            chain shows the holder's own exact transfer.
          required:
            - status
            - amount
            - destination_chain
            - destination_asset
            - destination_address
            - send_instructions
            - expires_at
            - timeline
          properties:
            id:
              type: string
              pattern: ^sin_
              description: Prefixed id (`sin_`). See RESOURCE-MODEL §0.2.
            object:
              type: string
              enum:
                - send_intent
            status:
              $ref: '#/components/schemas/SendIntentStatus'
            amount:
              $ref: '#/components/schemas/Money'
            amount_out:
              oneOf:
                - $ref: '#/components/schemas/Money'
                - type: 'null'
              description: >
                The quoted "recipient gets" amount. Null until the routing provider has quoted it — a client renders
                "exact amount shown next" while it is null (R13-wallet §2 W-G2).
            destination_chain:
              type: string
            destination_asset:
              type: string
            destination_address:
              type: string
            send_instructions:
              type: object
              description: Unsigned. The API cannot sign; the holder's passkey signs every step.
              required:
                - steps
                - request_id
              properties:
                steps:
                  type: array
                  items:
                    $ref: '#/components/schemas/UnsignedStep'
                request_id:
                  type: string
              additionalProperties: false
            source_tx_hash:
              type:
                - string
                - 'null'
              description: >
                The source leg's transaction hash, verbatim. Recorded by `source_tx.set`, or by the server for a
                payout-funding send whose client record never landed; a later `source_tx.set` with the same hash is a
                no-op and a different hash is refused.
            expires_at:
              type: string
              format: date-time
            timeline:
              type: array
              items:
                type: object
                required:
                  - state
                  - occurred_at
                properties:
                  state:
                    type: string
                  occurred_at:
                    type: string
                    format: date-time
                additionalProperties: false
          additionalProperties: false
      unevaluatedProperties: false
    SendIntentCreateRequest:
      type: object
      required:
        - amount
        - destination_chain
        - destination_asset
        - destination_address
      properties:
        amount:
          $ref: '#/components/schemas/MoneyPositive'
        destination_chain:
          type: string
        destination_asset:
          type: string
        destination_address:
          type: string
      additionalProperties: false
    SendIntentSourceTxRequest:
      type: object
      description: >
        Records the transaction hash the holder's wallet just broadcast for the intent's source leg. Call once,
        immediately after signing; resending the same hash is a safe no-op, and a different hash for the same intent is
        refused rather than accepted as a correction.
      required:
        - source_tx_hash
      properties:
        source_tx_hash:
          type: string
      additionalProperties: false
    SendIntentList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/SendIntent'
      unevaluatedProperties: false
    UnsignedCall:
      type: object
      description: >
        A raw, unsigned chain call. The API never signs (RESOURCE-MODEL §0.10) — the holder's own passkey is the only
        signer.
      required:
        - to
        - data
        - value
      properties:
        to:
          type: string
          description: The call's target address.
        data:
          type: string
          description: Hex-encoded calldata.
        value:
          type: string
          pattern: ^[0-9]+$
          description: Minor-unit integer as a string, mirroring `Money.amount`'s representation.
      additionalProperties: false
    UnsignedStep:
      type: object
      description: >
        One step in an ordered, unsigned ceremony. Conversions name `approve` and `swap` (R13-wallet §2 W-G9); a
        resource that needs a different vocabulary documents its own `kind` values at the field that carries it — this
        shape is shared, the vocabulary is not.
      required:
        - kind
        - call
      properties:
        kind:
          type: string
        call:
          $ref: '#/components/schemas/UnsignedCall'
      additionalProperties: false
    OfframpQuoteRequest:
      type: object
      description: Compute-only — nothing is persisted.
      required:
        - destination_currency
        - destination_payment_rail
        - amount
      properties:
        destination_currency:
          type: string
        destination_payment_rail:
          type: string
        amount:
          $ref: '#/components/schemas/MoneyPositive'
      additionalProperties: false
    OfframpQuote:
      type: object
      required:
        - destination_currency
        - destination_payment_rail
        - amount
        - expected_source
      properties:
        destination_currency:
          type: string
        destination_payment_rail:
          type: string
        amount:
          $ref: '#/components/schemas/Money'
        expected_source:
          $ref: '#/components/schemas/Money'
          description: >
            The estimated amount and asset the holder's wallet will send. Indicative, and quantised to 2 decimals —
            `decimals` is `2` here even though USDC itself is a 6-decimal asset, because the quote engine rounds this
            figure to cents before it is published; a finer scale would state precision that was never quoted. The
            binding figure is re-priced at `offramp_intents.create`.
          example:
            amount: '10812'
            currency: usdc
            decimals: 2
      additionalProperties: false
    ExternalAccount:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          description: >
            A masked bank-account destination the holder attested is their own (RESOURCE-MODEL §2.3) — an agent may
            never add one on a user's behalf.
          required:
            - currency
            - rail
            - bank_name
            - last4
          properties:
            id:
              type: string
              pattern: ^ba_
              description: Prefixed id (`ba_`). See RESOURCE-MODEL §0.2.
            object:
              type: string
              enum:
                - external_account
            currency:
              type: string
            rail:
              type: string
              enum:
                - ach
                - wire
                - sepa
                - spei
            bank_name:
              type: string
            last4:
              type: string
              minLength: 1
              description: >
                The last 4 characters of the account/IBAN/CLABE identifier — bare characters, never a masking marker
                such as `••`; a client formats its own display string. Matches `payroll`'s identically-named
                `destination_snapshot.last4`.
          additionalProperties: false
      unevaluatedProperties: false
    ExternalAccountCreateRequest:
      description: >
        Full bank details plus the holder's own-account attestation (RESOURCE-MODEL §2.3), discriminated by `rail` —
        exactly one fully-specified rail shape (R13-wallet §1: account+routing+account_type, IBAN+BIC, or CLABE). REST
        only, never an MCP tool argument — most clients log tool arguments verbatim.
      oneOf:
        - $ref: '#/components/schemas/ExternalAccountCreateRequestAch'
        - $ref: '#/components/schemas/ExternalAccountCreateRequestWire'
        - $ref: '#/components/schemas/ExternalAccountCreateRequestSepa'
        - $ref: '#/components/schemas/ExternalAccountCreateRequestSpei'
      discriminator:
        propertyName: rail
        mapping:
          ach: '#/components/schemas/ExternalAccountCreateRequestAch'
          wire: '#/components/schemas/ExternalAccountCreateRequestWire'
          sepa: '#/components/schemas/ExternalAccountCreateRequestSepa'
          spei: '#/components/schemas/ExternalAccountCreateRequestSpei'
    ExternalAccountCreateRequestAch:
      type: object
      description: '`rail: ach` — US domestic ACH transfer, account plus routing number.'
      required:
        - attest_own_account
        - currency
        - rail
        - account_number
        - routing_number
        - account_type
      properties:
        attest_own_account:
          type: boolean
          enum:
            - true
          description: Must be `true`. Re-verified against the provider again at spend time.
        currency:
          type: string
          enum:
            - usd
        rail:
          type: string
          enum:
            - ach
        bank_name:
          type: string
        account_number:
          type: string
        routing_number:
          type: string
        account_type:
          type: string
          enum:
            - checking
            - savings
      additionalProperties: false
    ExternalAccountCreateRequestWire:
      type: object
      description: '`rail: wire` — domestic or international wire, account plus routing number.'
      required:
        - attest_own_account
        - currency
        - rail
        - account_number
        - routing_number
        - account_type
      properties:
        attest_own_account:
          type: boolean
          enum:
            - true
          description: Must be `true`. Re-verified against the provider again at spend time.
        currency:
          type: string
          enum:
            - usd
        rail:
          type: string
          enum:
            - wire
        bank_name:
          type: string
        account_number:
          type: string
        routing_number:
          type: string
        account_type:
          type: string
          enum:
            - checking
            - savings
      additionalProperties: false
    ExternalAccountCreateRequestSepa:
      type: object
      description: '`rail: sepa` — EUR SEPA transfer, IBAN plus BIC.'
      required:
        - attest_own_account
        - currency
        - rail
        - iban
        - bic
      properties:
        attest_own_account:
          type: boolean
          enum:
            - true
          description: Must be `true`. Re-verified against the provider again at spend time.
        currency:
          type: string
          enum:
            - eur
        rail:
          type: string
          enum:
            - sepa
        bank_name:
          type: string
        iban:
          type: string
        bic:
          type: string
      additionalProperties: false
    ExternalAccountCreateRequestSpei:
      type: object
      description: '`rail: spei` — Mexican SPEI transfer, CLABE.'
      required:
        - attest_own_account
        - currency
        - rail
        - clabe
      properties:
        attest_own_account:
          type: boolean
          enum:
            - true
          description: Must be `true`. Re-verified against the provider again at spend time.
        currency:
          type: string
          enum:
            - mxn
        rail:
          type: string
          enum:
            - spei
        bank_name:
          type: string
        clabe:
          type: string
      additionalProperties: false
    ExternalAccountList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/ExternalAccount'
      unevaluatedProperties: false
    OfframpIntentStatus:
      type: string
      description: |
        11 values, American spelling `canceled`, exact match confirmed by R13-wallet §3 against RESOURCE-MODEL §2.3.
      enum:
        - quoted
        - awaiting_funds
        - in_review
        - funds_received
        - payment_submitted
        - payment_processed
        - refund_in_flight
        - refunded
        - refund_failed
        - canceled
        - failed
    OfframpIntent:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          description: >
            A withdrawal from the holder's wallet to one of their own external bank accounts (RESOURCE-MODEL §2.3). The
            transit deposit into `deposit_instructions` is a direct same-chain Tempo call the holder's own passkey signs
            — never a `/v1` write.
          required:
            - status
            - external_account_id
            - destination_currency
            - destination_payment_rail
            - amount
            - developer_fee_percent
            - deposit_instructions
          properties:
            id:
              type: string
              pattern: ^ofr_
              description: >
                Prefixed id (`ofr_`). See RESOURCE-MODEL §0.2 — R13-wallet §2 W-G13 flags a design fixture using `wof_`,
                which is wrong and must not ship.
            object:
              type: string
              enum:
                - offramp_intent
            status:
              $ref: '#/components/schemas/OfframpIntentStatus'
            external_account_id:
              type: string
              pattern: ^ba_
            destination_currency:
              type: string
            destination_payment_rail:
              type: string
            amount:
              $ref: '#/components/schemas/Money'
            developer_fee_percent:
              type: string
              example: '1.0'
            deposit_instructions:
              $ref: '#/components/schemas/DepositInstructions'
              description: >
                The transit deposit's address, amount and on-chain memo (R13-wallet §2 W-G12) — `chain` is always
                `tempo` and the memo, when the rail needs one, is carried on `memo`. This is a direct same-chain Tempo
                call the holder's own passkey signs; it is never a `/v1` write.
            settled_amount:
              $ref: '#/components/schemas/Money'
              description: >
                The provider-observed amount actually settled to the bank account, in `destination_currency`. Set only
                once the intent reaches `payment_processed` — this is Bridge's flexible-amount off-ramp, so rate
                movement between quote and deposit (or a partial/rounded deposit) can make this genuinely differ from
                `amount`, the originally requested figure. Absent (never a fabricated copy of `amount`) before
                settlement, and on an intent whose provider payload never carried a settlement receipt.
          additionalProperties: false
      unevaluatedProperties: false
    OfframpIntentCreateRequest:
      type: object
      required:
        - external_account_id
        - destination_currency
        - destination_payment_rail
        - amount
      properties:
        external_account_id:
          type: string
          pattern: ^ba_
        destination_currency:
          type: string
        destination_payment_rail:
          type: string
        amount:
          $ref: '#/components/schemas/MoneyPositive'
      additionalProperties: false
    OfframpIntentList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/OfframpIntent'
      unevaluatedProperties: false
    VirtualAccount:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          description: >
            A billable, provider-issued bank-deposit destination (RESOURCE-MODEL §2.3) — never auto-created. `purpose`
            says which product owns it; `settles_to` alone says where deposits land, and any account whose
            `settles_to.kind` is `wallet` (a `collection` account included) credits this wallet. A wallet's bank-details
            screen still lists `purpose=wallet_funding` only. The "in your name" rail guard
            (`docs/wallet/WALLET-CANON.md` §4) is compliance-load-bearing: USD and MXN details are issued in the
            holder's own name, EUR SEPA IBANs are held by the provider's EEA entity and are not —
            `destination.holder_kind` makes that machine-checkable (R13-wallet §2 W-G4).
          required:
            - status
            - purpose
            - settles_to
            - destination
            - source_deposit_instructions
            - developer_fee_percent
          properties:
            id:
              type: string
              pattern: ^va_
              description: Prefixed id (`va_`). See RESOURCE-MODEL §0.2.
            object:
              type: string
              enum:
                - virtual_account
            status:
              type: string
              enum:
                - activated
                - deactivated
            purpose:
              type: string
              x-swaps-open-enum: true
              enum:
                - wallet_funding
                - collection
                - legacy_wallet
              example: wallet_funding
              description: >-
                Which product owns this account. `wallet_funding`: the wallet's own "receive by bank" account.
                `collection`: a pay-in account for Payment links and Buy orders, one per customer, source currency and
                settlement destination — it belongs to Payment links, not to the wallet screen. `legacy_wallet` (W-R25):
                a wallet-purpose account whose stored destination is not this holder's Tempo wallet — an account of the
                wallet's previous settlement destination (e.g. Solana, Ethereum) from before the Tempo cutover, shown
                honestly rather than folded into `collection`. Read-only through `/v1`: the wallet API manages only
                accounts that settle to this holder's Tempo wallet; while `status` is `activated`, deposits still settle
                to `settles_to`. `virtual_accounts.deactivate` / `.reactivate` refuse it with `409 conflict`
                (`legacy_wallet_read_only`). Filter with `GET /v1/wallet/virtual_accounts?purpose=wallet_funding`.
            settles_to:
              description: >-
                Where deposits to this account are credited, read from the account's stored settlement destination.
                `null` when that destination is unknown — never guessed.
              oneOf:
                - $ref: '#/components/schemas/VirtualAccountSettlesTo'
                - type: 'null'
            destination:
              type: object
              required:
                - currency
                - rail
                - payment_rails
                - holder_name
                - holder_kind
                - bank_name
                - masked
                - details
              properties:
                currency:
                  type: string
                rail:
                  type: string
                  x-swaps-open-enum: true
                  enum:
                    - ach
                    - sepa
                    - spei
                    - faster_payments
                    - pix
                    - wire
                    - bre_b
                  description: >-
                    The bank rail this account receives on, one spelling per rail: the provider's own `ach_push` is
                    published as `ach` (the spelling `ExternalAccount.rail` uses); every other value, `wire` included,
                    is published as stored. `source_deposit_instructions.rail` carries the same value. Open enum: a
                    client tolerates a value it does not know — a stored rail outside this list is published as stored,
                    never mapped onto one of these.
                payment_rails:
                  type:
                    - array
                    - 'null'
                  items:
                    type: string
                    x-swaps-open-enum: true
                    enum:
                      - ach
                      - sepa
                      - spei
                      - faster_payments
                      - pix
                      - wire
                      - bre_b
                      - fednow
                  description: >-
                    Every bank rail this account accepts, as the provider listed them when it issued the account (a USD
                    account can list `ach`, `fednow` and `wire`). Each value is spelled like `rail` (the provider's
                    `ach_push` is `ach`), in the stored order, without duplicates. `rail` is unchanged and still names
                    one rail. `null` when Swaps holds no list for the account (never guessed from the currency): read
                    `null` as unknown, not as `rail` alone. Open enum: a client tolerates a value it does not know.
                holder_name:
                  type: string
                holder_kind:
                  type: string
                  enum:
                    - own_name
                    - provider_name
                    - developer_name
                  description: >-
                    Per record, read from the provider's beneficiary name — never derived from the currency. `own_name`:
                    the holder's own name (USD, MXN and, since 2026-09-02, EUR). `provider_name`: the cached beneficiary
                    is still the provider's entity (legacy EUR accounts until Bridge's propagation completes; deposits
                    addressed to it are accepted until about 2026-10-02). `developer_name`: memo-based transfer onramp
                    accounts, which carry the developer's name ("Swaps"). GBP `faster_payments` transfer on-ramps are
                    the exception among memo-based accounts: Bridge returns no holder name, its risk engine returns any
                    deposit whose beneficiary is not the customer's registered name, so Swaps supplies that name and it
                    reads `own_name`.

                    '
                bank_name:
                  type: string
                masked:
                  type: object
                  description: >
                    Masked display fields for this rail (e.g. IBAN tail, account tail, routing/BIC) — shown, never
                    enough on its own to originate a transfer.
                  additionalProperties:
                    type: string
                details:
                  type: object
                  description: The full named field set for this rail (e.g. IBAN, BIC, account, routing).
                  additionalProperties:
                    type: string
              additionalProperties: false
            source_deposit_instructions:
              $ref: '#/components/schemas/BankDepositInstructions'
              description: >-
                Provider-issued instructions for funding this VA from an external source. Masked on read where the canon
                requires it.
            developer_fee_percent:
              type: string
              example: '0.0'
              description: Zero on receive-by-bank today — the holder-identity distinction is the load-bearing fact, not price.
            provider_environment:
              type:
                - string
                - 'null'
              enum:
                - sandbox
                - production
                - null
              description: >-
                The provider environment this account was issued in, read only from Swaps' stored record of the account
                — never from the caller's `livemode` or from the server's configuration. `sandbox`: a provider test
                account — bank details are test data and no real bank transfer arrives. `production`: a live provider
                account. `null`: not recorded — never evidence of a live account. Every live account today answers
                `null`, and so, in the provider sandbox, does any account not created through `POST
                /v1/wallet/virtual_accounts` (for example a Payment links collection account, or one created by the
                dashboard's own action or found by a provider sync) and any account created before 2026-09-29. Only
                `sandbox` marks a test account: show a test-mode marker on the bank details for `sandbox` and for
                nothing else.
            events:
              type: array
              description: Redacted event history for this VA.
              items:
                type: object
                properties:
                  type:
                    type: string
                  created_at:
                    type: string
                    format: date-time
                additionalProperties: true
          additionalProperties: false
      unevaluatedProperties: false
    VirtualAccountSettlesTo:
      type: object
      description: >-
        The settlement destination of a virtual account. `kind: wallet` only when the destination is an active wallet of
        this holder on the network the destination settles on (Tempo mainnet for a live account); any other on-chain
        destination is `address`; a fiat destination rail is `bank`. `masked_address` shows the first six and last four
        characters, case preserved. `address` is the full address, for a live session whose role on the account is
        `owner` or `admin` only.
      required:
        - kind
        - label
      properties:
        kind:
          type: string
          x-swaps-open-enum: true
          enum:
            - wallet
            - address
            - bank
        label:
          type: string
          description: Display label, e.g. `Swaps Wallet`, `USDC on Ethereum`, `Bank account (SEPA)`.
        network:
          type: string
          description: The destination network for `wallet` and `address` kinds, e.g. `tempo`, `ethereum`.
        asset:
          type: string
          description: >-
            The destination asset ticker (e.g. `USDC`), for `kind: address` — read from the account's stored destination
            currency (W-R25: makes a `legacy_wallet` account's destination machine-readable alongside `network` and
            `masked_address`, not just the `label` text). Absent when the stored currency is unknown.
        masked_address:
          type: string
          description: The destination address, masked. Absent for `bank`.
        address:
          type: string
          description: >-
            The full settlement address. Present only for a live session (bearer) whose role on the account is `owner`
            or `admin`, which are the only callers these operations admit (a business key is refused with `403
            scope_denied`). Until per-client binding (K12) exists, a session token presented by any client counts as
            that session. Exactly as stored, case preserved (Solana, Tron and BTC addresses are case-sensitive). Absent
            for `bank`.
      additionalProperties: false
      examples:
        - kind: wallet
          label: Swaps Wallet
          network: tempo
          masked_address: 0x1a2B…9fE0
        - kind: address
          label: USDC on Ethereum
          network: ethereum
          asset: USDC
          masked_address: 0x7cD4…31aB
        - kind: address
          label: USDC on Ethereum
          network: ethereum
          asset: USDC
          masked_address: 0x7cD4…31aB
          address: '0x7cD4a3b5E1f09c2D8e6A4B7F1c3E5a9D000031aB'
        - kind: bank
          label: Bank account (SEPA)
    VirtualAccountCreateRequest:
      type: object
      description: >
        An explicit confirm — a VA is a billable provider object and is never auto-created (RESOURCE-MODEL §2.3
        invariants; R13-wallet `layer-w-va-create`).
      required:
        - currency
      properties:
        currency:
          type: string
          description: '`usd`, `eur` or `mxn` (case-insensitive) — the matching `wallet_bank_virtual_account` corridor.'
      additionalProperties: false
    VirtualAccountList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          required:
            - data
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/VirtualAccount'
      unevaluatedProperties: false
    VirtualAccountHistoryList:
      allOf:
        - $ref: '#/components/schemas/ListMeta'
        - type: object
          description: >
            "Money in" rows for one VA — a pull read over `virtual_account.deposit_received` (RESOURCE-MODEL §3), itself
            redacted; the real push producer is the Bridge webhook.
          required:
            - data
          properties:
            data:
              type: array
              items:
                type: object
                required:
                  - type
                  - amount
                  - occurred_at
                properties:
                  type:
                    type: string
                    enum:
                      - deposit_received
                  amount:
                    $ref: '#/components/schemas/Money'
                  occurred_at:
                    type: string
                    format: date-time
                additionalProperties: false
      unevaluatedProperties: false
    ConversionTrackingStatus:
      type: string
      description: >
        The tracked leg's status (RESOURCE-MODEL §2.3). A `kind: dex` conversion — the only kind this API builds (D-25:
        G cut the proposed `kind: cex` branch, 2026-09-08, Exolix removed) — reaches only `pending` (built, unsigned)
        and `submitted` (its `POST .../source_tx` recorded and on-chain-verified the swap's own broadcast transaction);
        `settled`/`failed` are not yet advanced automatically past that point.
      enum:
        - pending
        - submitted
        - settled
        - failed
    Conversion:
      allOf:
        - $ref: '#/components/schemas/ObjectBase'
        - type: object
          description: >
            Builds a same-chain conversion (RESOURCE-MODEL §2.3, v2 amendments; D-25). `kind: dex` builds an unsigned
            swap on Tempo — the API never signs: `steps[]` are unsigned calls the holder's own passkey signs. D-25's
            proposed `kind: cex` no-wallet exchange leg (deposit-address branch) is withdrawn — G cut it 2026-09-08
            (Exolix removed; Swap stays wallet-signed OKX DEX) — so `conversions` never carries a CEX projection.

            A1-6: `id` reserves the `cnv_` prefix (RESOURCE-MODEL §0.2) — this was the one `available` operation family
            with no prefixed resource id and no `livemode`. `tracking_id` is kept as an additive alias of the SAME
            identity for one version (`id` minus its `cnv_` prefix): `claude/dashboard-v2`'s already-built Convert flow
            (`WalletConvertScreen.tsx`, `useWalletConversions.ts`) reads `tracking_id` and passes it straight back as
            `GET/POST /v1/wallet/conversions/{id}(/source_tx)`'s path parameter, which still accepts the bare value (see
            that parameter's own description) — do not remove `tracking_id` before that caller migrates to `id`.
          required:
            - kind
            - provider_id
            - chain_id
            - from_token
            - to_token
            - amount_in
            - amount_out
            - min_amount_out
            - rate
            - slippage_bps
            - tracking_id
          properties:
            id:
              type: string
              pattern: ^cnv_
              description: Prefixed id (`cnv_`). See RESOURCE-MODEL §0.2. Equal to `tracking_id` with that prefix added.
            object:
              type: string
              enum:
                - conversion
            kind:
              type: string
              enum:
                - dex
              description: >
                Always `dex` — on-chain, returns `steps[]` for the holder's passkey to sign. The proposed `cex`
                (no-wallet exchange leg) branch was withdrawn by G on 2026-09-08 (Exolix removed); this field is kept,
                pinned to `dex`, so a future rail can extend the enum without a breaking projection change.
            provider_id:
              type: string
            chain_id:
              type: string
            from_token:
              type: string
            to_token:
              type: string
            amount_in:
              $ref: '#/components/schemas/Money'
            amount_out:
              $ref: '#/components/schemas/Money'
              description: The quoted receive amount, before the slippage bound (R13-wallet §2 W-G9).
            min_amount_out:
              $ref: '#/components/schemas/Money'
            rate:
              type: string
              description: Decimal string, never a float (R13-wallet §2 W-G9). Example `"0.9998"`.
            slippage_bps:
              type: integer
              minimum: 0
            steps:
              type: array
              description: >
                Ordered, unsigned — a two-step ceremony (`approve` then `swap`) is two entries, not one `call{}` —
                structurally explicit rather than inferred from calling `create` twice (R13-wallet §2 W-G9).
              items:
                $ref: '#/components/schemas/UnsignedStep'
            tracking_id:
              type: string
              description: >
                A1-6: additive alias of `id` (see this schema's own description) — kept for one version for
                `claude/dashboard-v2`'s existing caller. New callers should prefer `id`.
            tracking_status:
              $ref: '#/components/schemas/ConversionTrackingStatus'
          additionalProperties: false
      unevaluatedProperties: false
    ConversionCreateRequest:
      type: object
      required:
        - from_token
        - to_token
        - amount_in
      properties:
        chain_id:
          type: string
        from_token:
          type: string
        to_token:
          type: string
        amount_in:
          $ref: '#/components/schemas/MoneyPositive'
        slippage_bps:
          type: integer
          minimum: 0
      additionalProperties: false
    ConversionSourceTxRequest:
      type: object
      description: >
        Records the transaction hash the holder's wallet just broadcast for the swap step. Call once, immediately after
        signing; resending the same hash is a safe no-op, and a different hash for the same conversion is refused rather
        than accepted as a correction.
      required:
        - source_tx_hash
      properties:
        source_tx_hash:
          type: string
      additionalProperties: false
    ConversionPair:
      type: object
      description: Dark-flag (R13-wallet §2 W-G10) — the verified convertible token set with a liquidity flag.
      required:
        - from_token
        - to_token
        - has_liquidity
      properties:
        from_token:
          type: string
        to_token:
          type: string
        has_liquidity:
          type: boolean
      additionalProperties: false
    ConversionPairList:
      type: object
      description: >
        A1-6: a plain `{data}` catalogue, not a cursor list, matching DepositRouteList — the operation declares no
        `limit`/`cursor` and previously composed `ListMeta`, publishing `has_more`/`next_cursor` the handler always
        hardcoded `false`/`null` with no way to ever page.
      required:
        - data
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ConversionPair'
      additionalProperties: false
  responses:
    BadRequest:
      description: The request is malformed or fails validation — fix the argument and retry.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: No valid credential — re-authorize; do not retry as-is.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: >-
        Wrong actor or missing scope. Three codes reachable from every operation on this surface: `role_denied` (the
        credential's account role, not its scope), `account_inactive` (the account itself is restricted/suspended),
        `ip_not_allowed`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: The id is not visible to this caller — also the answer for another account's object.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: >-
        State moved, the idempotency key was reused with a different body, or this corridor cannot serve the request
        (`capability_unavailable` — do not retry).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UnprocessableEntity:
      description: >-
        The request is well-formed but cannot be carried out as the resource stands (`invalid_request`) — fix the state
        (not just the argument shape) and retry.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PaymentRequired:
      description: An allowance is spent (`quota_exhausted`) — backing off does not restore it.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PayloadTooLarge:
      description: >-
        The request body is over the 256 KiB cap (`payload_too_large`); no operation on this surface has a legitimate
        body that large.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    TooManyRequests:
      description: Per-window throttling (`rate_limit_exceeded`), reachable from every operation; `Retry-After` is set.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ServiceUnavailable:
      description: A kill switch is thrown or a dependency is out (`temporarily_unavailable`); retry after `Retry-After`.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalError:
      description: >-
        Something failed on our side (`api_error`/`internal_error`), including a response that failed our own contract
        validation. Retrying will not help — quote the `request_id` in a support ticket.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
x-swaps-auth-classes:
  description: |
    Corrected 2026-09-16 (review P1-S2, decision C4-D32): this used to name three formal auth
    classes, `businessKey` and `agentOAuth` as security schemes plus the public capability token.
    `agentOAuth` advertised a discoverable OAuth 2.1 authorization server that did not exist (its
    `authorizationUrl` resolved to the marketing SPA's catch-all route) — withdrawn, not built; see
    `docs/canon/API-CANON.md` §8. `businessKey` is the security scheme on every operation an agent or
    business integration can call — every `/v1` operation except the small `dashboardSession`-only
    set below. `dashboardSession` is the dashboard's own GoTrue session token: never a portable
    credential an external API or MCP caller can obtain or present, scoped to account self-service and
    API-key lifecycle operations a business key must structurally never reach (D-109, K1 — no single
    "the account" a business key could mean once it has more than one member; a key must never mint or
    revoke itself). The third class, the **public capability token**, is not an identity at all: it is
    embedded in the path of a payer-facing resource (`/payment_sessions/{token}`,
    `/payroll_recipient_sessions/{token}`), resolves exactly one object's payer projection, and those
    routes are dispatched before any auth resolution — they declare `security: []` and
    `x-swaps-caller: [public_token]`. Neither `businessKey` nor `dashboardSession` is structurally able
    to reach them.
x-swaps-extensions:
  description: >
    Vendor extensions used throughout this document.

    `x-swaps-status`: `available` | `dark-flag` | `proposed` — per operation (RESOURCE-MODEL §1 vocabulary).

    `x-swaps-money-boundary: true` — per-transaction human confirmation required; excluded from MCP.

    `x-swaps-noncustodial: unsigned_steps` — the response carries calls the holder's passkey signs; the API never signs.

    `x-swaps-caller`: which auth classes may call — `business_key`, `agent`, `public_token`, `dashboard_session`.

    `x-swaps-test-mode`: `full` | `sandbox` | `fixtures` | `dev-cron` | `unavailable` — how the operation behaves under

    `sk_test_` (`unavailable`: the operation 503s honestly rather than faking a test-mode result — e.g. `orders.create`,

    BL-47).

    `x-mcp`: `{tool: <name>, description: <source text>}` — exactly one per operation; marks the operations an
    intent-shaped MCP tool is curated from.

    `x-swaps-compute-only: true` — a POST with no side effect ANYWHERE, including at a provider (e.g. a price preview

    that mints nothing and persists nothing); no `Idempotency-Key` is required. Not the same as "persists no row in

    our own database" — an operation that computes but still allocates a live resource at a provider (see

    `wallet.deposit_quotes.create`) keeps the header even though it writes nothing here.

    `x-swaps-open-enum: true` (A1-2) — on a response-side enum member (a `string` schema's `enum:`), marks it

    growth-prone: the SDK generator widens it to `known-literal | (string & {})` and a client must not fail

    deserialization on an unrecognized member. Never set on a request-side enum, which stays closed by design (see

    `info.description` above).

    `x-swaps-event-status` (A1-9) — on `EventType`/`EventTypeOpen`, a map of every member to `live` (written to the
    `api_events` outbox) or `catalogued` (not in the outbox: never delivered or listed by `/v1/events`, but a
    per-resource event list may show it). Generated from

    `EVENT_PAYLOAD_ALLOWLIST` by `scripts/openapi/normalize.mjs`; never hand-edited. The `webhooks` section declares

    one delivery per event family, each carrying `WebhookEvent`.

    `x-swaps-response-tolerant` (A1-SDK-TOLERANT fixer round 1, finding #3) — not a per-schema tag; a document-wide

    rule, see `info.description` above: `additionalProperties: false`/`unevaluatedProperties: false` on any

    response-reachable schema is advisory, never grounds for a client to fail deserialization on an unknown key.
