openapi: 3.1.0

info:
  title: Avvio Partner Payouts
  version: "2026-08-18"
  summary: Pay out to your own customers, from your balance, over one API.
  description: |
    You hold a business account with us, complete verification once, and keep a
    funded USD balance. Your end users never onboard with us. Funds leave *your*
    balance and the beneficiary is your counterparty.

    ## Why this document exists

    This spec is the contract. Generate a client for whatever you already run —
    Java, C#, Python, PHP, Ruby, Go — rather than hand-rolling HTTP:

    ```
    openapi-generator-cli generate -i partner-payouts.openapi.yaml -g java -o ./avvio
    ```

    A zero-dependency Node SDK, CLI and MCP server are also available if Node
    suits you, but nothing here requires them.

    ## The two rules that prevent double payments

    1. **Every mutation takes `Idempotency-Key`.** Retry a timed-out request with
       the *same* key and you get the original result. Re-quoting instead is a
       second payment.
    2. **A timeout is an unknown outcome, not a failure.** If a send times out,
       the payout may exist. Retry the same key; never start over.

    ## The rail is ours to choose

    We route your organization to a payment network, and we may re-route it.
    Nothing in this API names one, and the fields a corridor requires can change
    with routing — always read `GET /recipients/{orgId}/corridors` rather than
    hardcoding a form.

servers:
  - url: https://api.avvio.xyz/business/api/v1
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Discovery
    description: What you can pay, and what each corridor needs.
  - name: Pricing
    description: What a payout costs, before and after a beneficiary exists.
  - name: Beneficiaries
    description: Who is being paid.
  - name: Payouts
    description: Moving the money, and reading what happened.
  - name: Funding
    description: Topping up the balance payouts debit.

