# 8x Payout Gateway — public API contract (OpenAPI 3.1).
#
# This file is the CANONICAL, published contract external customers integrate
# against. It MUST stay byte-accurate to the live `/api/v1` routes in
# apps/gateway/app/api/v1/**. Schema names mirror `@8x-payout/shared`
# (packages/shared/src/index.ts). When a route, shape, scope, error, or webhook
# changes, this file changes in the same commit (see the repo CLAUDE.md rule
# "The public API is a CONTRACT"). Validate with `pnpm docs:lint`.
openapi: 3.1.0

info:
  title: 8x Payout Gateway API
  version: 1.2.0
  summary: Execution-only, custody-free, multi-rail payouts to humans.
  description: |
    The **8x Payout Gateway** moves money to people across global rails. You
    integrate it the way you'd integrate Stripe: **create a payee → send them to a
    hosted onboarding link → get a `payee.updated` webhook when they're payable →
    call `POST /payouts`**.

    ### What the gateway is (and is not)
    - **Execution-only & custody-free** — it owns payout *identity*, multi-rail
      *routing*, hosted *onboarding*, *execution*, and the *transaction log*. For
      direct `POST /payouts` the gateway holds no balance and tracks no owed ledger —
      you decide who and how much, exactly as with Stripe. It also offers an optional
      owed/IOU ledger (`POST /disbursements` + `withdraw`) for a pull/wallet model:
      YOU decide what is earned and push the owed rows; the gateway then holds them as
      `owed` until an explicit withdraw. It never independently judges or computes
      what a payee is owed — your `externalRef` stays the source-of-truth ledger id.
    - **Funding is rail/platform funding, never a customer balance.** Some rails
      require the *company operating the gateway* to fund a platform account or
      rail wallet (Stripe platform balance, Tipalti payer balance) before payouts
      settle. Prepaid clients may also fund a **wallet**: an append-only record of
      money received from you, whose available balance is *computed* — deposits
      minus in-flight/paid payouts — and gates release (`held` with
      `insufficient_funds` when short). There is no stored balance, no interest,
      no customer money-holding product. Underfunding yields a retryable failure
      or a held payout — never a silent re-route.
    - **The caller decides WHO and HOW MUCH; the gateway decides WHERE.** A payout
      request carries `payeeId` + `amountCents` — **never** a bank account or
      destination. Money can only land on the payee's KYC-locked method, resolved
      server-side. No single compromised surface can both inflate an amount *and*
      redirect it.

    ### Authentication (every request)
    Every request is **authenticated** (which client), **signed** (tamper- and
    replay-proof), and **authorized** (scope + ownership). Send three things:

    - `Authorization: Bearer <API key>` — identifies your client. The gateway
      stores only `sha256(key)`.
    - `x-8x-timestamp` — Unix seconds when you signed the request.
    - `x-8x-signature` — `HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}")`,
      hex-encoded. For a `GET` the body is empty, so you sign `"{timestamp}."`.

    The timestamp must be within a **5-minute** window (300s) of the gateway clock,
    or the request is rejected as stale. **Mutations** (`POST`) are additionally
    **replay-protected**: a duplicate signature inside the window is rejected. Idempotent
    `GET`s are not replay-checked (so a polling client is never spuriously 401'd).

    The official `@8x-payout/sdk` `PayoutClient` performs the signing for you — see
    the [Quickstart](/docs/quickstart).

    ### Authorization (scopes)
    Each client carries a set of scopes; every route asserts the scope it needs and
    **fails closed** (`403`) if it's missing. A read-only integration can be issued
    a key whose client holds only the `*:read` scopes — it cannot move money.

    | Scope | Grants |
    | --- | --- |
    | `payouts:write` | `POST /payouts`, `POST /payouts/batch` †, `POST /payouts/{id}/retry`, `POST /payouts/{id}/release` |
    | `payouts:read` | `GET /payouts`, `GET /payouts/{id}` |
    | `payees:write` | `POST /payees`, `POST /payees/{id}/methods` |
    | `payees:read` | `GET /payees`, `GET /payees/{id}`, `GET /payees/{id}/readiness`, `GET /payees/{id}/balance` |
    | `onboarding:write` | `POST /payees/{id}/onboarding-link` |
    | `portal:write` | `POST /payees/{id}/portal-link` |
    | `disbursements:write` | `POST /disbursements`, `POST /disbursements/recovery-link`, `POST /payees/{id}/withdraw` |

    † Batch items that resolve-or-create a payee by email additionally require `payees:write`.

    ### Tenant isolation
    A client can only see and act on its **own** payees and transactions. A payee or
    transaction owned by another tenant is reported as `404` (existence never leaks
    across tenants).

    ### Test mode (no real money)
    Test mode is a property of **your client**, not a separate host — the base URL is
    the same. A client in `test` mode routes **every** payout and onboarding through
    the **mock** provider (settles synchronously, no real KYC, no real money), so you
    can run this entire contract end to end before going live. Flip to `live` when
    ready. See the [Quickstart](/docs/quickstart) for a zero-to-first-payout walkthrough.

    ### Money & values
    - Money is **always integer cents** (`amountCents`). The gateway converts to a
      rail's units at the execution seam, never on the wire.
    - `currency` is **ISO 4217** uppercase (e.g. `USD`).
    - Timestamps are **ISO 8601** strings (UTC).

    ### Error & held-reason catalog
    For a `held` or `failed` payout, `statusReason` (on `GET /payouts/{id}`) leads with
    a stable snake_case code from the closed set below. This same catalog is the
    authoritative source for the `statusReason` token and the stable `error` code; the
    withdraw, batch, and disbursement per-row error enums reference it.

    | code | trigger | kind | self-releases? | remediation |
    |---|---|---|---|---|
    | `missing_email` | payee has no email | held | no | upsert payee with email, then release |
    | `missing_name` | non-test payee has no legal name | held | no | supply payee name, then release |
    | `not_payable` | resolved method not payable | held | no | complete onboarding/KYC |
    | `rail_not_allowed` | rail outside client allow-list | held | no | contact 8x to widen allow-list |
    | `cap_exceeded_daily` | daily cap hit | held | on window reset | wait for the daily window, then release |
    | `cap_exceeded_single` | single-payout cap hit | held | no | split the payout or request a higher cap |
    | `insufficient_funds` | rail/wallet underfunded | held | on funding | fund the rail/wallet, then release |
    | `below_min_payout` | amount under economical floor | held | **yes** — accumulated-floor sweep | wait for accumulation, or force-release (bypasses floor) |
    | `no_rail` | no supported rail for country | held | no | fix country / choose a configured rail |
    | `route_blocked` | routing explicitly blocked | held | no | choose an allowed rail |
    | `missing_country` | no country on payee | held | no | set payee country |
    | `provider_not_supported` | provider can't serve country | held | no | choose a supported rail |
    | `provider_not_configured` | provider not wired for deployment | held | no | contact 8x |
    | `country_not_supported_by_provider` | country/provider mismatch | held | no | choose a supported rail |
    | `unsupported_country` | legacy: routing no longer produces it since Grade became the default rail; may remain on older held payouts | held | no | release |
    | `provider_<name>_not_implemented` | routed rail not yet wired | failed (permanent, 400) | no | contact 8x |
    | `authorized_payout_missing_provider` | authorized payout lost its provider | failed | no | contact 8x |

    `below_min_payout` is a **benign, self-releasing** hold (an accumulated-floor sweep
    releases it once enough accrues).

    ### Payout status lifecycle
    `held` is **caller-remediable** — not a dead end. Read `statusReason`, clear the
    blocker (see the catalog above), then `POST /payouts/{id}/release`.

    | status | meaning | terminal? | what you do | endpoint |
    |---|---|---|---|---|
    | `requested` | accepted, not yet routed | no | wait | — |
    | `authorized` | routed, payee payable | no | wait | — |
    | `submitted` | sent to the provider, awaiting result | no | wait (do NOT retry) | — |
    | `held` | blocked by a statusReason | no | read statusReason, clear the blocker, release | `POST /payouts/{id}/release` |
    | `failed` | attempt failed | no (transient) / yes (permanent) | retry if failureKind=transient | `POST /payouts/{id}/retry` |
    | `paid` | funds released to rail | terminal | key off `settledAt` for bank arrival | — |
    | `returned` | bounced after settlement | terminal | reverse in your ledger; read `restoredCents` | — |

    Legal transitions: `requested→authorized→submitted→paid`;
    `held→(release)` and `failed→(retry)` re-enter the flow; `paid→returned`.

    ### Status versions
    Your client's status version picks the words a payout's `status` uses on the
    `payout.status` webhook and on every payout response. The webhook and REST are
    versioned together. Version 1 is the default and uses the lifecycle above. Version 2
    is opt-in per client and set by 8x ops. It uses `owed`, `sent`, `awaiting_withdraw`,
    `paid`, `failed`, `returned`, `held` (see the `PayoutStatusV2` schema). `requested`
    and `authorized` become `sent`. `submitted` becomes `sent` on custodial rails
    (`stripe`, `tipalti`, and `mock` in test mode) and `awaiting_withdraw` on custody-free
    rails (`grade`, `sideshift`); a Grade payout reads `sent` once the creator has claimed
    it, a SideShift payout stays `awaiting_withdraw` until it settles as `paid`. The other
    words stay the same. `owed` never
    appears on a payout today because it lives on disbursements. The `status` filter
    on `GET /payouts` takes version 1 words only.

    ### Rate limit
    600 requests per minute per client, fixed 60-second window (configurable per
    deployment). No `Retry-After` header is returned — back off to the next minute
    boundary.

    ### Versioning
    `/api/v1` is stable. Within a version, changes are additive-only; anything breaking
    ships under a new version path. Deprecations are announced before removal.
  contact:
    name: 8x Payout Gateway
    url: https://8x-payout.com/docs

servers:
  - url: https://8x-payout.com
    description: Production gateway. Your exact base URL is provided when your client is provisioned.
  - url: http://localhost:3007
    description: Local development (the gateway's default dev port).

externalDocs:
  description: Overview & architecture, quickstart, webhooks guide, and changelog
  url: https://8x-payout.com/docs/overview

security:
  - bearerAuth: []
    x8xTimestamp: []
    x8xSignature: []

tags:
  - name: Payees
    description: Onboard people, fetch their payout identity, and check whether they can be paid.
  - name: Payouts
    description: Create idempotent payouts and read their status.
  - name: Disbursements
    description: Push owed (IOU) rows programmatically — the API twin of the client-portal CSV upload.

paths:
  /api/v1/disbursements:
    post:
      tags: [Disbursements]
      operationId: pushDisbursements
      summary: Push owed (IOU) rows
      description: |
        Push owed (IOU) rows programmatically — the API twin of the client-portal
        CSV upload. Each item identifies the payee by EXACTLY ONE of `payeeId` (one
        you own) or `email` (resolve-or-create, mirroring `BatchPayoutItem`), plus an
        `amountCents` and YOUR stable `externalRef` (e.g. your ledger row id).

        The response is **`207 Multi-Status`** with one entry per row, in request
        order: `status: ok` carries the created (or replayed) `disbursement`,
        `status: error` carries a stable `error` code from the closed per-row enum on
        `DisbursementPushResultItem.error`. A failing row **never** aborts the others.

        **Idempotent per row** on `(yourClient, externalRef)` — pushing the same
        `externalRef` again always returns the SAME owed row (`idempotentReplay:
        true`) rather than double-crediting, regardless of its current status, and a
        replay never re-touches payee data. Two rows in the SAME call sharing an
        `externalRef` is always a caller bug (an owed row's ledger id is unique by
        definition) and is rejected as a whole-request `400 duplicate_external_ref`
        before any row is processed.

        Set `sendEmail: true` to send the "you have money waiting" email — sent only
        for rows that were newly **created** in this call, after the batch commits.

        Unlike `POST /payouts/batch`, the email resolve-or-create path here is authorized
        by `disbursements:write` alone — no separate `payees:write` is required.

        **Required scope:** `disbursements:write`.
      x-required-scope: disbursements:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DisbursementPushRequest'
            examples:
              mixed:
                summary: One existing payee + one by email, notifying both
                value:
                  sendEmail: true
                  items:
                    - payeeId: 7d6f4c2e-0b1a-4e2c-9f3d-1a2b3c4d5e6f
                      amountCents: 5000
                      currency: USD
                      externalRef: ledger-row-901
                    - email: creator@example.com
                      amountCents: 3200
                      currency: USD
                      externalRef: ledger-row-902
                      name: Jamie Creator
      responses:
        '207':
          description: |
            Multi-status — the push was processed row by row. Inspect each entry's
            `status` (`ok` / `error`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DisbursementPushResponse'
        '400':
          description: |
            The body failed validation (`invalid_request`, with field `details`), or
            two rows in this call share the same `externalRef` (`duplicate_external_ref`,
            with the colliding row `indices` in `details`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_request:
                  value: { error: invalid_request }
                duplicate_external_ref:
                  summary: Two rows share the same externalRef (the colliding row indices are returned in `details`)
                  value: { error: duplicate_external_ref, details: { indices: [1] } }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/disbursements/recovery-link:
    post:
      tags: [Disbursements]
      operationId: linkRecoveryPayout
      summary: Link released disbursements to their replacement payout
      description: |
        Attach several already-released owed rows to ONE already-submitted replacement
        payout. A **ledger repair only**: it never creates, retries, or calls a payout
        provider — it repairs the pre-consolidation fan-out shape, where a withdrawal
        released N disbursements as N one-row payouts and a single replacement payout
        was later submitted in their place.

        **Idempotent** — re-sending the same `(replacementTransactionId,
        disbursementIds)` returns `idempotentReplay: true` and links nothing twice.
        The replacement's amount must equal the sum of the linked rows, and every row
        must belong to the replacement's payee, or the call is rejected whole.

        **Required scope:** `disbursements:write`.
      x-required-scope: disbursements:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecoveryLinkRequest'
            examples:
              link:
                summary: Link two legacy released rows to their one replacement payout
                value:
                  replacementTransactionId: f081cbf4-795d-4363-b252-481cea13c38a
                  disbursementIds:
                    - 6199a1eb-fa65-4ae3-80fe-1fd022dc65dd
                    - 069cf136-3dc8-49e7-b678-d707df16d57a
      responses:
        '200':
          description: 'The rows were linked (or were already linked — `idempotentReplay: true`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecoveryLinkResponse'
        '400':
          description: |
            Envelope failures (missing/mis-typed `disbursementIds`, fewer than 1 or more
            than 500 ids, a non-UUID) return `invalid_request`; a duplicate UUID within
            `disbursementIds` returns `invalid_disbursement_ids`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            The replacement transaction (`replacement_transaction_not_found`) or one of
            the disbursements (`disbursement_not_found`) does not exist or isn't yours.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: |
            The link is not legal in the current state — the replacement isn't active
            (`replacement_transaction_not_active`), a row belongs to another payee or
            payout (`recovery_link_mismatch`), a row is already linked to a different
            payout OR a concurrent link caused the final linked count to diverge from the
            request (`recovery_link_conflict`), the amounts don't add up
            (`recovery_link_amount_mismatch`), a superseded payout isn't failed
            (`superseded_transaction_not_failed`), or a withdrawal is in flight for that
            payee (`withdrawal_in_progress`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payees:
    post:
      tags: [Payees]
      operationId: upsertPayee
      summary: Create or update a payee
      description: |
        Onboard (or idempotently re-fetch) one of your people. Keyed on
        `(yourClient, externalId)` — re-sending the same `externalId` updates the
        existing payee rather than creating a duplicate. Send `email` so the payee
        can be matched in the hosted payee portal.

        **Required scope:** `payees:write`.
      x-required-scope: payees:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertPayeeRequest'
            examples:
              create:
                summary: Onboard a new payee
                value:
                  externalId: creator_8842
                  email: creator@example.com
                  country: Türkiye
      responses:
        '200':
          description: The created or updated payee.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payee'
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      tags: [Payees]
      operationId: listPayees
      summary: List your payees
      description: |
        List this client's payees, newest first, with cursor pagination. Filter by
        `kyc` status and/or `payable`.

        **Required scope:** `payees:read`.
      x-required-scope: payees:read
      parameters:
        - $ref: '#/components/parameters/limitParam'
        - $ref: '#/components/parameters/cursorParam'
        - name: kyc
          in: query
          description: Filter by KYC status.
          required: false
          schema:
            $ref: '#/components/schemas/KycStatus'
        - name: payable
          in: query
          description: |
            Filter by payability. Only the literal values `true` and `false` are
            recognized; any other value is silently ignored (no filter applied).
          required: false
          schema:
            type: boolean
      responses:
        '200':
          description: A page of payees.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayeeList'
        '400':
          description: |
            A query parameter was invalid: `invalid_kyc` (unknown `kyc` value) or
            `invalid_cursor` (un-parseable `cursor`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_kyc:
                  value: { error: invalid_kyc }
                invalid_cursor:
                  value: { error: invalid_cursor }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payees/{id}:
    parameters:
      - $ref: '#/components/parameters/payeeId'
    get:
      tags: [Payees]
      operationId: getPayee
      summary: Get one payee
      description: |
        Fetch a single payee by gateway id. A payee owned by another client is
        reported as `404` (existence never leaks across tenants).

        **Required scope:** `payees:read`.
      x-required-scope: payees:read
      responses:
        '200':
          description: The payee.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payee'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PayeeNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payees/{id}/methods:
    parameters:
      - $ref: '#/components/parameters/payeeId'
    post:
      tags: [Payees]
      operationId: adoptPayeeMethod
      summary: Adopt an existing provider account as a payout method
      description: |
        Attach an **already-KYC'd** provider account — a Stripe Connect `acct_…` on
        the gateway's own platform, or a Tipalti payee id under the gateway's payer —
        as this payee's payout method, bypassing hosted onboarding entirely. Use this
        when you're migrating a creator who is already verified on the rail directly.

        This is the gateway's trust boundary: the caller's payability claim is
        **never** believed. The gateway reads the account **live** from the rail
        (Stripe: `charges_enabled && payouts_enabled`; Tipalti: payable status) and
        sets `payable`/`onboardingStatus` from what the provider actually reports.

        One provider account can only ever fund **one** payee. An id already linked
        to a **different** payee is rejected `409` (without revealing who holds it).
        Re-adopting the SAME `(payee, provider, providerAccountId)` is a safe,
        idempotent replay. An account the rail can't confirm (nonexistent, on a
        foreign platform, or a failed live call) is rejected `422`. On a payable
        adoption the payee flips payable/verified and the same `payee.updated`
        webhook a normal onboarding completion fires is enqueued.

        **Required scope:** `payees:write`.
      x-required-scope: payees:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdoptMethodRequest'
            examples:
              stripe:
                summary: Adopt an existing Stripe Connect account
                value:
                  provider: stripe
                  providerAccountId: acct_1P2q3R4s5T6u7V8w
      responses:
        '200':
          description: The adopted method and the payee's fresh state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdoptMethodResponse'
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PayeeNotFound'
        '409':
          $ref: '#/components/responses/ProviderAccountAlreadyLinked'
        '422':
          $ref: '#/components/responses/ProviderAccountUnverifiable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payees/{id}/onboarding-link:
    parameters:
      - $ref: '#/components/parameters/payeeId'
    post:
      tags: [Payees]
      operationId: createOnboardingLink
      summary: Create a hosted onboarding link
      description: |
        Get a **gateway-hosted, rail-agnostic** onboarding URL for a payee — like a
        Stripe account link. Redirect your (already-authenticated) creator to the
        returned `url`, or embed it as an iframe; they need **no separate gateway
        login**. The URL points at the gateway's own `/onboard` page carrying a
        short-lived signed token — never at Tipalti/Stripe directly — and the gateway
        renders the right rail behind it.

        `returnUrl` is where the creator is sent when they finish. It **must** be an
        absolute `https` URL whose host is either your **registered webhook domain**
        (the host of your configured webhook URL) or the gateway's own origin;
        anything else is rejected at request time with `400 invalid_return_url`
        (open-redirect protection). Localhost over `http` is allowed in non-production
        environments only. When the payee becomes payable, the gateway fires a
        [`payee.updated`](#tag/webhooks) webhook.

        The onboarding experience depends on the payee's resolved rail. Custodial rails
        (Stripe, Tipalti) run hosted KYC and payout-method setup up front — the payee
        becomes payable only after they finish, at which point `payee.updated` fires.
        Custody-free rails (Grade, SideShift) are payable-by-design at account creation;
        KYC and method selection happen inline at claim/withdraw time via the claim link
        the payout produces, so there is no method to pre-set here.

        **Required scope:** `onboarding:write`.
      x-required-scope: onboarding:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OnboardingLinkRequest'
            examples:
              redirect:
                value:
                  returnUrl: https://yourapp.example.com/payouts/onboarded
                  mode: redirect
      responses:
        '200':
          description: A signed, hosted onboarding link.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingLink'
        '400':
          $ref: '#/components/responses/OnboardingLinkBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PayeeNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payees/{id}/readiness:
    parameters:
      - $ref: '#/components/parameters/payeeId'
    get:
      tags: [Payees]
      operationId: getPayeeReadiness
      summary: Check payout readiness
      description: |
        The **pull backstop**: can we pay this person right now, via which rail, and
        if not, what's blocking? Always correct even if a `payee.updated` webhook was
        missed. `blockers` is a list of machine-readable reasons (e.g.
        `kyc_incomplete`, `not_payable`).

        **Required scope:** `payees:read`.
      x-required-scope: payees:read
      responses:
        '200':
          description: The payee's readiness.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadinessResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PayeeNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payees/{id}/balance:
    parameters:
      - $ref: '#/components/parameters/payeeId'
    get:
      tags: [Payees]
      operationId: getPayeeBalance
      summary: Get the payee's wallet balance
      description: |
        The payee's wallet balance — the SAME numbers the hosted payouts portal
        shows — so a consuming platform's own wallet (e.g. 8x-core) stays in sync with
        the portal by construction rather than re-deriving from its own ledger. Like
        `/readiness`, this is a **pull backstop**: always correct even if a balance
        webhook was missed.

        The cent amounts describe where the payee's money is in the payout lifecycle:

        - `withdrawableCents` — still-**owed** disbursements, not yet released into a
          payout. This is what the payee could withdraw right now.
        - `onTheWayCents` — genuinely **in transit** (submitted to a rail, awaiting
          settlement) — not money that still needs the payee to act.
        - `actionNeededCents` — money the **creator must still claim/withdraw** before
          it can move (e.g. a Grade payout that's been triggered but is unclaimed, or a
          SideShift leg-1 awaiting the payee) — NOT yet on the way.
        - `landedCents` — **lifetime settled** (paid). A cumulative total, not a
          current balance.
        - `heldBelowMinimumCents` — money **held only because it's under the rail's
          economical floor** (a `below_min_payout` hold that self-releases once the
          balance clears the floor). 0 when nothing is floor-held.
        - `minPayoutCents` — the **floor** that held money must clear to release: the
          floor of the rail it's held on, or (when nothing is held) the payee's
          currently-resolved active rail. 0 if the rail can't be resolved.
        - `provider` — the payee's **single resolved payout rail** (null if none). Lets a
          consumer tell whether `actionNeededCents` is SideShift leg-1 money already staged
          in the wallet (withdrawable now via leg 2) or a Grade claim / Stripe auto-send.

        `currency` is the ISO 4217 code for these amounts.

        **Required scope:** `payees:read`.
      x-required-scope: payees:read
      responses:
        '200':
          description: The payee's wallet balance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BalanceResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PayeeNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payees/{id}/portal-link:
    parameters:
      - $ref: '#/components/parameters/payeeId'
    post:
      tags: [Payees]
      operationId: createPortalLink
      summary: Email the payee a hosted portal (wallet) link
      description: |
        Send the payee a signed, single-use link that lands them **already
        authenticated** in their gateway portal wallet (`/portal`) — no separate
        gateway login. The link is delivered **only to the payee's own email address**
        and is **never returned in the response**: it is a bearer credential that
        establishes a browser session **and** satisfies the withdraw
        re-authentication step-up for roughly one hour, and a portal session spans
        every client that shares the payee's email. Returning it would let a client
        obtain a working session for an address it merely asserted, so the API hands
        back only a delivery acknowledgement.

        The link is single-use (enforced by the gateway's auth provider) — that's also
        why this is gated by its own scope rather than `onboarding:write`.

        **Required scope:** `portal:write`.
      x-required-scope: portal:write
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
            examples:
              empty:
                value: {}
      responses:
        '200':
          description: The portal sign-in link was emailed to the payee.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PortalLink'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PayeeNotFound'
        '422':
          $ref: '#/components/responses/PayeeEmailMissing'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payees/{id}/withdraw:
    parameters:
      - $ref: '#/components/parameters/payeeId'
    post:
      tags: [Payees]
      operationId: withdrawPayee
      summary: Withdraw the payee's held balance as one consolidated payout
      description: |
        Release **every still-`owed` disbursement** for this payee as a **single
        consolidated payout** — the money credited to their wallet and held until they
        choose to take it.

        This is the API-key counterpart to the creator pressing **Withdraw** in the
        hosted portal: the same release spine, the same consolidation, the same gates.
        Use it when you keep your own wallet UI and want an in-app "Withdraw" button to
        trigger the payout rather than routing the creator to the portal.

        **Held-by-default (per rail).** Grade holds credited money as `owed` in the wallet
        until an explicit withdraw (so a creator who earned across cycles takes it as ONE
        payout and ONE claim link). SideShift auto-releases owed into its managed wallet
        the moment the credit lands; the creator drains it (leg-2) later. Stripe/Tipalti
        auto-release only when payability flips false→true (KYC completes). Auto-release is
        a per-rail behavior, not a property of "custody-free rails" as a class.

        **No body:** a wallet withdraws what is in it; the amount, rail and consolidation
        are resolved server-side. Refuses with `409` if the payee is not yet payable
        (KYC incomplete) or a per-source min/max/frequency policy blocks it — nothing is
        released in that case.

        **Required scope:** `disbursements:write`.
      x-required-scope: disbursements:write
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
            examples:
              empty:
                value: {}
      responses:
        '200':
          description: |
            The withdrawal was started. Rows that could not be funded stay `owed` and
            are reported as `awaitingFunding`, never silently advanced.
          content:
            application/json:
              schema:
                type: object
                properties:
                  released:
                    type: integer
                    description: Number of owed disbursements released into the payout.
                  releasedCents:
                    type: integer
                    description: Total released, in minor currency units.
                  awaitingFunding:
                    type: integer
                    description: >-
                      Rows left `owed` because a prepaid client's wallet was underfunded.
                  held:
                    type: integer
                    description: Owed rows whose consolidated payout was created but held for review (sub-floor or caps/AML checks); not paid.
                  failed:
                    type: integer
                    description: Rows whose consolidated payout failed to move money.
                  payoutLink:
                    type: string
                    description: Hosted claim link when the withdrawal created a claim-rail (Grade) payout; absent for non-claim rails or when nothing released.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PayeeNotFound'
        '409':
          description: |
            The payee is not payable, or a per-source withdrawal policy blocked the
            withdrawal. Branch on the stable `error` code — see the Error & held-reason
            catalog. Nothing was released.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notPayable:
                  value: { error: payee_not_payable }
                payerNotFound:
                  value: { error: payer_not_found }
                withdrawalsDisabled:
                  value: { error: withdrawals_disabled }
                nothingToWithdraw:
                  value: { error: nothing_to_withdraw }
                belowMinimum:
                  value: { error: below_minimum_withdrawal }
                aboveMaximum:
                  value: { error: above_maximum_withdrawal }
                frequencyLimited:
                  value: { error: withdrawal_frequency_limited }
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payouts:
    post:
      tags: [Payouts]
      operationId: createPayout
      summary: Create a payout
      description: |
        Create — or idempotently re-fetch — a payout. **Exactly-once** on
        `(yourClient, idempotencyKey)`: re-sending the same `idempotencyKey` returns
        the **existing** transaction (HTTP `200`), never a second payout. A brand-new
        payout returns HTTP `201`.

        The body carries **no destination** — the gateway resolves the rail from the
        payee's KYC-locked identity. If the payee isn't payable, the rail isn't built,
        a per-client rail allow-list excludes the resolved rail, or an operational
        ceiling is exceeded, the transaction is created in a non-terminal state
        (`held` / `failed`) and returned — inspect `status`. On a provider
        timeout/5xx the transaction stays `submitted` (never silently `failed`) for
        the reconcile cron to resolve.

        **Required scope:** `payouts:write`.
      x-required-scope: payouts:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutRequest'
            examples:
              oneDollarTest:
                summary: A $1.00 USD payout
                value:
                  payeeId: 7d6f4c2e-0b1a-4e2c-9f3d-1a2b3c4d5e6f
                  amountCents: 100
                  currency: USD
                  idempotencyKey: settle-invoice-123
                  ref: invoice_123
                  reason: October creator payout
      responses:
        '201':
          description: A new payout was created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
        '200':
          description: |
            Idempotent replay — a payout with this `idempotencyKey` already existed;
            the original transaction is returned unchanged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PayeeNotFound'
        '422':
          $ref: '#/components/responses/IdempotencyConflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      tags: [Payouts]
      operationId: listPayouts
      summary: List your payouts
      description: |
        List this client's payouts, newest first, with cursor pagination. Filter by
        `status`.

        **Required scope:** `payouts:read`.
      x-required-scope: payouts:read
      parameters:
        - $ref: '#/components/parameters/limitParam'
        - $ref: '#/components/parameters/cursorParam'
        - name: status
          in: query
          description: |
            Filter by payout status. Takes version 1 (`PayoutStatus`) words even when your
            client uses status version 2, because the filter matches the internal status.
          required: false
          schema:
            $ref: '#/components/schemas/PayoutStatus'
      responses:
        '200':
          description: A page of payouts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutList'
        '400':
          description: |
            A query parameter was invalid: `invalid_status` (unknown `status`) or
            `invalid_cursor` (un-parseable `cursor`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_status:
                  value: { error: invalid_status }
                invalid_cursor:
                  value: { error: invalid_cursor }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payouts/batch:
    post:
      tags: [Payouts]
      operationId: createPayoutBatch
      summary: Create many payouts in one call
      description: |
        Pay many people in a single signed request — the **email + amount** contract.
        Each item identifies a payee by **`payeeId`** (one you own) **or** by
        **`email`** (the gateway resolves-or-creates a payee keyed by that email),
        plus an `amountCents`. As always the body carries **no destination** — each
        rail is resolved server-side from the payee's KYC-locked method.

        The response is **`207 Multi-Status`** with one entry per row, in request
        order: `status: ok` carries the created/replayed `transaction` (inspect its
        own `status` — an un-onboarded `email` row comes back `held`), `status: error`
        carries a stable `error` code from the closed per-row enum on
        `BatchPayoutResultItem.error`. A failing row **never** aborts the others.

        **Exactly-once.** Each row's idempotency key is its own `idempotencyKey` when
        set, else it is derived from the batch key and the row's **payee identity**
        (not its array position). So re-sending the same batch key with rows added,
        removed, or reordered replays the rows already paid and pays only the genuinely
        new ones — it never double-pays. Two rows targeting the **same** payee without
        explicit per-row keys are ambiguous and rejected (`400 duplicate_target`); give
        them distinct `idempotencyKey`s to pay the same payee twice in one batch.

        **Required scope:** `payouts:write`. Items that use `email` (which mint a
        payee identity) additionally require `payees:write`.
      x-required-scope: payouts:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchPayoutRequest'
            examples:
              mixed:
                summary: One existing payee + one by email
                value:
                  idempotencyKey: payroll-2026-06
                  items:
                    - payeeId: 7d6f4c2e-0b1a-4e2c-9f3d-1a2b3c4d5e6f
                      amountCents: 25000
                      currency: USD
                      ref: invoice_55
                    - email: creator@example.com
                      amountCents: 18000
                      currency: USD
                      ref: invoice_56
      responses:
        '207':
          description: |
            Multi-status — the batch was processed row by row. Inspect each entry's
            `status` (`ok` / `error`) and, for `ok`, the transaction's own `status`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchPayoutResponse'
        '400':
          description: |
            The body failed validation (`invalid_request`, with field `details`), or two
            rows target the same payee without distinct idempotency keys
            (`duplicate_target`, with the colliding row `indices`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_request:
                  value: { error: invalid_request }
                duplicate_target:
                  summary: Two rows target the same payee without distinct keys (the colliding row indices are returned in `details`)
                  value: { error: duplicate_target }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payouts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: The gateway transaction id.
        schema:
          type: string
          format: uuid
    get:
      tags: [Payouts]
      operationId: getPayout
      summary: Get one payout
      description: |
        Fetch a single transaction by id, scoped to your client, with its full event
        trail and a `retryable` hint. A transaction owned by another client is
        reported as `404`.

        **Required scope:** `payouts:read`.
      x-required-scope: payouts:read
      responses:
        '200':
          description: The transaction, its retryability, and its event trail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/TransactionNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payouts/{id}/retry:
    parameters:
      - name: id
        in: path
        required: true
        description: The gateway transaction id.
        schema:
          type: string
          format: uuid
    post:
      tags: [Payouts]
      operationId: retryPayout
      summary: Retry a transiently-failed payout
      description: |
        Re-run a payout that `failed` with a **transient** cause (e.g. the company
        wallet was momentarily underfunded) — check the `retryable` hint on
        `GET /payouts/{id}` first. The retry re-sends under the SAME rail idempotency
        code, so the rail dedupes: exactly-once is preserved and a retry can never
        double-pay. A payout that is not `failed`, or whose failure was **permanent**
        (bad destination, not onboarded, unsupported currency), returns `409`.

        See the Error & held-reason catalog for the concrete action per `statusReason`.

        **Required scope:** `payouts:write`.
      x-required-scope: payouts:write
      responses:
        '200':
          description: The payout was re-submitted; inspect `status`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/TransactionNotFound'
        '409':
          description: |
            This payout is not retryable (not failed, or a permanent failure). The
            `message` field is a non-stable human-readable detail — e.g.
            `payout_superseded_by_disbursement_retry` when the owed money was already
            re-released elsewhere. Branch only on the stable `error` code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notRetryable:
                  value:
                    error: payout_not_retryable
                    message: payout_superseded_by_disbursement_retry
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/payouts/{id}/release:
    parameters:
      - name: id
        in: path
        required: true
        description: The gateway transaction id.
        schema:
          type: string
          format: uuid
    post:
      tags: [Payouts]
      operationId: releasePayout
      summary: Release a held payout
      description: |
        Re-drive a payout that is `held` — a **recoverable** anomaly hold, not a terminal
        failure. A payout holds for ordinary, fixable reasons: the payee isn't onboarded
        yet, a required profile field is missing, the resolved rail isn't in your
        allow-list, or a per-payee daily cap window hasn't elapsed. Read `statusReason`
        on `GET /payouts/{id}` (a leading snake_case code — `missing_email`,
        `not_payable`, `rail_not_allowed`, `cap_exceeded_daily`, …), clear the blocker,
        then call this.

        It re-runs routing + operational caps under the SAME transaction id: when the
        blocker has cleared it advances to `authorized`/`submitted`/`paid`; otherwise it
        stays `held` with an updated `statusReason`. The re-run reuses the persisted rail
        idempotency code, so a release can NEVER double-pay. A payout that is not `held`
        returns `409`.

        See the Error & held-reason catalog for the concrete action per `statusReason`.

        **Required scope:** `payouts:write`.
      x-required-scope: payouts:write
      responses:
        '200':
          description: The held payout was re-driven; inspect `status` (it may still be `held`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/TransactionNotFound'
        '409':
          description: |
            This payout is not releasable (not currently held, or superseded). The
            `message` field is a non-stable human-readable detail — e.g.
            `payout_superseded_by_disbursement_retry` when the owed money was already
            re-released elsewhere. Branch only on the stable `error` code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notReleasable:
                  value:
                    error: payout_not_releasable
                    message: payout_superseded_by_disbursement_retry
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

webhooks:
  payee.updated:
    post:
      tags: [Webhooks]
      operationId: onPayeeUpdated
      summary: payee.updated
      description: |
        Fired when a payee's onboarding state changes — typically when they finish
        onboarding and become **payable**. Map it back to your own record via
        `externalId`. (Tipalti payability is discovered by the gateway's reconcile
        polling, so this can arrive shortly after the payee finishes, not instantly.)

        You subscribe to specific event types when your client is provisioned. An event
        type you are not subscribed to is recorded but never delivered.

        **Signature.** The gateway POSTs to your registered webhook URL with
        `x-8x-timestamp` and `x-8x-signature` headers, where the signature is
        `HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}")`, hex-encoded — the same
        scheme as outbound API requests. Verify before trusting the body
        (`PayoutClient.verifyWebhook` does this). Reject deliveries whose `x-8x-timestamp`
        is more than 5 minutes (300s) from now — `verifyWebhook` enforces this. Return
        any `2xx` to acknowledge; non-2xx (or a timeout) triggers retry with exponential
        backoff.
      parameters:
        - $ref: '#/components/parameters/webhookTimestamp'
        - $ref: '#/components/parameters/webhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayeeUpdatedEvent'
            examples:
              becamePayable:
                value:
                  type: payee.updated
                  externalId: creator_8842
                  payeeId: 9b2e4c1a-7f3d-4a6b-8c2e-1d5f7a9b3c4e
                  payable: true
                  kycStatus: verified
                  kycReason: null
                  occurredAt: '2026-06-24T18:30:00.000Z'
      responses:
        '2XX':
          description: Acknowledged. Any 2xx stops delivery retries.

  payout.status:
    post:
      tags: [Webhooks]
      operationId: onPayoutStatus
      summary: payout.status
      description: |
        Fired on every payout state change. Settle your own ledger row by the `ref`
        you supplied on `POST /payouts`. `status` is the new payout status; terminal
        states are `paid` and `returned`.

        You subscribe to specific event types when your client is provisioned. An event
        type you are not subscribed to is recorded but never delivered.

        **Signature.** Same scheme as `payee.updated`: verify
        `x-8x-signature` = `HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}")`
        before trusting the body. Reject deliveries whose `x-8x-timestamp` is more than
        5 minutes (300s) from now — `verifyWebhook` enforces this. Delivery is
        **at-least-once** — a webhook may arrive more than once, so process by
        `(transactionId, status)` idempotently. Return any `2xx` to acknowledge.
      parameters:
        - $ref: '#/components/parameters/webhookTimestamp'
        - $ref: '#/components/parameters/webhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutStatusEvent'
            examples:
              paid:
                summary: An electronic `paid` — funds released to the rail, but settledAt stays null (bank arrival not yet confirmed)
                value:
                  type: payout.status
                  transactionId: 9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d
                  ref: invoice_123
                  status: paid
                  providerRef: ss_pmt_9f3c21
                  externalId: creator_8842
                  payeeId: 1b2c3d4e-5f60-7a8b-9c0d-1e2f3a4b5c6d
                  amountCents: 5000
                  currency: USD
                  provider: sideshift
                  claimUrl: null
                  failureKind: null
                  settledAt: null
                  occurredAt: '2026-06-24T18:32:10.000Z'
              returned:
                summary: A settled payout bounced back — carries liftingFeeCents and restoredCents
                value:
                  type: payout.status
                  transactionId: 9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d
                  ref: invoice_123
                  status: returned
                  providerRef: ss_pmt_9f3c21
                  externalId: creator_8842
                  payeeId: 1b2c3d4e-5f60-7a8b-9c0d-1e2f3a4b5c6d
                  amountCents: 5000
                  currency: USD
                  provider: sideshift
                  claimUrl: null
                  failureKind: null
                  settledAt: null
                  liftingFeeCents: 500
                  restoredCents: 5000
                  occurredAt: '2026-06-25T09:00:00.000Z'
      responses:
        '2XX':
          description: Acknowledged. Any 2xx stops delivery retries.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Your client API key. `Authorization: Bearer <API key>`. The gateway stores
        only `sha256(key)`; keys are rotatable and revocable.
    x8xTimestamp:
      type: apiKey
      in: header
      name: x-8x-timestamp
      description: Unix time (seconds) at which the request was signed. Must be within 300s of gateway time.
    x8xSignature:
      type: apiKey
      in: header
      name: x-8x-signature
      description: |
        Hex `HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}")` over the exact raw
        request body (empty string for `GET`). Defeats tampering and replay.

  parameters:
    payeeId:
      name: id
      in: path
      required: true
      description: The gateway payee id.
      schema:
        type: string
        format: uuid
    limitParam:
      name: limit
      in: query
      required: false
      description: Page size, 1–100. Defaults to 100; values above 100 are clamped.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 100
    cursorParam:
      name: cursor
      in: query
      required: false
      description: |
        Opaque pagination cursor — pass back the `nextCursor` from the previous page
        to fetch the next. It is the ISO-8601 `createdAt` of the last row returned. The
        boundary is exclusive (rows strictly older than this createdAt). `createdAt` is
        not guaranteed unique; rows sharing the boundary createdAt may be skipped. Treat
        the cursor as opaque and stop when `nextCursor` is null.
      schema:
        type: string
        format: date-time
    webhookTimestamp:
      name: x-8x-timestamp
      in: header
      required: true
      description: Unix time (seconds) at which the gateway signed this webhook delivery.
      schema:
        type: string
    webhookSignature:
      name: x-8x-signature
      in: header
      required: true
      description: |
        Hex `HMAC-SHA256(signingSecret, "{x-8x-timestamp}.{rawBody}")` over the raw
        delivery body. Verify it before trusting the payload.
      schema:
        type: string

  responses:
    Unauthorized:
      description: |
        Authentication failed — missing/invalid bearer key, missing/forged signature,
        a stale timestamp (outside the 5-minute window), or a detected replay. The
        `error` field carries a human-readable reason.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            badSignature:
              value: { error: Bad signature }
            staleTimestamp:
              value: { error: Stale or invalid timestamp }
            replay:
              value: { error: Replay detected }
    Forbidden:
      description: |
        Authenticated but not permitted — the client is missing the required scope,
        is inactive/suspended, or tried to act on a payee it does not own.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missingScope:
              value: { error: 'Missing required scope: payouts:write' }
            inactive:
              value: { error: Client is inactive or suspended }
    PayeeNotFound:
      description: No such payee for this client (a foreign payee is reported as not-found — no cross-tenant existence leak).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notFound:
              value: { error: payee_not_found }
    TransactionNotFound:
      description: No such transaction for this client.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notFound:
              value: { error: transaction_not_found }
    RateLimited:
      description: |
        Per-client request rate limit exceeded — 600 requests per minute, fixed
        60-second window. No `Retry-After` header is returned; back off to the next
        minute boundary and retry.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              value: { error: Rate limit exceeded }
    IdempotencyConflict:
      description: |
        The `idempotencyKey` was reused with a **different** logical payout
        (a different `amountCents`, `payeeId`, or `currency` than the original).
        The gateway refuses to confirm the stored payout under a mismatched key —
        use a fresh `idempotencyKey` for a new payout.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            reused:
              value: { error: idempotency_key_reused }
    OnboardingLinkBadRequest:
      description: |
        The request body failed validation (`error: invalid_request` with field
        `details`), **or** the `returnUrl` host is not allowed (`error:
        invalid_return_url` with a human-readable `detail`) — see the endpoint
        description for the exact host rule.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/ValidationError'
              - $ref: '#/components/schemas/InvalidReturnUrlError'
          examples:
            invalid-return-url:
              value:
                error: invalid_return_url
                detail: returnUrl must be https and on the gateway origin or your registered webhook domain
            invalid-body:
              value:
                error: invalid_request
                details:
                  - code: invalid_type
                    path: [returnUrl]
                    message: Required
    ValidationFailed:
      description: The request body failed validation. `error` is `invalid_request`; `details` lists the field issues.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationError'
          examples:
            invalid:
              value:
                error: invalid_request
                details:
                  - code: too_small
                    path: [amountCents]
                    message: Number must be greater than 0
    PayeeEmailMissing:
      description: |
        This payee has no email on file, so there is nowhere to address a portal
        link — the gateway fails closed rather than minting a link no one can use.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missingEmail:
              value: { error: payee_email_missing }
    ProviderAccountAlreadyLinked:
      description: |
        This `providerAccountId` is already linked to a **different** payee. One
        provider account can only ever fund one payee; the message never reveals
        which payee currently holds it.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            alreadyLinked:
              value: { error: provider_account_already_linked }
    ProviderAccountUnverifiable:
      description: |
        The rail could not confirm this account (it doesn't exist, it's on a
        different platform, or the live verification call failed). The gateway
        never takes the caller's payability claim on trust, so adoption fails
        closed. The message names the provider only — the raw rail error is never
        surfaced.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unverifiable:
              value: { error: provider_account_unverifiable }
    InternalError:
      description: Unexpected gateway error. Safe to retry idempotent operations.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            internal:
              value: { error: internal_error }

  schemas:
    KycStatus:
      type: string
      description: KYC lifecycle of a payee.
      enum: [none, pending, verified, rejected]

    PayoutStatus:
      type: string
      description: |
        Lifecycle of a single payout.
        `requested` accepted, not yet routed ·
        `authorized` routed, payee payable ·
        `submitted` sent to the provider, awaiting result (do NOT retry) ·
        `paid` funds released to the rail — this does NOT imply bank arrival ·
        `failed` did not go through ·
        `returned` was paid then bounced back (terminal) ·
        `held` blocked by a `statusReason`. Clear the blocker the catalog names for that reason,
        then `POST /payouts/{id}/release`; release re-runs routing and caps on the unchanged
        payout, so it only succeeds once the blocker is gone.
      enum: [requested, authorized, submitted, paid, failed, returned, held]

    PayoutStatusV2:
      type: string
      description: |
        Version 2 lifecycle of a single payout, sent to clients whose status version is 2.
        `owed` earnings exist, no payout transaction yet (lives on disbursements, so it
        never appears on a payout today) ·
        `sent` the rail accepted the payout and money is on its way ·
        `awaiting_withdraw` funds reached a custody-free rail (Grade claim link, SideShift
        second leg) and the creator must still claim or withdraw ·
        `paid` settled, same as version 1 (not proof of bank arrival, see `settledAt`) ·
        `failed` the rail rejected the payout, nothing landed ·
        `returned` landed then bounced back (terminal, kept distinct from `failed`) ·
        `held` deliberately held, not yet released.
      enum: [owed, sent, awaiting_withdraw, paid, failed, returned, held]

    PayoutProvider:
      type: string
      description: |
        The resolved payout rail. `grade` is the **default**: a payee with no
        `preferredProvider` pin, no already-payable Stripe method and no client
        `default_provider` routes to Grade, whatever their country. `grade` cannot be set
        through `preferredProvider` (which accepts `stripe`, `tipalti`, `sideshift`); it is
        reached as the default or through a client's `default_provider`. `stripe` and
        `sideshift` are reached by pin, and a creator already payable on Stripe stays on
        Stripe. `tipalti` is retired from routing: a `tipalti` pin is accepted but resolves
        to Grade, and only payouts already in flight on Tipalti still settle there. `mock`
        is test mode only. `wise` is **reserved and not yet implemented**: routing always
        holds a `wise` payout and pinning it is rejected. Do not offer `wise` as a
        selectable option to your users.
      enum: [stripe, tipalti, wise, sideshift, mock, grade]

    OnboardingMode:
      type: string
      description: How you asked to present the onboarding link.
      enum: [redirect, iframe]

    UpsertPayeeRequest:
      type: object
      additionalProperties: false
      required: [externalId]
      properties:
        externalId:
          type: string
          minLength: 1
          maxLength: 256
          description: Your own stable id for this human (the join key).
        email:
          type: string
          format: email
          description: The payee's email — the portal match key. Send it so the payee can see their payouts.
        country:
          type: string
          maxLength: 128
          description: ISO 3166-1 alpha-2 country code preferred. Legacy full country names are accepted during transition and normalized by the gateway.
        name:
          type: string
          minLength: 1
          maxLength: 256
          description: |
            Payee name collected by the gateway when omitted. Trimmed before validation;
            a whitespace-only value is rejected `400` despite `minLength: 1`.
        preferredProvider:
          description: |
            Optionally PIN the payout rail for this payee, overriding the default rail
            (Grade, or your client's `default_provider`). Allowed: `stripe`, `tipalti`,
            `sideshift`. A `tipalti` pin is accepted but resolves to Grade, since Tipalti is
            retired from routing. Send `null` to CLEAR a previous pin and return to the
            default; omit to leave it unchanged. `wise` and `mock` are rejected (`400`) —
            `wise` is reserved/unimplemented and would strand the payee.
          oneOf:
            - type: string
              enum: [stripe, tipalti, sideshift]
            - type: 'null'

    Payee:
      type: object
      required: [id, source, externalId, email, name, country, kycStatus, provider, preferredProvider, payable]
      properties:
        id:
          type: string
          format: uuid
          description: The gateway payee id.
        source:
          type: string
          description: The client (tenant) that owns this payee.
        externalId:
          type: string
          description: Your own id for this human, as supplied on upsert.
        email:
          type: [string, 'null']
          format: email
        country:
          type: [string, 'null']
          description: Canonical ISO 3166-1 alpha-2 code when known.
        name:
          type: [string, 'null']
        kycStatus:
          $ref: '#/components/schemas/KycStatus'
        provider:
          oneOf:
            - $ref: '#/components/schemas/PayoutProvider'
            - type: 'null'
          description: Deprecated alias for `preferredProvider`. Use readiness.provider for resolved rail.
        preferredProvider:
          oneOf:
            - $ref: '#/components/schemas/PayoutProvider'
            - type: 'null'
          description: Explicit payee/client rail pin, if set.
        payable:
          type: boolean
          description: Whether the payee can currently be paid.

    PayoutRequest:
      type: object
      additionalProperties: false
      required: [payeeId, amountCents, currency, idempotencyKey]
      properties:
        payeeId:
          type: string
          format: uuid
          description: A payee you own.
        amountCents:
          type: integer
          format: int64
          minimum: 1
          description: Amount in integer cents (e.g. 100 = $1.00).
        currency:
          type: string
          enum: [USD]
          description: |
            Settlement currency. The gateway is **USD-only** today (every rail is funded
            and instructed in USD cents). A non-USD code — or any value that is not exactly
            3 characters — is rejected as a Zod field error on `currency`: `400`
            `invalid_request` (see the ValidationFailed response). The `unsupported_currency`
            code appears only as a per-row error in the batch and disbursement responses,
            never on this single-payout route. Case-insensitive; normalized to uppercase
            server-side.
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 128
          description: One per LOGICAL payout. Re-sending it with the SAME amount/payee/currency returns the existing transaction; reusing it for a DIFFERENT payout returns `422`.
        ref:
          type: string
          maxLength: 256
          description: Your own row id, echoed back verbatim on the `payout.status` webhook.
        reason:
          type: string
          maxLength: 512
          description: Free text stored on the transaction for the audit trail.

    BatchPayoutItem:
      type: object
      additionalProperties: false
      description: |
        One row of a batch. Identify the payee by EXACTLY ONE of `payeeId` or
        `email`. With `email`, the gateway resolves-or-creates a payee keyed by that
        email (the agnostic email+amount contract).
      required: [amountCents, currency]
      properties:
        payeeId:
          type: string
          format: uuid
          description: A payee you own. Mutually exclusive with `email`.
        email:
          type: string
          format: email
          description: Resolve-or-create a payee by this email. Mutually exclusive with `payeeId`. Requires `payees:write`.
        amountCents:
          type: integer
          format: int64
          minimum: 1
          description: Amount in integer cents (e.g. 100 = $1.00).
        currency:
          type: string
          enum: [USD]
          description: USD only today; case-insensitive, normalized server-side.
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 128
          description: |
            Optional per-row key. When omitted it is derived from the batch
            `idempotencyKey` plus the row's resolved payee identity (payeeId, or
            normalized email) — never the array position. Re-ordering, inserting, or
            removing rows therefore replays already-paid rows instead of double-paying
            them.
        ref:
          type: string
          maxLength: 256
          description: Your own row id, echoed back on the `payout.status` webhook.
        reason:
          type: string
          maxLength: 512
          description: Free text stored on the transaction for the audit trail.

    BatchPayoutRequest:
      type: object
      additionalProperties: false
      required: [idempotencyKey, items]
      properties:
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 128
          description: One stable key per logical batch — per-row keys derive from it when omitted.
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/BatchPayoutItem'

    BatchPayoutResultItem:
      type: object
      required: [index, status]
      properties:
        index:
          type: integer
          description: 0-based index of this row in the request `items` array.
        status:
          type: string
          enum: [ok, error]
        transaction:
          $ref: '#/components/schemas/Transaction'
        idempotentReplay:
          type: boolean
          description: |
            Present when `status` is `ok`. `true` if this row returned an EXISTING
            transaction (the idempotency key was already used) rather than a new one —
            every row `true` means the batch key was reused and nothing new was paid.
        error:
          type: [string, 'null']
          enum: [invalid_target, payees_write_scope_required, invalid_amount, unsupported_currency, payee_not_found, idempotency_key_reused, internal_error, null]
          description: |
            Stable per-row error code when status is `error`, else null:
            - `invalid_target` — neither, or both, of payeeId/email supplied
            - `payees_write_scope_required` — an email row without the payees:write scope
            - `invalid_amount` — amountCents ≤ 0 or non-integer
            - `unsupported_currency` — non-USD currency
            - `payee_not_found` — payeeId did not resolve
            - `idempotency_key_reused` — a different payload replayed a used per-row key
            - `internal_error` — unexpected failure processing the row

    BatchPayoutResponse:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/BatchPayoutResultItem'

    Transaction:
      type: object
      required: [id, source, payeeId, idempotencyKey, amountCents, currency, status, provider, providerRef, reason, ref, failureKind, retryable, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        source:
          type: string
          description: The client (tenant) that owns this transaction.
        payeeId:
          type: string
          format: uuid
        idempotencyKey:
          type: string
        amountCents:
          type: integer
          format: int64
        currency:
          type: string
        status:
          description: |
            Which vocabulary you get depends on your client's status version:
            `PayoutStatus` for version 1 (the default), `PayoutStatusV2` for version 2.
          anyOf:
            - $ref: '#/components/schemas/PayoutStatus'
            - $ref: '#/components/schemas/PayoutStatusV2'
        provider:
          oneOf:
            - $ref: '#/components/schemas/PayoutProvider'
            - type: 'null'
        providerRef:
          type: [string, 'null']
          description: The rail's own reference for the payment, once it has one.
        reason:
          type: [string, 'null']
        ref:
          type: [string, 'null']
        failureKind:
          description: |
            Classification of a `failed` payout: `transient` (safe to retry under the same
            idempotency key, e.g. a temporary platform-balance shortfall) vs `permanent`.
            `null` unless `status` is `failed`. Surfaced on the list so an automated retry
            job doesn't need a per-row `GET`.
          oneOf:
            - type: string
              enum: [transient, permanent]
            - type: 'null'
        retryable:
          type: boolean
          description: |
            `true` when this payout can be re-driven right now — a `failed`+`transient`
            payout via `POST /payouts/{id}/retry`, or a `held` payout via
            `POST /payouts/{id}/release`. Triage the list without a follow-up call per row.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        liftingFeeCents:
          type: [integer, 'null']
          format: int64
          description: Provider return/"lifting" fee (cents) recorded when a settled payout BOUNCED (paid→returned); platform-absorbed today. Null otherwise.
        claimUrl:
          type: [string, 'null']
          description: |
            Always null on /api/v1 payout responses. The claim link reaches you on the
            `payout.status` webhook (`claimUrl`) and on `POST /payees/{id}/withdraw`
            (`payoutLink`). The gateway emails it to the creator for payouts created by
            `POST /payouts` and for owed-balance releases and withdrawals. It does not email
            after `POST /payouts/{id}/release`, `POST /payouts/{id}/retry` or a batch row, so
            keep the webhook's copy and show it to the creator yourself.

    TransactionEvent:
      type: object
      description: One state transition in a transaction's audit trail.
      required: [from, to, reason, at]
      properties:
        from:
          anyOf:
            - $ref: '#/components/schemas/PayoutStatus'
            - $ref: '#/components/schemas/PayoutStatusV2'
            - type: 'null'
          description: |
            Prior status (null for the initial event). Which vocabulary you get depends on
            your client's status version: `PayoutStatus` for version 1 (the default),
            `PayoutStatusV2` for version 2.
        to:
          description: |
            Which vocabulary you get depends on your client's status version:
            `PayoutStatus` for version 1 (the default), `PayoutStatusV2` for version 2.
          anyOf:
            - $ref: '#/components/schemas/PayoutStatus'
            - $ref: '#/components/schemas/PayoutStatusV2'
        reason:
          type: [string, 'null']
        at:
          type: string
          format: date-time

    TransactionDetail:
      type: object
      description: A transaction plus its retryability hint and full event trail (returned by `GET /payouts/{id}`).
      allOf:
        - $ref: '#/components/schemas/Transaction'
        - type: object
          required: [retryable, statusReason, events]
          properties:
            retryable:
              type: boolean
              description: Whether this payout can be re-driven now (retry if failed+transient, release if held).
            statusReason:
              type: [string, 'null']
              description: |
                Reason for the CURRENT status. For a held/failed payout it leads with a
                stable snake_case code token from the closed Error & held-reason catalog
                (see info.description). `null` before any transition has a reason.
            events:
              type: array
              items:
                $ref: '#/components/schemas/TransactionEvent'

    ReadinessResponse:
      type: object
      required: [payable, provider, onboardingStatus, blockers, payability, routing, sideshiftResumable, sideshiftWithdrawalStatus]
      properties:
        payable:
          type: boolean
        provider:
          oneOf:
            - $ref: '#/components/schemas/PayoutProvider'
            - type: 'null'
          description: The rail this payee would route to.
        onboardingStatus:
          $ref: '#/components/schemas/KycStatus'
        blockers:
          type: array
          description: Machine-readable reasons the resolved payout method isn't payable (e.g. `kyc_incomplete`, `not_payable`). Empty when payable.
          items:
            type: string
        payability:
          $ref: '#/components/schemas/PayabilitySummary'
        routing:
          type: object
          additionalProperties: false
          required: [source, country, blockers, preferredProvider]
          properties:
            source:
              type: string
              enum: [test_mode, preferred_provider, stripe_payable, tipalti_payable, source_default, default_rail, country_matrix, held]
            country:
              type: [string, 'null']
              description: Canonical ISO 3166-1 alpha-2 code used for routing.
            heldReason:
              type: string
            blockers:
              type: array
              items:
                type: string
            preferredProvider:
              oneOf:
                - $ref: '#/components/schemas/PayoutProvider'
                - type: 'null'
        sideshiftResumable:
          type: boolean
          description: True when the payee has an in-flight SideShift leg-2 withdrawal that can be resumed. Always present.
        sideshiftWithdrawalStatus:
          type: [string, 'null']
          enum: [pending, completed, failed, null]
          description: Status of the payee's most recent SideShift withdrawal, or null if none. Always present.
        sideshiftWithdrawableCents:
          type: [integer, 'null']
          format: int64
          description: SideShift-wallet balance the payee can drain (leg-2), in cents. Omitted for non-SideShift payees.

    BalanceResponse:
      type: object
      additionalProperties: false
      required: [withdrawableCents, onTheWayCents, actionNeededCents, landedCents, currency]
      description: |
        The payee's wallet balance — the same numbers the hosted payouts portal shows.
        The cent amounts describe the payee's money by lifecycle stage.
      properties:
        withdrawableCents:
          type: integer
          format: int64
          description: Still-owed disbursements not yet released into a payout — what the payee could withdraw right now.
        onTheWayCents:
          type: integer
          format: int64
          description: Money genuinely in transit (submitted to a rail, awaiting settlement) — not awaiting payee action.
        actionNeededCents:
          type: integer
          format: int64
          description: Money the creator must still claim/withdraw before it can move (e.g. Grade unclaimed, SideShift leg-1) — not yet on the way.
        landedCents:
          type: integer
          format: int64
          description: Lifetime settled (paid) — a cumulative total, not a current balance.
        heldBelowMinimumCents:
          type: integer
          format: int64
          description: >-
            Money held ONLY because it's under the rail's economical payout floor
            (a `below_min_payout` hold that self-releases once the balance clears the
            floor). 0 when nothing is floor-held. Optional — older clients that don't
            read it still validate.
        minPayoutCents:
          type: integer
          format: int64
          description: >-
            The floor the held money must clear to release, for the rail it's held on;
            when nothing is floor-held, the floor of the payee's currently-resolved active
            rail (0 if it can't be resolved). Optional — older clients still validate.
        currency:
          type: string
          description: ISO 4217 currency code for these amounts (e.g. `USD`).
        provider:
          type: [string, 'null']
          description: >-
            The payee's single currently-resolved payout rail (`sideshift`, `grade`,
            `stripe`, `tipalti`), or null when none resolves. Consumers read this to tell
            whether `actionNeededCents` is SideShift leg-1 money already staged in the
            provider wallet (withdrawable now via leg 2) versus a Grade claim link or a
            Stripe auto-send. Optional — older clients that don't read it still validate.

    PayabilityState:
      type: string
      enum: [ready, needs_profile, needs_onboarding, under_review, rejected, provider_blocked, blocked]

    PayabilityBlocker:
      type: object
      additionalProperties: false
      required: [code, label, description, action, severity]
      properties:
        code:
          type: string
          description: Stable machine-readable blocker code, usually the token before `:` in `readiness.blockers`.
        label:
          type: string
        description:
          type: string
        action:
          type: string
          description: Suggested payer-facing next step.
        severity:
          type: string
          enum: [info, warning, error]

    PayabilitySummary:
      type: object
      additionalProperties: false
      required: [state, label, description, action, provider, onboardingStatus, payable, blockers]
      properties:
        state:
          $ref: '#/components/schemas/PayabilityState'
        label:
          type: string
        description:
          type: string
        action:
          type: string
        provider:
          oneOf:
            - $ref: '#/components/schemas/PayoutProvider'
            - type: 'null'
        onboardingStatus:
          $ref: '#/components/schemas/KycStatus'
        payable:
          type: boolean
        blockers:
          type: array
          items:
            $ref: '#/components/schemas/PayabilityBlocker'

    OnboardingLinkRequest:
      type: object
      additionalProperties: false
      required: [returnUrl]
      properties:
        returnUrl:
          type: string
          format: uri
          description: Absolute https URL on your registered domain to send the payee back to when they finish.
        mode:
          $ref: '#/components/schemas/OnboardingMode'

    OnboardingLink:
      type: object
      required: [url, mode, provider]
      properties:
        url:
          type: string
          format: uri
          description: Gateway-hosted, rail-agnostic signed onboarding URL. Redirect to it or embed as an iframe.
        mode:
          $ref: '#/components/schemas/OnboardingMode'
        provider:
          oneOf:
            - $ref: '#/components/schemas/PayoutProvider'
            - type: 'null'

    DisbursementPushItem:
      type: object
      additionalProperties: false
      description: |
        One owed row. Identify the payee by EXACTLY ONE of `payeeId` or `email`
        (mirroring `BatchPayoutItem`). `externalRef` is YOUR stable id for this IOU
        (e.g. your ledger row id) — pushing the same `(yourClient, externalRef)`
        twice returns the existing row instead of double-crediting.
      required: [amountCents, externalRef]
      properties:
        payeeId:
          type: string
          format: uuid
          description: A payee you own. Mutually exclusive with `email`.
        email:
          type: string
          format: email
          description: Resolve-or-create a payee by this email. Mutually exclusive with `payeeId`.
        amountCents:
          type: integer
          format: int64
          minimum: 1
          description: Amount in integer cents (e.g. 100 = $1.00).
        currency:
          type: string
          enum: [USD]
          description: |
            USD only today; case-insensitive, normalized server-side. Defaults to USD.
            A non-USD value is returned as a per-row `unsupported_currency` error in the
            207 (not an envelope 400).
        externalRef:
          type: string
          minLength: 1
          maxLength: 128
          description: |
            Your own stable id for this owed row (the idempotency key for this push).
            An out-of-range externalRef is reported as a per-row `invalid_external_ref`
            in the 207 response — it does not 400 the whole request.
        periodStart:
          type: string
          format: date
          description: Billing period start (YYYY-MM-DD) this credit covers. Supply together with periodEnd; either alone, an invalid date, or start>end yields a per-row `invalid_period` error.
        periodEnd:
          type: string
          format: date
          description: Billing period end (YYYY-MM-DD). See periodStart.
        invoiceUrl:
          type: string
          format: uri
          description: Deeplink to your invoice for this credit; surfaced as the CTA in the credited-email. Must be a valid URL.
        name:
          type: string
          maxLength: 256
          description: Payee name, used only when resolving-or-creating by `email`.
        country:
          type: string
          maxLength: 128
          description: ISO 3166-1 alpha-2 preferred, used only when resolving-or-creating by `email`.
        reason:
          type: string
          maxLength: 512
          description: Free text stored on the owed row for the audit trail.

    DisbursementPushRequest:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 500
          items:
            $ref: '#/components/schemas/DisbursementPushItem'
        sendEmail:
          type: boolean
          description: Send the payee the "you have money waiting" email per newly-created row. Default false.

    Disbursement:
      type: object
      required: [id, payeeId, email, amountCents, currency, status, externalRef, createdAt]
      properties:
        id:
          type: string
          format: uuid
        payeeId:
          type: [string, 'null']
          format: uuid
        email:
          type: string
          format: email
        amountCents:
          type: integer
          format: int64
        currency:
          type: string
        status:
          type: string
          enum: [owed, released, canceled]
          description: '`owed` not yet paid · `released` handed to the payout spine · `canceled` withdrawn.'
        externalRef:
          type: [string, 'null']
          description: Your stable id for this owed row, as supplied on push.
        createdAt:
          type: string
          format: date-time

    DisbursementPushResultItem:
      type: object
      required: [index, status]
      properties:
        index:
          type: integer
          description: 0-based index of this row in the request `items` array.
        status:
          type: string
          enum: [ok, error]
        disbursement:
          $ref: '#/components/schemas/Disbursement'
        idempotentReplay:
          type: boolean
          description: |
            Present when `status` is `ok`. `true` if this row returned an EXISTING
            owed row for the same `externalRef` rather than creating a new one.
        error:
          type: [string, 'null']
          enum: [payee_target_missing, payee_target_ambiguous, invalid_amount, unsupported_currency, invalid_external_ref, invalid_period, payee_not_found, payee_email_missing, internal_error, null]
          description: |
            Stable per-row error code when status is `error`, else null:
            - `payee_target_missing` — neither payeeId nor email
            - `payee_target_ambiguous` — both payeeId and email
            - `invalid_amount` — amountCents ≤ 0 or non-integer
            - `unsupported_currency` — non-USD currency
            - `invalid_external_ref` — externalRef length <1 or >128
            - `invalid_period` — missing/invalid/inverted periodStart|periodEnd
            - `payee_not_found` — payeeId did not resolve
            - `payee_email_missing` — resolved payee has no email on file
            - `internal_error` — unexpected failure processing the row

    DisbursementPushResponse:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/DisbursementPushResultItem'

    RecoveryLinkRequest:
      type: object
      additionalProperties: false
      required: [replacementTransactionId, disbursementIds]
      properties:
        replacementTransactionId:
          type: string
          format: uuid
          description: The already-submitted payout the rows should be attached to.
        disbursementIds:
          type: array
          minItems: 1
          maxItems: 500
          items:
            type: string
            format: uuid
          description: The released owed rows to link. Must be unique, and all owned by the replacement's payee.

    RecoveryLinkResponse:
      type: object
      required: [linked, idempotentReplay]
      properties:
        linked:
          type: integer
          description: How many disbursements were attached to the replacement payout.
        idempotentReplay:
          type: boolean
          description: True when the requested rows were ALREADY linked to this replacement — nothing changed.

    AdoptMethodRequest:
      type: object
      additionalProperties: false
      required: [provider, providerAccountId]
      description: |
        Adopt an already-KYC'd provider account as this payee's payout method. The
        gateway verifies the account LIVE against the provider — the caller's claim
        is never trusted.
      properties:
        provider:
          type: string
          enum: [stripe, tipalti]
          description: The rail the account lives on. Only rails that support adopting an existing account.
        providerAccountId:
          type: string
          minLength: 1
          maxLength: 256
          description: The rail's own account id (a Stripe Connect `acct_…`, or a Tipalti payee id).

    AdoptedMethod:
      type: object
      required: [provider, providerAccountId, payable, onboardingStatus]
      properties:
        provider:
          $ref: '#/components/schemas/PayoutProvider'
        providerAccountId:
          type: string
        payable:
          type: boolean
          description: Whether the rail reports this account as currently payable.
        onboardingStatus:
          $ref: '#/components/schemas/KycStatus'

    AdoptMethodResponse:
      type: object
      required: [method, payee]
      properties:
        method:
          $ref: '#/components/schemas/AdoptedMethod'
        payee:
          $ref: '#/components/schemas/Payee'

    PortalLink:
      type: object
      required: [delivered]
      description: |
        Acknowledgement that a signed, single-use portal sign-in link was emailed to
        the payee. The link itself is a bearer credential (session + withdraw re-auth
        step-up, ~1h) that spans every client sharing the payee's email, so it is
        delivered only to the payee's inbox and never returned over the API — the caller
        only learns that delivery succeeded.
      properties:
        delivered:
          type: boolean

    PayeeList:
      type: object
      required: [data, nextCursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Payee'
        nextCursor:
          type: [string, 'null']
          format: date-time
          description: Pass as `cursor` to fetch the next page. `null` on the last page.

    PayoutList:
      type: object
      required: [data, nextCursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Transaction'
        nextCursor:
          type: [string, 'null']
          format: date-time
          description: Pass as `cursor` to fetch the next page. `null` on the last page.

    PayeeUpdatedEvent:
      type: object
      description: Webhook body for `payee.updated`.
      required: [type, externalId, payeeId, payable, kycStatus, kycReason, occurredAt]
      properties:
        type:
          type: string
          const: payee.updated
        externalId:
          type: string
          description: Your own id for this human (the join key).
        payeeId:
          type: string
          format: uuid
          description: The gateway's own payee id (fallback join key when externalId doesn't match).
        payable:
          type: boolean
        kycStatus:
          $ref: '#/components/schemas/KycStatus'
        kycReason:
          type: [string, 'null']
          description: The active method's KYC rejection reason when kycStatus is rejected; null when the provider gives none (e.g. Grade).
        occurredAt:
          type: string
          format: date-time

    PayoutStatusEvent:
      type: object
      description: Webhook body for `payout.status`.
      required: [type, transactionId, ref, status, providerRef, externalId, payeeId, amountCents, currency, provider, claimUrl, failureKind, settledAt, occurredAt]
      properties:
        type:
          type: string
          const: payout.status
        transactionId:
          type: string
          format: uuid
        ref:
          type: [string, 'null']
          description: Your original `ref`, so you can settle your own ledger row.
        status:
          description: |
            Which vocabulary you get depends on your client's status version:
            `PayoutStatus` for version 1 (the default), `PayoutStatusV2` for version 2.
          anyOf:
            - $ref: '#/components/schemas/PayoutStatus'
            - $ref: '#/components/schemas/PayoutStatusV2'
        providerRef:
          type: [string, 'null']
        externalId:
          type: string
          description: >-
            Your own stable payee id (`payees.external_id`), so you can join this event to
            the creator in your system — e.g. resolve their Discord id and DM them.
        payeeId:
          type: string
          format: uuid
          description: The gateway's payee id.
        amountCents:
          type: integer
          description: The payout amount in integer cents.
        currency:
          type: string
        provider:
          oneOf:
            - $ref: '#/components/schemas/PayoutProvider'
            - type: 'null'
          description: The rail this payout went out on; null before routing.
        claimUrl:
          type: [string, 'null']
          description: |
            The creator's claim link (Grade) when they must act; null on rails without one.
            The gateway emails it to the creator for payouts created by `POST /payouts` and for
            owed-balance releases and withdrawals. It does not email after
            `POST /payouts/{id}/release`, `POST /payouts/{id}/retry` or a batch row, so keep the
            webhook's copy and show it to the creator yourself.
        failureKind:
          type: [string, 'null']
          description: Structured failure category, set on a failed/held transition; null otherwise.
        settledAt:
          type: [string, 'null']
          format: date-time
          description: |
            True bank-settlement time, or null. `paid` does NOT imply this is set.
            `settledAt` is stamped only on a genuine bank-settlement signal, which several
            rails never emit for electronic payouts: Tipalti stamps it only on CLEARED
            (paper checks); Grade only when a non-null landedDate appears. For those rails
            a payout can stay `paid` with `settledAt: null` indefinitely. Key off
            `settledAt`, not `status: paid`, when you need confirmed bank arrival.
        occurredAt:
          type: string
          format: date-time
        liftingFeeCents:
          type: [integer, 'null']
          description: Present only on a `returned` event — the provider return/lifting fee (cents). Null on other statuses.
        restoredCents:
          type: [integer, 'null']
          description: Present only on a `returned` event — net cents put back to the payee's balance after the bounce. Render this rather than re-deriving it.

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: |
            A short error identifier. Stable codes for validation/not-found
            (`invalid_request`, `invalid_kyc`, `invalid_status`, `invalid_cursor`,
            `payee_not_found`, `transaction_not_found`, `internal_error`); a
            human-readable reason for auth/rate-limit failures (e.g. `Bad signature`,
            `Rate limit exceeded`).
        details:
          type: object
          description: Optional structured context. E.g. duplicate_external_ref returns details.indices (the colliding row indices).
          properties:
            indices:
              type: array
              items: { type: integer }

    InvalidReturnUrlError:
      type: object
      description: The returnUrl was rejected by the open-redirect allowlist (request-time enforcement).
      required: [error]
      properties:
        error:
          type: string
          const: invalid_return_url
        detail:
          type: string
          description: Human-readable statement of the host rule that failed.
    ValidationError:
      type: object
      description: A body-validation failure, with the underlying field issues.
      required: [error]
      properties:
        error:
          type: string
          const: invalid_request
        details:
          type: array
          description: The field-level issues (Zod issue objects).
          items:
            type: object
            additionalProperties: true
