openapi: 3.1.0

info:
  title: Avvio Partner Payouts
  version: "2026-09-30.1"
  summary: Pay out to your own customers, from your balance, over one API.
  description: |
    Pay people in their local currency from your funded USD balance. Your end
    users never onboard with Avvio: the money leaves your balance, and the
    recipient is your counterparty.

    Send your API key as `x-api-key` on every request. A test key
    (`avvio_test_…`) runs the same calls against your sandbox on the same base
    URL. To try a call here, authorize with your complete test key and enter
    your organization id as `orgId`. The two `/payout-links/{token}` operations
    take no key: the link token is their credential.

    Retry a timed-out write with the same `Idempotency-Key` and you get the
    original result. A new key pays twice.
    [Idempotency](https://docs.avvio.xyz/idempotency/) lists which operations
    require the header.

    The fields a recipient needs depend on how your organization is routed, and
    routing can change, so build your form from `GET /recipients/{orgId}/corridors`.

    Generate a client in any language from this file
    (`openapi-generator-cli generate -i partner-payouts.openapi.yaml -g java -o ./avvio`),
    or use `@avvio/payments` for Node.

servers:
  - url: https://api.avvio.xyz/business/api/v1
    description: Sandbox with a test key, live with a live key

security:
  - ApiKeyAuth: []

tags:
  - name: Policy
    description: "Your caps, approval threshold, features and rate limits. Read this first."
  - name: Corridors
    description: "What you can pay, and what each corridor needs."
  - name: Recipients
    description: "Who is being paid, and the payment methods you pay them on."
  - name: "Quotes & rates"
    description: "What a payout costs: an indicative rate before a recipient exists, a locked quote after."
  - name: Payouts
    description: "Sending one payout, and reading or canceling it."
  - name: Payout batches
    description: "Many payouts in one upload, confirmed or canceled as a unit."
  - name: Payout links
    description: "A hosted page where the recipient enters their own bank details."
  - name: Approvals
    description: "Payouts held above your approval threshold until enough people approve."
  - name: "Funding & balance"
    description: "Topping up the balance payouts debit, and reading its ledger."
  - name: Events
    description: "Every payout state change, as a feed you poll and as webhooks we send."
  - name: Webhook endpoints
    description: "Where we send events, and what we delivered."
  - name: Audit
    description: "Who did what, with which credential."
  - name: Sandbox
    description: "Test-key only: fund the sandbox and receive test webhooks."

x-tagGroups:
  - name: Payouts API
    tags: [Policy, Corridors, Recipients, "Quotes & rates", Payouts, Payout batches, Payout links, Approvals, "Funding & balance", Events, Webhook endpoints, Audit, Sandbox]


paths:
  /recipients/{orgId}/corridors:
    get:
      tags: [Corridors]
      operationId: listCorridors
      summary: List corridors
      description: |
        Currencies you can pay out to, and the fields each needs.

        Render your recipient form from this response. Do not hardcode fields:
        both the set of corridors and the field *names* within a corridor depend
        on how your organization is routed. A form built against one routing
        fails against another with a missing-field error naming a field you have
        never seen. When you know the destination currency, pass it so discovery
        and recipient creation use the same approved provider decision.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: currency
          in: query
          required: false
          description: ISO-4217 destination currency used for provider routing.
          schema: { type: string, pattern: "^[A-Za-z]{3}$" }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Available corridors
          content:
            application/json:
              example:
                corridors:
                  - currency: MXN
                    fields:
                      - id: clabeNumber
                        title: CLABE
                        description: 18-digit CLABE issued by the recipient's Mexican bank.
                        type: string
                        pattern: "^[0-9]{18}$"
                        checksum: clabe
                        required: true
                    limits: { min: "1.00", max: "5000.00" }
                    settlement: null
                capabilities:
                  exactOutput: true
                  indicativePricing: true
              schema:
                type: object
                required: [corridors, capabilities]
                properties:
                  corridors:
                    type: array
                    items: { $ref: "#/components/schemas/Corridor" }
                  capabilities:
                    type: object
                    description: |
                      What this routing can do. Read `exactOutput` here before
                      offering an exact receiving amount in your UI. The alternative is discovering it from a 400 on a payout you
                      have already promised somebody.
                    required: [exactOutput, indicativePricing]
                    properties:
                      exactOutput: { type: boolean }
                      # Read this before pricing without a recipient (see ERRORS.md).
                      indicativePricing: { type: boolean }
        "400": { $ref: "#/components/responses/NoApprovedProvider" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /organizations/{organizationId}/webhook-endpoints:
    get:
      tags: [Webhook endpoints]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      description: |
        Lists the webhook endpoints registered for your organization.

        Read-only. Registering, pausing, deleting, rotating the secret of or
        replaying an endpoint is a dashboard action and cannot be done with an
        API key, because a credential that could repoint its own webhook URL could
        quietly redirect every payout notification you receive. The dashboard
        routes, for completeness: `POST .../webhook-endpoints/{endpointId}/rotate-secret`
        (the old secret keeps signing beside the new one for 24 hours, so
        nothing in flight drops) and `POST .../webhook-endpoints/{endpointId}/deliveries/replay`
        (re-fires up to 1,000 dead deliveries in a `from`/`to` window under
        their original event ids, so your dedupe still holds).

        The health fields are how you see an endpoint dying without asking us:
        `consecutiveFailures` counts exhausted retry ladders since the last
        success, and after three of them with no success for five days we
        disable the endpoint (`disabledReason: auto_disabled_after_failures`),
        email your organization's owners, and deliver
        `webhook_endpoint.disabled` to your other endpoints whose `events` list
        is empty. It cannot be named in `events`, so an endpoint with an
        explicit list never receives it.

        The signing secret is never returned here. You are shown it once, when
        the endpoint is created or rotated.
      parameters:
        - name: organizationId
          in: path
          required: true
          description: Your organization id.
          schema: { type: string, minLength: 1 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenDeveloper" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Registered endpoints
          content:
            application/json:
              example:
                - id: cmf3k2xe80006q8b7d2f4g6hj
                  url: https://example.com/hooks/avvio
                  events: ["payout.completed", "payout.failed"]
                  disabledAt: null
                  disabledReason: null
                  consecutiveFailures: 0
                  lastSuccessAt: "2026-09-03T10:00:04.000Z"
                  lastFailureAt: null
                  createdAt: "2026-08-20T13:58:02.000Z"
              schema:
                type: array
                items:
                  type: object
                  required: [id, url, events, disabledAt, disabledReason, consecutiveFailures, lastSuccessAt, lastFailureAt, createdAt]
                  properties:
                    id: { type: string }
                    url: { type: string, format: uri }
                    events:
                      type: array
                      items: { type: string }
                      description: |
                        Empty means every payout-side type (payout, approval,
                        batch and endpoint lifecycle), but not
                        `checkout_payment.*`, which is delivered only when
                        named here.
                    disabledAt:
                      type: [string, "null"]
                      format: date-time
                      description: Non-null while the endpoint is paused or auto-disabled.
                    disabledReason:
                      type: [string, "null"]
                      enum: [paused_by_owner, auto_disabled_after_failures, null]
                    consecutiveFailures:
                      type: integer
                      description: Retry ladders exhausted since the last accepted delivery. Reset to 0 on success and on re-enable.
                    lastSuccessAt: { type: [string, "null"], format: date-time }
                    lastFailureAt: { type: [string, "null"], format: date-time }
                    createdAt: { type: string, format: date-time }

  /organizations/{organizationId}/webhook-endpoints/{endpointId}/deliveries:
    get:
      tags: [Webhook endpoints]
      operationId: listWebhookDeliveries
      summary: List webhook deliveries
      description: |
        Lists the 50 most recent delivery attempts for one endpoint, newest
        first, with what your server answered.
        This is the answer to "did you send me that event" without anyone
        having to open a dashboard.

        `lastError` carries your server's response when an attempt failed, and
        `nextAttemptAt` is when we will try again. Retries back off over roughly
        70 hours (nine retries, ±20% jitter); after that the delivery is dead and
        only the event feed (`GET /payments/organizations/{orgId}/events`) will
        still have it.

        Payloads are not returned. Read the event from the feed by `eventId`,
        or replay the delivery from the dashboard (one at a time, or every dead
        delivery in a window). A replay re-fires under the same `eventId`, so
        your deduplication still holds.
      parameters:
        - name: organizationId
          in: path
          required: true
          description: Your organization id.
          schema: { type: string, minLength: 1 }
        - name: endpointId
          in: path
          required: true
          description: From the endpoint listing.
          schema: { type: string, minLength: 1 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenDeveloper" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Delivery attempts, newest first
          content:
            application/json:
              example:
                - id: cmf3k2xe80007q8b7k8l0m2np
                  eventId: cmf3k2xb20002q8b7c1s9m4rw
                  eventType: payout.completed
                  attempts: 2
                  deliveredAt: "2026-08-20T14:05:41.300Z"
                  nextAttemptAt: null
                  lastError: HTTP 500
                  createdAt: "2026-08-20T14:03:12.900Z"
              schema:
                type: array
                items:
                  type: object
                  required: [id, eventId, eventType, attempts, createdAt]
                  properties:
                    id: { type: string }
                    eventId:
                      type: string
                      description: |
                        The event's `id`: the `svix-id` header we sent, the `id` in
                        the delivered body, and the `id` of the same row in
                        `GET /events`. Dedupe on this.
                    eventType: { type: string, examples: ["payout.completed"] }
                    attempts: { type: integer }
                    deliveredAt:
                      type: [string, "null"]
                      format: date-time
                      description: Null until an attempt is accepted.
                    nextAttemptAt:
                      type: [string, "null"]
                      format: date-time
                    lastError:
                      type: [string, "null"]
                      description: |
                        The status your server answered with on the most recent
                        failure (`HTTP 500`), or the transport error. Never your response body; we do not store one.
                    createdAt: { type: string, format: date-time }

  /payments/organizations/{orgId}/payment-reasons:
    get:
      tags: [Corridors]
      operationId: listPaymentReasons
      summary: List payment reasons
      description: |
        Lists the values `purposeOfPayment` accepts.

        Some corridors require a stated purpose for the payment. `POST /payouts`
        and quote-accept take it as `purposeOfPayment`, and this is the
        catalog they validate against. Read it rather than guessing, because
        a rejected value is a 400 on a payout you have already promised
        somebody. Pass `currency` to get the catalog of the network that would
        carry a payout in that currency. The ids differ between networks (for
        example `FAMILY_SUPPORT` on one, `family_support` on another), so send
        back exactly an `id` from this list.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: currency
          in: query
          description: Destination currency, ISO 4217. Omit for the default catalog.
          schema: { type: string, pattern: '^[A-Za-z]{3}$', examples: ["MXN"] }
      responses:
        "400": { $ref: "#/components/responses/NoApprovedProvider" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The accepted payment reasons
          content:
            application/json:
              example:
                payment_reasons:
                  - { id: FAMILY_SUPPORT, label: Family support }
                  - { id: EDUCATION, label: Education fees }
                default: FAMILY_SUPPORT
              schema:
                type: object
                required: [payment_reasons]
                properties:
                  payment_reasons:
                    type: array
                    items:
                      type: object
                      required: [id, label]
                      properties:
                        id:
                          type: string
                          description: The value to send as `purposeOfPayment` (or `paymentReason`).
                        label: { type: string }
                  default:
                    type: string
                    description: The value used when a corridor requires one and you send none.

  /payments/organizations/{orgId}/policy:
    get:
      tags: [Policy]
      operationId: getPolicy
      summary: Get your policy
      description: |
        Returns your organization's caps, approval threshold, features and rate
        limits. Read this first. Everything a client needs to know about its own
        organization before it sends money, in one call: the payout caps
        (`null` means no cap), the approval threshold and how many approvers a
        held payout needs, which features are on (`mass_payouts` for batches,
        `developer` for webhook endpoints), the rate-limit buckets per minute,
        the idempotency windows, the currencies that require a
        `purposeOfPayment`, and where to read corridors, events and the ledger.

        Every value is what the server enforces with, read from your
        organization at request time, not a published table. A test key reads
        the sandbox's caps and approval policy (they are set per environment),
        `features` from the live organization the sandbox belongs to, and
        `mode: test`. A read-only key may read this.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The policy your organization is under right now.
          content:
            application/json:
              example:
                organizationId: cmsx0h2k900a1n11ib3qz9v42
                mode: test
                features: [developer, mass_payouts]
                limits:
                  maxSinglePayoutUsd: "5000.00"
                  maxDailyPayoutUsd: "25000.00"
                  maxDailyPerEndUserUsd: null
                approvals:
                  thresholdUsd: "1000.00"
                  requiredApprovals: 2
                  appliesTo: [payouts, batches, payout_links]
                purposeOfPayment:
                  requiredForCurrencies: [BRL, CNY, GHS, INR]
                fees:
                  payout:
                    bps: null
                    fixedUsd: null
                    byCurrency: {}
                    note: 'Not published before a quote: the fee is priced inside each quote. Read `fee` on the quote or the payout.'
                rateLimits:
                  default: 100
                  payouts: 600
                  batches: 30
                  reads: 600
                idempotency:
                  required: true
                  replayWindowDays: 7
                  nearDuplicateWindowMinutes: 15
                links:
                  corridors: /recipients/cmsx0h2k900a1n11ib3qz9v42/corridors
                  events: /payments/organizations/cmsx0h2k900a1n11ib3qz9v42/events
                  balanceTransactions: /payments/organizations/cmsx0h2k900a1n11ib3qz9v42/balance_transactions
              schema: { $ref: "#/components/schemas/Policy" }

  /payments/organizations/{orgId}/rates:
    get:
      tags: ["Quotes & rates"]
      operationId: getIndicativeQuote
      summary: Get an indicative rate
      description: |
        Price a corridor before a recipient exists.

        What the recipient receives, the fee, the rate, and the corridor's
        minimum and maximum, with nothing created yet. This is what you show
        while someone is still choosing an amount.

        **Indicative, not locked.** The binding price comes from
        `POST /quotes/offramp` against a real recipient. Show this as an
        estimate and confirm the final number before sending.

        The fee is deducted from the amount you send, so
        `destinationAmount = (sourceAmount - fee) x rate`.

        The query parameters are not validated on the server today. Always
        send `from`, `to` and a decimal `amount` as described: a missing
        currency can answer `500 INTERNAL` rather than a `400`, and a
        non-numeric amount returns amounts that are not numbers.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: from
          in: query
          required: true
          description: Three-letter ISO 4217 source currency code.
          schema: { type: string, pattern: '^[A-Za-z]{3}$', examples: ["USD"] }
        - name: to
          in: query
          required: true
          description: Three-letter ISO 4217 destination currency code.
          schema: { type: string, pattern: '^[A-Za-z]{3}$', examples: ["MXN"] }
        - name: amount
          in: query
          description: |
            Decimal string. **Omitting it prices $100** rather than returning an
            amountless rate, so a UI that renders the response shows a figure the
            user never typed. Pass the amount you are actually showing.
          schema: { type: string, pattern: '^\d+(\.\d{1,6})?$', examples: ["200.00"] }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Indicative price
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PreviewQuote" }
        "400":
          description: |
            `BAD_REQUEST`: no such corridor for this organization.
            `INDICATIVE_PRICING_UNAVAILABLE`: your routing publishes no
            indicative rates. Do not retry; check
            `capabilities.indicativePricing` on the corridors call and price
            against a real recipient with `POST /quotes/offramp` instead.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                noCorridor:
                  value:
                    type: BAD_REQUEST
                    status: 400
                    detail: No payments provider available for this request
                    requestId: req-9uh
                    message: No payments provider available for this request
                    statusCode: 400
                indicativeUnavailable:
                  value:
                    type: INDICATIVE_PRICING_UNAVAILABLE
                    status: 400
                    detail: This organization is routed to a network that does not publish indicative rates. Do not retry — check `capabilities.indicativePricing` on the corridors call, and price against a real beneficiary instead.
                    requestId: req-9ui
                    message: This organization is routed to a network that does not publish indicative rates. Do not retry — check `capabilities.indicativePricing` on the corridors call, and price against a real beneficiary instead.
                    statusCode: 400
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /recipients/{orgId}:
    get:
      tags: [Recipients]
      operationId: listBeneficiaries
      summary: List recipients
      description: |
        Lists your recipients, optionally for one of your end users.

        **Pass `endUserId` for anything shown to an end user.** Omitting it
        returns every recipient in your organization, which on a
        consumer-facing screen means one of your users seeing another's saved
        bank accounts. The response states which scope was applied.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: endUserId
          in: query
          description: Your id for the person sending the money.
          schema: { type: string, minLength: 1, maxLength: 128, examples: ["customer_42"] }
        - name: limit
          in: query
          description: Page size, 1–100; default 50. Outside the range is a `400`, not clamped.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          description: |
            The opaque `nextCursor` from the previous page. Pass it back
            unchanged; do not parse or construct it. A cursor we did not issue
            is a `400`.
          schema: { type: string }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Recipients
          content:
            application/json:
              example:
                scope: organization
                recipients:
                  - id: cmf3k2xa10004q8b7r5t8u1vw
                    name: Ana Ruiz
                    email: ana.ruiz@example.com
                    country: MX
                    externalId: payroll-4471
                    endUserId: customer_42
                    type: individual
                    phone: "+525512345678"
                    createdAt: "2026-08-20T13:58:02.000Z"
                    updatedAt: "2026-08-20T13:58:02.000Z"
                    organizationId: cmsx0h2k900a1n11ib3qz9v42
                    paymentMethods:
                      - id: cmf3k2xa10005q8b7w9x2y3za
                        kind: fiat
                        currency: MXN
                        last4: "4471"
                        status: active
                        destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
                hasMore: false
                nextCursor: null
              schema:
                type: object
                required: [scope, recipients, hasMore, nextCursor]
                properties:
                  scope:
                    description: Which scope was applied.
                    oneOf:
                      - type: string
                        const: organization
                      - type: object
                        properties:
                          endUserId: { type: string }
                  recipients:
                    type: array
                    items: { $ref: "#/components/schemas/Beneficiary" }
                  hasMore: { type: boolean }
                  nextCursor:
                    type: [string, "null"]
                    description: Pass back as `cursor` when `hasMore` is true.
        "400": { $ref: "#/components/responses/ListBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

    post:
      tags: [Recipients]
      operationId: createBeneficiary
      summary: Create a recipient
      description: |
        Register who is being paid.

        `method.recipientDetails` carries the corridor's fields, exactly as
        named by `GET /corridors`.

        Two separate protections, and they do different jobs:
        `Idempotency-Key` makes a *retry* safe; `externalId` makes a *repeat*
        safe by returning the existing recipient instead of registering a
        second bank account. Send both. The repeat only matches when the
        account details are the same: an `externalId` you already used with
        a different account is `409 BENEFICIARY_EXTERNAL_ID_CONFLICT`, and a
        second account needs a second `externalId`.

        A recipient refused by screening is `422 PAYOUT_REFUSED`. The
        recipient is still stored, blocked, and every payout to it is refused
        the same way; a repeat with the same `externalId` returns it with
        `201`. Do not retry the registration.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateBeneficiary" }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: Created, or the existing recipient for this externalId
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BeneficiaryRegistration" }
        "400":
          description: Validation failed, or a corridor field is missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: 1 field(s) failed validation for MXN.
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - 'clabeNumber: CLABE does not match the required format (^[0-9]{18}$)'
                    requestId: req-9pc
                    message: 1 field(s) failed validation for MXN.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "409":
          description: |
            `BANK_ACCOUNT_ALREADY_LINKED`: this bank account is already saved
            on another recipient (`existingRecipientId`, `existingMethodId`).
            `BENEFICIARY_EXTERNAL_ID_CONFLICT`: this `externalId` already
            names a recipient with different account details; nothing was
            changed. Or an idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`,
            `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                externalIdConflict:
                  value:
                    type: BENEFICIARY_EXTERNAL_ID_CONFLICT
                    status: 409
                    detail: externalId "payroll-4471" already identifies a beneficiary with different account details. Nothing was changed. Use a new externalId for a different account, or read the existing beneficiary if this was meant as a retry.
                    requestId: req-9vj
                    message: externalId "payroll-4471" already identifies a beneficiary with different account details. Nothing was changed. Use a new externalId for a different account, or read the existing beneficiary if this was meant as a retry.
                    statusCode: 409
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
                alreadyLinked:
                  value:
                    type: BANK_ACCOUNT_ALREADY_LINKED
                    status: 409
                    detail: This bank account is already saved on María González.
                    existingRecipientId: cmf3k2xe80004q8b7a1c2d3e4
                    existingMethodId: cmf3k2xe80005q8b7f5g6h7i8
                    requestId: req-9vi
                    message: This bank account is already saved on María González.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
        "422": { $ref: "#/components/responses/PayoutRefused" }
        "503":
          description: Provider registration is temporarily unavailable; retry safely.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unavailable:
                  value:
                    type: PAYOUT_ACCOUNT_PROVIDER_UNAVAILABLE
                    status: 503
                    detail: Payout account registration is temporarily unavailable. Please retry.
                    resolution: No beneficiary account was saved locally. Retry the same request and Idempotency-Key.
                    requestId: req-9ob
                    message: Payout account registration is temporarily unavailable. Please retry.
                    statusCode: 503

  /recipients/{orgId}/{recipientId}/methods:
    post:
      tags: [Recipients]
      operationId: addBeneficiaryMethod
      summary: Add a payment method
      description: |
        Add another way to pay an existing recipient.

        Use this when the same person can be paid in more than one currency or
        over more than one rail. Creating a second recipient instead
        duplicates them in your book and in ours.

        The response is the whole recipient, with `method` naming the one this
        call added.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: recipientId
          in: path
          required: true
          description: Opaque recipient id returned by this API. Pass it unchanged.
          schema: { type: string, minLength: 1 }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentMethodInput"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The recipient, with `method` set to the payment method just added
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BeneficiaryRegistration" }
        "400":
          description: Validation failed, or a corridor field is missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: 1 field(s) failed validation for MXN.
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - 'clabeNumber: CLABE does not match the required format (^[0-9]{18}$)'
                    requestId: req-9pc
                    message: 1 field(s) failed validation for MXN.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "404":
          description: Unknown or deleted recipient
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Recipient not found
                    requestId: req-9l8
                    message: Recipient not found
                    statusCode: 404
        "409":
          description: |
            `BANK_ACCOUNT_ALREADY_LINKED`: this bank account is already saved
            on a recipient (`existingRecipientId`, `existingMethodId`). Or an
            idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`,
            `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                alreadyLinked:
                  value:
                    type: BANK_ACCOUNT_ALREADY_LINKED
                    status: 409
                    detail: This bank account is already saved on María González.
                    existingRecipientId: cmf3k2xe80004q8b7a1c2d3e4
                    existingMethodId: cmf3k2xe80005q8b7f5g6h7i8
                    requestId: req-9vi
                    message: This bank account is already saved on María González.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
        "503":
          description: Provider registration is temporarily unavailable; retry safely.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unavailable:
                  value:
                    type: PAYOUT_ACCOUNT_PROVIDER_UNAVAILABLE
                    status: 503
                    detail: Payout account registration is temporarily unavailable. Please retry.
                    resolution: No beneficiary account was saved locally. Retry the same request and Idempotency-Key.
                    requestId: req-9ob
                    message: Payout account registration is temporarily unavailable. Please retry.
                    statusCode: 503

  /recipients/{orgId}/external/{externalId}:
    get:
      tags: [Recipients]
      operationId: getBeneficiaryByExternalId
      summary: Get a recipient by external ID
      description: |
        Look a recipient up by your id for them.

        `externalId` was already the key that makes creation idempotent. Send it again and you get the existing recipient rather than a second bank
        account registered at the rail. This reads it back, so the route from
        "worker 4471" to a `destinationAccountId` is a point lookup instead of
        paging your whole book and filtering client-side.

        Unique per organization, so this returns exactly one recipient or 404.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: externalId
          in: path
          required: true
          description: The `externalId` you sent when you created the recipient.
          schema: { type: string, minLength: 1, maxLength: 128, examples: ["payroll-4471"] }
        - name: endUserId
          in: query
          description: |
            Your id for the person the recipient belongs to. **Pass it for
            anything rendered to an end user.** Without it any recipient in
            your organization matches, including org-wide ones your admins
            created. A mismatch answers 404, not 403, so the response never
            confirms that someone else's recipient carries this id.
          schema: { type: string, minLength: 1, maxLength: 128, examples: ["customer_42"] }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The recipient
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Beneficiary" }
        "404":
          description: No recipient carries that externalId
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Recipient not found
                    requestId: req-9l8
                    message: Recipient not found
                    statusCode: 404

  /recipients/{orgId}/{recipientId}:
    get:
      tags: [Recipients]
      operationId: getBeneficiary
      summary: Get a recipient
      description: |
        One recipient by id.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: recipientId
          in: path
          required: true
          description: Opaque recipient id returned by this API. Pass it unchanged.
          schema: { type: string, minLength: 1 }
        - name: endUserId
          in: query
          description: |
            Your id for the person the recipient belongs to. **Pass it for
            anything rendered to an end user.** Without it any recipient in
            your organization matches, including org-wide ones your admins
            created. A mismatch answers 404, not 403, so the response never
            confirms that someone else's recipient carries this id.
          schema: { type: string, minLength: 1, maxLength: 128, examples: ["customer_42"] }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The recipient
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Beneficiary" }
        "404":
          description: Unknown recipient
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Recipient not found
                    requestId: req-9l8
                    message: Recipient not found
                    statusCode: 404
    patch:
      tags: [Recipients]
      operationId: updateBeneficiary
      summary: Update a recipient
      description: |
        Correct a recipient's own details.

        Contact details only: name, email, phone, country, individual/business.
        An empty `name`, `email` or `country` is ignored rather than clearing
        the value; `phone: ""` clears the phone.

        **The bank account behind a payment method cannot be edited, by
        design.** The rail validated that account; silently swapping it would
        send the next payout somewhere you never registered. A wrong account is
        a new payment method, and the old one is deleted.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
        - name: recipientId
          in: path
          required: true
          description: Opaque recipient id returned by this API. Pass it unchanged.
          schema: { type: string, minLength: 1 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                type: { type: string, enum: [individual, business] }
                name: { type: string, minLength: 1 }
                email: { type: string, format: email }
                phone: { type: string }
                country:
                  type: string
                  minLength: 2
                  maxLength: 2
                  description: ISO-3166 alpha-2.
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated recipient
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Beneficiary" }
        "400":
          description: Validation failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: 1 field(s) failed validation
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - email must be an email
                    requestId: req-9wj
                    message: 1 field(s) failed validation
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "404":
          description: Unknown recipient
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Recipient not found
                    requestId: req-9l8
                    message: Recipient not found
                    statusCode: 404
    delete:
      tags: [Recipients]
      operationId: deleteBeneficiary
      summary: Delete a recipient
      description: |
        Remove a recipient and every payment method on it.

        Payouts already sent are unaffected; they are history, not references.
        Deleting does not cancel anything in flight.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
        - name: recipientId
          in: path
          required: true
          description: Opaque recipient id returned by this API. Pass it unchanged.
          schema: { type: string, minLength: 1 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success: { type: boolean, const: true }
        "400": { $ref: "#/components/responses/IdempotencyInvalid" }
        "404":
          description: Unknown recipient
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Recipient not found
                    requestId: req-9l8
                    message: Recipient not found
                    statusCode: 404

  /recipients/{orgId}/{recipientId}/methods/{methodId}:
    delete:
      tags: [Recipients]
      operationId: deleteBeneficiaryMethod
      summary: Delete a payment method
      description: |
        Remove one way of paying a recipient.

        Use this when an account is closed or was entered wrong. The
        recipient and their other methods are untouched.

        The `destinationAccountId` this method carried stops being payable.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
        - name: recipientId
          in: path
          required: true
          description: Opaque recipient id returned by this API. Pass it unchanged.
          schema: { type: string, minLength: 1 }
        - name: methodId
          in: path
          required: true
          description: From the recipient's `paymentMethods[].id`.
          schema: { type: string, minLength: 1 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The recipient, without that method
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Beneficiary" }
        "400": { $ref: "#/components/responses/IdempotencyInvalid" }
        "404":
          description: Unknown recipient or method
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Payment method not found
                    requestId: req-9m9
                    message: Payment method not found
                    statusCode: 404

  /recipients/{orgId}/{recipientId}/methods/{methodId}/details:
    get:
      tags: [Recipients]
      operationId: getBeneficiaryMethodDetails
      summary: Get payment method details
      description: |
        Returns the full account details behind one payment method. Lists
        carry only `last4`. This returns what was actually registered, which is useful for showing a user the account on file before they authorize a
        payout, and for confirming what you stored matches what we hold.

        Fetch it on demand for one method. List payloads leave it out so bulk
        reads do not move full account numbers around. A read-only key is
        refused this read (`403 INSUFFICIENT_SCOPE`); lists carry `last4`,
        which is what reconciliation needs.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: recipientId
          in: path
          required: true
          description: Opaque recipient id returned by this API. Pass it unchanged.
          schema: { type: string, minLength: 1 }
        - name: methodId
          in: path
          required: true
          description: From the recipient's `paymentMethods[].id`.
          schema: { type: string, minLength: 1 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `FORBIDDEN` (a key for a different organization), `ACCOUNT_BLOCKED`
            (API access suspended), or `INSUFFICIENT_SCOPE` (a read-only key:
            full account details need the `write` scope).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                forbidden: { $ref: "#/components/examples/Forbidden" }
                accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
                insufficientScope:
                  value:
                    type: INSUFFICIENT_SCOPE
                    status: 403
                    detail: This API key is read-only and cannot read full account details. Lists carry `last4`, which is what most reconciliation needs; issue a key with the `write` scope if you genuinely need the registered account back.
                    requestId: req-9d5
                    message: This API key is read-only and cannot read full account details. Lists carry `last4`, which is what most reconciliation needs; issue a key with the `write` scope if you genuinely need the registered account back.
                    statusCode: 403
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The registered account details
          content:
            application/json:
              example:
                id: cmf3k2xa10005q8b7w9x2y3za
                kind: fiat
                currency: MXN
                address: null
                details:
                  clabeNumber: "012180000080004471"
              schema:
                type: object
                required: [id, kind, currency, address, details]
                properties:
                  id: { type: string }
                  kind: { type: string, enum: [fiat, crypto] }
                  currency: { type: [string, "null"] }
                  address:
                    type: [string, "null"]
                    description: Crypto methods only. The destination wallet address.
                  details:
                    type: [object, "null"]
                    additionalProperties: true
                    description: |
                      The corridor fields as registered, keyed by the field ids
                      `GET /corridors` published (for example `clabeNumber`).
                      Null on a crypto method, and on a method saved before we
                      kept the full details.
        "404":
          description: Unknown recipient or method
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Payment method not found
                    requestId: req-9m9
                    message: Payment method not found
                    statusCode: 404

  /payments/organizations/{orgId}/quotes/offramp:
    post:
      tags: ["Quotes & rates"]
      operationId: pricePayout
      summary: Quote a payout
      description: |
        Lock a price against a real recipient.

        Returns a snapshot with the locked rate and the amount the recipient
        receives. **No money moves.** Your caps and screening run here too, so
        an over-cap amount or a refused recipient fails before a quote is
        spent (`422`).

        Where an underfunded balance fails depends on the environment. The
        sandbox checks it here (`400 BAD_REQUEST`, "Insufficient USD balance
        … Top up with POST …/sandbox/fund"). Live, the balance is held when
        you accept the quote, so `400 INSUFFICIENT_BALANCE` comes from
        `POST /quotes/accept` or `POST /payouts`.

        Quotes expire. Price and send close together.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [amount, destinationAccountId]
              properties:
                amount:
                  type: string
                  pattern: '^\d+(\.\d{1,6})?$'
                  description: |
                    Decimal string with at most six fractional digits. Its
                    currency is selected by `amountLeg`.
                  examples: ["200.00"]
                destinationAccountId:
                  type: string
                  minLength: 1
                  description: From the recipient's `paymentMethods[].destinationAccountId`.
                purposeOfPayment:
                  type: string
                  description: |
                    Corridor-defined payment purpose, from `GET /payment-reasons`.
                    Required for payouts to INR, GHS, CNY and BRL (`400
                    VALIDATION_ERROR` naming the field, before anything is
                    priced); validated against the catalog whenever you send
                    it; never defaulted. Do not invent a default for regulated
                    payments.
                  examples: ["FAMILY_SUPPORT"]
                amountLeg:
                  type: string
                  enum: [source, destination, source_net]
                  default: source
                  description: |
                    `source` prices the amount to debit. `destination` locks the
                    amount the recipient receives in their currency.
                    `source_net` starts with an amount in your source currency,
                    converts it at the published market rate, and locks that
                    destination amount. The two exact-output modes require
                    `capabilities.exactOutput: true` from the corridors call.
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `USE_POST_PAYOUTS`: this organization has an approval threshold or
            a velocity cap, and only `POST /payouts` enforces them. Nothing was
            sent; send the payout through `POST /payouts`, which may answer
            `202 pending_approval`. Also the refusals every write can answer:
            `FORBIDDEN` (a key for a different organization),
            `ACCOUNT_BLOCKED`, `LIVE_KEY_ORG_NOT_APPROVED` and
            `INSUFFICIENT_SCOPE`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                usePostPayouts:
                  value:
                    type: USE_POST_PAYOUTS
                    status: 403
                    detail: This organization has payout controls (approvals or limits) that are enforced on POST /payouts only. Create the payout there instead. Nothing was sent.
                    resolution: Nothing was sent. This organization has payout controls that only POST /payouts enforces; send the payout through it (you may receive 202 pending_approval).
                    requestId: req-9k7
                    message: This organization has payout controls (approvals or limits) that are enforced on POST /payouts only. Create the payout there instead. Nothing was sent.
                    statusCode: 403
                forbidden: { $ref: "#/components/examples/Forbidden" }
                accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
                liveKeyOrgNotApproved: { $ref: "#/components/examples/LiveKeyOrgNotApproved" }
                insufficientScope: { $ref: "#/components/examples/InsufficientScope" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: A locked quote snapshot
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QuoteSnapshot" }
        "400":
          description: |
            Nothing was sent.

            - `VALIDATION_ERROR`: a field is malformed; `errors` names it.
            - `QUOTE_NOT_POSITIVE`: fees would leave the recipient with zero or
              less. Increase the amount and re-quote.
            - `EXACT_OUTPUT_UNSUPPORTED`: `amountLeg: destination` or
              `source_net` on a routing without `capabilities.exactOutput`.
            - `INDICATIVE_PRICING_UNAVAILABLE`: `amountLeg: source_net` on a
              routing that publishes no market rate. Send
              `amountLeg: destination` instead.
            - `BAD_REQUEST`: an unsupported corridor, an amount outside the
              corridor's minimum or maximum, or (sandbox only) an underfunded
              balance; `detail` says which.
            - `IDEMPOTENCY_KEY_INVALID`: a malformed `Idempotency-Key`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                sandboxInsufficientBalance:
                  value:
                    type: BAD_REQUEST
                    status: 400
                    detail: Insufficient USD balance for this payout. Balance 150.00, needed 200.00. Top up with POST /payments/organizations/{orgId}/sandbox/fund.
                    requestId: req-9xk
                    message: Insufficient USD balance for this payout. Balance 150.00, needed 200.00. Top up with POST /payments/organizations/{orgId}/sandbox/fund.
                    statusCode: 400
                exactOutputUnsupported:
                  value:
                    type: EXACT_OUTPUT_UNSUPPORTED
                    status: 400
                    detail: Locking the amount the beneficiary receives is not available for this account. Send the amount you want to debit instead, and show your user the resulting destination amount.
                    requestId: req-9xl
                    message: Locking the amount the beneficiary receives is not available for this account. Send the amount you want to debit instead, and show your user the resulting destination amount.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "404": { $ref: "#/components/responses/DestinationNotFound" }
        "422": { $ref: "#/components/responses/PayoutControlRefused" }

  /payments/organizations/{orgId}/quotes/accept:
    post:
      tags: ["Quotes & rates"]
      operationId: sendPayout
      summary: Accept a quote
      description: |
        Send the money.

        **This is the step that moves money.** It debits your balance and
        returns the payout. A balance-funded payout needs nothing else. A
        payout you fund yourself comes back with `requiresFunding: true`: read
        `GET /payouts/{payoutId}/funding`, send the money, and confirm it with
        `POST /payouts/{payoutId}/funding/confirm`.

        If this times out, the outcome is unknown: the payout may have been
        accepted. **Retry with the same `Idempotency-Key`.** A replay returns
        the original payout; re-quoting sends a second payment. A
        `500 PAYOUT_OUTCOME_UNKNOWN` means the same thing: do not re-quote or
        mint a new key; look for the payout with
        `GET /orders?reference=<your reference>` first.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/AllowDuplicate"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [snapshotId, type]
              properties:
                snapshotId:
                  type: string
                  minLength: 1
                  description: The snapshot's `id` from the pricing step.
                quoteId:
                  type: string
                  minLength: 1
                  description: The snapshot's `best_quote_id`.
                type:
                  type: string
                  const: OFFRAMP
                  description: Fixed payout quote type. Send the literal string `OFFRAMP`.
                reference:
                  type: string
                  pattern: '^[A-Za-z0-9 :-]*$'
                  description: Your payment reference. Echoed back and searchable.
                endUser:
                  $ref: "#/components/schemas/EndUser"
                files:
                  type: array
                  description: |
                    Compliance attachments as data URIs. Each item is
                    `data:<mime>;base64,<contents>`; use PDF, JPEG, or PNG, up
                    to 5 MB each. Some regulated corridors require an invoice.
                    When both `files` and `attachment` are sent, `files` wins.
                  items:
                    type: string
                    pattern: '^data:(application/pdf|image/jpeg|image/png);base64,.+$'
                    description: One PDF, JPEG, or PNG encoded as a base64 data URI.
                attachment:
                  type: string
                  pattern: '^data:(application/pdf|image/jpeg|image/png);base64,.+$'
                  description: Single-attachment convenience using the same data-URI format as `files`.
                paymentReason:
                  type: string
                  description: |
                    Payment purpose, from `GET /payment-reasons` (its `id`).
                    The vocabulary depends on how your organization is routed,
                    so read it rather than hardcoding values.
                  examples: ["FAMILY_SUPPORT"]
                comment:
                  type: string
                  description: Free-text note forwarded to compliance.
                attestedAt:
                  type: string
                  format: date-time
                  description: When the supporting document was attested as genuine.
                signerName:
                  type: string
                  description: Name of the person who made the attestation.
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `USE_POST_PAYOUTS`: this organization has an approval threshold or
            a velocity cap, and only `POST /payouts` enforces them. Nothing was
            sent; send the payout through `POST /payouts`, which may answer
            `202 pending_approval`. Also the refusals every write can answer:
            `FORBIDDEN` (a key for a different organization),
            `ACCOUNT_BLOCKED`, `LIVE_KEY_ORG_NOT_APPROVED` and
            `INSUFFICIENT_SCOPE`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                usePostPayouts:
                  value:
                    type: USE_POST_PAYOUTS
                    status: 403
                    detail: This organization has payout controls (approvals or limits) that are enforced on POST /payouts only. Create the payout there instead. Nothing was sent.
                    resolution: Nothing was sent. This organization has payout controls that only POST /payouts enforces; send the payout through it (you may receive 202 pending_approval).
                    requestId: req-9k7
                    message: This organization has payout controls (approvals or limits) that are enforced on POST /payouts only. Create the payout there instead. Nothing was sent.
                    statusCode: 403
                forbidden: { $ref: "#/components/examples/Forbidden" }
                accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
                liveKeyOrgNotApproved: { $ref: "#/components/examples/LiveKeyOrgNotApproved" }
                insufficientScope: { $ref: "#/components/examples/InsufficientScope" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: |
            The payout. A replayed idempotent request returns the original and
            sets the `Idempotency-Replayed` response header.
          headers:
            Idempotency-Replayed:
              schema: { type: boolean }
              description: Present and true when this response was replayed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payout" }
        "400":
          description: |
            Nothing was sent.

            - `BAD_REQUEST`: the quote expired or is not one we issued (price
              again), or a field this route does not take (`provider`,
              `billId`, `fundingId`); `detail` says which.
            - `INSUFFICIENT_BALANCE`: your balance does not cover the payout.
              Top up and quote again.
            - `VALIDATION_ERROR`: a field is malformed; `errors` names it.
            - `IDEMPOTENCY_KEY_REQUIRED` or `IDEMPOTENCY_KEY_INVALID`: the
              header is missing or malformed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                insufficientBalance:
                  value:
                    type: INSUFFICIENT_BALANCE
                    status: 400
                    detail: Insufficient USD balance for this payout. Balance 150.00 USD, needed 200.00 USD. Nothing was sent. Top up by wiring to the account on GET /payments/organizations/{orgId}/payin-accounts.
                    requestId: req-9xk
                    message: Insufficient USD balance for this payout. Balance 150.00 USD, needed 200.00 USD. Nothing was sent. Top up by wiring to the account on GET /payments/organizations/{orgId}/payin-accounts.
                    statusCode: 400
                missingKey:
                  value:
                    type: IDEMPOTENCY_KEY_REQUIRED
                    status: 400
                    detail: Idempotency-Key header is required. Use a unique value per operation, and reuse it to retry.
                    resolution: Add an Idempotency-Key header with a unique value for this operation.
                    requestId: req-9f2
                    message: Idempotency-Key header is required. Use a unique value per operation, and reuse it to retry.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "409": { $ref: "#/components/responses/MoneyIdempotencyConflict" }
        "500": { $ref: "#/components/responses/OutcomeUnknown" }

  /payments/organizations/{orgId}/payouts:
    post:
      tags: [Payouts]
      operationId: createPayout
      summary: Create a payout
      description: |
        Price and send in one call.

        Build on this endpoint. It prices and sends in a single request,
        which is what makes a retry safe: the body you send is the body you can
        send again. Pricing separately and then sending means a retry re-prices,
        produces a different request, and the `Idempotency-Key` meant to protect
        the retry conflicts with itself instead.

        Pass `expectDestination`, the amount you told the payer they would receive. If the binding quote has moved further than `maxDriftBps` from
        it, we refuse and **nothing is sent**.

        If this times out the outcome is unknown and the payout may exist.
        **Retry with the same `Idempotency-Key`.** A
        `500 PAYOUT_OUTCOME_UNKNOWN` means the same thing: never retry it under
        a new key. Look for the payout with
        `GET /orders?reference=<your reference>` first.

        **202 instead of 200.** When your organization requires approval on
        API payouts and this one is above the threshold, nothing is priced or
        sent: you get `{ status: "pending_approval", approvalId, … }`. Watch
        `GET /payouts/approvals/{approvalId}` or the `payout_approval.*`
        events; `payout_approval.executed` names the `payoutId` it became.
        Your approvers act in the dashboard; a key cannot approve.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/AllowDuplicate"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePayout" }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: |
            The payout. A replayed idempotent request returns the original and
            sets the `Idempotency-Replayed` response header.
          headers:
            Idempotency-Replayed:
              schema: { type: boolean }
          content:
            application/json:
              example:
                payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                status: pending
                stage: awaiting_details
                sourceAmount: { currency: USD, amount: "200.00" }
                destinationAmount: { currency: MXN, amount: "3384.65" }
                destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
                fee: { currency: USD, amount: "1.02" }
                rate: "16.923250"
                reference: ZZ-WAGE-1
                endUser: { id: customer_42, name: Northwind Payroll LLC, email: payroll@northwind.example }
                createdAt: "2026-08-20T14:03:11.000Z"
                updatedAt: "2026-08-20T14:03:11.000Z"
                completedAt: null
              schema: { $ref: "#/components/schemas/Payout" }
        "202":
          description: |
            Held for your approvers. Nothing was priced or sent. Idempotent: a
            replay of the same key returns the same approval.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PendingApproval" }
        "400":
          description: |
            **Nothing was sent** on any of these.

            - `VALIDATION_ERROR`: a field is malformed; `errors` names it.
            - `INSUFFICIENT_BALANCE`: your balance does not cover the payout.
            - `RATE_DRIFT_EXCEEDED`, `QUOTE_UNVERIFIABLE`: the drift guard
              refused. Re-quote and send again.
            - `QUOTE_NOT_POSITIVE`: fees would leave the recipient with zero
              or less. Increase the amount first.
            - `EXACT_OUTPUT_UNSUPPORTED`: `amountLeg: destination` or
              `source_net` on a routing without `capabilities.exactOutput`.
            - `INDICATIVE_PRICING_UNAVAILABLE`: `amountLeg: source_net` on a
              routing that publishes no market rate.
            - `DESTINATION_ACCOUNT_NOT_FOUND` (as a `400`): `amountLeg:
              source_net` for a recipient with no payout currency on file. An
              unknown `destinationAccountId` is a `404`.
            - `BAD_REQUEST`: the network refused the quote (for example it
              expired) or the amount is outside the corridor's range; `detail`
              says which.
            - `IDEMPOTENCY_KEY_REQUIRED`, `IDEMPOTENCY_KEY_INVALID`: the
              header is missing or malformed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: 1 field(s) failed validation
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - amount must be a decimal string with at most 2 decimal places, e.g. "200.00"
                    requestId: req-9yl
                    message: 1 field(s) failed validation
                    statusCode: 400
                rateDrift:
                  value:
                    type: RATE_DRIFT_EXCEEDED
                    status: 400
                    detail: 'Refusing to send: quoted 3301.20 but you expected ~3384.65 (247 bps of drift, limit 200). Re-quote and confirm with the payer. Nothing was sent.'
                    resolution: Nothing was sent. Re-quote, show the payer the new amount, and send again.
                    requestId: req-9zm
                    message: 'Refusing to send: quoted 3301.20 but you expected ~3384.65 (247 bps of drift, limit 200). Re-quote and confirm with the payer. Nothing was sent.'
                    statusCode: 400
                insufficientBalance:
                  value:
                    type: INSUFFICIENT_BALANCE
                    status: 400
                    detail: Insufficient USD balance for this payout. Balance 150.00 USD, needed 200.00 USD. Nothing was sent. Top up by wiring to the account on GET /payments/organizations/{orgId}/payin-accounts.
                    requestId: req-a0n
                    message: Insufficient USD balance for this payout. Balance 150.00 USD, needed 200.00 USD. Nothing was sent. Top up by wiring to the account on GET /payments/organizations/{orgId}/payin-accounts.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "404": { $ref: "#/components/responses/DestinationNotFound" }
        "409": { $ref: "#/components/responses/MoneyIdempotencyConflict" }
        "422": { $ref: "#/components/responses/PayoutControlRefused" }
        "500": { $ref: "#/components/responses/OutcomeUnknown" }

  /payments/organizations/{orgId}/payouts/batches:
    post:
      tags: [Payout batches]
      operationId: createPayoutBatch
      summary: Create a payout batch
      description: |
        Submit up to 1,000 payouts as one run.

        Mass payouts. Each line of `items` is exactly a `POST /payouts` body;
        the batch validates every line first (nothing is priced or debited),
        then turns the valid lines into ordinary payouts.

        `202` means **received**, not paid: poll `GET .../batches/{batchId}` or
        subscribe to the `payout_batch.*` webhooks. With `autoCommit: true`
        (the default) a run with zero validation errors proceeds straight to
        creation. When your organization requires approvals, `autoCommit` is
        forced to `false` (and echoed back as `false`) so the run waits for
        `POST .../confirm`. If any line fails validation, or `autoCommit` is false, the batch holds at `awaiting_confirmation`: read
        `GET .../batches/{batchId}/items?status=invalid`, then either
        `POST .../confirm` to proceed with the valid lines or
        `POST .../cancel` to stop the run.

        The batch tracks creation. Once a line is `created` it carries a
        `payoutId` and that payout lives the ordinary payout lifecycle: `payout.*` webhooks, `GET /orders/{payoutId}`, the event feed. A batch
        that reads `completed` is a run whose every line was resolved, not a
        claim that the money has settled.

        The `Idempotency-Key` covers the run. A submit loop that dies and
        resubmits the same file under the same key gets the same batch back, never a second payroll.
        A `500 PAYOUT_OUTCOME_UNKNOWN` means the run may exist: list batches
        by `externalReferenceId` before submitting again, and never under a
        new key.

        Batch submission has its own rate limit: 30 per minute.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/AllowDuplicate"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [externalReferenceId, items]
              properties:
                externalReferenceId:
                  type: string
                  minLength: 1
                  maxLength: 128
                  pattern: '^[A-Za-z0-9 :._-]*$'
                  description: |
                    Your own run id (a payroll file name, a cycle id). Required,
                    and unique per organization: a second batch with the same id
                    answers `409 PAYOUT_BATCH_DUPLICATE_REFERENCE` naming the
                    original, and nothing is submitted. The `Idempotency-Key`
                    protects this HTTP attempt for 7 days; the run id protects
                    the payroll run forever, including a re-run under a fresh
                    key. A corrected resubmission is a new run and needs its own
                    id (for example `payroll-2026-09-01-r2`). Echoed back and
                    filterable on the batch list.
                autoCommit:
                  type: boolean
                  default: true
                  description: |
                    True: a run with zero validation errors proceeds straight
                    to creation. False, or any errors found: the batch holds at
                    `awaiting_confirmation` for your review.
                items:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  description: The payout instructions, in order.
                  items: { $ref: "#/components/schemas/CreatePayout" }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenBatch" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "202":
          description: |
            The batch, in `received`. Nothing has been validated yet, let
            alone paid. A replayed idempotent request returns the original and
            sets the `Idempotency-Replayed` response header.
          headers:
            Idempotency-Replayed:
              schema: { type: boolean }
          content:
            application/json:
              example:
                batchId: cmf3k2xg00009q8b7v0w2x4yz
                externalReferenceId: payroll-2026-09-01
                status: received
                autoCommit: true
                counts: { received: 250, invalid: 0, validated: 0, creating: 0, created: 0, create_failed: 0, canceled: 0, requires_review: 0 }
                estimatedSourceTotal: null
                createdAt: "2026-09-01T14:03:11.000Z"
                updatedAt: "2026-09-01T14:03:11.000Z"
                completedAt: null
              schema: { $ref: "#/components/schemas/PayoutBatch" }
        "400":
          description: Validation of the envelope itself (a malformed line, too many lines, a bad `externalReferenceId`), or a missing or malformed `Idempotency-Key` (`IDEMPOTENCY_KEY_REQUIRED`, `IDEMPOTENCY_KEY_INVALID`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: 1 field(s) failed validation
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - items.3.amount must be a decimal string with at most 2 decimal places, e.g. "200.00"
                    requestId: req-a2p
                    message: 1 field(s) failed validation
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "409":
          description: |
            `PAYOUT_BATCH_DUPLICATE_REFERENCE`: a batch with this
            `externalReferenceId` already exists. The run id is unique per
            organization, which is the guard against a submit job that crashed
            and re-ran with a fresh key. `originalBatchId` names the existing
            run; **nothing was submitted**. Or an idempotency conflict
            (`IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`,
            `PAYOUT_OUTCOME_UNKNOWN` on a replay, `DUPLICATE_REQUEST_DETECTED`;
            see `MoneyIdempotencyConflict`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                duplicateReference:
                  value:
                    type: PAYOUT_BATCH_DUPLICATE_REFERENCE
                    status: 409
                    detail: A batch with externalReferenceId "payroll-2026-09-01" already exists (cmf3k2xg00009q8b7v0w2x4yz). NOTHING WAS SUBMITTED. If this is a retry, read that batch. If this is genuinely a new run, give it its own externalReferenceId.
                    requestId: req-a3q
                    message: A batch with externalReferenceId "payroll-2026-09-01" already exists (cmf3k2xg00009q8b7v0w2x4yz). NOTHING WAS SUBMITTED. If this is a retry, read that batch. If this is genuinely a new run, give it its own externalReferenceId.
                    originalBatchId: cmf3k2xg00009q8b7v0w2x4yz
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
                outcomeUnknown: { $ref: "#/components/examples/OutcomeUnknownReplay" }
        "500": { $ref: "#/components/responses/OutcomeUnknown" }
    get:
      tags: [Payout batches]
      operationId: listPayoutBatches
      summary: List payout batches
      description: |
        Your runs, newest first.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: status
          in: query
          required: false
          description: One batch status to filter by.
          schema:
            type: string
            enum: [received, validating, awaiting_confirmation, creating, completed, canceled, failed]
        - name: externalReferenceId
          in: query
          required: false
          description: Exact match on your own run id.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: Page size, 1-100. Defaults to 50. Outside the range is a `400`, not clamped.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          required: false
          description: The `nextCursor` from your previous page. A cursor we did not issue is a 400, never an empty page.
          schema: { type: string }
      responses:
        "400": { $ref: "#/components/responses/ListBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: One page of batches.
          content:
            application/json:
              schema:
                type: object
                required: [data, hasMore, nextCursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PayoutBatch" }
                  hasMore: { type: boolean }
                  nextCursor:
                    type: [string, "null"]
                    description: Pass as `cursor` to continue. Null on the last page.
                    example: null

  /payments/organizations/{orgId}/payouts/batches/{batchId}:
    get:
      tags: [Payout batches]
      operationId: getPayoutBatch
      summary: Get a payout batch
      description: |
        Returns one run with its counts. This is the authoritative batch state. `counts` sums to the number of lines
        submitted; `created` lines carry payouts you track individually.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/BatchId"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "404":
          description: "`BATCH_NOT_FOUND`: no such batch in this organization."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: BATCH_NOT_FOUND
                    status: 404
                    detail: No payout batch cmf3k2xg00009q8b7v0w2x4yz belongs to this organization.
                    requestId: req-9na
                    message: No payout batch cmf3k2xg00009q8b7v0w2x4yz belongs to this organization.
                    statusCode: 404
        "200":
          description: The batch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutBatch" }

  /payments/organizations/{orgId}/payouts/batches/{batchId}/items:
    get:
      tags: [Payout batches]
      operationId: listPayoutBatchItems
      summary: List batch items
      description: |
        Lists every line of a run, in submitted order, with the instruction you sent echoed
        back verbatim. Join errors to your own file by content, not by
        counting rows. `?status=invalid` is the review screen after
        `awaiting_confirmation`; `?status=created` joins the run to the payout
        ledger; `?status=requires_review` are lines whose outcome could not be
        established and were not retried.

        `?format=csv` returns the run as a CSV file instead of a JSON page,
        honouring `status` and ignoring `limit` and `cursor`. Columns:
        `index,status,payoutId,amount,amountLeg,destinationAccountId,reference,errorCode,errorMessage`
        (the first error per line).
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/BatchId"
        - name: status
          in: query
          required: false
          description: One item status to filter by.
          schema:
            type: string
            enum: [received, invalid, validated, creating, created, create_failed, canceled, requires_review]
        - name: limit
          in: query
          required: false
          description: Page size, 1-1000. Defaults to 100.
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
        - name: cursor
          in: query
          required: false
          description: The `nextCursor` from your previous page, which is the last line index you saw. Not a whole number is a `400`.
          schema: { type: string }
        - name: format
          in: query
          required: false
          description: "`csv` for the whole run as a file. JSON otherwise."
          schema: { type: string, enum: [csv] }
      responses:
        "400": { $ref: "#/components/responses/ListBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "404":
          description: "`BATCH_NOT_FOUND`: no such batch in this organization."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: BATCH_NOT_FOUND
                    status: 404
                    detail: No payout batch cmf3k2xg00009q8b7v0w2x4yz belongs to this organization.
                    requestId: req-9na
                    message: No payout batch cmf3k2xg00009q8b7v0w2x4yz belongs to this organization.
                    statusCode: 404
        "200":
          description: One page of lines, or the CSV file when `format=csv`.
          content:
            application/json:
              schema:
                type: object
                required: [data, hasMore, nextCursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PayoutBatchItem" }
                  hasMore: { type: boolean }
                  nextCursor:
                    type: [string, "null"]
                    description: Pass as `cursor` to continue. Null on the last page.
                    example: null
            text/csv:
              schema: { type: string }

  /payments/organizations/{orgId}/payouts/batches/{batchId}/confirm:
    post:
      tags: [Payout batches]
      operationId: confirmPayoutBatch
      summary: Confirm a payout batch
      description: |
        Proceed with the valid lines of a held run.

        Only legal while the batch is `awaiting_confirmation`. The valid lines
        go to creation; `invalid` lines stay refused. Correct them and resubmit as a new batch. Confirming a batch that already moved on
        answers `409 PAYOUT_BATCH_NOT_CONFIRMABLE` naming where it actually is.

        **When your organization requires approval on API payouts**, the run
        is held at `awaiting_confirmation` whatever `autoCommit` said, and the
        first confirm answers **202** with one approval for the whole run, never one per line. A repeat confirm returns the same approval; once
        your approvers reach quorum the run is released within a minute. If an
        approver rejected it, confirm answers `409 PAYOUT_BATCH_AWAITING_APPROVAL`.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/BatchId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "400": { $ref: "#/components/responses/IdempotencyRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenBatch" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "404":
          description: "`BATCH_NOT_FOUND`: no such batch in this organization."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: BATCH_NOT_FOUND
                    status: 404
                    detail: No payout batch cmf3k2xg00009q8b7v0w2x4yz belongs to this organization.
                    requestId: req-9na
                    message: No payout batch cmf3k2xg00009q8b7v0w2x4yz belongs to this organization.
                    statusCode: 404
        "409":
          description: "`PAYOUT_BATCH_NOT_CONFIRMABLE`: the batch is not awaiting confirmation. `PAYOUT_BATCH_AWAITING_APPROVAL`: an approver rejected the run; the approval id is in `detail` (there is no `approvalId` field). Or an idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notConfirmable:
                  value:
                    type: PAYOUT_BATCH_NOT_CONFIRMABLE
                    status: 409
                    detail: This batch is creating, not awaiting_confirmation. It was already confirmed; read GET .../batches/{batchId} for progress.
                    requestId: req-a4r
                    message: This batch is creating, not awaiting_confirmation. It was already confirmed; read GET .../batches/{batchId} for progress.
                    statusCode: 409
                rejected:
                  value:
                    type: PAYOUT_BATCH_AWAITING_APPROVAL
                    status: 409
                    detail: Approval cmf3k2xh0000aq8b7c3d5e7fg for this batch was rejected, so it cannot be confirmed. Cancel the batch and submit a corrected run.
                    requestId: req-a4s
                    message: Approval cmf3k2xh0000aq8b7c3d5e7fg for this batch was rejected, so it cannot be confirmed. Cancel the batch and submit a corrected run.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
        "202":
          description: Held for your approvers. One approval covers the run; nothing is created until it is approved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PendingApproval" }
        "200":
          description: The batch, now `creating`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutBatch" }

  /payments/organizations/{orgId}/payouts/batches/{batchId}/cancel:
    post:
      tags: [Payout batches]
      operationId: cancelPayoutBatch
      summary: Cancel a payout batch
      description: |
        Stop a run before any payout exists.

        Honored while the batch is `received`, `validating` or
        `awaiting_confirmation`, before creation starts, so **no payout ever
        exists** from a canceled batch. Once creation begins the run is
        committed; cancel individual payouts while they are still `pending`
        via `POST .../payouts/{payoutId}/cancel` instead. Refused with
        `409 PAYOUT_BATCH_NOT_CANCELABLE` after that point.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/BatchId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "400": { $ref: "#/components/responses/IdempotencyRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "404":
          description: "`BATCH_NOT_FOUND`: no such batch in this organization."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: BATCH_NOT_FOUND
                    status: 404
                    detail: No payout batch cmf3k2xg00009q8b7v0w2x4yz belongs to this organization.
                    requestId: req-9na
                    message: No payout batch cmf3k2xg00009q8b7v0w2x4yz belongs to this organization.
                    statusCode: 404
        "409":
          description: "`PAYOUT_BATCH_NOT_CANCELABLE`: creation already started. Or an idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notCancelable:
                  value:
                    type: PAYOUT_BATCH_NOT_CANCELABLE
                    status: 409
                    detail: This batch is creating and can no longer be canceled. A batch is cancelable until creation starts; after that, cancel individual payouts via POST .../payouts/{payoutId}/cancel while they are still pending.
                    requestId: req-a5s
                    message: This batch is creating and can no longer be canceled. A batch is cancelable until creation starts; after that, cancel individual payouts via POST .../payouts/{payoutId}/cancel while they are still pending.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
        "200":
          description: The batch, now `canceled`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutBatch" }

  /payments/organizations/{orgId}/payouts/approvals:
    get:
      tags: [Approvals]
      operationId: listPayoutApprovals
      summary: List approvals
      description: |
        Payouts and batch runs waiting on your approvers.

        When your organization requires M-of-N approval on API payouts, a
        `POST /payouts` or a batch `confirm` above the threshold answers **202**
        with an approval id instead of a payout. This is that queue.

        An approval is a resource of its own, **not a payout status**: the
        payout does not exist until the approval executes, and then it begins
        at `pending` like any other. An `executed` approval carries the
        `payoutId` it became; so does the `payout_approval.executed` event.

        **Approving and rejecting are human actions**, done in the dashboard by
        an `owner` or `admin` on their own passkey:
        `POST .../payouts/approvals/{approvalId}/approve` and
        `POST .../payouts/approvals/{approvalId}/reject`. An API key is refused
        on both, whatever role its member holds. The initiator cannot approve
        their own request, a second vote from the same signer changes nothing,
        one rejection is terminal, and a pending request expires after 24 hours.

        There is no cursor: `data` holds at most `limit` approvals, newest
        first, and older ones are not reachable from this list. Filter by
        `status` to see the ones that matter.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: status
          in: query
          description: One approval status. Unknown values are a 400.
          schema: { $ref: "#/components/schemas/PayoutApprovalStatus" }
        - name: limit
          in: query
          description: From 1 to 100. Defaults to 50. Outside the range is a `400`, not clamped.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "400":
          description: "`VALIDATION_ERROR`: `status` is not an approval status, or `limit` is out of range."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: 'status must be one of: pending, approved, rejected, expired, executing, executed, execution_failed, execution_unknown.'
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - 'status: "waiting" is not an approval status'
                    requestId: req-a6t
                    message: 'status must be one of: pending, approved, rejected, expired, executing, executed, execution_failed, execution_unknown.'
                    statusCode: 400
        "200":
          description: Approvals, newest first.
          content:
            application/json:
              example:
                data:
                  - id: cmf3k2xf90008q8b7q4r6s8tu
                    kind: payout
                    status: pending
                    requiredApprovals: 2
                    approvals: 1
                    amount: "12000.00"
                    currency: USD
                    destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
                    createdAt: "2026-09-03T10:00:00.000Z"
                    expiresAt: "2026-09-04T10:00:00.000Z"
                    updatedAt: "2026-09-03T10:12:41.000Z"
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PayoutApproval" }

  /payments/organizations/{orgId}/payouts/approvals/{approvalId}:
    get:
      tags: [Approvals]
      operationId: getPayoutApproval
      summary: Get an approval
      description: |
        One approval, by the id a 202 gave you.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: approvalId
          in: path
          required: true
          description: The `approvalId` from a 202, or an `id` from the list.
          schema: { type: string, minLength: 1 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "404":
          description: No such approval in this organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Approval request not found
                    requestId: req-a7u
                    message: Approval request not found
                    statusCode: 404
        "200":
          description: The approval.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutApproval" }

  /payments/organizations/{orgId}/payout-links:
    post:
      tags: [Payout links]
      operationId: createPayoutLink
      summary: Create a payout link
      description: |
        Mint a one-time link for the person being paid.

        The alternative to collecting bank details yourself. You send the amount
        and who it is for on your side; we return a URL. The recipient enters
        their own account details, so you never hold them.

        Your `Idempotency-Key` is carried onto the link, so the payout it
        eventually creates deduplicates against your retry, not only against ours. Minting the same link twice cannot become two payments even though
        a stranger spends them minutes apart.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [amount, destinationCurrency, endUserId]
              properties:
                amount:
                  type: string
                  pattern: '^\d+(\.\d{1,2})?$'
                  description: USD debit amount as a decimal string with at most two fractional digits.
                  examples: ["75.00"]
                destinationCurrency:
                  type: string
                  pattern: '^[A-Za-z]{3}$'
                  description: ISO 4217 payout currency; normalized to uppercase.
                  examples: ["MXN"]
                endUserId:
                  type: string
                  minLength: 1
                  maxLength: 128
                  description: Your id for the person being paid.
                reference:
                  type: string
                  minLength: 1
                  maxLength: 128
                  pattern: '^[A-Za-z0-9 :._-]*$'
                  description: Your payment reference. Echoed back and searchable.
                expiresInMinutes:
                  type: integer
                  minimum: 1
                  maximum: 10080
                  default: 60
                  description: Whole minutes. Defaults to 60 and is capped at 7 days (10,080 minutes).
                endUser:
                  type: object
                  additionalProperties: false
                  description: The sender, your own end user; the same shape `POST /payouts` takes.
                  properties:
                    name: { type: string, maxLength: 200 }
                    email:
                      type: string
                      format: email
                      maxLength: 254
                      description: Where the Reg E receipt goes when the link holder supplies no `senderEmail`.
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `USE_POST_PAYOUTS`: this organization has an approval threshold or
            a velocity cap, and only `POST /payouts` enforces them. Nothing was
            sent; send the payout through `POST /payouts`, which may answer
            `202 pending_approval`. Also the refusals every write can answer:
            `FORBIDDEN` (a key for a different organization),
            `ACCOUNT_BLOCKED`, `LIVE_KEY_ORG_NOT_APPROVED` and
            `INSUFFICIENT_SCOPE`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                usePostPayouts:
                  value:
                    type: USE_POST_PAYOUTS
                    status: 403
                    detail: This organization has payout controls (approvals or limits) that are enforced on POST /payouts only. Create the payout there instead. Nothing was sent.
                    resolution: Nothing was sent. This organization has payout controls that only POST /payouts enforces; send the payout through it (you may receive 202 pending_approval).
                    requestId: req-9k7
                    message: This organization has payout controls (approvals or limits) that are enforced on POST /payouts only. Create the payout there instead. Nothing was sent.
                    statusCode: 403
                forbidden: { $ref: "#/components/examples/Forbidden" }
                accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
                liveKeyOrgNotApproved: { $ref: "#/components/examples/LiveKeyOrgNotApproved" }
                insufficientScope: { $ref: "#/components/examples/InsufficientScope" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The link.
          content:
            application/json:
              example:
                payoutLinkId: cmf3k2xh10010q8b7a3c5e7gj
                url: https://pay.avvio.xyz/l/eyJhbGciOiJIUzI1NiJ9.ZXhhbXBsZQ
                expiresAt: "2026-08-20T15:03:11.000Z"
                status: pending
              schema:
                type: object
                required: [payoutLinkId, url, expiresAt, status]
                properties:
                  payoutLinkId: { type: string }
                  url: { type: string, format: uri }
                  expiresAt: { type: string, format: date-time }
                  status: { type: string, const: pending }
        "400":
          description: |
            `VALIDATION_ERROR`: a field is malformed, or the amount is outside
            the corridor's minimum or maximum (`errors` names the figure).
            `IDEMPOTENCY_KEY_REQUIRED`, `IDEMPOTENCY_KEY_INVALID`: the header
            is missing or malformed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                belowMinimum:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: MXN payouts start at 1.00 USD.
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - 'amount: minimum 1.00 USD for MXN'
                    requestId: req-a9y
                    message: MXN payouts start at 1.00 USD.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "503":
          description: |
            `PAYOUT_LINKS_UNAVAILABLE`: hosted payout links are not configured
            on this environment. Nothing was sent; contact support.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unavailable:
                  value:
                    type: PAYOUT_LINKS_UNAVAILABLE
                    status: 503
                    detail: 'Hosted payout links are not configured on this environment: receipt provider contacts are missing.'
                    resolution: Hosted payout links are not configured on this environment. Nothing was sent; contact support.
                    requestId: req-a9x
                    message: 'Hosted payout links are not configured on this environment: receipt provider contacts are missing.'
                    statusCode: 503

  /payout-links/{token}:
    get:
      tags: [Payout links]
      operationId: resolvePayoutLink
      summary: Get a payout link
      description: |
        Returns what the recipient's page renders.

        **Takes no credential.** The signed token in the URL is the credential,
        which is why it is short-lived and single-use.

        Returns the amount, the fields that corridor needs, and saved
        destinations masked to name, label, and last four digits. It returns no
        organization id, no `endUserId`, and never full bank details.
        A public route that echoes back what was typed turns a forwarded link
        into a disclosure of someone's account number.

        Expired, spent, forged and tampered tokens are all a flat `404`. A
        stranger probing links learns nothing from the difference.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: |
            Opaque signed bearer token from the link URL. It is JWT-shaped but
            its claims are not a public contract; do not decode, construct, log,
            or store it beyond the recipient session.
          schema: { type: string, minLength: 1 }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: What to render.
          content:
            application/json:
              example:
                sender: { name: Northstar Logistics }
                amount: { currency: USD, amount: "75.00" }
                destinationCurrency: MXN
                reference: ZZ-WAGE-1
                expiresAt: "2026-08-20T15:03:11.000Z"
                status: pending
                requirements:
                  - id: clabeNumber
                    title: CLABE
                    pattern: "^[0-9]{18}$"
                    required: true
                savedDestinations:
                  - destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
                    name: Ana Ruiz
                    label: BBVA México
                    last4: "4471"
                paymentReasons:
                  - { id: FAMILY_SUPPORT, label: Family support }
                  - { id: SALARY_PAYMENT, label: Salary }
                defaultPaymentReason: FAMILY_SUPPORT
                preview:
                  indicative: true
                  sourceAmount: { currency: USD, amount: "75.00" }
                  destinationAmount: { currency: MXN, amount: "1275.75" }
                  fee: { currency: USD, amount: "0.38" }
                  totalDebit: { currency: USD, amount: "75.38" }
                  rate: "17.010050924685068"
                disclosure:
                  kind: prepayment
                  estimated: true
                  lines:
                    - { label: Transfer Amount, value: 75.00 USD }
                    - { label: Transfer Fees, value: 0.38 USD, sign: "+" }
                    - { label: Total, value: 75.38 USD }
                    - { label: Exchange Rate, value: USD 1.00 = 17.0101 MXN }
                    - { label: Total to Recipient, value: 1275.75 MXN }
                  otherFeesDisclaimer: Recipient may receive less due to fees charged by the recipient’s bank and foreign taxes.
              schema:
                type: object
                required: [amount, destinationCurrency, expiresAt, status, requirements, savedDestinations]
                properties:
                  sender:
                    type: object
                    description: |
                      Who is paying: your organization's display name, so the
                      recipient can recognize the payment. The name only; the
                      organization id is never exposed here.
                    required: [name]
                    properties:
                      name: { type: string }
                  amount: { $ref: "#/components/schemas/Money" }
                  destinationCurrency: { type: string, pattern: '^[A-Z]{3}$' }
                  reference: { type: string }
                  expiresAt: { type: string, format: date-time }
                  status:
                    type: string
                    enum: [pending, consuming, failed]
                    description: |
                      `consumed` and `expired` links return 404 and are never
                      exposed here.
                  requirements:
                    type: array
                    items:
                      type: object
                      required: [id]
                      properties:
                        id: { type: string }
                        title: { type: string }
                        type: { type: string }
                        description: { type: string }
                        pattern: { type: string }
                        required: { type: boolean }
                  savedDestinations:
                    type: array
                    description: Masked destinations scoped to this link's end user and currency.
                    items:
                      type: object
                      required: [destinationAccountId, name]
                      properties:
                        destinationAccountId: { type: string }
                        name: { type: string }
                        label: { type: string }
                        last4: { type: string, pattern: '^.{1,4}$' }
                  paymentReasons:
                    type: array
                    description: |
                      The corridor's payment purposes, for the page's picker;
                      send one `id` as `paymentReason` on submit. Omitted when
                      the corridor asks for none.
                    items:
                      type: object
                      required: [id, label]
                      properties:
                        id: { type: string }
                        label: { type: string }
                  defaultPaymentReason:
                    type: string
                    description: The purpose used when none is chosen. Present only with `paymentReasons`, and only when the corridor names one.
                  preview:
                    allOf: [{ $ref: "#/components/schemas/PreviewQuote" }]
                    description: |
                      What the page shows above the form: the rate, the fee, and
                      roughly what lands in the recipient's account. The same
                      shape as `GET /rates`, and indicative for the same reason: the binding quote is priced at submit, against the bank
                      details this page collects. Omitted when the corridor
                      cannot be priced in advance; the page still renders and
                      the link is still payable.

                      **A link converts its whole amount and adds the fee on
                      top**, unlike `GET /rates`: `destinationAmount = amount x
                      rate`, and `totalDebit = amount + fee` (not equal to
                      `sourceAmount` here).
                  disclosure:
                    $ref: "#/components/schemas/RegEDisclosure"
        "404":
          description: Not valid (expired, already spent, or never existed).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: This link is not valid.
                    requestId: req-a8v
                    message: This link is not valid.
                    statusCode: 404

  /payout-links/{token}/submit:
    post:
      tags: [Payout links]
      operationId: submitPayoutLink
      summary: Submit a payout link
      description: |
        Spend the link and send the money.

        **Takes no credential**, as above.

        Submitting twice returns the original payout with `status:
        "already_submitted"`, so a worker who double-taps on a slow connection gets
        the payout they already have, never a second one.

        A validation failure leaves the link spendable, so a mistyped account
        number is correctable rather than fatal. A link that is already being
        spent or has failed is `400 PAYOUT_LINK_UNUSABLE`: it is not
        spendable, so ask for a new one.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: Opaque signed bearer token from the link URL.
          schema: { type: string, minLength: 1 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - title: Register a new destination
                  type: object
                  additionalProperties: false
                  required: [name, email, details, esignConsent]
                  properties:
                    esignConsent:
                      type: boolean
                      const: true
                      description: The payer's consent to receive the Reg E receipt electronically. Required; the receipt is emailed on submit.
                    senderEmail:
                      type: string
                      format: email
                      maxLength: 254
                      description: Where the receipt is sent when the link itself carries no payer email. Required in that case.
                    name:
                      type: string
                      minLength: 1
                      maxLength: 200
                      description: Recipient legal or commonly used name.
                      examples: ["Maria Gonzalez"]
                    email:
                      type: string
                      format: email
                      maxLength: 254
                      description: Recipient email address in standard email format.
                    details:
                      type: object
                      description: The fields named by `requirements` on the read, keyed by `id`.
                      additionalProperties: true
                    paymentReason:
                      type: string
                      minLength: 1
                      maxLength: 256
                      description: From the corridor's own list, served on the resolve.
                    recipientType:
                      type: string
                      enum: [individual, business]
                      description: The account holder's kind, never the sending organization's.
                - title: Use a saved destination
                  type: object
                  additionalProperties: false
                  required: [destinationAccountId, esignConsent]
                  properties:
                    esignConsent:
                      type: boolean
                      const: true
                      description: The payer's consent to receive the Reg E receipt electronically. Required; the receipt is emailed on submit.
                    senderEmail:
                      type: string
                      format: email
                      maxLength: 254
                      description: Where the receipt is sent when the link itself carries no payer email. Required in that case.
                    destinationAccountId:
                      type: string
                      minLength: 1
                      maxLength: 128
                      description: An id returned in `savedDestinations` by the resolve call.
                    paymentReason:
                      type: string
                      minLength: 1
                      maxLength: 256
                      description: From the corridor's own list, served on the resolve.
                    recipientType:
                      type: string
                      enum: [individual, business]
                      description: The account holder's kind, never the sending organization's.
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The payout.
          content:
            application/json:
              example:
                payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                status: pending
              schema:
                type: object
                required: [status]
                properties:
                  payoutId: { type: string }
                  status:
                    oneOf:
                      - $ref: "#/components/schemas/PayoutStatus"
                      - type: string
                        const: already_submitted
                  requiresFunding:
                    type: boolean
                    description: |
                      Present and true when the payout this link created is
                      waiting on you to fund it. The worker has done everything
                      they can; nothing reaches their account until you read the
                      funding instructions for that payout and send the money.
                      Absent where accepting the payout was already paying it.
                  disclosure:
                    $ref: "#/components/schemas/RegEDisclosure"
        "403":
          description: |
            `USE_POST_PAYOUTS`:
            this organization has an approval threshold or a velocity cap, and
            only `POST /payouts` enforces them. Nothing was sent; send the
            payout through `POST /payouts`, which may answer `202
            pending_approval`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                usePostPayouts:
                  value:
                    type: USE_POST_PAYOUTS
                    status: 403
                    detail: This organization has payout controls (approvals or limits) that are enforced on POST /payouts only. Create the payout there instead. Nothing was sent.
                    resolution: Nothing was sent. This organization has payout controls that only POST /payouts enforces; send the payout through it (you may receive 202 pending_approval).
                    requestId: req-9k7
                    message: This organization has payout controls (approvals or limits) that are enforced on POST /payouts only. Create the payout there instead. Nothing was sent.
                    statusCode: 403
        "400":
          description: |
            `VALIDATION_ERROR`: the details did not match what the corridor
            requires. The link is still spendable; correct and resubmit.
            `PAYOUT_LINK_UNUSABLE`: the link is being spent or has failed. It
            is **not** spendable; ask the sender for a new one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: 1 field(s) failed validation for MXN.
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - 'clabeNumber: CLABE does not match the required format (^[0-9]{18}$)'
                    requestId: req-9pc
                    message: 1 field(s) failed validation for MXN.
                    statusCode: 400
                unusable:
                  value:
                    type: PAYOUT_LINK_UNUSABLE
                    status: 400
                    detail: This link can no longer be used (failed). Ask for a new one.
                    requestId: req-a9v
                    message: This link can no longer be used (failed). Ask for a new one.
                    statusCode: 400
        "404":
          description: |
            `NOT_FOUND`: the token is not valid (expired, spent, forged).
            `DESTINATION_ACCOUNT_NOT_FOUND`: the saved destination is outside
            this link's end-user scope, or does not exist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: This link is not valid.
                    requestId: req-a9w
                    message: This link is not valid.
                    statusCode: 404
                savedDestination:
                  value:
                    type: DESTINATION_ACCOUNT_NOT_FOUND
                    status: 404
                    detail: That saved account was not found.
                    requestId: req-a9u
                    message: That saved account was not found.
                    statusCode: 404
        "503":
          description: |
            `PAYOUT_LINKS_UNAVAILABLE`: hosted payout links are not configured
            on this environment. Nothing was sent; contact support.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unavailable:
                  value:
                    type: PAYOUT_LINKS_UNAVAILABLE
                    status: 503
                    detail: 'Hosted payout links are not configured on this environment: receipt provider contacts are missing.'
                    resolution: Hosted payout links are not configured on this environment. Nothing was sent; contact support.
                    requestId: req-a9x
                    message: 'Hosted payout links are not configured on this environment: receipt provider contacts are missing.'
                    statusCode: 503

  /payments/organizations/{orgId}/balance_transactions:
    get:
      tags: ["Funding & balance"]
      operationId: listBalanceTransactions
      summary: List balance transactions
      description: |
        Lists every movement on your balance, newest first. Reconcile your
        balance by pulling this list. It has one row per change to what you can spend: funding in, payouts out, returns, holds and their
        release, and operator adjustments. Rows are append-only and each carries
        `balanceAfter`, so your ledger can be checked row by row rather than
        against a single number.

        `id` is the cursor: page with `cursor=<last id>` and rows come back
        strictly older. Idempotent by `id`: a resumed run that re-reads a row
        is harmless.

        `net` is what reached the corridor: the amount less the fee, carrying
        the amount's sign. It is absent when the fee is not known.

        Holds appear here too. If `available` on `GET /balance` is lower than
        the movements explain, the difference is a hold and it is listed here.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: cursor
          in: query
          description: The `nextCursor` from your last page. Rows strictly older than it.
          schema: { type: string, pattern: '^\d+$', examples: ["48213"] }
        - name: limit
          in: query
          description: Rows per page, from 1 to 100. Defaults to 100.
          schema: { type: integer, minimum: 1, default: 100, maximum: 100 }
        - name: type
          in: query
          description: Comma-separated. Any other value is a 400.
          schema:
            type: string
            examples: ["payout,payout_return"]
            description: "`funding`, `payout`, `payout_return`, `hold`, `hold_release`, `adjustment`."
        - name: orderId
          in: query
          description: Everything that moved for one payout.
          schema: { type: string, minLength: 1 }
        - name: currency
          in: query
          schema: { type: string, pattern: '^[A-Za-z]{3,5}$', examples: ["USD"] }
        - name: createdAfter
          in: query
          description: Inclusive. ISO-8601 with a timezone.
          schema: { type: string, format: date-time }
        - name: createdBefore
          in: query
          description: Inclusive. ISO-8601 with a timezone.
          schema: { type: string, format: date-time }
      responses:
        "400": { $ref: "#/components/responses/ListBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: |
            Rows, newest first. Served on every environment; before the
            historical rows are loaded on yours this is an empty page, not an
            error.
          content:
            application/json:
              example:
                data:
                  - id: "48213"
                    type: payout
                    amount: "-100.00"
                    fee: "0.50"
                    net: "-99.50"
                    currency: USD
                    balanceAfter: "9900.00"
                    orderId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                    batchId: null
                    reference: payroll-2026-09
                    endUserId: emp_412
                    reason: null
                    description: null
                    createdAt: "2026-09-01T10:00:00.000Z"
                  - id: "48212"
                    type: funding
                    amount: "10000.00"
                    fee: null
                    currency: USD
                    balanceAfter: "10000.00"
                    orderId: null
                    batchId: null
                    reference: null
                    endUserId: null
                    reason: null
                    description: null
                    createdAt: "2026-09-01T09:00:00.000Z"
                hasMore: true
                nextCursor: "48212"
              schema:
                type: object
                required: [data, hasMore, nextCursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BalanceTransaction" }
                  hasMore: { type: boolean }
                  nextCursor:
                    type: [string, "null"]
                    description: Feed back as `cursor`. Null on the last page.

  /payments/organizations/{orgId}/payouts/{payoutId}/cancel:
    post:
      tags: [Payouts]
      operationId: cancelPayout
      summary: Cancel a payout
      description: |
        Stop a payout that has not moved yet.

        Only a payout still waiting on your funds can be canceled: one created by mistake, or for the wrong amount, before anyone sent anything.

        Anything else is refused with `PAYOUT_NOT_CANCELABLE`. Where acceptance
        is execution the money left when the payout was created, and canceling
        on our side while the network still holds an order would let us report
        `canceled` for something that could still settle.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: payoutId
          in: path
          required: true
          description: Opaque `payoutId` returned by create or accept. Pass it unchanged.
          schema: { type: string, minLength: 1 }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The payout, now canceled.
          content:
            application/json:
              example:
                payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                status: canceled
                fundsReturned: true
                sourceAmount: { currency: USD, amount: "200.00" }
                destinationAmount: { currency: MXN, amount: "3384.65" }
                destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
                rate: "16.923250"
                reference: ZZ-WAGE-1
                endUser: { id: customer_42, name: Northwind Payroll LLC, email: payroll@northwind.example }
                createdAt: "2026-08-20T14:03:11.000Z"
                updatedAt: "2026-08-20T14:05:40.220Z"
                completedAt: null
              schema: { $ref: "#/components/schemas/Payout" }
        "400":
          description: "`PAYOUT_NOT_CANCELABLE`: this payout cannot be canceled. Or a missing or malformed `Idempotency-Key` (`IDEMPOTENCY_KEY_REQUIRED`, `IDEMPOTENCY_KEY_INVALID`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notCancelable:
                  value:
                    type: PAYOUT_NOT_CANCELABLE
                    status: 400
                    detail: A payout that is processing can no longer be cancelled.
                    requestId: req-aax
                    message: A payout that is processing can no longer be cancelled.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "404": { $ref: "#/components/responses/PayoutNotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /payments/organizations/{orgId}/payouts/{payoutId}/funding:
    get:
      tags: ["Funding & balance"]
      operationId: getPayoutFunding
      summary: Get funding instructions
      description: |
        How to fund a payout that needs it.

        Some routings price a payout and then wait for you to fund it from your
        own wallet. You will know because the payout came back with
        `requiresFunding: true`.

        **We hold no key to your funds and cannot move them for you.** Send the
        amount to the address on the network given, then confirm.

        This is a pure read; poll it as often as you like. It returns only the
        deposit details a business needs to broadcast funding from its own
        wallet.

        `expiresAt` is normally `null`. There is no client-side countdown on the
        deposit address: the rail decides when an unfunded payout is over, and
        it reports that as the payout's own status. Check `GET /orders/{id}`
        before sending against instructions you fetched a while ago, rather than
        watching a clock.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: payoutId
          in: path
          required: true
          description: Opaque `payoutId` returned by create or accept. Pass it unchanged.
          schema: { type: string, minLength: 1 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Where to send, how much, on which network, and by when.
          content:
            application/json:
              example:
                payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                amount: "200.00"
                currency: USDC
                depositAddress: "0x5f2e8a1c9b7d4e6f0a3b8c2d1e4f7a9b0c3d6e8f"
                network: base
                expiresAt: null
                instructions: Send exactly this amount of USDC to depositAddress on this network from a wallet you control, then POST the transaction hash to /payouts/{payoutId}/funding/confirm. We hold no key to your funds and cannot move them for you.
              schema:
                type: object
                required: [payoutId, amount, currency, depositAddress, network, expiresAt]
                properties:
                  payoutId: { type: string }
                  amount: { type: string, examples: ["200.00"] }
                  currency: { type: string, examples: ["USDC"] }
                  depositAddress: { type: string }
                  network: { type: string, examples: ["base"] }
                  expiresAt:
                    type: [string, "null"]
                    format: date-time
                    description: |
                      Almost always `null`. The rail publishes no deadline on
                      the deposit address. Treat the payout's own status as the
                      authority on whether it can still be funded.
                  instructions: { type: string }
        "400":
          description: "`BAD_REQUEST` (sandbox): this payout settles from your balance and needs no funding."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFundable:
                  value:
                    type: BAD_REQUEST
                    status: 400
                    detail: This payout settles from your balance and needs no funding.
                    requestId: req-abz
                    message: This payout settles from your balance and needs no funding.
                    statusCode: 400
        "404": { $ref: "#/components/responses/PayoutNotFound" }
        "501":
          description: |
            `FUNDING_NOT_APPLICABLE`: this payout settles from your balance on
            a routing where there is nothing to fund. Not an error in your
            integration; do not retry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notApplicable:
                  value:
                    type: FUNDING_NOT_APPLICABLE
                    status: 501
                    detail: This payout settles from your balance; there is nothing to fund.
                    requestId: req-ac0
                    message: This payout settles from your balance; there is nothing to fund.
                    statusCode: 501

  /payments/organizations/{orgId}/payouts/{payoutId}/funding/confirm:
    post:
      tags: ["Funding & balance"]
      operationId: confirmPayoutFunding
      summary: Confirm funding
      description: |
        Tell us you have sent the funds.

        Send the `transactionHash` after you broadcast the funding transfer
        from your registered wallet.

        **We read the chain before recording anything.** The transaction must
        exist, have succeeded, and have moved the expected token to the deposit
        address we issued, in at least the expected amount, from the wallet
        registered for this payout. If it did not, nothing is recorded and the
        payout stays fundable, so a rejection here is always safe to correct
        and retry.

        Three outcomes are worth handling separately:

        - `FUNDING_TRANSACTION_INVALID` (400): we read the chain and it does
          not fund this payout. Do not retry unchanged; send the right one.
        - `FUNDING_NOT_YET_VERIFIABLE` (409): we could not read it yet (not
          mined, or we could not reach the chain). Retry the same request once
          it is mined. If you already paid, your funds are unaffected.
        - `FUNDING_TRANSACTION_ALREADY_USED` (409): one transfer funds exactly
          one payout. The deposit address is shared between payouts, so this is
          reachable by honest mistake; the message names the payout it funded.

        In the sandbox there is no chain to read, so the last four digits of the hash choose the outcome (`0001` invalid, `0002` not-yet-verifiable, anything else accepted), and reuse is refused exactly as in production. The sandbox requires an EVM-shaped hash (`0x` and 64 hex digits).

        Solana funding is verified on-chain the same way, including the
        retryable `FUNDING_NOT_YET_VERIFIABLE`.

        A `500 PAYOUT_OUTCOME_UNKNOWN` means we may have recorded the funding:
        read `GET /orders/{payoutId}` before confirming again, and never under
        a new key.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: payoutId
          in: path
          required: true
          description: Opaque `payoutId` returned by create or accept. Pass it unchanged.
          schema: { type: string, minLength: 1 }
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/AllowDuplicate"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [transactionHash]
              properties:
                transactionHash:
                  type: string
                  minLength: 1
                  description: Transaction identifier from the network on which you broadcast funding.
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The payout, now funded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payout" }
        "400":
          description: |
            Nothing was recorded.

            - `VALIDATION_ERROR`: `transactionHash` is missing or malformed.
            - `FUNDING_TRANSACTION_INVALID`: the transaction does not fund this
              payout. Send the right one.
            - `PAYOUT_NOT_FUNDABLE`: the payout is canceled or otherwise
              finished and can no longer be funded.
            - `IDEMPOTENCY_KEY_REQUIRED`, `IDEMPOTENCY_KEY_INVALID`: the header
              is missing or malformed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: transactionHash must be a 0x-prefixed 32-byte hex string.
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - 'transactionHash: "0xabc" is not a tx hash'
                    requestId: req-aby
                    message: transactionHash must be a 0x-prefixed 32-byte hex string.
                    statusCode: 400
                invalid:
                  value:
                    type: FUNDING_TRANSACTION_INVALID
                    status: 400
                    detail: This transaction does not fund this payout — the transaction moved no funds of the expected token to the deposit address. Nothing has been recorded, so the payout can still be funded with the correct transaction.
                    requestId: req-ac1
                    message: This transaction does not fund this payout — the transaction moved no funds of the expected token to the deposit address. Nothing has been recorded, so the payout can still be funded with the correct transaction.
                    statusCode: 400
                notFundable:
                  value:
                    type: PAYOUT_NOT_FUNDABLE
                    status: 400
                    detail: This payout is canceled and can no longer be funded.
                    requestId: req-ac2
                    message: This payout is canceled and can no longer be funded.
                    statusCode: 400
        "404": { $ref: "#/components/responses/PayoutNotFound" }
        "409":
          description: |
            - `FUNDING_NOT_YET_VERIFIABLE`: we could not read the transaction
              yet. Retry the same request once it is mined.
            - `FUNDING_TRANSACTION_ALREADY_USED`: this transfer already funded
              another payout, named in `detail`.
            - Or an idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`,
              `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`, `PAYOUT_OUTCOME_UNKNOWN`
              on a replay, `DUPLICATE_REQUEST_DETECTED`; see
              `MoneyIdempotencyConflict`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notYetVerifiable:
                  value:
                    type: FUNDING_NOT_YET_VERIFIABLE
                    status: 409
                    detail: We could not confirm this transaction yet — the transaction is not mined yet, or does not exist on this chain. Nothing has been recorded. Send this request again once the transaction is mined; if you already paid, your funds are unaffected.
                    requestId: req-ac3
                    message: We could not confirm this transaction yet — the transaction is not mined yet, or does not exist on this chain. Nothing has been recorded. Send this request again once the transaction is mined; if you already paid, your funds are unaffected.
                    statusCode: 409
                alreadyUsed:
                  value:
                    type: FUNDING_TRANSACTION_ALREADY_USED
                    status: 409
                    detail: This transaction has already been used to fund payout sbx_pay_1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d. One transfer funds one payout — send a separate transfer for this one.
                    requestId: req-ac4
                    message: This transaction has already been used to fund payout sbx_pay_1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d. One transfer funds one payout — send a separate transfer for this one.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                outcomeUnknown: { $ref: "#/components/examples/OutcomeUnknownReplay" }
        "500": { $ref: "#/components/responses/OutcomeUnknown" }

  /payments/organizations/{orgId}/events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List events
      description: |
        Everything that has happened to your payouts, in order.

        Reconcile from this feed. Each event is written once and never
        changes, carries its own `id`, and `sequence` is a total order you cursor on, so "page forward from where I left off" is expressible, which
        listing payouts cannot do: that is ordered by creation and can never
        surface a change to one you have already read.

        `payout.returned` is its own type rather than a flavour of
        `payout.failed`, because a bank returning a settled payment days later is
        the one event that reverses something you already booked.

        **At-least-once: dedupe on `id`.** And the cursor is `sequence`, not a
        timestamp: timestamps tie, and a tie is indistinguishable from a
        boundary.

        **The row is the webhook body.** `data` is the same object a delivery
        carries, so the amounts, fee, rate, your `reference` and `endUserId`
        are here, so you can book from the feed alone. `id` is the delivery's
        `svix-id`, so a webhook is the trigger to read `GET /events?since=`
        from the right place.

        **Every family is here**, `type`-discriminated: `payout.*`,
        `payout_batch.*` (with `batchId` set and `payoutId` null),
        `payout_approval.*`, `webhook_endpoint.*` and `checkout_payment.*`
        (whose `data` is the checkout payment: `fee` is a decimal string and
        there is no `payoutId`). A reconciler that wants only payouts passes
        `type=`; otherwise skip types it does not recognize.

        **An event becomes readable about two seconds after it happens.** That
        lag is what makes `since` safe against a write that commits out of
        order.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: since
          in: query
          description: |
            Return events after this `sequence`. Omit it on the first call
            (or send `0`) to start from the first row; afterwards feed back
            the page's `nextSince`.
          schema: { type: string, pattern: '^\d+$', examples: ["43"] }
        - name: payoutId
          in: query
          description: Everything that ever happened to one payout.
          schema: { type: string, minLength: 1 }
        - name: type
          in: query
          description: |
            Comma-separated event types to include. Each must be a known type
            (see `PayoutEventType`); an unknown one is a `400 VALIDATION_ERROR`,
            never an empty page.
          schema: { type: string, examples: ["payout.completed,payout.failed,payout.returned"] }
        - name: limit
          in: query
          description: Maximum events to return, from 1 to 500. Defaults to 100.
          schema: { type: integer, minimum: 1, default: 100, maximum: 500 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Events, oldest first.
          content:
            application/json:
              example:
                data:
                  - id: cmf3k2x9a0001q8b7h4d2e6zt
                    sequence: "1041"
                    type: payout.pending
                    payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                    batchId: null
                    status: pending
                    createdAt: "2026-08-20T14:03:11.000Z"
                    apiVersion: 1
                    data:
                      payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                      status: pending
                      stage: awaiting_details
                      sourceCurrency: USD
                      sourceAmount: "200.00"
                      destinationCurrency: MXN
                      destinationAmount: "3384.65"
                      destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
                      fee: { currency: USD, amount: "1.02" }
                      rate: "16.923250"
                      reference: ZZ-WAGE-1
                      endUser: { id: customer_42 }
                      endUserId: customer_42
                      createdAt: "2026-08-20T14:03:11.000Z"
                      completedAt: null
                  - id: cmf3k2xb20002q8b7c1s9m4rw
                    sequence: "1042"
                    type: payout.processing
                    payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                    batchId: null
                    status: processing
                    createdAt: "2026-08-20T14:03:12.480Z"
                    apiVersion: 1
                    data:
                      payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                      status: processing
                      sourceCurrency: USD
                      sourceAmount: "200.00"
                      destinationCurrency: MXN
                      destinationAmount: "3384.65"
                      destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
                      fee: { currency: USD, amount: "1.02" }
                      rate: "16.923250"
                      reference: ZZ-WAGE-1
                      endUser: { id: customer_42 }
                      endUserId: customer_42
                      createdAt: "2026-08-20T14:03:11.000Z"
                      completedAt: null
                hasMore: false
                nextSince: "1042"
              schema:
                type: object
                required: [data, hasMore]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PayoutEvent" }
                  hasMore: { type: boolean }
                  nextSince: { type: [string, "null"] }

  /payments/organizations/{orgId}/audit-events:
    get:
      tags: [Audit]
      operationId: listAuditEvents
      summary: List audit events
      description: |
        Records who did what, from where, with which credential. It has one
        row per audited mutation on your organization (payouts created or canceled, batches, approvals decided, recipients changed, keys and webhook endpoints managed), recorded whether it succeeded or was
        refused. A refused attempt is as much of an audit record as a
        successful one.

        Rows name the key by its **prefix** (`apiKeyPrefix`), which is how you
        named it in the dashboard; a dashboard action carries `actorUserId`
        instead. Exactly one of the two is set. `requestId` is the same value
        as the `x-request-id` header on that request and the `requestId` in any
        error body it returned.

        A read-only key may read this; that is the point of a read-only key.
        Newest first, cursor on `id`. Retained for five years.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: cursor
          in: query
          description: A `nextCursor` from a previous page; rows strictly older than it.
          schema: { type: string, pattern: '^\d+$' }
        - name: limit
          in: query
          description: From 1 to 100. Defaults to 50.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: action
          in: query
          description: One action, e.g. `payout.create`.
          schema: { type: string, examples: ["payout.create"] }
        - name: resourceId
          in: query
          description: Everything done to one payout, batch, approval, recipient, key or endpoint.
          schema: { type: string }
        - name: apiKey
          in: query
          description: A key prefix. An unknown prefix is an empty page, not a 404.
          schema: { type: string }
        - name: actorUserId
          in: query
          schema: { type: string }
        - name: createdAfter
          in: query
          description: Inclusive, ISO-8601 with a timezone.
          schema: { type: string, format: date-time }
        - name: createdBefore
          in: query
          description: Inclusive, ISO-8601 with a timezone.
          schema: { type: string, format: date-time }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "400":
          description: "`VALIDATION_ERROR`: a cursor that is not one we issued, or a malformed instant."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                validation:
                  value:
                    type: VALIDATION_ERROR
                    status: 400
                    detail: 1 field(s) failed validation
                    resolution: Correct the fields listed in `errors` and retry.
                    errors:
                      - cursor is not a cursor this API issued
                    requestId: req-acz
                    message: 1 field(s) failed validation
                    statusCode: 400
        "200":
          description: Audit events, newest first.
          content:
            application/json:
              example:
                data:
                  - id: "9182"
                    action: payout.create
                    resourceType: payout
                    resourceId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                    apiKeyPrefix: avvio_live_7f3a
                    actorUserId: null
                    ip: "203.0.113.7"
                    requestId: 7d2f5e0a-3c4b-4e8a-9f1d-2b6c8a1e4f37
                    outcome: ok
                    errorType: null
                    createdAt: "2026-09-03T10:00:00.000Z"
                  - id: "9181"
                    action: payout.create
                    resourceType: payout
                    resourceId: null
                    apiKeyPrefix: avvio_live_7f3a
                    actorUserId: null
                    ip: "203.0.113.7"
                    requestId: 3b9e1c72-5a4d-4f60-8e2b-9c1d7a0f5e48
                    outcome: error
                    errorType: PAYOUT_LIMIT_EXCEEDED
                    createdAt: "2026-09-03T09:58:12.000Z"
                hasMore: true
                nextCursor: "9181"
              schema:
                type: object
                required: [data, hasMore, nextCursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AuditEvent" }
                  hasMore: { type: boolean }
                  nextCursor: { type: [string, "null"] }

  /payments/organizations/{orgId}/balance:
    get:
      tags: ["Funding & balance"]
      operationId: getBalance
      summary: Get your balance
      description: |
        What you can currently send.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: |
            Everything a payout can draw on, as one number per currency:
            what the payment network holds for you (`provider`) plus the USD
            stablecoins in your own wallet (`wallet`, per chain, USDC and
            USDT at face value). Some routings settle from the network's
            balance, others price the payout and fund it from your wallet;
            `balances` is the sum so you never have to know which. Both
            sources are read live and cached for up to one minute.

            `ledger`, when present, is our own append-only record of the
            network-held balance, per currency, with what is currently
            reserved by holds. It is published beside the others and never
            summed into them; a difference between `provider` and `ledger` is
            what `GET /balance_transactions` exists to explain.

            `unavailable` names any source that could not be read this time.
            When it is non-empty the figures are a floor, not the balance:
            retry before deciding you cannot fund a payout. In sandbox the
            balance is one provider-held USD figure and `wallet` is always
            empty.
          content:
            application/json:
              example:
                currency: USD
                amount: "13185.03"
                balances:
                  - { currency: USD, amount: "13185.03" }
                provider:
                  - { currency: USD, amount: "10000.00" }
                wallet:
                  - { currency: USDC, network: Ethereum, amount: "3185.030147" }
                unavailable: []
                ledger:
                  - { currency: USD, available: "9800.00", held: "200.00", total: "10000.00" }
              schema:
                type: object
                required: [currency, amount, balances, provider, wallet, unavailable, ledger]
                properties:
                  currency: { type: string, examples: ["USD"] }
                  amount:
                    type: string
                    pattern: '^-?\d+(\.\d+)?$'
                    description: The USD headline, `provider` USD plus every `wallet` row.
                  balances:
                    type: array
                    description: Per currency, both sources summed. Size payouts against this.
                    items: { $ref: "#/components/schemas/Money" }
                  provider:
                    type: array
                    description: Per currency, what the payment network holds for you.
                    items: { $ref: "#/components/schemas/Money" }
                  wallet:
                    type: array
                    description: Per chain, USD stablecoins in your own wallet. Empty when you hold none, and always empty in sandbox.
                    items: { $ref: "#/components/schemas/WalletBalance" }
                  unavailable:
                    type: array
                    description: Sources that failed to read this time. Empty when every figure is live; a non-empty result is not cached, so a retry re-reads.
                    items: { type: string, enum: [network, wallet] }
                  ledger:
                    type: array
                    description: Per currency, from our own append-only record; empty until a movement has been recorded.
                    items: { $ref: "#/components/schemas/LedgerBalance" }

  /payments/organizations/{orgId}/balance/history:
    get:
      tags: ["Funding & balance"]
      operationId: getBalanceHistory
      summary: Get balance history
      description: |
        Why the balance is what it is.

        Every movement, in the order it settled, with the running balance after
        each. `balanceAfter` is published only when the running total reconciles
        with `GET /balance`; otherwise a `note` says so and the entries stand
        alone.

        Entries are ordered by settlement time: a deposit by when it completed,
        not when it was first seen. A settlement we record late (a deposit whose
        completion reaches us after its own settlement time) is dated when it
        settled, so it can land behind a cursor you already hold and not be
        re-delivered. This view is for reading the balance's story. For exact
        incremental reconciliation, page `GET /balance_transactions`, whose `id`
        is assigned when a row is written and never lands behind one you have
        seen.

        Limited to 60 requests per minute per credential, below the read budget,
        because each call replays your whole history. A high-frequency poller
        belongs on `GET /balance_transactions`.

        What adds an entry: a deposit to your funding account once it has
        completed (never while pending), a sandbox top-up, and a payout drawn
        from the prefunded balance (`DEBITED`). A debited payout that fails or is
        canceled with the money coming back keeps its original debit and gains a
        separate `reversal` entry (`RETURNED` or `CANCELED`); there is no
        zero-delta row. One rejected by compliance stays debited.

        What adds nothing: a payout you fund from your own wallet (one that
        returns funding instructions, or `requiresFunding` in sandbox) never
        touched the balance, including when you cancel it before funding. Nor
        do movements on a payment network whose balance this record does not
        track. When those leave the running total short of `GET /balance`,
        `balanceAfter` is withheld under a `note`.

        The whole history is read on every call, so `hasMore` is exact. An
        organization with more than 100,000 orders is more than this view can
        replay and gets `413 HISTORY_TOO_LARGE`; page `GET /balance_transactions`
        instead, which has no such ceiling.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: limit
          in: query
          description: Maximum entries to return, from 1 to 500. Defaults to 100. Out-of-range values are clamped, not refused.
          schema: { type: integer, minimum: 1, default: 100, maximum: 500 }
        - name: cursor
          in: query
          description: |
            The `nextCursor` from your last page: entries after that one.
            Entries are append-only, so carrying it gives you what settled
            since — including a reversal appended days after the debit it
            reverses. A settlement recorded after its own settlement time is
            the exception (see above); `GET /balance_transactions` has none.
            Opaque; do not build one. Send `cursor` or `since`, not both.
          schema: { type: string }
        - name: since
          in: query
          description: |
            Deprecated: use `cursor`. Entries strictly after this time. Two
            entries can share a millisecond, and paging on the last one's time
            skips the rest of that millisecond; `cursor` cannot.
          deprecated: true
          schema: { type: string, format: date-time }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "400":
          description: "`VALIDATION_ERROR` — `cursor` and `since` sent together, a cursor that is not one we issued, or a malformed `since`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "413":
          description: "`HISTORY_TOO_LARGE` — this organization has more orders than this view replays. Page `GET /balance_transactions`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "200":
          description: Movements, in settlement order.
          content:
            application/json:
              example:
                hasMore: false
                nextCursor: eyJhdCI6IjIwMjYtMDgtMjBUMTQ6MDM6MTEuMDAwWiIsImlkIjoiMDFKN044UTJZUTFDNEc5TTJYNlIifQ
                nextSince: "2026-08-20T14:03:11.000Z"
                entries:
                  - id: sbx_fund_3d6f9a2c-1b4e-4c7d-8a5f-0e2b9c4d7a61
                    type: funding
                    status: COMPLETED
                    currency: USD
                    amount: "10000.00"
                    balanceAfter: "10000.00"
                    at: "2026-08-20T13:40:00.000Z"
                  - id: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                    type: payout
                    status: DEBITED
                    currency: USD
                    amount: "-200.00"
                    balanceAfter: "9800.00"
                    reference: ZZ-WAGE-1
                    at: "2026-08-20T14:03:11.000Z"
              schema:
                type: object
                description: |
                  When this ledger does not account for every movement of the
                  balance (deposits that landed before they were tracked, or
                  movements on a network this record does not track), the rows
                  come without `balanceAfter` and the envelope carries a
                  human-readable `note` saying so. The same happens, with its own
                  `note`, when the balance could not be read to check the total;
                  retry to get it back. A running total that disagrees with
                  `GET /balance`, or that could not be checked against it, is
                  never published.
                properties:
                  hasMore: { type: boolean }
                  nextCursor:
                    type: [string, "null"]
                    description: |
                      Feed back as `cursor`. It is NOT null when you are caught
                      up: an empty page returns the position you sent —
                      terminate on `hasMore: false`. Null only when the history
                      is empty and no `cursor` or `since` was sent.
                  nextSince:
                    type: [string, "null"]
                    format: date-time
                    deprecated: true
                    description: |
                      Deprecated: use `nextCursor`. The last entry's time;
                      paging on it skips entries sharing that millisecond. Feed
                      it back as `since`. It is not null when you are caught up:
                      an empty page returns the `since` you sent, so terminate on
                      `hasMore: false`. A loop waiting for null never ends. Null
                      only when the history is empty and no `since` was sent.
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          description: The payout or deposit id; a reversal is `<payoutId>:reversal`.
                        type:
                          type: string
                          enum: [funding, payout, reversal]
                          description: |
                            `reversal` is a debited payout's money coming back, returned by the bank (`RETURNED`) or canceled
                            (`CANCELED`): a separate appended entry, never a rewrite of the original debit.
                        status:
                          type: string
                          enum: [COMPLETED, DEBITED, RETURNED, CANCELED]
                          description: |
                            What this entry records, frozen when it was written, and not the payout's current status, which would make a
                            historical row mutate under a reconciler that had
                            already read it. `DEBITED` money left, `RETURNED`
                            money came back from a failed payout, `CANCELED` money came back from a canceled one, `COMPLETED`
                            funding landed.
                        currency: { type: string }
                        amount: { type: string }
                        balanceAfter:
                          type: string
                          description: Present only when the ledger reconciles with `GET /balance`.
                        reference:
                          type: string
                          description: Your payout `reference`. Absent when there was none (a sandbox top-up has none).
                        at: { type: string, format: date-time }
  /payments/organizations/{orgId}/sandbox/fund:
    post:
      tags: [Sandbox]
      operationId: fundSandbox
      summary: Fund the sandbox
      description: |
        Credit a sandbox balance.

        Test keys only. A sandbox starts empty and you fund it with this call, so
        you can test the underfunded path on purpose. A `400` at send time is a case your integration has to handle, and a sandbox that
        always has money never exercises it.

        The body is validated against the schema below: an extra key or a
        numeric `amount` answers `400 VALIDATION_ERROR`.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                amount:
                  type: string
                  pattern: '^\d+(\.\d+)?$'
                  description: Positive decimal string. Defaults to `10000.00` when the body or field is omitted.
                  examples: ["5000.00"]
      responses:
        "400":
          description: "`BAD_REQUEST`: a live key (this is test-only), or an amount that is not a positive number. Or a malformed `Idempotency-Key`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                liveKey:
                  value:
                    type: BAD_REQUEST
                    status: 400
                    detail: Sandbox top-ups are only available with a test API key (avvio_test_…) or from the sandbox environment in the dashboard.
                    requestId: req-ac6
                    message: Sandbox top-ups are only available with a test API key (avvio_test_…) or from the sandbox environment in the dashboard.
                    statusCode: 400
                notPositive:
                  value:
                    type: BAD_REQUEST
                    status: 400
                    detail: amount must be a positive decimal string
                    requestId: req-ac7
                    message: amount must be a positive decimal string
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenWrite" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The new balance.
          content:
            application/json:
              schema:
                type: object
                required: [balance]
                properties:
                  # A bare decimal string, not a Money object.
                  balance: { type: string, examples: ["5000.00"] }

  /payments/organizations/{orgId}/sandbox/webhook-endpoints:
    post:
      tags: [Sandbox]
      operationId: createSandboxWebhookEndpoint
      summary: Create a sandbox webhook endpoint
      description: |
        Register a sandbox webhook endpoint and get its signing secret.

        Test keys only. Register a publicly reachable HTTPS receiver. Use a public
        HTTPS tunnel (for example cloudflared or ngrok) for webhooks from the
        sandbox.

        Live endpoints are https and are created from the dashboard by a human:
        a credential able to repoint its own webhook URL could quietly redirect
        every payout notification, so that stays off the API.

        The secret is returned **once** and is not retrievable afterwards.

        The body is validated against the schema below: an unknown event
        name, or an `events` that is not an array, answers
        `400 VALIDATION_ERROR`.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: |
                    Publicly reachable HTTPS URL. From your own machine, use a
                    public HTTPS tunnel such as cloudflared or ngrok.
                  examples: ["https://example.com/hooks/avvio"]
                events:
                  type: array
                  description: Omit to receive every event type.
                  uniqueItems: true
                  items:
                    type: string
                    description: |
                      One event type to deliver. The subscription list takes the
                      payout, approval, batch and checkout families; an endpoint with no
                      list gets every payout-side type, including
                      `payout_approval.*` and `webhook_endpoint.*`, but not `checkout_payment.*`; name those explicitly.
                    enum:
                      - payout.pending
                      - payout.processing
                      - payout.completed
                      - payout.failed
                      - payout.returned
                      - payout.canceled
                      - payout_approval.pending
                      - payout_approval.approved
                      - payout_approval.rejected
                      - payout_approval.expired
                      - payout_approval.executed
                      - payout_approval.execution_failed
                      - payout_batch.awaiting_confirmation
                      - payout_batch.completed
                      - payout_batch.canceled
                      - payout_batch.failed
                      - checkout_payment.paid
                      - checkout_payment.failed
                      - checkout_payment.refunded
                      - checkout_payment.reversed
                      - checkout_payment.partially_refunded
      responses:
        "400":
          description: |
            `BAD_REQUEST`: a live key (this is test-only), or no `url`.
            `UNSAFE_WEBHOOK_DESTINATION`: the URL is malformed, carries
            credentials, or resolves to an address we will not deliver to.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                liveKey:
                  value:
                    type: BAD_REQUEST
                    status: 400
                    detail: Sandbox webhook endpoints are only available with a test API key (avvio_test_…) or from the sandbox environment in the dashboard.
                    requestId: req-ac8
                    message: Sandbox webhook endpoints are only available with a test API key (avvio_test_…) or from the sandbox environment in the dashboard.
                    statusCode: 400
                unsafe:
                  value:
                    type: UNSAFE_WEBHOOK_DESTINATION
                    status: 400
                    detail: Webhook URLs must not include credentials.
                    requestId: req-ac9
                    message: Webhook URLs must not include credentials.
                    statusCode: 400
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `DEVELOPER_FEATURE_DISABLED`: developer tools are off for your
            organization. Or the refusals every write can answer: `FORBIDDEN`,
            `ACCOUNT_BLOCKED`, `INSUFFICIENT_SCOPE`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                developerDisabled: { $ref: "#/components/examples/DeveloperFeatureDisabled" }
                forbidden: { $ref: "#/components/examples/Forbidden" }
                accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
                insufficientScope: { $ref: "#/components/examples/InsufficientScope" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The endpoint, including its signing secret.
          content:
            application/json:
              example:
                id: cmf3k2xe80006q8b7d2f4g6hj
                url: https://example.com/hooks/avvio
                events: [payout.pending, payout.processing, payout.completed, payout.failed]
                secret: whsec_…
                warning: Store this secret now — it is not retrievable.
                createdAt: "2026-08-20T13:58:02.000Z"
              schema:
                type: object
                required: [id, url, events, secret, warning, createdAt]
                properties:
                  id: { type: string }
                  url: { type: string }
                  events: { type: array, items: { type: string } }
                  secret:
                    type: string
                    description: Shown once. Store it now.
                    examples: ["whsec_…"]
                  warning: { type: string }
                  createdAt: { type: string, format: date-time }

  /payments/organizations/{orgId}/sandbox/webhook-endpoints/{endpointId}/deliveries:
    get:
      tags: [Sandbox]
      operationId: listSandboxWebhookDeliveries
      summary: List sandbox webhook deliveries
      description: |
        What we sent, what came back, and what we retried.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: endpointId
          in: path
          required: true
          description: Opaque endpoint id returned by sandbox webhook registration.
          schema: { type: string, minLength: 1 }
      responses:
        "400":
          description: "`BAD_REQUEST`: a live key. This is test-only."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                liveKey:
                  value:
                    type: BAD_REQUEST
                    status: 400
                    detail: Sandbox webhook deliveries are only available with a test API key (avvio_test_…) or from the sandbox environment in the dashboard.
                    requestId: req-ad0
                    message: Sandbox webhook deliveries are only available with a test API key (avvio_test_…) or from the sandbox environment in the dashboard.
                    statusCode: 400
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenDeveloper" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Recent delivery attempts.
          content:
            application/json:
              example:
                - id: cmf3k2xe80007q8b7k8l0m2np
                  eventId: cmf3k2xb20002q8b7c1s9m4rw
                  eventType: payout.processing
                  attempts: 1
                  deliveredAt: "2026-08-20T14:03:13.100Z"
                  nextAttemptAt: null
                  lastError: null
                  createdAt: "2026-08-20T14:03:12.900Z"
              schema:
                type: array
                maxItems: 50
                items:
                  type: object
                  required: [id, eventId, eventType, attempts, createdAt]
                  properties:
                    id: { type: string }
                    eventId: { type: string, description: "The event's `id`: equals the `svix-id` header, the body's `id` and the feed row's `id`." }
                    eventType:
                      type: string
                      enum:
                        - payout.pending
                        - payout.processing
                        - payout.completed
                        - payout.failed
                        - payout.returned
                        - payout.canceled
                        - payout_batch.awaiting_confirmation
                        - payout_batch.completed
                        - payout_batch.canceled
                        - payout_batch.failed
                        - payout_approval.pending
                        - payout_approval.approved
                        - payout_approval.rejected
                        - payout_approval.expired
                        - payout_approval.executed
                        - payout_approval.execution_failed
                        - webhook_endpoint.disabled
                        - checkout_payment.paid
                        - checkout_payment.failed
                        - checkout_payment.refunded
                        - checkout_payment.reversed
                        - checkout_payment.partially_refunded
                    attempts: { type: integer, minimum: 0 }
                    deliveredAt: { type: [string, "null"], format: date-time }
                    nextAttemptAt: { type: [string, "null"], format: date-time }
                    lastError: { type: [string, "null"] }
                    createdAt: { type: string, format: date-time }

  /payments/organizations/{orgId}/orders:
    get:
      tags: [Payouts]
      operationId: listPayouts
      summary: List payouts
      description: |
        Recent payouts.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: updatedSince
          in: query
          description: |
            Use this as the change feed. It returns payouts whose state changed
            at or after this time, oldest-changed first.

            Ordinary listing is newest-first by creation, which by construction can never tell you that an old payout changed, and `completed →
            failed` on a bank return, days later, is exactly the change a ledger
            cannot afford to miss. Page forward with `nextCursor`, carry the last
            `updatedAt` you saw as your watermark, and you will observe every
            revision without re-reading your whole history.

            At-least-once, not exactly-once: `updatedSince` is inclusive, so
            resuming from your watermark re-reads the row at that instant.
            Dedupe on `payoutId` + `updatedAt`.
          schema: { type: string, format: date-time }
        # Filters, so a caller need not pull the whole list and filter client-side.
        - name: status
          in: query
          description: One comma-separated list of canonical statuses, e.g. `processing,completed`.
          style: form
          explode: false
          schema:
            type: array
            minItems: 1
            uniqueItems: true
            items: { $ref: "#/components/schemas/PayoutStatus" }
        - name: endUserId
          in: query
          description: Your id for the person the payout belongs to.
          schema: { type: string }
        - name: reference
          in: query
          description: Your payment reference, matched exactly.
          schema: { type: string }
        - name: createdAfter
          in: query
          description: Return payouts created at or after this RFC 3339 timestamp.
          schema: { type: string, format: date-time }
        - name: createdBefore
          in: query
          description: Return payouts created at or before this RFC 3339 timestamp.
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          description: Maximum payouts to return, from 1 to 100. Defaults to 50. Outside the range is a `400`, not clamped.
          schema: { type: integer, minimum: 1, default: 50, maximum: 100 }
        - name: cursor
          in: query
          description: |
            The `nextCursor` from the previous page, passed back verbatim. It is opaque; do not parse or construct one.
          schema: { type: string }
      responses:
        "400": { $ref: "#/components/responses/ListBadRequest" }
        "503":
          description: |
            `ORDERS_TEMPORARILY_UNAVAILABLE`: we could not read the full list,
            so we refuse to hand you a page that would look complete. Retry
            shortly; nothing is wrong with your request.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unavailable:
                  value:
                    type: ORDERS_TEMPORARILY_UNAVAILABLE
                    status: 503
                    detail: We could not read the full payout list, so this page would have been incomplete and we will not report it as complete. Retry shortly.
                    requestId: req-ad1
                    message: We could not read the full payout list, so this page would have been incomplete and we will not report it as complete. Retry shortly.
                    statusCode: 503
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: |
            A page of payouts. Newest-created first, or oldest-changed first when
            `updatedSince` is set.
          content:
            application/json:
              example:
                data:
                  - payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                    status: processing
                    stage: settling
                    sourceAmount: { currency: USD, amount: "200.00" }
                    destinationAmount: { currency: MXN, amount: "3384.65" }
                    destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
                    fee: { currency: USD, amount: "1.02" }
                    rate: "16.923250"
                    reference: ZZ-WAGE-1
                    endUser: { id: customer_42, name: Northwind Payroll LLC, email: payroll@northwind.example }
                    createdAt: "2026-08-20T14:03:11.000Z"
                    updatedAt: "2026-08-20T14:03:12.480Z"
                    completedAt: null
                hasMore: false
                nextCursor: null
              schema:
                type: object
                description: |
                  A page, not a bare array: `{ data, hasMore, nextCursor }`.
                required: [data, hasMore]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Payout" }
                  hasMore: { type: boolean }
                  nextCursor: { type: [string, "null"] }

  /payments/organizations/{orgId}/orders/{payoutId}:
    get:
      tags: [Payouts]
      operationId: getPayout
      summary: Get a payout
      description: |
        Returns one payout, refreshed against the payment network on read, so this is authoritative, more so than a webhook you may have missed. Build reconciliation
        against this and treat webhooks as the nudge to look.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: payoutId
          in: path
          required: true
          description: Opaque `payoutId` returned by create or accept. Pass it unchanged.
          schema: { type: string, minLength: 1 }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The payout
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payout" }
        "404":
          description: |
            Unknown payout.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notFound:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Unknown payout sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                    requestId: req-ae0
                    message: Unknown payout sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                    statusCode: 404

  /payments/organizations/{orgId}/payin-accounts:
    get:
      tags: ["Funding & balance"]
      operationId: getFundingAccounts
      summary: List funding accounts
      description: |
        Returns where to wire money to top up your balance.

        Money you wire is held as your balance. It is not forwarded anywhere; payouts debit it.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Funding instructions
          content:
            application/json:
              example:
                accounts:
                  - id: sbx_acct_usd_cmsx0h2k900a1n11ib3qz9v42
                    currency: USD
                    shape: us
                    status: active
                    paymentRails: [wire, ach]
                    settlesTo: provider_balance
                    destination: null
                    balance: { currency: USD, amount: 980000, decimals: 2 }
                    depositInstructions:
                      bank_name: Example Bank
                      account_number: "****4471"
                      routing_number: "123456789"
                      beneficiary_name: Avvio
                      reference: "ORG-7f3a91"
              # An object, not a bare array. It carries a `note`; in sandbox,
              # "balances are not funded by wire, use POST /sandbox/fund".
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items: { $ref: "#/components/schemas/FundingAccount" }
                  note:
                    type: string
                    description: Present only for a test key with nothing to show, saying why (sandbox balances are funded with `POST /sandbox/fund`, not by wire).
        "503":
          description: "`INTERNAL` (status 503): the account list could not be read in time. Retry shortly."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unavailable:
                  value:
                    type: INTERNAL
                    status: 503
                    detail: Unable to load receiving accounts
                    resolution: Nothing was recorded under this Idempotency-Key, so retrying with the same key is safe. If it persists, send us the requestId.
                    requestId: req-ad2
                    message: Unable to load receiving accounts
                    statusCode: 503
webhooks:
  # OpenAPI 3.1 webhooks: what Avvio POSTs to your endpoint. The full contract
  # for every event family (batch, approval, endpoint) stays under
  # components.x-webhooks; this entry reuses its payout payload so the event
  # enum lives in one place.
  payoutEvent:
    post:
      tags: [Events]
      summary: Payout event
      description: |
        Sent to your endpoint on every payout state change, one event per
        transition. Verify `svix-signature` over the raw body before parsing,
        dedupe on `svix-id`, and answer `2xx` before you process. Failed
        attempts are retried nine times over 4,221 minutes (±20% jitter per
        delay). The full contract is on the Webhooks guide.
      parameters:
        - name: svix-id
          in: header
          required: true
          description: The event id. Stable across retries; equals the body's `id` and the feed row's `id`. Dedupe on it.
          schema: { type: string }
        - name: svix-timestamp
          in: header
          required: true
          description: Unix seconds when this attempt was signed. Reject a delivery more than five minutes from your clock.
          schema: { type: string, examples: ["1756893600"] }
        - name: svix-signature
          in: header
          required: true
          description: |
            Space-separated `v1,<base64>` values: HMAC-SHA256 of
            `${svix-id}.${svix-timestamp}.${raw body}` with the base64-decoded
            secret minus its `whsec_` prefix. During a secret rotation it
            carries two; accept the delivery if any one verifies.
          schema: { type: string }
        - name: Avvio-Webhook-Version
          in: header
          required: true
          description: The shape of `data`; equals the body's `apiVersion` (`1` today).
          schema: { type: string, examples: ["1"] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/x-webhooks/payout/payload" }
      responses:
        "2XX":
          description: Delivered. Any `2xx` counts; the body is ignored.
        default:
          description: Anything else, including a timeout, is retried on the schedule above.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        **This key is a secret. Treat it like a database password.** Anyone
        holding it can move money on your organization's behalf, so it belongs
        in a secrets vault or an environment variable, never in source control, a client-side bundle, a mobile app, a screenshot, or a support
        ticket. If one is exposed, revoke it in the dashboard and issue a
        successor; the new key is live before the old one stops, so there is no
        window where neither works.

        The docs **Try It** console holds a test key only for the duration of your
        documentation session. Use only an `avvio_test_*` key there. In your own
        product, keep both test and live keys in server-side secret storage;
        never embed them in a browser bundle or mobile application.

        A key is scoped to one organization and rejected on any other's routes.
        It can move money. It cannot accept provider terms, manage your team or
        issue further keys; those stay human actions.

        Reduce the blast radius where you can. A key can be issued read-only,
        pinned to specific source addresses, and given an expiry. All three are set when you issue it, and all three are enforced on every request.

        Format: `avvio_live_` or `avvio_test_`, followed by a 32-character
        lowercase hexadecimal public id, `_`, and a 43-character base64url
        secret. Copy the entire value. Example:
        `avvio_test_0123456789abcdef0123456789abcdef_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`.
  parameters:
    OrgId:
      name: orgId
      in: path
      required: true
      description: |
        The opaque organization id issued to you, normally CUID-shaped (for
        example `cmsx…`). It is not an `org_`-prefixed alias. Pass it unchanged
        in every organization-scoped path.
      schema: { type: string, minLength: 1, examples: ["cmsx…"] }

    BatchId:
      name: batchId
      in: path
      required: true
      description: From the 202 that accepted the run, or the batch list.
      schema: { type: string, minLength: 1 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A unique value per logical operation, 1-255 chars of `A-Z a-z 0-9 _ . : -`.

        Reuse it to retry. Same key with the same body replays the stored
        response; same key with a *different* body is a `409`, because
        answering with the first call's result would hand you a receipt for a
        payout you did not request. A `4xx` releases the key, so you can fix the
        body and reuse it.

        **Reuse it; do not generate one per attempt.** A key minted per attempt
        defeats replay entirely: every retry looks like a new request, so every
        retry pays. We also watch for an identical body arriving under a
        *different* key within 15 minutes and refuse it with
        `DUPLICATE_REQUEST_DETECTED`.

        Records are kept for 7 days. That is a retention window only: there is no path where an expired key is re-executed.
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: '^[A-Za-z0-9_.:-]+$'
        examples: ["5a81e921-bc01-447a-9a11-0982716a5b42"]

    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Same semantics as `Idempotency-Key` on the money routes (replay on the same body, `409` on a different one, released by a `4xx`), but optional here: without it the request simply runs once with no replay. Every
        `POST`, `PATCH` and `DELETE` honors it; send one whenever your client
        might retry.
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: '^[A-Za-z0-9_.:-]+$'
        examples: ["c3e1f0a4-2b7d-4e61-9f3a-8d5c2b1e7a90"]

    AllowDuplicate:
      name: X-Allow-Duplicate
      in: header
      required: false
      description: |
        Set to `true` to send a request that is byte-identical to one you sent
        within the last 15 minutes under a different key. `true` is the only
        accepted value. It switches off the guard that catches a retry arriving
        under a fresh key, so send it only when you mean to pay twice.
      schema: { type: string }

  examples:
    Forbidden:
      summary: A key for a different organization
      value:
        type: FORBIDDEN
        status: 403
        detail: This API key cannot access that organization
        resolution: This key is scoped to a different organization.
        requestId: req-9d0
        message: This API key cannot access that organization
        statusCode: 403
    AccountBlocked:
      summary: API access suspended for the organization
      value:
        type: ACCOUNT_BLOCKED
        status: 403
        detail: API access for this organization has been suspended.
        requestId: req-9d1
        message: API access for this organization has been suspended.
        statusCode: 403
    LiveKeyOrgNotApproved:
      summary: A live key writing before business verification is approved
      value:
        type: LIVE_KEY_ORG_NOT_APPROVED
        status: 403
        detail: Live API keys can move money once Avvio approves your business verification. Use a test key until then. Nothing was changed.
        requestId: req-9d2
        message: Live API keys can move money once Avvio approves your business verification. Use a test key until then. Nothing was changed.
        statusCode: 403
    InsufficientScope:
      summary: A read-only key on a write
      value:
        type: INSUFFICIENT_SCOPE
        status: 403
        detail: This API key is read-only and cannot POST /business/api/v1/payments/organizations/cmsx0h2k900a1n11ib3qz9v42/payouts. Issue a key with the `write` scope to move money.
        requestId: req-9d3
        message: This API key is read-only and cannot POST /business/api/v1/payments/organizations/cmsx0h2k900a1n11ib3qz9v42/payouts. Issue a key with the `write` scope to move money.
        statusCode: 403
    KeyExpired:
      summary: The key passed its expiry
      value:
        type: KEY_EXPIRED
        status: 401
        detail: This key expired on 2026-09-01T00:00:00.000Z. Issue a new key in the dashboard; an expired key cannot be rotated.
        requestId: req-9c0
        message: This key expired on 2026-09-01T00:00:00.000Z. Issue a new key in the dashboard; an expired key cannot be rotated.
        statusCode: 401
    KeyIpNotAllowed:
      summary: A pinned key used from another address
      value:
        type: KEY_IP_NOT_ALLOWED
        status: 401
        detail: This key is pinned to specific source addresses and this request did not come from one.
        requestId: req-9c1
        message: This key is pinned to specific source addresses and this request did not come from one.
        statusCode: 401
    IdempotencyKeyInvalid:
      summary: A malformed Idempotency-Key
      value:
        type: IDEMPOTENCY_KEY_INVALID
        status: 400
        detail: "Idempotency-Key must be 1-255 characters of A-Z a-z 0-9 _ . : or -"
        resolution: "Use 1-255 characters of A-Z a-z 0-9 _ . : or -. A UUID works."
        requestId: req-9f3
        message: "Idempotency-Key must be 1-255 characters of A-Z a-z 0-9 _ . : or -"
        statusCode: 400
    IdempotencyKeyConflict:
      summary: The key was reused with a different body
      value:
        type: IDEMPOTENCY_KEY_CONFLICT
        status: 409
        detail: This Idempotency-Key was already used with a different request body. Use a new key for a different request.
        resolution: This key was used with a different body. Do not retry — use a new key for a new request.
        requestId: req-9g3
        message: This Idempotency-Key was already used with a different request body. Use a new key for a different request.
        statusCode: 409
    IdempotencyKeyInProgress:
      summary: The first request with this key is still running
      value:
        type: IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS
        status: 409
        detail: A request with this Idempotency-Key is still in flight.
        resolution: An identical request is still running. Back off and retry the SAME key.
        requestId: req-9h4
        message: A request with this Idempotency-Key is still in flight.
        statusCode: 409
    OutcomeUnknown:
      summary: First call, outcome unknown
      value:
        type: PAYOUT_OUTCOME_UNKNOWN
        status: 500
        detail: 'The outcome of this request is unknown: the payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference>, or replay this key, which answers 409 until we resolve it. Contact support with the requestId.'
        resolution: The payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference> (or replay the same key, which answers 409 until we resolve it) and contact support with the requestId.
        requestId: req-2k9f
        message: 'The outcome of this request is unknown: the payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference>, or replay this key, which answers 409 until we resolve it. Contact support with the requestId.'
        statusCode: 500
    OutcomeUnknownReplay:
      summary: A replay of that key, still unresolved
      value:
        type: PAYOUT_OUTCOME_UNKNOWN
        status: 409
        detail: 'The outcome of this request is unknown: the payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference>, or replay this key, which answers 409 until we resolve it. Contact support with the requestId.'
        resolution: The payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference> (or replay the same key, which answers 409 until we resolve it) and contact support with the requestId.
        originalRequestId: req-2k9f
        requestId: req-9i5
        message: 'The outcome of this request is unknown: the payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference>, or replay this key, which answers 409 until we resolve it. Contact support with the requestId.'
        statusCode: 409
    DeveloperFeatureDisabled:
      summary: Developer tools are off for the organization
      value:
        type: DEVELOPER_FEATURE_DISABLED
        status: 403
        detail: Developer tools are not enabled for this organization.
        requestId: req-9d4
        message: Developer tools are not enabled for this organization.
        statusCode: 403
    DuplicateRequest:
      summary: An identical body under a different key
      value:
        type: DUPLICATE_REQUEST_DETECTED
        status: 409
        detail: An identical request was received in the last 15 minutes under a different Idempotency-Key. Nothing was executed.
        resolution: 'Nothing was executed. If this was a retry, send it again with originalIdempotencyKey. If you really meant to send twice, add the header X-Allow-Duplicate: true.'
        originalIdempotencyKey: zz_advance_88213
        originalPayoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
        requestId: req-9j6
        message: An identical request was received in the last 15 minutes under a different Idempotency-Key. Nothing was executed.
        statusCode: 409

  responses:
    Unauthorized:
      description: |
        The key was refused. Nothing ran.

        - `UNAUTHORIZED`: missing, invalid or revoked, or a key on a route that
          does not accept one.
        - `KEY_EXPIRED`: the key passed the expiry it was issued with. Issue a
          new one; an expired key cannot be rotated.
        - `KEY_IP_NOT_ALLOWED`: the key is pinned to source addresses and this
          request came from another.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            unauthorized:
              value:
                type: UNAUTHORIZED
                status: 401
                detail: Invalid API key
                resolution: Check the API key was copied whole and has not been revoked.
                requestId: req-9bz
                message: Invalid API key
                statusCode: 401
            keyExpired: { $ref: "#/components/examples/KeyExpired" }
            keyIpNotAllowed: { $ref: "#/components/examples/KeyIpNotAllowed" }
    Forbidden:
      description: |
        A valid key that may not make this call. Nothing ran.

        - `FORBIDDEN`: the key belongs to a different organization.
        - `ACCOUNT_BLOCKED`: API access for your organization is suspended, and
          every key is refused until we lift it. Contact support.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            forbidden: { $ref: "#/components/examples/Forbidden" }
            accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
    ForbiddenWrite:
      description: |
        A valid key that may not make this write. Nothing was changed.

        - `FORBIDDEN`: the key belongs to a different organization.
        - `ACCOUNT_BLOCKED`: API access for your organization is suspended.
        - `LIVE_KEY_ORG_NOT_APPROVED`: a live key, before we have approved your
          business verification. Use a test key until then.
        - `INSUFFICIENT_SCOPE`: a read-only key. Issue one with the `write`
          scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            forbidden: { $ref: "#/components/examples/Forbidden" }
            accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
            liveKeyOrgNotApproved: { $ref: "#/components/examples/LiveKeyOrgNotApproved" }
            insufficientScope: { $ref: "#/components/examples/InsufficientScope" }
    RateLimited:
      description: |
        Too many requests. The default ceiling is **100 requests per minute per
        API credential** on a 60-second window. High-volume payout and
        reconciliation routes declare a 600/minute override, and batch
        submission a 30/minute ceiling. A separate 2,000/minute per-source-IP
        abuse ceiling always applies.

        Obey `Retry-After`; it is in seconds and is authoritative. A 429 means
        the request was refused before the handler ran. Retry reads normally;
        retry an idempotent mutation with its same `Idempotency-Key`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying. Authoritative.
          schema: { type: integer }
        X-RateLimit-Limit:
          description: Requests permitted in the window.
          schema: { type: integer }
        X-RateLimit-Remaining:
          description: Requests left in the window; `0` on a 429.
          schema: { type: integer }
        X-RateLimit-Reset:
          description: Seconds until the window resets.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            rateLimited:
              value:
                type: RATE_LIMITED
                status: 429
                detail: 'ThrottlerException: Too Many Requests'
                resolution: Back off and retry after the Retry-After interval.
                requestId: req-9e1
                message: 'ThrottlerException: Too Many Requests'
                statusCode: 429
    IdempotencyRequired:
      description: Missing (`IDEMPOTENCY_KEY_REQUIRED`) or malformed (`IDEMPOTENCY_KEY_INVALID`) `Idempotency-Key`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missing:
              value:
                type: IDEMPOTENCY_KEY_REQUIRED
                status: 400
                detail: Idempotency-Key header is required. Use a unique value per operation, and reuse it to retry.
                resolution: Add an Idempotency-Key header with a unique value for this operation.
                requestId: req-9f2
                message: Idempotency-Key header is required. Use a unique value per operation, and reuse it to retry.
                statusCode: 400
            invalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
    ForbiddenDeveloper:
      description: |
        A valid key that may not make this call. Nothing ran.

        - `FORBIDDEN`: the key belongs to a different organization.
        - `ACCOUNT_BLOCKED`: API access for your organization is suspended.
        - `DEVELOPER_FEATURE_DISABLED`: developer tools (webhook endpoints) are
          off for your organization. `GET /policy` lists `developer` in
          `features` when they are on.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            forbidden: { $ref: "#/components/examples/Forbidden" }
            accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
            developerDisabled: { $ref: "#/components/examples/DeveloperFeatureDisabled" }
    ListBadRequest:
      description: |
        `VALIDATION_ERROR`: a query parameter was refused, and `errors` names
        it. A `limit` outside its range is refused, not clamped; a `cursor` we
        did not issue for this organization is refused (start from the first
        page); an unknown filter value or a parameter sent twice is refused.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            limit:
              value:
                type: VALIDATION_ERROR
                status: 400
                detail: limit must be a whole number between 1 and 100.
                resolution: Correct the fields listed in `errors` and retry.
                errors:
                  - 'limit: "500" is above the maximum of 100'
                requestId: req-9ul
                message: limit must be a whole number between 1 and 100.
                statusCode: 400
    PayoutRefused:
      description: |
        `PAYOUT_REFUSED`: this payout cannot be sent to this recipient.
        Nothing was sent. Do not retry; contact us with the `requestId` if you
        believe it is wrong. The response never says why.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            refused:
              value:
                type: PAYOUT_REFUSED
                status: 422
                detail: This payout cannot be sent to this beneficiary. Nothing was sent.
                resolution: Nothing was sent. Do not retry. Contact us with the requestId if you believe this is wrong.
                requestId: req-a1p
                message: This payout cannot be sent to this beneficiary. Nothing was sent.
                statusCode: 422
    IdempotencyInvalid:
      description: "`IDEMPOTENCY_KEY_INVALID`: an `Idempotency-Key` was sent but is not 1-255 characters of `A-Z a-z 0-9 _ . : -`. Nothing ran."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            invalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
    DestinationNotFound:
      description: |
        `DESTINATION_ACCOUNT_NOT_FOUND`: no payment method with this
        `destinationAccountId` belongs to your organization. Nothing was sent.
        Use a `destinationAccountId` from a recipient you created.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notFound:
              value:
                type: DESTINATION_ACCOUNT_NOT_FOUND
                status: 404
                detail: No payout account sbx_acct_MXN_9999_00000000 belongs to this organization. Use a destinationAccountId returned by a beneficiary you created. Nothing was sent.
                requestId: req-9xm
                message: No payout account sbx_acct_MXN_9999_00000000 belongs to this organization. Use a destinationAccountId returned by a beneficiary you created. Nothing was sent.
                statusCode: 404
    PayoutControlRefused:
      description: |
        A control refused it and **nothing was sent**.
        `PAYOUT_LIMIT_EXCEEDED`: over a single, daily, or per-end-user daily
        cap. The message names which; split it or ask us to raise the cap.
        `PAYOUT_REFUSED`: this payout cannot be sent to this recipient;
        do not retry, contact us with the `requestId`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            limit:
              value:
                type: PAYOUT_LIMIT_EXCEEDED
                status: 422
                detail: This payout would exceed your organization's daily limit of 25000.00 USD. Nothing was sent.
                resolution: Nothing was sent. Split the amount across days or end users, or contact us to raise the limit named in the message.
                requestId: req-a1o
                message: This payout would exceed your organization's daily limit of 25000.00 USD. Nothing was sent.
                statusCode: 422
            refused:
              value:
                type: PAYOUT_REFUSED
                status: 422
                detail: This payout cannot be sent to this beneficiary. Nothing was sent.
                resolution: Nothing was sent. Do not retry. Contact us with the requestId if you believe this is wrong.
                requestId: req-a1p
                message: This payout cannot be sent to this beneficiary. Nothing was sent.
                statusCode: 422
    ForbiddenBatch:
      description: |
        A valid key that may not make this write. Nothing was changed.

        - `MASS_PAYOUTS_DISABLED`: batch submission is off for your
          organization. `GET /policy` lists `mass_payouts` in `features` when
          it is on; contact support to enable it.
        - `FORBIDDEN`, `ACCOUNT_BLOCKED`, `LIVE_KEY_ORG_NOT_APPROVED`,
          `INSUFFICIENT_SCOPE`: as on every write (`ForbiddenWrite`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            massPayoutsDisabled:
              value:
                type: MASS_PAYOUTS_DISABLED
                status: 403
                detail: Mass payouts are not enabled for this organization. Contact support to enable batch submission.
                requestId: req-9d6
                message: Mass payouts are not enabled for this organization. Contact support to enable batch submission.
                statusCode: 403
            forbidden: { $ref: "#/components/examples/Forbidden" }
            accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
            liveKeyOrgNotApproved: { $ref: "#/components/examples/LiveKeyOrgNotApproved" }
            insufficientScope: { $ref: "#/components/examples/InsufficientScope" }
    PayoutNotFound:
      description: "`NOT_FOUND`: no payout with this id belongs to your organization."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notFound:
              value:
                type: NOT_FOUND
                status: 404
                detail: Unknown payout sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                requestId: req-ac5
                message: Unknown payout sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
                statusCode: 404
    NoApprovedProvider:
      description: |
        `BAD_REQUEST`: your organization has no approved way to carry this
        request (for a `currency` you passed, or at all). Nothing ran. Check
        the currency against your corridors, or contact us.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            noProvider:
              value:
                type: BAD_REQUEST
                status: 400
                detail: No approved payments provider is available for this request
                requestId: req-9uh
                message: No approved payments provider is available for this request
                statusCode: 400
    IdempotencyConflict:
      description: |
        Either the key was reused with a different body
        (`IDEMPOTENCY_KEY_CONFLICT`: use a new key), or the first request with
        this key is still running (`IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`: back
        off and retry the same key).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
            inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
    MoneyIdempotencyConflict:
      description: |
        On a route that moves money:

        - `IDEMPOTENCY_KEY_CONFLICT`: the key was reused with a different body.
          Use a new key for a new request.
        - `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`: the first request with this key
          is still running. Back off and retry the same key.
        - `PAYOUT_OUTCOME_UNKNOWN`: a replay of a key whose first call answered
          `500 PAYOUT_OUTCOME_UNKNOWN`. Money may have moved. Do not send a new
          key; look the outcome up first. `originalRequestId` names the first
          call.
        - `DUPLICATE_REQUEST_DETECTED`: an identical body under a *different*
          key within 15 minutes. Nothing was executed. Retry with
          `originalIdempotencyKey`, or send `X-Allow-Duplicate: true` if you
          mean to pay twice.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
            inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
            outcomeUnknown: { $ref: "#/components/examples/OutcomeUnknownReplay" }
            duplicate: { $ref: "#/components/examples/DuplicateRequest" }
    OutcomeUnknown:
      description: |
        `PAYOUT_OUTCOME_UNKNOWN`: the call failed after the money may have
        moved, and we cannot yet say whether it did. **Do not retry with a new
        `Idempotency-Key`**; that is how a payment goes out twice. Look the
        outcome up first (the operation says where), or replay the same key,
        which answers `409 PAYOUT_OUTCOME_UNKNOWN` until we resolve it. Send
        support the `requestId`.

        Every other `500` is `INTERNAL`: nothing was recorded under the key, and
        retrying with the same key is safe.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            outcomeUnknown: { $ref: "#/components/examples/OutcomeUnknown" }

  schemas:

    Money:
      type: object
      description: |
        Always a decimal string, never a float and never base units. Floats lose
        cents at scale; base units mean every integrator re-derives the exponent.
      required: [currency, amount]
      properties:
        currency: { type: string, pattern: '^[A-Z]{3,5}$', examples: ["USD"] }
        amount: { type: string, pattern: '^-?\d+(\.\d+)?$', examples: ["200.00"] }

    WalletBalance:
      type: object
      description: One USD stablecoin position in your own wallet, on one chain. Counted into `balances` at face value.
      required: [currency, network, amount]
      properties:
        currency: { type: string, enum: [USDC, USDT], examples: ["USDC"] }
        network: { type: string, description: Chain name., examples: ["Ethereum", "Base", "Solana"] }
        amount: { type: string, pattern: '^-?\d+(\.\d+)?$', description: Token units, up to six decimals., examples: ["3185.030147"] }

    LedgerBalance:
      type: object
      description: One currency of our append-only balance ledger. Decimal strings.
      required: [currency, available, held, total]
      properties:
        currency: { type: string, examples: ["USD"] }
        available:
          type: string
          description: What a payout can draw on right now.
          examples: ["9800.00"]
        held:
          type: string
          description: Reserved by holds not yet converted to payouts or released.
          examples: ["200.00"]
        total:
          type: string
          description: "`available` + `held`."
          examples: ["10000.00"]

    BalanceTransaction:
      type: object
      description: |
        One append-only change to what you can spend. `amount` is signed and
        always means "how this row changed `available`": positive for
        `funding`, `payout_return` and `hold_release`; negative for `payout`
        and `hold`; either for `adjustment`.
      required: [id, type, amount, fee, currency, balanceAfter, createdAt]
      properties:
        id:
          type: string
          description: Monotonic, and the cursor. A string because it can exceed 2^53.
          examples: ["48213"]
        type:
          type: string
          enum: [funding, payout, payout_return, hold, hold_release, adjustment]
        amount: { type: string, pattern: '^-?\d+(\.\d+)?$', examples: ["-100.00"] }
        fee:
          type: [string, "null"]
          description: The fee portion of `amount`, same currency. Null when not known, which is different from zero.
          examples: ["0.50"]
        net:
          type: string
          description: What reached the corridor, the amount less the fee, carrying the amount's sign. Absent when `fee` is null.
          examples: ["-99.50"]
        currency: { type: string, examples: ["USD"] }
        balanceAfter:
          type: string
          description: "`available` after this row."
          examples: ["9800.00"]
        orderId:
          type: [string, "null"]
          description: The payout this row belongs to, when it belongs to one. The same id `GET /orders/{payoutId}` takes.
        snapshotId:
          type: [string, "null"]
          description: |
            The quote snapshot that sized this row. A `hold` is placed before
            the network has answered, so it has no `orderId`; its
            `hold_release` and the `payout` that converts it carry the same
            `snapshotId`, which is how you join a hold to its payout. Null on
            rows no snapshot sized (`funding`, `adjustment`).
        batchId: { type: [string, "null"] }
        reference: { type: [string, "null"], description: Your reference on the payout. }
        endUserId: { type: [string, "null"] }
        reason:
          type: [string, "null"]
          description: Why, for returns, releases and adjustments, e.g. `bank_return`, `canceled`, `quote_expired`, `stale_hold_released`, `operator`.
        description: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }

    PreviewQuote:
      type: object
      properties:
        indicative:
          type: boolean
          # Not `const: true`, though it always is. The most widely
          # used generator mishandles `const` on a boolean under OpenAPI 3.1 and
          # emits a string enum, so a client generated from the spec fails to
          # deserialize this response at all. A partner who is not on Node gets a
          # broken client, which costs more than the lost precision here.
          description: Always true here. This is an estimate, not a locked rate.
        sourceAmount: { $ref: "#/components/schemas/Money" }
        destinationAmount: { $ref: "#/components/schemas/Money" }
        fee:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: Charged on the send side and deducted before conversion.
        totalDebit:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: |
            What leaves your balance. On `GET /rates` the fee is deducted from
            the send rather than added on top, so this equals `sourceAmount`.
            A payout link's preview adds the fee on top, so there it is
            `sourceAmount + fee`. Read this field rather than assuming either.
        rate:
          type: string
          description: |
            The indicative mid-rate, before the fee: destination units per
            source unit converted, "1 USD = 17.01 MXN".
            `destinationAmount = (sourceAmount - fee) x rate`. This is a
            different number from `Payout.rate`, which is the effective rate
            with the fee inside (destination over source), so it reads lower
            by the fee share for the same conversion. The rate did not move.
          examples: ["17.010001005126146"]
        limits:
          type: object
          description: The corridor's floor and ceiling, in the source currency.
          properties:
            min: { type: string, examples: ["1.00"] }
            max: { type: string, examples: ["5000.00"] }
      examples:
        - indicative: true
          sourceAmount: { currency: USD, amount: "200.00" }
          destinationAmount: { currency: MXN, amount: "3384.65" }
          fee: { currency: USD, amount: "1.02" }
          totalDebit: { currency: USD, amount: "200.00" }
          rate: "17.010001005126146"
          limits: { min: "1.00", max: "5000.00" }

    Corridor:
      type: object
      required: [currency, fields, settlement]
      properties:
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: ISO 4217 payout currency.
          examples: ["MXN"]
        country:
          type: string
          pattern: '^[A-Z]{2}$'
          description: |
            ISO 3166-1 alpha-2 country; `XX` means an international corridor.
            Present only when the routing publishes one; the sandbox does not.
            Key on `currency`, never on this.
        fields:
          type: array
          description: Render these, in order. Names and count vary by routing.
          items:
            type: object
            required: [id, title, type, required]
            properties:
              id: { type: string, examples: ["clabeNumber"] }
              title: { type: string, examples: ["Clabe Number"] }
              description: { type: string }
              type: { type: string, examples: ["string"] }
              pattern:
                type: string
                description: Regex the value must match.
                examples: ["^[0-9]{18}$"]
              checksum:
                type: string
                enum: [clabe]
                description: |
                  A check the server applies beyond `pattern`, named so you can
                  run the same one client-side. Present only on fields that
                  have one; a value matching `pattern` but failing this check
                  is refused with `400 VALIDATION_ERROR` and an `errors[]`
                  entry naming the check digit. `clabe`: digit 18 of a Mexican
                  CLABE checks digits 1-17 with weights 3,7,1 repeating, each
                  product taken mod 10 before summing, then (10 - sum mod 10)
                  mod 10 (Banxico's algorithm). See [Register recipients](https://docs.avvio.xyz/recipients/).
              required: { type: boolean }
              options:
                type: array
                description: Present for select fields; submit the chosen `value`.
                items:
                  type: object
                  required: [value, label]
                  properties:
                    value: {}
                    label: { type: string }
        limits:
          type: object
          description: |
            Present when the routing publishes a floor or ceiling. Denominated
            in the destination currency (`currency`), not the source.
          properties:
            min: { type: string }
            max: { type: string }
        settlement:
          type: "null"
          description: |
            Settlement windows, cutoffs and name-matching rules are not
            published per corridor yet, and this field says so explicitly
            rather than a docs page saying "ask us". It stays `null` until the
            networks confirm figures we can stand behind. What is known lands
            on each payout as `expectedSettlementAt` when the network reports
            it.
        minimumSourceAmount:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: |
            The floor for this corridor, stated by the routing in USD on the
            source side. Distinct from `limits`, which is denominated in the
            destination currency when a routing publishes one. Absent when no
            floor is known.

    RegEDisclosure:
      type: object
      description: |
        The US Regulation E remittance disclosure, as the hosted page renders
        it: `prepayment` on the resolve (estimated, omitted when the corridor
        cannot be priced), `receipt` on the submit. Render `lines` in order.
      required: [kind, estimated, lines, otherFeesDisclaimer]
      additionalProperties: true
      properties:
        kind: { type: string, enum: [prepayment, receipt] }
        estimated: { type: boolean }
        lines:
          type: array
          items:
            type: object
            required: [label, value]
            properties:
              label: { type: string }
              value: { type: string }
              sign: { type: string, enum: ["+", "-"] }
        otherFeesDisclaimer: { type: string }

    PaymentMethodInput:
      oneOf:
        - title: Fiat bank destination
          type: object
          additionalProperties: false
          required: [kind, currency, recipientDetails]
          properties:
            kind:
              type: string
              const: fiat
              description: Send the literal string `fiat` for a bank destination.
            currency:
              type: string
              pattern: '^[A-Za-z]{3}$'
              description: ISO 4217 payout currency. Matching is case-insensitive.
              examples: ["MXN"]
            rail:
              type: string
              description: |
                Optional. The corridor's payout rail. Omit it and the corridor's
                default rail is used, which is what almost every integration
                wants. It has no example, so a generated request does not
                carry one.
            recipientDetails:
              type: object
              description: |
                The selected corridor's fields, keyed exactly by field `id`.
                Values are normally strings; nested objects are accepted where
                the field definition represents a structured address. Fields in
                the `bank_address` group (the recipient's own postal address,
                on corridors that require one) may be sent flat or nested under
                `bank_address`.
              additionalProperties: true
              examples:
                - clabeNumber: "012180000080004471"
        - title: Cryptocurrency destination
          type: object
          additionalProperties: false
          required: [kind, address]
          properties:
            kind:
              type: string
              const: crypto
              description: Send the literal string `crypto` for a wallet destination.
            address:
              type: string
              minLength: 1
              description: Valid EVM, Solana, or Bitcoin address. Leading and trailing whitespace is ignored.

    CreateBeneficiary:
      type: object
      additionalProperties: false
      required: [type, name, method]
      properties:
        type:
          type: string
          enum: [individual, business]
          description: Whether the recipient is a person or a business.
        name:
          type: string
          minLength: 1
          description: Recipient legal or commonly used name.
          examples: ["María González"]
        email:
          type: string
          format: email
          description: |
            Optional. A contact address kept on the recipient for your own
            records and returned on reads; it plays no part in routing the
            payout. Omit it and the recipient is stored with `email: null`.
            When sent, it must be a valid email address.
        phone:
          type: string
          description: Optional phone number as text, including any international prefix.
        country:
          type: string
          minLength: 2
          maxLength: 2
          pattern: '^[A-Za-z]{2}$'
          description: ISO-3166 alpha-2.
          examples: ["MX"]
        externalId:
          type: string
          minLength: 1
          maxLength: 128
          description: |
            Your id for this recipient. Sending it makes creation repeat-safe:
            the same value returns the existing recipient rather than
            registering a second bank account.
          examples: ["cust42_maria"]
        endUserId:
          type: string
          minLength: 1
          maxLength: 128
          description: |
            Your id for the person sending. Scopes the recipient to them.
            Omit it and the recipient is visible to every one of your users.
          examples: ["customer_42"]
        method:
          $ref: "#/components/schemas/PaymentMethodInput"

    Beneficiary:
      type: object
      example:
        id: cmf3k2xa10004q8b7r5t8u1vw
        name: Ana Ruiz
        email: ana.ruiz@example.com
        country: MX
        externalId: payroll-4471
        endUserId: customer_42
        type: individual
        phone: "+525512345678"
        createdAt: "2026-08-20T13:58:02.000Z"
        updatedAt: "2026-08-20T13:58:02.000Z"
        organizationId: cmsx0h2k900a1n11ib3qz9v42
        paymentMethods:
          - id: cmf3k2xa10005q8b7w9x2y3za
            kind: fiat
            currency: MXN
            last4: "4471"
            status: active
            destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: [string, "null"], description: "The contact address you supplied, or null when none was sent." }
        country:
          type: [string, "null"]
          description: ISO 3166-1 alpha-2. Null when none was stored (crypto recipients).
        externalId:
          type: [string, "null"]
          description: Your id for the recipient, or null when none was sent.
        endUserId: { type: [string, "null"] }
        type:
          type: string
          enum: [individual, business]
        phone: { type: [string, "null"] }
        screeningStatus:
          type: [string, "null"]
          deprecated: true
          description: Internal compliance state. Returned today but not part of the contract, and it will be removed. Do not read it.
        screeningReason:
          type: [string, "null"]
          deprecated: true
          description: Internal compliance state. Returned today but not part of the contract, and it will be removed. Do not read it.
        screenedAt:
          type: [string, "null"]
          format: date-time
          deprecated: true
          description: Internal compliance state. Returned today but not part of the contract, and it will be removed. Do not read it.
        deletedAt:
          type: [string, "null"]
          format: date-time
          deprecated: true
          description: Always null on a read (deleted recipients are not returned). It will be removed.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        organizationId:
          type: string
          description: The organization id you authenticate with, the same one you put in the URL, for test and live keys alike.
        paymentMethods:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              kind: { type: string, enum: [fiat, crypto] }
              currency: { type: [string, "null"] }
              last4: { type: [string, "null"] }
              status: { type: string, enum: [active, pending, failed] }
              destinationAccountId:
                type: [string, "null"]
                description: Pass this as `destinationAccountId` when pricing a payout.
              address:
                type: [string, "null"]
                description: Crypto methods only. The destination wallet address.
              chain:
                type: [string, "null"]
                description: Crypto methods only. The network the address is on.
              label: { type: [string, "null"] }
              rail:
                type: [string, "null"]
                deprecated: true
                description: An internal routing label. Returned today but not part of the contract, and it will be removed. Do not read it.

    BeneficiaryRegistration:
      description: |
        The answer to `POST /recipients` and `POST /recipients/{orgId}/{recipientId}/methods`:
        the recipient, plus `method`, the payment method this call registered or,
        on a repeat, the one it matched.
      allOf:
        - $ref: "#/components/schemas/Beneficiary"
        - type: object
          properties:
            method:
              type: object
              description: |
                The payment method this call registered, or on a repeat the one it
                matched. Keep `method.destinationAccountId`. Absent only if the call
                could not name a single method; then read the recipient again
                rather than choosing one.
              properties:
                id: { type: string }
                kind: { type: string }
                currency: { type: [string, "null"] }
                last4: { type: [string, "null"] }
                status: { type: string }
                destinationAccountId: { type: [string, "null"] }
    QuoteSnapshot:
      type: object
      example:
        id: sbx_quote_eyJkIjoic2J4X2FjY3RfTVhOXzQ0NzFfYWU2NmNiYzUiLCJzIjoiMjAwLjAwIn0
        best_quote_id: sbx_quote_eyJkIjoic2J4X2FjY3RfTVhOXzQ0NzFfYWU2NmNiYzUiLCJzIjoiMjAwLjAwIn0
        expires_at: "2026-08-20T14:08:11.000Z"
        quotes:
          - id: sbx_quote_eyJkIjoic2J4X2FjY3RfTVhOXzQ0NzFfYWU2NmNiYzUiLCJzIjoiMjAwLjAwIn0
            in: { amount: "200.00", currency: USD }
            out: { amount: "3384.65", currency: MXN }
            fees: { total: "1.02", currency: USD }
            rate: "17.010000"
            expires_at: "2026-08-20T14:08:11.000Z"
      properties:
        id: { type: string, description: Pass as `snapshotId` when sending. }
        best_quote_id: { type: string, description: Pass as `quoteId` when sending. }
        expires_at: { type: string, format: date-time }
        quotes:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              in:
                type: object
                properties:
                  amount: { type: string }
                  currency: { type: string }
              out:
                type: object
                properties:
                  amount: { type: string }
                  currency: { type: string }
              fees:
                type: object
                properties:
                  total: { type: string }
                  currency: { type: string }
              rate:
                type: string
                description: |
                  The rail's own indicative rate, passed through for display.
                  Its direction and whether the fee is inside it differ by
                  rail, so do not expect `out = (in - fees.total) x rate` to
                  hold. Reconcile on `in` and `out`; `Payout.rate` is the one
                  derived from the amounts.
              expires_at: { type: string, format: date-time }

    PayoutStatus:
      type: string
      enum: [pending, processing, completed, failed, canceled]
      description: |
        A payout moves only along these transitions:

        ```
        pending    -> processing | completed | failed | canceled
        processing -> completed | failed
        completed  -> failed                  (returned by the recipient's bank)
        ```

        - `pending`: accepted, reserved, not sent. The only cancelable state.
        - `processing`: handed to the payment network. Committed.
        - `completed`: the recipient was paid. **Not absolutely final.**
        - `failed`: not paid, or paid and then returned. Branch on `failureCode`.
        - `canceled`: you ended it before it was sent. It is not a failure,
          so it carries no `failureCode`; `fundsReturned` is `true` because the
          money never left.

        **`completed` can still become `failed`.** A receiving bank can return a
        payment days after settlement, giving `failureCode: returned_by_bank` and
        `fundsReturned: true`. Do not write a ledger that treats `completed` as
        immutable, and keep processing webhooks for a payout after it completes.

    PayoutFailureCode:
      type: string
      description: New codes may be added; treat an unknown one as `execution_failed`.
      enum:
        - quote_expired
        - insufficient_funds
        - limit_exceeded
        - account_invalid
        - account_cannot_receive
        - compliance_rejected
        - authorization_not_completed
        - returned_by_bank
        - execution_failed
        - unknown

    CreatePayout:
      description: |
        One payout instruction: the body of `POST /payouts`, and exactly the
        shape of each line in `POST /payouts/batches`.
      type: object
      additionalProperties: false
      required: [amount, destinationAccountId]
      properties:
        amount:
          type: string
          pattern: '^\d{1,15}(\.\d{1,2})?$'
          description: |
            How much, as a decimal string. What you send by default; what
            the recipient receives, in their currency, when `amountLeg`
            is `destination`.
          examples: ["200.00"]
        destinationAccountId:
          type: string
          minLength: 1
          description: The recipient's `method.destinationAccountId` from the registration response (the account that call registered), or the one you stored for it.
        amountLeg:
          type: string
          enum: [source, destination, source_net]
          default: source
          description: |
            Which side of the payout `amount` describes.

            `source` takes every fee out of what you sent, so the
            recipient receives less than the figure you named.

            `destination` pays them that figure exactly, in their currency, and adds the fees to your debit instead. Naming
            3400 MXN on a live corridor debited 201.879397 USDC and paid
            out 3400.00.

            `source_net` keeps the figure in your currency but means it
            the same way: "send them $200 worth". We convert at the
            market rate published by `GET /rates` and lock that
            destination, so the fees land on your debit. Naming 200 USD
            on a live corridor paid out 3426.81 MXN and debited 203.447236: the fees, plus the difference between the market
            rate we quoted you and the rate the network executed at.

            The two locking modes need `capabilities.exactOutput` on the
            corridors call; elsewhere they are refused with
            `EXACT_OUTPUT_UNSUPPORTED` rather than quietly pricing the
            other side.
        expectDestination:
          type: string
          pattern: '^\d+(\.\d{1,6})?$'
          description: |
            What you told the payer they would receive. Omit it and you
            send at whatever the market did between quoting and sending.
          examples: ["3384.65"]
        maxDriftBps:
          type: integer
          minimum: 0
          maximum: 10000
          default: 200
          description: Tolerated drift in basis points. Defaults to 200 (2%). Only read when `expectDestination` is set; ignored otherwise.
          examples: [200]
        reference:
          type: string
          minLength: 1
          maxLength: 128
          pattern: '^[A-Za-z0-9 :._-]*$'
          description: Your payment reference. Echoed back and searchable.
          examples: ["ZZ-2026-0042"]
        purposeOfPayment:
          type: string
          description: |
            Corridor-defined payment purpose, from `GET /payment-reasons`.
            Required for payouts to INR, GHS, CNY and BRL; validated against
            the catalog whenever you send it; never defaulted.
        endUser:
          $ref: "#/components/schemas/EndUser"
    Payout:
      type: object
      # Whole-object example, because field-by-field examples synthesise
      # incoherent bodies, such as a `pending` payout carrying both a `failureCode` and
      # a `completedAt`. Operations that settle on a different state (create,
      # cancel) override this at the response.
      example:
        payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
        status: processing
        stage: settling
        sourceAmount: { currency: USD, amount: "200.00" }
        destinationAmount: { currency: MXN, amount: "3384.65" }
        destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
        fee: { currency: USD, amount: "1.02" }
        rate: "16.923250"
        reference: ZZ-WAGE-1
        endUser: { id: customer_42, name: Northwind Payroll LLC, email: payroll@northwind.example }
        createdAt: "2026-08-20T14:03:11.000Z"
        updatedAt: "2026-08-20T14:03:12.480Z"
        completedAt: null
      properties:
        payoutId: { type: string }
        status: { $ref: "#/components/schemas/PayoutStatus" }
        stage:
          type: string
          enum: [awaiting_details, under_review, settling]
          description: |
            Informational sub-state on a slow payout, so your support team can
            answer "where is it?". Non-authoritative: never branch integration
            behavior on it, and treat an unrecognized value as the status alone.
        failureCode: { $ref: "#/components/schemas/PayoutFailureCode" }
        requiresFunding:
          type: boolean
          description: |
            Present and true when this payout is waiting on you to fund it from
            your own wallet. Absent on routings that settle on acceptance, so its
            presence is the signal.
        fundsReturned:
          type: boolean
          description: |
            Whether the money is back in your balance. Separate from
            `failureCode`, because the same cause can go either way depending on
            how far the payment got.
        # Money objects on the REST surface. The webhook body is flat strings
        # beside separate `*Currency` fields instead; see `WebhookPayout`.
        sourceAmount:
          anyOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
        destinationAmount:
          anyOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
        destinationAccountId: { type: [string, "null"] }
        fee:
          anyOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
          description: |
            The fee, already inside `sourceAmount`, deducted from the send not
            added on top, so `destinationAmount = sourceAmount x rate` still
            holds. The same value the event `data` and the `payout` row on
            `/balance_transactions` report. Null when the rail has not disclosed
            it, which is "not known", not zero.
        rate:
          type: [string, "null"]
          description: |
            The effective rate: `destinationAmount / sourceAmount`, derived
            from this payout's own amounts with the fee inside, so
            `destinationAmount = sourceAmount x rate` always holds. Lower than
            the indicative mid-rate `GET /rates` showed for the same conversion
            by exactly the fee share; that is not the rate moving. Compare it
            to `PreviewQuote.rate` only after netting the fee. Always six
            decimal places, rounded half-up ("16.923250"). Null until the
            amounts are known.
        reference: { type: [string, "null"] }
        endUser:
          anyOf:
            - $ref: "#/components/schemas/EndUser"
            - type: "null"
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt:
          type: [string, "null"]
          format: date-time
          description: |
            When this payout last changed. Carry the highest value you have seen
            as your `updatedSince` watermark. Without it in the payload the
            change feed cannot be paged.
        completedAt: { type: [string, "null"], format: date-time }
        expectedSettlementAt:
          type: string
          format: date-time
          description: |
            When the funds are expected to be available to the recipient, where
            the routing states a settlement timeline. Present only when known;
            an estimate, not a promise.

    PayoutBatchStatus:
      type: string
      description: |
        A batch moves `received` -> `validating` -> either `creating`
        (autoCommit, zero errors) or `awaiting_confirmation` (anything else)
        -> `creating` -> `completed`. `canceled` is reachable until creation
        starts; `failed` means the run broke, not a line. The batch tracks creation; payouts settle on their own lifecycle afterwards.
      enum:
        - received
        - validating
        - awaiting_confirmation
        - creating
        - completed
        - canceled
        - failed

    PayoutBatchItemStatus:
      type: string
      description: |
        One line's outcome at the creation level. `invalid` and
        `create_failed` are terminal refusals with `errors` attached and no payout behind them. Correct and resubmit in a new batch.
        `requires_review` is a line whose outcome could not be established (a
        process or network failure mid-flight). It is never retried
        automatically, because retrying an unknown outcome is how a
        crash becomes a double payment. Contact support with the batch id.
      enum:
        - received
        - invalid
        - validated
        - creating
        - created
        - create_failed
        - canceled
        - requires_review

    PayoutBatch:
      type: object
      example:
        batchId: cmf3k2xg00009q8b7v0w2x4yz
        externalReferenceId: payroll-2026-09-01
        status: awaiting_confirmation
        autoCommit: false
        counts: { received: 0, invalid: 1, validated: 249, creating: 0, created: 0, create_failed: 0, canceled: 0, requires_review: 0 }
        estimatedSourceTotal: "49800.00"
        createdAt: "2026-09-01T14:03:11.000Z"
        updatedAt: "2026-09-01T14:03:19.000Z"
        completedAt: null
      required: [batchId, externalReferenceId, status, autoCommit, counts, estimatedSourceTotal, createdAt, updatedAt, completedAt]
      properties:
        batchId: { type: string }
        externalReferenceId:
          type: [string, "null"]
          description: Your own run id, echoed back.
        status: { $ref: "#/components/schemas/PayoutBatchStatus" }
        autoCommit: { type: boolean }
        counts:
          type: object
          description: Lines by status. The values sum to the number of lines submitted.
          required: [received, invalid, validated, creating, created, create_failed, canceled, requires_review]
          properties:
            received: { type: integer }
            invalid: { type: integer }
            validated: { type: integer }
            creating: { type: integer }
            created: { type: integer }
            create_failed: { type: integer }
            canceled: { type: integer }
            requires_review: { type: integer }
        estimatedSourceTotal:
          type: [string, "null"]
          description: |
            Advisory. What the valid lines will draw from your balance, set
            when validation finishes; null while validation is running and
            when any line locks the destination side (no rate is consulted at
            validation time). Compare against `GET /balance` before confirming. Half a paid payroll is worse than none.
        error:
          type: object
          description: Present only when `status` is `failed`, with the run-level reason.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        completedAt:
          type: [string, "null"]
          format: date-time

    PayoutBatchItem:
      type: object
      example:
        index: 0
        status: created
        instruction:
          amount: "200.00"
          destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
          amountLeg: source
        instructionScrubbedAt: null
        payoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
      required: [index, status, instruction]
      properties:
        index:
          type: integer
          description: Zero-based position in the `items` array you submitted.
        status: { $ref: "#/components/schemas/PayoutBatchItemStatus" }
        instruction:
          oneOf:
            - $ref: "#/components/schemas/CreatePayout"
            - type: "null"
          description: The line you submitted, echoed back verbatim. Null once the echo has been scrubbed, 90 days after the batch completed; see `instructionScrubbedAt`.
        instructionScrubbedAt:
          type: [string, "null"]
          format: date-time
          description: When the echoed instruction was removed under the retention policy.
        errors:
          type: array
          description: Present on `invalid` and `create_failed` lines.
          items:
            type: object
            required: [code, message]
            properties:
              code: { type: string }
              message: { type: string }
        payoutId:
          type: string
          description: Present once `created`, and the payout to track from here on.

    PayoutEventType:
      type: string
      description: |
        Every event the feed and the webhooks can carry. New types are added
        without a major version. Ignore ones you do not handle, and return 2xx
        for them on a webhook.
      enum:
        - payout.pending
        - payout.processing
        - payout.completed
        - payout.failed
        - payout.returned
        - payout.canceled
        - payout_batch.awaiting_confirmation
        - payout_batch.completed
        - payout_batch.canceled
        - payout_batch.failed
        - payout_approval.pending
        - payout_approval.approved
        - payout_approval.rejected
        - payout_approval.expired
        - payout_approval.executed
        - payout_approval.execution_failed
        - webhook_endpoint.disabled
        - checkout_payment.paid
        - checkout_payment.failed
        - checkout_payment.refunded
        - checkout_payment.reversed
        - checkout_payment.partially_refunded

    EventData:
      description: |
        The body, built once when the event was recorded and never rebuilt.
        Discriminate on the row's `type`: `payout.*` carries a `WebhookPayout`,
        `payout_batch.*` a `PayoutBatch`, `payout_approval.*` a
        `PayoutApprovalEvent`, `webhook_endpoint.*` a `WebhookEndpointDisabled`,
        `checkout_payment.*` a `WebhookCheckoutPayment`, documented in the
        Checkout spec (`partner-checkout.openapi.yaml`), not in this file.
      oneOf:
        - $ref: "#/components/schemas/WebhookPayout"
        - $ref: "#/components/schemas/PayoutBatch"
        - $ref: "#/components/schemas/PayoutApprovalEvent"
        - $ref: "#/components/schemas/WebhookEndpointDisabled"

    PayoutEvent:
      type: object
      description: |
        One state change, written once and never updated. The same object a
        webhook delivers: `data` is the delivery's `data`, verbatim, and `id`
        is its `svix-id`.
      required: [id, sequence, type, payoutId, batchId, status, createdAt, apiVersion, data]
      properties:
        id:
          type: string
          description: Stable. Dedupe on this, because the feed is at-least-once. Equals the webhook `svix-id`.
        sequence:
          type: string
          description: |
            The cursor, as a decimal string. A 64-bit sequence past 2^53 is not
            representable as a JSON number, and silently losing precision on a
            cursor is unrecoverable.
        type: { $ref: "#/components/schemas/PayoutEventType" }
        payoutId:
          type: [string, "null"]
          description: |
            The payout this event is about. Also set on
            `payout_approval.executed`, naming the payout the approval became,
            so `?payoutId=` returns that event too. Null on the other batch,
            approval, endpoint and checkout events; a checkout payment is
            identified by `data.paymentId`.
        batchId:
          type: [string, "null"]
          description: Set on `payout_batch.*` rows and on a batch approval.
        status:
          type: string
          description: |
            The resource's status after this transition, a payout status on
            `payout.*`, a batch status on `payout_batch.*`, an approval status
            on `payout_approval.*`.
        failureCode: { $ref: "#/components/schemas/PayoutFailureCode" }
        fundsReturned: { type: boolean }
        createdAt: { type: string, format: date-time }
        apiVersion:
          type: integer
          description: The `data` shape version. Same value as the `Avvio-Webhook-Version` header on a delivery.
        data: { $ref: "#/components/schemas/EventData" }

    WebhookPayout:
      type: object
      description: |
        The payout inside a webhook body has a different shape from `Payout`.
        Here the amounts are flat
        decimal strings beside separate currency fields; on the REST surface they
        are `Money` objects. Code written against the wrong one throws on the
        first delivery, which is why they are two schemas rather than one.
      properties:
        payoutId: { type: string }
        status: { $ref: "#/components/schemas/PayoutStatus" }
        requiresFunding:
          type: boolean
          description: |
            Present and true when this payout is waiting on you to fund it from
            your own wallet, right now. `pending` alone cannot tell you this. On a routing that settles from a held balance the same word means the
            network is working on it.

            This is the delivery that matters for a hosted payout link: the
            worker fills in their details, the payout is created, and you are the
            only party who can release it. Absent once the funds are on their
            way, and absent entirely on routings where it cannot happen.
        stage: { type: string }
        failureCode: { $ref: "#/components/schemas/PayoutFailureCode" }
        fundsReturned: { type: boolean }
        reference:
          type: [string, "null"]
          description: |
            Your own reference, echoed on every delivery. This is the field a
            ledger joins on.
        sourceCurrency: { type: [string, "null"] }
        sourceAmount: { type: [string, "null"] }
        destinationCurrency: { type: [string, "null"] }
        destinationAmount: { type: [string, "null"] }
        destinationAccountId: { type: [string, "null"] }
        rate:
          type: [string, "null"]
          description: Same value and precision as `Payout.rate` (`destinationAmount / sourceAmount`, six decimal places), not the pre-fee market rate from `GET /rates`.
        fee:
          description: |
            The rail's fee for this payout, enough to book the movement from the
            delivery alone. Null until the rail reports one. `currency` falls
            back to `sourceCurrency` when the rail did not name one.
          oneOf:
            - type: object
              required: [currency, amount]
              properties:
                currency: { type: string }
                amount:
                  type: string
                  description: Decimal string, as the rail reported it.
            - type: "null"
        endUser:
          anyOf:
            - $ref: "#/components/schemas/EndUser"
            - type: "null"
        endUserId:
          type: [string, "null"]
          description: Your id for the end user this payout was for, the same value as `endUser.id`. Null when you sent none.
        createdAt: { type: string, format: date-time }
        completedAt: { type: [string, "null"], format: date-time }

    WebhookEvent:
      type: object
      description: |
        A signed delivery. Verify over the raw bytes. Re-serializing a parsed
        object does not reproduce them, and one reordered key fails every
        signature.

        The envelope is the feed row: the same `id`, `sequence`, `type`,
        `createdAt`, `apiVersion` and `data` come back from `GET /events`, so a
        delivery is the trigger to read the feed from the right place.
      required: [id, sequence, type, createdAt, apiVersion, livemode, data]
      properties:
        id:
          type: string
          description: The event id. Equals the `svix-id` header and the feed row's `id`. Dedupe on it.
        sequence:
          type: string
          description: The feed cursor for this event, as a decimal string. Pass it as `since` to read everything after it.
        type: { $ref: "#/components/schemas/PayoutEventType" }
        createdAt: { type: string, format: date-time }
        apiVersion:
          type: integer
          description: Which body shape `data` is. Also sent as the `Avvio-Webhook-Version` header, so you can branch before parsing.
        livemode:
          type: boolean
          description: False for a sandbox organization's endpoint.
        data: { $ref: "#/components/schemas/EventData" }

    PayoutApprovalStatus:
      type: string
      description: |
        The lifecycle of an approval, a pre-payout resource. `executing` and
        `execution_unknown` are visible on the resource but emit no event.
      enum: [pending, approved, rejected, expired, executing, executed, execution_failed, execution_unknown]

    PayoutApproval:
      type: object
      example:
        id: cmf3k2xh1000aq8b7w1x3y5za
        kind: payout
        status: pending
        requiredApprovals: 2
        approvals: 1
        amount: "2500.00"
        currency: USD
        destinationAccountId: sbx_acct_MXN_4471_ae66cbc5
        createdAt: "2026-09-01T14:03:11.000Z"
        expiresAt: "2026-09-02T14:03:11.000Z"
        updatedAt: "2026-09-01T14:20:02.000Z"
      description: |
        One request for M-of-N human approval of an API payout or a batch run.
        Not a payout status: the payout does not exist until this executes.
      required: [id, kind, status, requiredApprovals, approvals, amount, currency, createdAt, expiresAt, updatedAt]
      properties:
        id: { type: string }
        kind:
          type: string
          enum: [payout, payout_batch]
        status: { $ref: "#/components/schemas/PayoutApprovalStatus" }
        requiredApprovals:
          type: integer
          description: M, snapshotted when the request was created.
        approvals:
          type: integer
          description: Approve votes so far.
        payoutId:
          type: string
          description: Present once a `payout` approval is `executed`, naming the payout it became.
        batchId:
          type: string
          description: Present on `payout_batch` approvals.
        amount:
          type: [string, "null"]
          description: Source-currency amount; for a batch, the run's estimated total.
        currency: { type: [string, "null"] }
        destinationAccountId: { type: string }
        error:
          type: [object, "null"]
          required: [message]
          description: |
            Present on `execution_failed` and `execution_unknown`: why the
            approved payout could not be sent, or why its outcome is unknown.
          properties:
            code:
              type: string
              description: The error `type` the send answered with, when there was one (`PAYOUT_OUTCOME_UNKNOWN` on `execution_unknown`).
            message: { type: string }
        createdAt: { type: string, format: date-time }
        expiresAt:
          type: [string, "null"]
          format: date-time
          description: A `pending` request expires 24 hours after creation.
        updatedAt: { type: string, format: date-time }

    PayoutApprovalEvent:
      type: object
      description: The `data` of a `payout_approval.*` event.
      required: [approval]
      properties:
        approval: { $ref: "#/components/schemas/PayoutApproval" }
        payoutId:
          type: string
          description: On `payout_approval.executed`, the payout the approval became.

    WebhookEndpointDisabled:
      type: object
      description: The `data` of `webhook_endpoint.disabled`, delivered to your other endpoints that subscribe to every type (an empty `events` list).
      required: [endpointId, url, reason, consecutiveFailures, lastSuccessAt, disabledAt]
      properties:
        endpointId: { type: string }
        url: { type: string, format: uri }
        reason: { type: string, const: auto_disabled_after_failures }
        consecutiveFailures: { type: integer }
        lastSuccessAt: { type: [string, "null"], format: date-time }
        disabledAt: { type: string, format: date-time }

    PendingApproval:
      type: object
      example:
        status: pending_approval
        approvalId: cmf3k2xh1000aq8b7w1x3y5za
        approvalRequestId: cmf3k2xh1000aq8b7w1x3y5za
        requiredApprovals: 2
        expiresAt: "2026-09-02T14:03:11.000Z"
      description: |
        The 202 body from `POST /payouts` and batch `confirm` when the
        organization's policy holds the instruction for its approvers. Nothing
        was priced or sent.
      required: [status, approvalId, requiredApprovals, expiresAt]
      properties:
        status: { type: string, const: pending_approval }
        approvalId:
          type: string
          description: Read it at `GET /payouts/approvals/{approvalId}`; `payout_approval.executed` names the payout.
        approvalRequestId:
          type: string
          description: Same value as `approvalId`; kept for the dashboard client.
        requiredApprovals: { type: integer }
        expiresAt: { type: string, format: date-time }

    AuditEvent:
      type: object
      description: One audited mutation. Append-only; never updated.
      required: [id, action, resourceType, resourceId, apiKeyPrefix, actorUserId, ip, requestId, outcome, errorType, createdAt]
      properties:
        id:
          type: string
          description: Monotonic, and the cursor. A decimal string.
        action:
          type: string
          description: |
            `payout.create`, `payout.cancel`, `payout_batch.create`,
            `payout_batch.confirm`, `payout_batch.cancel`,
            `payout_approval.approve`, `payout_approval.reject`,
            `recipient.create`, `recipient.update`, `recipient.delete`,
            `api_key.create`, `api_key.rotate`, `api_key.revoke`,
            `webhook_endpoint.create`, `webhook_endpoint.delete`,
            `webhook_endpoint.enabled`, `webhook_endpoint.rotate_secret`,
            `webhook_endpoint.replay`, `checkout_payment.refund`, and the
            dashboard funding actions `payout_funding.prepare`,
            `payout_funding.signing_claim`, `payout_funding.accept`,
            `payout_funding.confirm`, `payout_funding.refresh`. New actions are
            added without a version.
          examples: ["payout.create"]
        resourceType: { type: string, examples: ["payout"] }
        resourceId:
          type: [string, "null"]
          description: The payout, batch, approval, recipient, key or endpoint id. Null when the request was refused before one existed.
        apiKeyPrefix:
          type: [string, "null"]
          description: The key's prefix as you named it. Null for a dashboard action.
        actorUserId:
          type: [string, "null"]
          description: The dashboard user. Null for an API-key action.
        ip: { type: [string, "null"] }
        requestId:
          type: [string, "null"]
          description: The `x-request-id` of that request, also the `requestId` in its error body.
        outcome:
          type: string
          enum: [ok, error]
        errorType:
          type: [string, "null"]
          description: The error `type` when `outcome` is `error`. Never a body, never a bank field.
        createdAt: { type: string, format: date-time }

    Policy:
      type: object
      description: What your organization is bound by, read live. `null` on a cap or a threshold means it is not set.
      required: [organizationId, mode, features, limits, approvals, purposeOfPayment, fees, rateLimits, idempotency, links]
      properties:
        organizationId:
          type: string
          description: The id you addressed. A test key sends the live organization id; this echoes it.
        mode:
          type: string
          enum: [test, live]
          description: "`test` for a test key or a sandbox environment: nothing here reaches a payment network."
        features:
          type: array
          items: { type: string }
          description: Effective feature names, sorted. `mass_payouts` enables batches; `developer` enables webhook endpoints. Both are on unless your organization opted out.
        limits:
          type: object
          required: [maxSinglePayoutUsd, maxDailyPayoutUsd, maxDailyPerEndUserUsd]
          description: USD caps enforced on `POST /payouts`, batch lines and payout links; over one is `422 PAYOUT_LIMIT_EXCEEDED`. `null` is no cap.
          properties:
            maxSinglePayoutUsd: { type: [string, "null"], pattern: '^\d+\.\d{2}$' }
            maxDailyPayoutUsd: { type: [string, "null"], pattern: '^\d+\.\d{2}$' }
            maxDailyPerEndUserUsd:
              type: [string, "null"]
              pattern: '^\d+\.\d{2}$'
              description: Counted against `endUser.id`; when set, a payout without an `endUser` is refused.
        approvals:
          type: object
          required: [thresholdUsd, requiredApprovals, appliesTo]
          description: When `thresholdUsd` is set, a send above it answers `202` with an approval that `requiredApprovals` humans must approve in the dashboard. `null` means approvals are off.
          properties:
            thresholdUsd: { type: [string, "null"], pattern: '^\d+\.\d{2}$' }
            requiredApprovals: { type: [integer, "null"], minimum: 1 }
            appliesTo:
              type: array
              items: { type: string, enum: [payouts, batches, payout_links] }
        purposeOfPayment:
          type: object
          required: [requiredForCurrencies]
          properties:
            requiredForCurrencies:
              type: array
              items: { type: string, pattern: '^[A-Z]{3}$' }
              description: Destination currencies for which `purposeOfPayment` is required on a payout; values come from `GET .../payment-reasons?currency=`.
        fees:
          type: object
          required: [payout]
          description: |
            What your routing can state about its fees before a quote exists,
            read from its configuration. `null` on a number means "not
            published before a quote", never zero. The binding figure is always
            `fee` on the quote and on the payout; this is what to plan with.
          properties:
            payout:
              type: object
              required: [bps, fixedUsd, byCurrency, note]
              description: The payout fee, deducted from the send before conversion.
              properties:
                bps:
                  type: [integer, "null"]
                  description: Basis points of the send for every corridor not listed in `byCurrency`.
                fixedUsd:
                  type: [string, "null"]
                  pattern: '^\d+\.\d{2}$'
                  description: Fixed component in USD. Null when the network's own fixed charge is only priced on a quote.
                byCurrency:
                  type: object
                  description: Corridors priced differently from the default, keyed by destination currency. Empty when none are.
                  additionalProperties:
                    type: object
                    required: [bps, fixedUsd]
                    properties:
                      bps: { type: integer }
                      fixedUsd: { type: [string, "null"], pattern: '^\d+\.\d{2}$' }
                note:
                  type: string
                  description: What the numbers cover and where the binding figure is. Prose; do not branch on it.
              examples:
                - bps: 51
                  fixedUsd: "0.00"
                  byCurrency: { EUR: { bps: 17, fixedUsd: "0.54" }, INR: { bps: 77, fixedUsd: "0.99" } }
                  note: "Deducted from the send before conversion: fee = fixedUsd + sourceAmount x bps / 10000, rounded to the cent."
                - bps: null
                  fixedUsd: null
                  byCurrency: {}
                  note: "Not published before a quote: the fee is priced inside each quote. Read `fee` on the quote or the payout."
        rateLimits:
          type: object
          required: [default, payouts, batches, reads]
          description: Requests per minute per credential. `payouts` is `POST /payouts`; `reads` covers `events`, `orders`, `balance_transactions` and `audit-events`; `batches` is batch creation; everything else is `default`. A source-IP ceiling applies on top.
          properties:
            default: { type: integer }
            payouts: { type: integer }
            batches: { type: integer }
            reads: { type: integer }
        idempotency:
          type: object
          required: [required, replayWindowDays, nearDuplicateWindowMinutes]
          properties:
            required:
              type: boolean
              description: An API key must send `Idempotency-Key` on every route that moves money.
            replayWindowDays:
              type: integer
              description: How long a key replays its original response.
            nearDuplicateWindowMinutes:
              type: integer
              description: A byte-identical body under a different key inside this window is `409 DUPLICATE_REQUEST_DETECTED`.
        links:
          type: object
          required: [corridors, events, balanceTransactions]
          description: Relative paths, with your organization id filled in, for the reads an integration needs next.
          properties:
            corridors: { type: string }
            events: { type: string }
            balanceTransactions: { type: string }

    EndUser:
      type: object
      additionalProperties: false
      description: |
        Your own customer this payout is sent for: the party sending through
        your platform, never the recipient. In payroll the employer is the
        end user and the worker is the recipient; omit the object when you are
        the sender yourself. Attribution and the per-end-user daily cap key on
        `id`; it is not forwarded to the payment network and does not change
        the sender of record, which stays your organization. Echoed on the
        payout and in every webhook, so a support question is answerable
        without your own id map.
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 128
          description: Your stable identifier for the sending customer (not the recipient).
          examples: ["customer_42"]
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Person's display or legal name.
        email:
          type: string
          format: email
          description: Person's email address in standard email format.

    FundingAccount:
      type: object
      properties:
        id: { type: string, description: Opaque id for this funding account. }
        currency: { type: string }
        shape:
          type: [string, "null"]
          description: |
            The bank-coordinate family these instructions use: `us` for
            account/routing, `iban` for IBAN/BIC, `crypto` for a deposit
            address, and so on. It tells you which keys to expect inside
            `depositInstructions`. `null` on an account your operator attached
            by hand; read `depositInstructions` as given.
          examples: ["us"]
        status: { type: string, examples: ["active"] }
        bankName:
          type: string
          description: |
            A display label for the account. When no bank name is known it is
            a generic placeholder, so show `depositInstructions.bank_name` to
            the payer instead, and never branch on this.
        paymentRails:
          type: array
          items: { type: string }
          examples: [["wire", "ach"]]
        settlesTo:
          type: string
          enum: [provider_balance, wallet]
          description: Where a deposit lands. `provider_balance` is your payout balance; `wallet` is your organization's own wallet (accounts your operator attached by hand).
          examples: ["provider_balance"]
        destination:
          type: [string, "null"]
          description: Null unless the deposit forwards somewhere rather than resting as balance.
        balance:
          type: object
          description: |
            The balance held on this account, in minor units. `amount` is an
            integer and `decimals` tells you where the point goes, so 980000 with
            decimals 2 is 9,800.00. It is not the decimal string the payout
            endpoints use.
          properties:
            currency: { type: string }
            amount: { type: integer, examples: [980000] }
            decimals: { type: integer, examples: [2] }
        depositInstructions:
          type: object
          description: |
            The bank coordinates to send money to. **Keys are snake_case** (`account_number`, `routing_number`, `bank_name`, `iban`, `sort_code`, `reference`) because both rails are normalized to one
            vocabulary. Which keys appear depends on `shape`, so render what is
            here rather than reading a fixed list. `reference` (where present)
            must travel with the payment or the deposit cannot be attributed.
          additionalProperties: true

    Error:
      type: object
      description: |
        Branch on `type`, never on the HTTP status or the message. `type` is stable across versions; the prose is not.
      required: [type, detail, message, status, statusCode, requestId]
      properties:
        type:
          type: string
          description: Stable machine-readable code.
          examples: ["IDEMPOTENCY_KEY_CONFLICT", "DUPLICATE_REQUEST_DETECTED"]
        detail:
          type: string
          description: |
            What went wrong, in a sentence. Always a string, so
            `detail.toLowerCase()` is safe.

            This is the field to read on `BAD_REQUEST` and
            `PROVIDER_REJECTED`, where the type alone does not name the
            condition.
        message:
          type: string
          deprecated: true
          description: The same text as `detail`, kept for integrations written before `detail` existed. Read `detail`.
        resolution:
          type: string
          description: |
            What to do about it, when there is a specific answer. It is not on every error (it is absent on `BAD_REQUEST`, `NOT_FOUND`, `PAYOUT_NOT_CANCELABLE` and `DESTINATION_ACCOUNT_NOT_FOUND`), so treat it as optional and fall back to `detail`.
        status:
          type: integer
          description: HTTP status, repeated in the body.
        statusCode:
          type: integer
          deprecated: true
          description: The same value as `status`, kept for integrations written before `status` existed. Read `status`.
        requestId:
          type: string
          description: |
            Quote this to support and we can find the exact request. Also sent
            as the `x-request-id` response header, which is the only place it appears on a successful response. Success bodies do not carry it.
            Send your own `x-request-id` on the request and we use it, so your
            trace and ours share one identifier; otherwise we mint one.
        errors:
          type: array
          items: { type: string }
          description: Present on VALIDATION_ERROR; names each field that failed.
        originalIdempotencyKey:
          type: string
          description: |
            On `DUPLICATE_REQUEST_DETECTED` only. Send the request again with
            this to receive the original payout instead of making a second one.
            Without it there is no way to recover except by risking a double
            payment.
        originalPayoutId:
          type: string
          description: On `DUPLICATE_REQUEST_DETECTED` only. The payout the first request created.
        originalBatchId:
          type: string
          description: On a batch `DUPLICATE_REQUEST_DETECTED`. The run the first request created.
        originalRequestId:
          type: string
          description: |
            On a `409 PAYOUT_OUTCOME_UNKNOWN` replay. The `requestId` of the
            call whose outcome is unknown; quote it to support.
        existingRecipientId:
          type: string
          description: On `BANK_ACCOUNT_ALREADY_LINKED`. The recipient in your organization that already holds this account.
        existingMethodId:
          type: string
          description: On `BANK_ACCOUNT_ALREADY_LINKED`. The payment method on that recipient.

  # Delivered to the endpoint you configure in the dashboard, signed with
  # Standard Webhooks so any Svix-compatible verifier works.
  x-webhooks:
    payout:
      description: |
        Events: `payout.pending`, `payout.processing`, `payout.completed`,
        `payout.failed`, `payout.returned`, `payout.canceled`.

        `payout.pending` fires when a payout is accepted, so you get an early
        nudge as well as the outcome. `payout.processing` is delivered when a
        payout is seen in that state; not every payout passes through it.

        Generate your client from this enum, but treat an unrecognized `type` as
        informational rather
        than an error: we would rather you ignore an event you do not know than
        reject one you should have handled.

        A canceled payout arrives as `payout.canceled`, its own type, so a
        `payout.failed` handler that re-attempts a wage will not re-send one you
        stopped on purpose.

        `payout.returned` is its own type and not a flavor of `payout.failed`:
        it is a payout that already completed and was then reversed by the
        receiving bank, days later. It carries `failureCode: returned_by_bank`
        and `fundsReturned: true`. It is the one event that reverses something
        you have already booked, so a validator built from an events enum must
        accept it.

        Headers: `svix-id` (the event id, stable across retries, equals the
        body's `id` and the feed row's `id`; dedupe on it), `svix-timestamp`,
        `svix-signature`, and `Avvio-Webhook-Version` (the `data` shape
        version, `1` today; equals the body's `apiVersion`). Signed content is
        `${svix-id}.${svix-timestamp}.${raw body}`, HMAC-SHA256 with the
        base64-decoded secret minus its `whsec_` prefix. Verify over the raw
        body, before parsing. Reject timestamps outside ±5 minutes. During a
        secret rotation `svix-signature` carries two signatures for 24 hours,
        one per secret; accept the delivery if either verifies.

        Body: `{ id, sequence, type, createdAt, apiVersion, livemode, data }`
        (`WebhookEvent`). `data` is the payload below, verbatim: the same object `GET /events` returns for the same `id`.

        **Every state change is delivered, one event per transition**, written
        in the same transaction as the change. Delivery is at-least-once and
        in `sequence` order per endpoint. Retries after a failed attempt: 1m,
        5m, 15m, 1h, 3h, 6h, 12h, 24h, 24h: nine retries spread over 4,221
        minutes (70 hours 21 minutes), each delay with ±20% jitter, then the
        delivery is dead and only the feed still has it. **Webhooks are the fast path, not the guarantee.**
        Reconcile from `GET /events`, and dedupe on `id`.
      payload:
        type: object
        properties:
          type:
            type: string
            # Every payout type that is delivered, including `payout.pending`,
            # `payout.processing` and `payout.returned` (the reversal of an
            # already-booked payment). A validator generated from this enum must
            # accept all six.
            enum:
              - payout.pending
              - payout.completed
              - payout.failed
              - payout.returned
              - payout.canceled
              - payout.processing
          id: { type: string, description: The event id. Equals `svix-id` and the feed row's `id`. }
          sequence: { type: string, description: The feed cursor for this event. }
          createdAt: { type: string, format: date-time }
          apiVersion: { type: integer, description: Equals the `Avvio-Webhook-Version` header. }
          livemode: { type: boolean }
          # WebhookPayout, not Payout. The delivered body carries flat strings
          # (`"sourceAmount": "25.00"`, `"sourceCurrency": "USD"`), not the
          # nested Money objects the REST `Payout` uses.
          data: { $ref: "#/components/schemas/WebhookPayout" }
    payout_batch:
      description: |
        Events: `payout_batch.awaiting_confirmation` (validation finished and
        the run is holding for your review), `payout_batch.completed` (every
        line resolved at the creation level; read `counts`),
        `payout_batch.canceled`, `payout_batch.failed`.

        There is no `payout_batch.creating`: between confirmation and
        completion the interesting facts are per payout, and those already
        arrive as `payout.*` events. Return 2xx for types you do not handle.

        Same signing, envelope and retry contract as the payout webhook.
      payload:
        type: object
        properties:
          type:
            type: string
            enum:
              - payout_batch.awaiting_confirmation
              - payout_batch.completed
              - payout_batch.canceled
              - payout_batch.failed
          data: { $ref: "#/components/schemas/PayoutBatch" }
    payout_approval:
      description: |
        Events: `payout_approval.pending`, `payout_approval.approved`,
        `payout_approval.rejected`, `payout_approval.expired`,
        `payout_approval.executed` (carries the `payoutId` it became),
        `payout_approval.execution_failed`. Same signing and retry contract.
        Same envelope as the payout webhook.
      payload:
        type: object
        properties:
          type:
            type: string
            enum:
              - payout_approval.pending
              - payout_approval.approved
              - payout_approval.rejected
              - payout_approval.expired
              - payout_approval.executed
              - payout_approval.execution_failed
          data: { $ref: "#/components/schemas/PayoutApprovalEvent" }
    webhook_endpoint:
      description: |
        Events: `webhook_endpoint.disabled`, sent when one of your endpoints was
        auto-disabled after repeated failed deliveries (three exhausted retry
        ladders with no accepted delivery for five days). Delivered to your
        other endpoints whose `events` list is empty (it cannot be subscribed
        to by name), and your organization's owners are emailed. Same
        signing and retry contract. Re-enable it from the dashboard once the
        receiver is fixed, then replay the dead deliveries.
      payload:
        type: object
        properties:
          type:
            type: string
            enum:
              - webhook_endpoint.disabled