paths:
  /recipients/{orgId}/corridors:
    get:
      tags: [Discovery]
      operationId: listCorridors
      summary: Currencies you can pay out to, and the fields each needs
      description: |
        Render your beneficiary 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.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Available corridors
          content:
            application/json:
              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 }
                      # ERRORS.md tells partners to read this before pricing
                      # without a beneficiary. The server returns it; no schema
                      # declared it, so a generated client dropped the field the
                      # documentation sends you to.
                      indicativePricing: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /payments/organizations/{orgId}/rates:
    get:
      tags: [Pricing]
      operationId: getIndicativeQuote
      summary: Price a corridor before a beneficiary exists
      description: |
        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 beneficiary. 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`.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: from
          in: query
          required: true
          schema: { type: string, examples: ["USD"] }
        - name: to
          in: query
          required: true
          schema: { type: string, 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, 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: No such corridor for this organization
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /recipients/{orgId}:
    get:
      tags: [Beneficiaries]
      operationId: listBeneficiaries
      summary: Beneficiaries, optionally for one of your end users
      description: |
        **Pass `endUserId` for anything shown to an end user.** Omitting it
        returns every beneficiary 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, examples: ["employee_42"] }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Beneficiaries
          content:
            application/json:
              schema:
                type: object
                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" }
        "401": { $ref: "#/components/responses/Unauthorized" }

    post:
      tags: [Beneficiaries]
      operationId: createBeneficiary
      summary: Register who is being paid
      description: |
        `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 beneficiary instead of registering a
        second bank account. Send both.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateBeneficiary" }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: Created, or the existing beneficiary for this externalId
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Beneficiary" }
        "400":
          description: Validation failed, or a corridor field is missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            Either this bank account is already linked to another beneficiary,
            or the idempotency key was reused with a different body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /recipients/{orgId}/{recipientId}/methods:
    post:
      tags: [Beneficiaries]
      operationId: addBeneficiaryMethod
      summary: Add another way to pay an existing beneficiary
      description: |
        Use this when the same person can be paid in more than one currency or
        over more than one rail. Creating a second beneficiary instead
        duplicates them in your book and in ours.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: recipientId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, currency, recipientDetails]
              properties:
                kind: { type: string, const: fiat }
                currency: { type: string, examples: ["MXN"] }
                recipientDetails:
                  type: object
                  description: The corridor's fields, keyed by their `id`.
                  additionalProperties: { type: string }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The added payment method
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Beneficiary" }
        "400":
          description: Validation failed, or a corridor field is missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /payments/organizations/{orgId}/quotes/offramp:
    post:
      tags: [Pricing]
      operationId: pricePayout
      summary: Lock a price against a real beneficiary
      description: |
        Returns a snapshot with the locked rate and the amount the beneficiary
        receives. **No money moves.** An underfunded balance fails here, before
        a quote is spent.

        Quotes expire. Price and send close together.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, destinationAccountId]
              properties:
                amount:
                  type: string
                  description: What you send, as a decimal string.
                  examples: ["200.00"]
                destinationAccountId:
                  type: string
                  description: From the beneficiary's `paymentMethods[].destinationAccountId`.
                purposeOfPayment:
                  type: string
                  description: Required by some corridors.
                  examples: ["FAMILY_SUPPORT"]
      responses:
        # 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: Insufficient balance, unknown beneficiary, or unsupported corridor
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payments/organizations/{orgId}/quotes/accept:
    post:
      tags: [Payouts]
      operationId: sendPayout
      summary: Send the money
      description: |
        **This is the step that moves money.** It debits your balance and
        returns the payout. There is no separate funding or signing step.

        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.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [snapshotId, type]
              properties:
                snapshotId:
                  type: string
                  description: The snapshot's `id` from the pricing step.
                quoteId:
                  type: string
                  description: The snapshot's `best_quote_id`.
                type:
                  type: string
                  const: OFFRAMP
                reference:
                  type: string
                  description: Your payment reference. Echoed back and searchable.
                endUser:
                  $ref: "#/components/schemas/EndUser"
      responses:
        # 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": { $ref: "#/components/responses/IdempotencyRequired" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /payments/organizations/{orgId}/payouts:
    post:
      tags: [Payouts]
      operationId: createPayout
      summary: Price and send in one call
      description: |
        **The endpoint to build on.** 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`.**
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/AllowDuplicate"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, destinationAccountId]
              properties:
                amount:
                  type: string
                  description: The USD amount to debit, as a decimal string.
                  examples: ["200.00"]
                destinationAccountId:
                  type: string
                  description: From the beneficiary's `paymentMethods[0]`.
                expectDestination:
                  type: string
                  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
                  description: Tolerated drift in basis points. Defaults to 200 (2%).
                  examples: [200]
                reference:
                  type: string
                  description: Your payment reference. Echoed back and searchable.
                purposeOfPayment:
                  type: string
                endUser:
                  $ref: "#/components/schemas/EndUser"
      responses:
        # 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:
              schema: { $ref: "#/components/schemas/Payout" }
        "400":
          description: |
            Validation, an underfunded balance, or the drift guard refusing.
            `RATE_DRIFT_EXCEEDED` and `QUOTE_UNVERIFIABLE` both mean **nothing
            was sent** — re-quote and send again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /payments/organizations/{orgId}/payout-links:
    post:
      tags: [Payouts]
      operationId: createPayoutLink
      summary: Mint a one-time link for the person being paid
      description: |
        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 merely 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
              required: [amount, destinationCurrency, endUserId]
              properties:
                amount: { type: string, examples: ["75.00"] }
                destinationCurrency: { type: string, examples: ["MXN"] }
                endUserId:
                  type: string
                  description: Your id for the person being paid.
                reference: { type: string }
                expiresInMinutes:
                  type: integer
                  description: Defaults to 60. Capped at 7 days.
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The link.
          content:
            application/json:
              schema:
                type: object
                properties:
                  payoutLinkId: { type: string }
                  url: { type: string }
                  expiresAt: { type: string, format: date-time }
                  status: { type: string, examples: ["pending"] }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /payout-links/{token}:
    get:
      tags: [Payouts]
      operationId: resolvePayoutLink
      summary: What the recipient's page renders
      description: |
        **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 and the fields that corridor needs, and deliberately
        nothing else — no organization id, no `endUserId`, and never previously
        entered 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
          schema: { type: string }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: What to render.
          content:
            application/json:
              schema:
                type: object
                properties:
                  amount: { $ref: "#/components/schemas/Money" }
                  destinationCurrency: { type: string }
                  reference: { type: string }
                  expiresAt: { type: string, format: date-time }
                  status:
                    type: string
                    examples: ["pending", "consumed", "expired", "failed"]
                  requirements: { type: array, items: { type: object } }
        "404":
          description: Not valid — expired, already spent, or never existed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payout-links/{token}/submit:
    post:
      tags: [Payouts]
      operationId: submitPayoutLink
      summary: Spend the link and send the money
      description: |
        **Takes no credential**, as above.

        Submitting twice returns the ORIGINAL payout with `status:
        "already_submitted"` — 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.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              # `email` IS required — enforced downstream, and a recipient
              # form built from an optional marking fails every submission.
              required: [name, email, details]
              properties:
                name: { type: string, examples: ["Maria Gonzalez"] }
                email: { type: string }
                details:
                  type: object
                  description: The fields named by `requirements` on the read.
                  additionalProperties: { type: string }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The payout.
          content:
            application/json:
              schema:
                type: object
                properties:
                  payoutId: { type: string }
                  status: { type: string }
        "400":
          description: The details did not match what the corridor requires. The link is still spendable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payments/organizations/{orgId}/balance/history:
    get:
      tags: [Funding]
      operationId: getBalanceHistory
      summary: Why the balance is what it is
      description: |
        Every movement, oldest first, with the running balance after each —
        computed from the same rows the balance itself sums, so the explanation
        can never disagree with the number.

        A failed or cancelled payout keeps its original debit and gains a SEPARATE
        `reversal` entry — there is no zero-delta row. One
        rejected by compliance stays debited.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
        - name: since
          in: query
          description: |
            Entries added after this time. Entries are append-only, so carry the
            `nextSince` from your last page and you receive exactly what is new —
            including a reversal appended days after the debit it reverses.
          schema: { type: string, format: date-time }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Movements, oldest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  currency: { type: string }
                  balance: { type: string }
                  hasMore: { type: boolean }
                  nextSince:
                    type: string
                    format: date-time
                    nullable: true
                    description: |
                      Feed back as `since`. It is NOT null when you are caught
                      up — terminate on `hasMore: false`. A loop waiting for
                      null never ends.
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        type:
                          type: string
                          description: |
                            `reversal` is a returned payout crediting the balance
                            back — a SEPARATE appended entry, never a rewrite of
                            the original debit.
                          examples: ["funding", "payout", "reversal"]
                        status:
                          type: string
                          description: |
                            What this ENTRY records, frozen when it was written —
                            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, `COMPLETED` funding landed.
                          # CANCELED is the fourth value: a reversal on a
                          # cancelled payout carries it, and a reconciler
                          # switching on status hit an unhandled case on the
                          # one flow the quickstart tells you to build.
                          examples: ["DEBITED", "RETURNED", "COMPLETED", "CANCELED"]
                        amount: { type: string }
                        balanceAfter: { type: string }
                        reference: { type: string }
                        at: { type: string, format: date-time }

  /payments/organizations/{orgId}/payouts/{payoutId}/cancel:
    post:
      tags: [Payouts]
      operationId: cancelPayout
      summary: Stop a payout that has not moved yet
      description: |
        Only a payout still waiting on YOUR funds can be cancelled — 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 cancelling
        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
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The payout, now canceled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payout" }
        "400":
          description: This payout cannot be cancelled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payments/organizations/{orgId}/payouts/{payoutId}/funding:
    get:
      tags: [Payouts]
      operationId: getPayoutFunding
      summary: How to fund a payout that needs it
      description: |
        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.

        `expiresAt` is the one to respect: after it the price behind the payout
        is no longer valid and you must ask again. Sending funds against expired
        instructions is the only irreversible mistake available here.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: payoutId
          in: path
          required: true
          schema: { type: string }
      responses:
        # 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:
              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, format: date-time }
                  signableOperations:
                    type: array
                    description: |
                      Present when the wallet holding your funds is one you can
                      sign for through this API rather than broadcasting
                      yourself. Sign these and return them as `signedOperations`.
                    items: { type: object }
                  instructions: { type: string }

  /payments/organizations/{orgId}/payouts/{payoutId}/funding/confirm:
    post:
      tags: [Payouts]
      operationId: confirmPayoutFunding
      summary: Tell us you have sent the funds
      description: |
        Send **either** `transactionHash` (you broadcast it yourself) **or**
        `signedOperations` (you signed what `/funding` returned). Which one you
        use depends on how you hold the money, not on anything we prefer.

        **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.

        Solana funding is not chain-verified today and is recorded on shape
        alone.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: payoutId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                transactionHash: { type: string }
                signedOperations: { type: array, items: { type: object } }
                tamperProofSignature: { type: string }
      responses:
        # 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: Neither proof of funding was supplied.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payments/organizations/{orgId}/events:
    get:
      tags: [Payouts]
      operationId: listEvents
      summary: Everything that has happened to your payouts, in order
      description: |
        **The reconciliation primitive.** 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.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: since
          in: query
          description: Return events after this `sequence`. Use `nextSince`.
          schema: { type: string }
        - name: payoutId
          in: query
          description: Everything that ever happened to one payout.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Events, oldest first.
          content:
            application/json:
              schema:
                type: object
                required: [data, hasMore]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PayoutEvent" }
                  hasMore: { type: boolean }
                  nextSince: { type: string, nullable: true }

  /payments/organizations/{orgId}/balance:
    get:
      tags: [Funding]
      operationId: getBalance
      summary: What you can currently send
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Your available balance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Money" }

  /payments/organizations/{orgId}/sandbox/fund:
    post:
      tags: [Sandbox]
      operationId: fundSandbox
      summary: Credit a sandbox balance
      description: |
        Test keys only. Deliberately an explicit call rather than a balance we
        hand you, 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.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                amount: { type: string, examples: ["5000.00"] }
      responses:
        # 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. The spec claimed
                  # Money here, so a client generated from it failed to
                  # deserialize the response to the second call in the quickstart.
                  balance: { type: string, examples: ["5000.00"] }

  /payments/organizations/{orgId}/sandbox/webhook-endpoints:
    post:
      tags: [Sandbox]
      operationId: createSandboxWebhookEndpoint
      summary: Register a sandbox webhook endpoint and get its signing secret
      description: |
        Test keys only. `http://localhost` is accepted here and nowhere else, so
        your first receiver can be a script rather than a tunnel.

        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.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  examples: ["https://example.com/hooks/avvio"]
                events:
                  type: array
                  description: Omit to receive every event type.
                  items: { type: string }
      responses:
        # 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:
              schema:
                type: object
                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 }

  /payments/organizations/{orgId}/sandbox/webhook-endpoints/{endpointId}/deliveries:
    get:
      tags: [Sandbox]
      operationId: listSandboxWebhookDeliveries
      summary: What we sent, what came back, and what we retried
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: endpointId
          in: path
          required: true
          schema: { type: string }
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Recent delivery attempts.
          content:
            application/json:
              schema: { type: array, items: { type: object } }

  /payments/organizations/{orgId}/orders:
    get:
      tags: [Payouts]
      operationId: listPayouts
      summary: Recent payouts
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: updatedSince
          in: query
          description: |
            **The change feed.** Return 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 }
        # These filters were live on the server and absent from the spec, so a
        # generated client had no way to express them and every caller pulled the
        # whole list and filtered client-side.
        - name: status
          in: query
          description: Comma-separated canonical statuses, e.g. `processing,completed`.
          schema: { type: string }
        - 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
          schema: { type: string, format: date-time }
        - name: createdBefore
          in: query
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          schema: { type: integer, 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:
        # 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:
              schema:
                type: object
                description: |
                  A PAGE, not a bare array. This was declared as an array while
                  the server returned `{ data, hasMore, nextCursor }`, so any
                  generated client deserialized it as a list and broke on the
                  first real call.
                required: [data, hasMore]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Payout" }
                  hasMore: { type: boolean }
                  nextCursor: { type: string, nullable: true }

  /payments/organizations/{orgId}/orders/{payoutId}:
    get:
      tags: [Payouts]
      operationId: getPayout
      summary: One payout, always live
      description: |
        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
          schema: { type: string }
      responses:
        # 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. This was declared as a 400; the server returns 404,
            and ERRORS.md always said 404 — so a client generated from this file
            had no branch for the status it actually receives.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payments/organizations/{orgId}/payin-accounts:
    get:
      tags: [Funding]
      operationId: getFundingAccounts
      summary: Where to wire money to top up your balance
      description: |
        Money you wire is held as your balance. It is not forwarded anywhere —
        payouts debit it.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        # Every endpoint is throttled, so every endpoint can answer 429.
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Funding instructions
          content:
            application/json:
              # An OBJECT, not a bare array. It carries a `note` — in sandbox,
              # "balances are not funded by wire, use POST /sandbox/fund".
              # Declared as an array, a generated client threw on the very
              # endpoint GOING_LIVE points at for live funding.
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items: { $ref: "#/components/schemas/FundingAccount" }
                  note:
                    type: string
                    description: Present when there is nothing to show and a reason why.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        Server-side only. It is a bearer credential for your money — never ship
        it in a mobile app or a browser bundle. Our CORS policy does not allow
        this header, so a browser cannot send one even by accident.

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

  parameters:
    OrgId:
      name: orgId
      in: path
      required: true
      description: Your organization id.
      schema: { type: string }

    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, not a
        correctness one — there is no path where an expired key is re-executed.
      schema: { type: string, maxLength: 255 }

    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
        seconds ago under a different key. Only set it deliberately: it switches
        off the guard that catches a retry arriving under a fresh key.
      schema: { type: string, enum: ["true"] }

  responses:
    Unauthorized:
      description: Missing, invalid, or revoked key — or a key on a route that does not accept one.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Valid key, wrong organization.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: |
        Too many requests. The ceiling is **100 requests per minute per API
        credential**, on a 60-second window, plus a per-IP abuse ceiling well
        above it.

        Obey `Retry-After`; it is in seconds and is authoritative. Every payout
        endpoint is idempotent under an `Idempotency-Key`, so retrying after the
        interval is safe and cannot double-pay — a 429 means the request was
        REFUSED, never that it was accepted and throttled.

        Sizing a payroll run: at 100/min, and roughly three calls per new
        beneficiary paid, plan for batching or spreading the run rather than
        firing it in a burst.
      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" }
    IdempotencyRequired:
      description: Missing or malformed `Idempotency-Key`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missing:
              value:
                type: IDEMPOTENCY_KEY_REQUIRED
                message: Idempotency-Key header is required.
    IdempotencyConflict:
      description: |
        Either the key was reused with a different body, or an identical request
        is still in flight. The second is safe to retry after a backoff; the
        first means you should use a new key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            conflict:
              value:
                type: IDEMPOTENCY_KEY_CONFLICT
                message: This Idempotency-Key was already used with a different request body.
            inFlight:
              value:
                type: IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS
                message: A request with this Idempotency-Key is still in flight.
            duplicate:
              value:
                type: DUPLICATE_REQUEST_DETECTED
                message: >-
                  An identical request was received in the last 15 minutes under a different
                  Idempotency-Key. Nothing was executed.
                originalIdempotencyKey: zz_advance_88213
                originalPayoutId: pay_01J
                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.

  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, examples: ["USD"] }
        amount: { type: string, examples: ["200.00"] }

    PreviewQuote:
      type: object
      properties:
        indicative:
          type: boolean
          # Deliberately 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. Because the fee is deducted from the send
            rather than added on top, this equals `sourceAmount`. It is a
            separate field so your affordability check is one comparison and
            stays correct if that ever changes.
        rate:
          type: string
          description: |
            Destination units per source unit — "1 USD = 17.0138 MXN".
            `destinationAmount = (sourceAmount - fee) x rate`.
          examples: ["17.0138"]
        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: "3385.41" }
          fee: { currency: USD, amount: "1.02" }
          totalDebit: { currency: USD, amount: "200.00" }
          rate: "17.0138"
          limits: { min: "1.00", max: "5000.00" }

    Corridor:
      type: object
      properties:
        currency: { type: string, examples: ["MXN"] }
        fields:
          type: array
          description: Render these, in order. Names and count vary by routing.
          items:
            type: object
            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}$"]
              required: { type: boolean }

    CreateBeneficiary:
      type: object
      required: [type, name, email, method]
      properties:
        type:
          type: string
          enum: [individual, business]
        name: { type: string, examples: ["María González"] }
        email: { type: string }
        country:
          type: string
          description: ISO-3166 alpha-2.
          examples: ["MX"]
        externalId:
          type: string
          description: |
            Your id for this beneficiary. Sending it makes creation repeat-safe:
            the same value returns the existing beneficiary rather than
            registering a second bank account.
        endUserId:
          type: string
          description: |
            Your id for the person SENDING. Scopes the beneficiary to them.
            Omit it and the beneficiary is visible to every one of your users.
          examples: ["employee_42"]
        method:
          type: object
          required: [kind, currency, recipientDetails]
          properties:
            kind: { type: string, const: fiat }
            currency: { type: string, examples: ["MXN"] }
            recipientDetails:
              type: object
              description: The corridor's fields, keyed by their `id`.
              additionalProperties: { type: string }
              examples:
                - clabeNumber: "012345678901234567"

    Beneficiary:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: string }
        country: { type: string }
        externalId: { type: string }
        endUserId: { type: string, nullable: true }
        type:
          type: string
          enum: [individual, business]
        phone: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        organizationId:
          type: string
          description: |
            **Not the org id you authenticate with.** This is an internal id;
            putting it in a URL returns 403 with a message about your
            credentials. Keep using the org id you were issued. Declared here
            only because it is returned — `npm run docs:drift` compares the two
            and will not let an undeclared field ship again.
        paymentMethods:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              kind: { type: string }
              currency: { type: string, nullable: true }
              last4: { type: string, nullable: true }
              status: { type: string }
              destinationAccountId:
                type: string
                nullable: true
                description: Pass this as `destinationAccountId` when pricing a payout.

    QuoteSnapshot:
      type: object
      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 }
              expires_at: { type: string, format: date-time }

    PayoutStatus:
      type: string
      enum: [pending, processing, completed, failed, canceled]
      description: |
        The whole vocabulary. Permitted transitions, and no others:

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

        - `pending` — accepted, reserved, not sent. The only cancellable state.
        - `processing` — handed to the payment network. Committed.
        - `completed` — the beneficiary was paid. **Not absolutely final.**
        - `failed` — not paid, or paid and then returned. Branch on `failureCode`.
        - `canceled` — deliberately ended before sending. Carries
          `failureCode: canceled_by_platform` and `fundsReturned: true` (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
        - canceled_by_platform
        - unknown

    Payout:
      type: object
      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
            behaviour on it, and treat an unrecognised 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 spec declared these as flat
        # strings beside separate `*Currency` fields — which is the WEBHOOK
        # shape, not this one — so a client generated from the spec failed to
        # deserialize the response to the send call itself. The two shapes are
        # genuinely different and are now documented separately; see
        # `WebhookPayout`.
        sourceAmount: { $ref: "#/components/schemas/Money" }
        destinationAmount: { $ref: "#/components/schemas/Money" }
        destinationAccountId: { type: string }
        rate: { type: string }
        reference: { type: string }
        endUser: { $ref: "#/components/schemas/EndUser" }
        createdAt: { type: string, format: date-time }
        updatedAt:
          type: string
          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, format: date-time, nullable: true }

    PayoutEvent:
      type: object
      description: |
        One state change, written once and never updated.
      required: [id, sequence, type, payoutId, status, createdAt]
      properties:
        id:
          type: string
          description: Stable. Dedupe on this — the feed is at-least-once.
        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:
          type: string
          description: |
            New types are added without a major version — ignore ones you do not
            handle.
          examples:
            ["payout.pending", "payout.processing", "payout.completed", "payout.failed", "payout.returned"]
        payoutId: { type: string }
        status: { $ref: "#/components/schemas/PayoutStatus" }
        failureCode: { $ref: "#/components/schemas/PayoutFailureCode" }
        fundsReturned: { type: boolean }
        createdAt: { type: string, format: date-time }

    WebhookPayout:
      type: object
      description: |
        The payout as it appears INSIDE A WEBHOOK BODY.

        Deliberately not the same shape as `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" }
        stage: { type: string }
        failureCode: { $ref: "#/components/schemas/PayoutFailureCode" }
        fundsReturned: { type: boolean }
        reference:
          type: string
          nullable: true
          description: |
            Your own reference, echoed on every delivery. This is the field a
            ledger joins on, and it shipped while being undeclared here — so a
            generated client dropped it from the one payload where it is the
            join key.
        sourceCurrency: { type: string, nullable: true }
        sourceAmount: { type: string, nullable: true }
        destinationCurrency: { type: string, nullable: true }
        destinationAmount: { type: string, nullable: true }
        destinationAccountId: { type: string, nullable: true }
        rate: { type: string, nullable: true }
        endUser: { $ref: "#/components/schemas/EndUser" }
        createdAt: { type: string, format: date-time }
        completedAt: { type: string, format: date-time, nullable: true }

    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.
      required: [type, data]
      properties:
        type:
          type: string
          description: |
            New types are added without a major version. Return 2xx for any type
            you do not handle.
          examples: ["payout.pending", "payout.completed", "payout.failed"]
        data: { $ref: "#/components/schemas/WebhookPayout" }

    EndUser:
      type: object
      description: |
        Who you are paying on behalf of. Attribution only — 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, examples: ["employee_42"] }
        name: { type: string }
        email: { type: string }

    FundingAccount:
      type: object
      properties:
        currency: { type: string }
        bankName: { type: string }
        paymentRails:
          type: array
          items: { type: string }
        depositInstructions:
          type: object
          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 — and it was missing from this schema, so generated
            clients dropped exactly the field the error documentation tells
            you to read.
        message:
          type: string
          description: Same text as `detail`. Present for clients that expect it.
        resolution:
          type: string
          description: |
            What to do about it, when there is a specific answer. NOT on every
            error — 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
          description: Alias of `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.
        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 — and it was absent from this schema, so generated clients
            never saw it.
        originalPayoutId:
          type: string
          description: On `DUPLICATE_REQUEST_DETECTED` only. The payout the first request created.

  # 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 too —
        observed on the funding-confirm path for a self-funded payout.

        This enum has been wrong four times, in both directions. Generate your
        client from it, but treat an UNRECOGNISED `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 CANCELLED payout arrives as `payout.canceled` — its own type, so a
        `payout.failed` handler that re-attempts a wage will not re-send one you
        deliberately stopped.

        `payout.returned` is its own type and NOT a flavour 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 — an earlier version of this document omitted it, and a strict
        validator would have rejected exactly the event that matters most.

        Headers: `svix-id` (stable across retries — dedupe on it),
        `svix-timestamp`, `svix-signature`. 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.

        Retries: 1m, 5m, 30m, 2h, 6h — six attempts over roughly 8.5 hours.
        **Webhooks are the fast path, not the guarantee.** `GET` the payout for
        the authoritative answer, and do not build a ledger that assumes
        at-least-once delivery.
      payload:
        type: object
        properties:
          type:
            type: string
            # Terminal states only. `payout.returned` MUST be here: it is the
            # reversal of an already-booked payment, and a validator generated
            # from an enum without it rejects the single most consequential
            # event we send.
            # Verified against captured deliveries. `payout.pending` IS sent —
            # an earlier revision of this file removed it and declared
            # "terminal states only", which would have made a generated
            # validator reject roughly half of all traffic.
            #
            # `payout.canceled` IS sent, as its own type. It used to arrive as
            # `payout.failed`, which an earned-wage platform's failure handler
            # would re-attempt — sending a cancelled wage again.
            #
            # `payout.processing` IS delivered — observed on the
            # funding-confirm path for a self-funded payout. An earlier revision
            # asserted it never was.
            enum:
              - payout.pending
              - payout.completed
              - payout.failed
              - payout.returned
              - payout.canceled
              - payout.processing
          # WebhookPayout, not Payout. The delivered body carries FLAT strings
          # (`"sourceAmount": "25.00"`, `"sourceCurrency": "USD"`), not the
          # nested Money objects the REST `Payout` uses. This block declared
          # `Payout` while the rest of the documentation warned about exactly
          # this confusion — so the webhook section shipped the bug its own
          # prose apologised for.
          data: { $ref: "#/components/schemas/WebhookPayout" }
