> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pay.aptahq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Send money — money out

> **Send money out to somebody — the money-out entry point.** This is what your app calls to pay a person: a seller payout, a withdrawal to a user's momo wallet, a payroll run, a refund you are settling manually. You name an amount, a destination (momo number, bank account, or a provider wallet) and the gateway debits YOUR float and credits them.

ONE call. Providers that need a quotation-then-execute handshake do it INTERNALLY; that two-call shape never leaks to you, which is also why you never have to race a quote's expiry window.

Returns 201 with a gateway `reference`. Payouts are asynchronous — `pending` is the normal first answer, and the webhook (or `GET /v1/payouts/{reference}`) tells you when it actually landed.

**`Idempotency-Key` is required.** A retried payout without one is a double payment; with one, a replay returns the original response verbatim and no second transfer happens.

**Check the corridor first.** Capability is per `(country, currency, direction, method)`, never per country — Nigeria takes bank payouts but no momo, Rwanda momo but no bank, Tanzania only wallet-to-wallet. `GET /v1/reference/corridors` is the matrix to render your UI against; a route that does not exist is a 422 here, not a surprise at settlement.

**Cross-currency payouts REQUIRE `amount_type`.** It decides whether the sender pays a fixed amount or the recipient RECEIVES one — a materially different product, so there is no silent default.

Refused BEFORE any provider call, with no money moved: an amount over your float, outside a corridor's min/max window, over your velocity cap, or (in test mode) over the test ceiling. A 422 is therefore always safe — nothing was sent.

**The provider's fee is charged ON TOP of `amount_minor`, and it comes out of your float.** A UGX 10,000 bank payout debits the wallet UGX 15,400 at current rates (bank fees are far higher than mobile money — UGX 5,400 versus UGX 1,000 on a comparable momo transfer). `fees_minor` on the response carries the provider's own figure, and your float is debited both the amount and the fee when the payout settles. Budget for amount + fee, not the amount alone.

**Your float is checked against amount + fee, so a payout can never take you negative.** Before reserving anything the gateway asks the provider what this exact transfer will cost and holds that full total, refusing the payout if it does not fit — the same rule the aggregators enforce, applied here where you still get a usable error instead of a provider rejection. On refusal, `error.details` carries `amount_minor`, `estimated_fee_minor`, `shortfall_minor`, and — when a cheaper rail would fit — `suggested_method` with `suggested_total_minor`, so an unaffordable bank payout can point you at momo rather than leaving you to guess.

Call `POST /v1/payouts/quote` yourself to show the same figure on a confirm screen: `total_amount_minor` is what leaves your float. Quoting is free, moves no money, and works even when the amount is unaffordable.

**To withdraw a whole balance, set `deduct_fee_from_amount: true`** and pass the balance as `amount_minor`. The fee is taken out of it rather than added on top, so the total lands exactly on the balance. The same flag works on `/quote`, so a confirm screen shows the reduced `destination_amount_minor` the recipient will actually receive.

Note the interaction with per-method minimums: the reduction happens BEFORE the minimum is checked, so a UGX 10,000 withdraw-all by BANK nets 4,600 and is refused against bank's own 10,000 floor. By momo the same 10,000 nets 9,000 and succeeds. `error.details.suggested_method` points at the rail that fits.

**Minimums are per corridor AND per method.** Some rails impose a floor the others do not: a Uganda BANK payout is refused below **UGX 10,000**, while momo on the same corridor is accepted from UGX 500. An amount under the floor is `amount_below_minimum`, and `error.details` names the `minimum_minor` that applies to the `method` you sent — check it there rather than hardcoding the figure, since floors are provider-set and change without a release.




## OpenAPI

````yaml /openapi.json post /v1/payouts
openapi: 3.0.3
info:
  title: AptaPay Gateway
  version: 1.0.0
  description: >
    One provider-neutral payment API owned by many apps: collections, payouts,

    bank resolution and FX, with signed webhooks back.


    ## Authentication


    Every `/v1/*` request carries four headers. **A plain API key is not

    enough** — a key in a proxy log or an error report would be immediately

    usable, whereas a signature binds the request to a body and a moment.


    | Header | Value |

    | --- | --- |

    | `X-AptaPay-Key` | `{app_id}.{key_id}.{api_key}` |

    | `X-AptaPay-Timestamp` | unix seconds |

    | `X-AptaPay-Nonce` | 16-64 chars, `[A-Za-z0-9_-]`, unique per request |

    | `X-AptaPay-Signature` | `v1={hex HMAC-SHA256}` |


    The signature is HMAC-SHA256, with your **inbound** signing secret, over

    exactly these ten fields joined by `\n`:


    ```

    v1

    {APP_ID}

    {KEY_ID}

    {HOST}                      lowercased

    {METHOD}                    uppercased

    {PATH}                      no query, NOT url-decoded

    {CANONICAL_QUERY}           sorted, RFC3986-encoded, "" when absent

    {TIMESTAMP}

    {NONCE}

    {SHA256_HEX(RAW_BODY)}      sha256("") for GET/DELETE

    ```


    Every field earns its place. `CANONICAL_QUERY` is in there because without

    it `?limit=1` and `?limit=100000` sign identically, so a captured read

    signature could be replayed to dump everything. `HOST` stops a signature

    captured against one deployment replaying into another.


    The timestamp window is **asymmetric**: `-120s <= now - ts <= +30s`. A

    symmetric window is a ten-minute replay window, and there is no legitimate

    reason to sign in the future beyond small clock skew.


    A working reference implementation is in `docs/TENANT_INTEGRATION.md`, and

    `scripts/sign_request.mjs` signs a request from the terminal.


    ## Idempotency


    `Idempotency-Key` is **required** on every money-moving POST. A missing

    key is a 400 — the server never invents one, because a server-generated

    key is unique per request and protects nothing, which is worse than none

    because it looks like protection.


    - Replaying a completed key returns the **original response verbatim**,
      with its original status code. No second provider call.
    - The same key with **different parameters** is a 422, never a replay.

    - A concurrent duplicate is a 409 `request_in_progress`.


    ## Errors


    Branch on `error.code` and the three booleans, never on the HTTP status:

    `terminal` (reverse and tell the user), `retriable` (retry with the same

    key), `ambiguous` (**do not reverse** — poll instead).


    ## Amounts


    Integer minor units, with a per-currency exponent. **UGX, RWF, XAF and

    XOF are zero-decimal**: 2000 UGX is `2000`. Getting this wrong is a 100x

    error.


    ## Test mode is NOT a sandbox


    **Every request reaches a real provider. There is no sandbox, and no

    test host.** AptaPay routes to a single upstream provider, and that provider
    publishes

    exactly one API host for live and test traffic alike. No sandbox base URL

    exists anywhere in its reference documentation.


    So "test mode" here does not mean what it means on Stripe. It is a

    **second, real provider account** holding a small real balance, reached

    over the same wire as production. A test-mode payout moves real money out

    of a real wallet to a real phone number. It cannot be reversed, because

    the provider exposes no refund endpoint.


    | | What you might expect | What actually happens |

    | --- | --- | --- |

    | Host | a separate sandbox host | the one production host, always |

    | Money | simulated | real, in a real wallet |

    | Recipients | fake numbers accepted | real MSISDNs, really credited |

    | Undo | reverse the test charge | none — no refund endpoint exists |


    Two guardrails stand in for the sandbox, and they are permanent, not

    temporary scaffolding:


    - **A hard amount ceiling on every test-mode transaction**, enforced by
      this gateway *before* any provider call. Over it, you get a `422` and
      no request ever leaves.
    - **`is_test_mode` stamped on every transaction record**, so a test
      transaction is identifiable forever after the fact.

    Mode is a property of **which credentials you hold**, never of a request

    field — there is no `test: true` to send, and no header that switches it.

    A live key transacts live; that is the only thing that decides it. See

    `GET /healthz` for which mode a deployment is running.


    ## CORS


    There isn't any, deliberately. This is a server-to-server API and

    browsers must never call it: signing from client-side JS would put your

    secret in view-source. Cross-origin browser requests are denied.
servers:
  - url: https://api.pay.aptahq.com
    description: >-
      Production. There is no sandbox host, because the upstream provider has
      none — see "Test mode is NOT a sandbox". Sign the host you dial: the host
      is part of the canonical string, so a request dialled here must claim
      `api.pay.aptahq.com`.
security:
  - AptaPaySignature: []
tags:
  - name: Collections
    description: Money in.
  - name: Payouts
    description: Money out.
  - name: Refunds
    description: Reversals — 501 where the routed provider has no refund endpoint.
  - name: Transactions
    description: The gateway's own ledger of what it was asked to do.
  - name: Beneficiaries
    description: Saved recipients. Stored masked, names re-resolved at payout time.
  - name: Reference
    description: Corridors, banks, countries, balances, capability matrix.
  - name: Float
    description: Your own claim on the shared wallet pool — what you can actually pay out.
  - name: FX
    description: Exchange between your own wallets. Quote is free; exchange moves money.
  - name: Webhooks
    description: Provider ingress. Documented for completeness; tenants never call it.
  - name: Ops
    description: Health.
paths:
  /v1/payouts:
    post:
      tags:
        - Payouts
      summary: Send money — money out
      description: >
        **Send money out to somebody — the money-out entry point.** This is what
        your app calls to pay a person: a seller payout, a withdrawal to a
        user's momo wallet, a payroll run, a refund you are settling manually.
        You name an amount, a destination (momo number, bank account, or a
        provider wallet) and the gateway debits YOUR float and credits them.


        ONE call. Providers that need a quotation-then-execute handshake do it
        INTERNALLY; that two-call shape never leaks to you, which is also why
        you never have to race a quote's expiry window.


        Returns 201 with a gateway `reference`. Payouts are asynchronous —
        `pending` is the normal first answer, and the webhook (or `GET
        /v1/payouts/{reference}`) tells you when it actually landed.


        **`Idempotency-Key` is required.** A retried payout without one is a
        double payment; with one, a replay returns the original response
        verbatim and no second transfer happens.


        **Check the corridor first.** Capability is per `(country, currency,
        direction, method)`, never per country — Nigeria takes bank payouts but
        no momo, Rwanda momo but no bank, Tanzania only wallet-to-wallet. `GET
        /v1/reference/corridors` is the matrix to render your UI against; a
        route that does not exist is a 422 here, not a surprise at settlement.


        **Cross-currency payouts REQUIRE `amount_type`.** It decides whether the
        sender pays a fixed amount or the recipient RECEIVES one — a materially
        different product, so there is no silent default.


        Refused BEFORE any provider call, with no money moved: an amount over
        your float, outside a corridor's min/max window, over your velocity cap,
        or (in test mode) over the test ceiling. A 422 is therefore always safe
        — nothing was sent.


        **The provider's fee is charged ON TOP of `amount_minor`, and it comes
        out of your float.** A UGX 10,000 bank payout debits the wallet UGX
        15,400 at current rates (bank fees are far higher than mobile money —
        UGX 5,400 versus UGX 1,000 on a comparable momo transfer). `fees_minor`
        on the response carries the provider's own figure, and your float is
        debited both the amount and the fee when the payout settles. Budget for
        amount + fee, not the amount alone.


        **Your float is checked against amount + fee, so a payout can never take
        you negative.** Before reserving anything the gateway asks the provider
        what this exact transfer will cost and holds that full total, refusing
        the payout if it does not fit — the same rule the aggregators enforce,
        applied here where you still get a usable error instead of a provider
        rejection. On refusal, `error.details` carries `amount_minor`,
        `estimated_fee_minor`, `shortfall_minor`, and — when a cheaper rail
        would fit — `suggested_method` with `suggested_total_minor`, so an
        unaffordable bank payout can point you at momo rather than leaving you
        to guess.


        Call `POST /v1/payouts/quote` yourself to show the same figure on a
        confirm screen: `total_amount_minor` is what leaves your float. Quoting
        is free, moves no money, and works even when the amount is unaffordable.


        **To withdraw a whole balance, set `deduct_fee_from_amount: true`** and
        pass the balance as `amount_minor`. The fee is taken out of it rather
        than added on top, so the total lands exactly on the balance. The same
        flag works on `/quote`, so a confirm screen shows the reduced
        `destination_amount_minor` the recipient will actually receive.


        Note the interaction with per-method minimums: the reduction happens
        BEFORE the minimum is checked, so a UGX 10,000 withdraw-all by BANK nets
        4,600 and is refused against bank's own 10,000 floor. By momo the same
        10,000 nets 9,000 and succeeds. `error.details.suggested_method` points
        at the rail that fits.


        **Minimums are per corridor AND per method.** Some rails impose a floor
        the others do not: a Uganda BANK payout is refused below **UGX 10,000**,
        while momo on the same corridor is accepted from UGX 500. An amount
        under the floor is `amount_below_minimum`, and `error.details` names the
        `minimum_minor` that applies to the `method` you sent — check it there
        rather than hardcoding the figure, since floors are provider-set and
        change without a release.
      operationId: createPayout
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - amount_minor
                - currency
                - country
                - method
                - destination
              properties:
                amount_minor:
                  $ref: '#/components/schemas/MinorUnits'
                currency:
                  $ref: '#/components/schemas/Currency'
                destination_currency:
                  allOf:
                    - $ref: '#/components/schemas/Currency'
                  description: Defaults to `currency` for same-currency payouts.
                country:
                  type: string
                  example: UG
                method:
                  type: string
                  enum:
                    - momo
                    - bank
                    - eversend
                destination:
                  $ref: '#/components/schemas/PayoutDestination'
                amount_type:
                  type: string
                  enum:
                    - SOURCE
                    - DESTINATION
                  description: REQUIRED when currency != destination_currency.
                deduct_fee_from_amount:
                  type: boolean
                  description: >-
                    Treat `amount_minor` as the total BUDGET rather than what
                    the recipient receives: the provider's fee is taken OUT of
                    it instead of added on top. This is "withdraw everything" —
                    a UGX 10,000 momo payout delivers 9,000 with a 1,000 fee,
                    the full 10,000 leaves your float, and nothing is stranded.
                    Defaults to false, where `amount_minor` is what the
                    recipient gets and the fee is charged on top.
                narration:
                  type: string
                metadata:
                  type: object
                  description: >-
                    Free-form correlation data, stored on the transaction and
                    ECHOED BACK on every webhook delivery as `data.metadata`.
                    This is how you map an AptaPay reference to one of your own:
                    our `reference` is opaque and carries no structure you can
                    parse, so put your order id or reference here at charge time
                    and read it back off the callback. Must serialize to at most
                    4096 bytes — it is re-sent on every delivery attempt.
            examples:
              ugandaMomo:
                summary: Same-currency momo payout
                value:
                  amount_minor: 50000
                  currency: UGX
                  country: UG
                  method: momo
                  destination:
                    type: momo
                    msisdn: '+256781234567'
                    network: MTN
                    country: UG
              nigeriaBank:
                summary: Nigeria is BANK ONLY — a momo payout to NG is refused
                value:
                  amount_minor: 250000
                  currency: NGN
                  country: NG
                  method: bank
                  destination:
                    type: bank
                    country: NG
                    bank_code: '044'
                    account_number: '0011223344'
                    account_name: Jane Doe
                    msisdn: '+2348012345678'
              crossCurrency:
                summary: Cross-currency — amount_type is mandatory
                value:
                  amount_minor: 10000
                  currency: USD
                  destination_currency: KES
                  amount_type: DESTINATION
                  country: KE
                  method: momo
                  destination:
                    type: momo
                    msisdn: '+254712345678'
                    country: KE
      responses:
        '201':
          description: Submitted to the provider.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
        '400':
          description: >
            Validation failed — including `amount_type_required` on a
            cross-currency payout.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >
            `tenant_frozen` (your tenant has been frozen and cannot move money —
            contact support), `destination_blocked` (the recipient has been
            blocked from receiving payouts), or `screening_denied` (refused by
            pre-provider screening). No provider call was made in any case.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
        '409':
          description: >
            `request_in_progress` (a concurrent identical request), or
            `indeterminate_outcome` — a previous attempt's result is UNKNOWN and
            needs manual review. Do NOT reverse on the latter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
        '422':
          description: >
            `insufficient_tenant_float`, `insufficient_wallet_float`,
            `corridor_unsupported`, `amount_below_minimum`,
            `amount_above_maximum`, or `idempotency_key_reused`. No provider
            call was made.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
        '429':
          description: >
            `velocity_limit_exceeded` — you have hit a payout velocity cap.
            `error.details.limit` names which one: `max_single_payout_minor`,
            `max_payout_minor_per_hour`, `max_payout_minor_per_day`,
            `first_seen_destination_cap` (a lower cap applies the first time you
            pay a new destination), `max_payouts_per_destination_per_day`, or
            `max_minor_per_destination_per_day`. No provider call was made and
            nothing was reserved — safe to retry once the window rolls over or
            the destination has a history.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
        '503':
          description: >
            `global_ceiling_reached` — the platform-wide daily payout ceiling
            for this currency has been reached. Not specific to your tenant; no
            provider call was made. Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        maxLength: 255
      description: |
        Required. A missing key is a 400 — never a server-generated one.
      example: laces-order-8f14e45f
  schemas:
    MinorUnits:
      type: integer
      description: >
        An integer count of the currency's smallest unit. NEVER a float, and

        never a decimal major amount. The `_minor` suffix on a field name is
        part

        of the contract: `amount` could be read as `20.00` or `2000`, whereas

        `amount_minor` can only be read one way.


        **The exponent is PER CURRENCY.** There is no universal "cents":


        | Exponent | Currencies | `2000` means |

        |---|---|---|

        | **0** (zero-decimal) | UGX, RWF, XAF, XOF | 2,000 shillings/francs |

        | **2** | KES, GHS, NGN, TZS, ZMW, USD, EUR, GBP | 20.00 |


        So `2000` UGX is two thousand shillings, while `2000` KES is twenty.

        Applying a blanket x100 to a zero-decimal currency is a **100x money

        error** — the kind that reconciles to nothing months later.


        To send an amount, multiply the major amount by `10^exponent` and
        confirm

        the result is an exact integer (KES `19.99` -> `1999`). To display one,

        divide by `10^exponent` for presentation only — keep the minor-unit

        integer as the value you store, compare, and sum.


        Rules the gateway enforces:


        - **An unknown currency is rejected, never defaulted.** A currency with
          no declared exponent fails with `unknown_currency` rather than assuming
          2, because a silent wrong exponent is worse than a loud failure.
        - **A non-exact amount is rejected, never rounded.** KES `19.999` fails
          with `inexact_amount`. AptaPay will not decide on your behalf which way
          to round someone's money.
        - **Amounts are never floats internally.** Providers send amounts as
          strings (`"2000"`) AND sometimes as numbers, so parsing is deliberate
          at the adapter boundary and converts to an exact integer or fails.

        A field's exponent always follows the currency named alongside it. On an

        FX quote, `source_amount_minor` and `destination_amount_minor` routinely

        use DIFFERENT exponents in the same response.
      example: 2000
    Currency:
      type: string
      enum:
        - UGX
        - RWF
        - XAF
        - XOF
        - KES
        - GHS
        - NGN
        - TZS
        - ZMW
        - USD
        - EUR
        - GBP
    PayoutDestination:
      oneOf:
        - $ref: '#/components/schemas/PayoutMomoDestination'
        - $ref: '#/components/schemas/BankDestination'
      discriminator:
        propertyName: type
    APIResult:
      type: object
      description: >
        The single response envelope, used by EVERY endpoint so a consuming app
        parses one shape everywhere.
      required:
        - code
        - message
      properties:
        code:
          type: integer
          example: 200
        message:
          type: string
          example: OK
        data:
          description: Present on success. Shape varies per endpoint.
        error:
          $ref: '#/components/schemas/GatewayErrorBody'
    PayoutMomoDestination:
      type: object
      description: >
        A momo destination on a PAYOUT. `country` is REQUIRED and is NOT
        inherited from the request's top-level `country`.


        A payout pushes money to whoever answers that number, and once it lands
        it cannot be recalled. The field that decides which country's subscriber
        is paid must therefore be stated explicitly on the leg that spends the
        money, never defaulted from a field set for another reason.
      required:
        - type
        - msisdn
        - country
      properties:
        type:
          type: string
          enum:
            - momo
        msisdn:
          type: string
          description: >
            The customer's phone number in whatever form they typed it. All of
            `0772123456`, `256772123456`, `+256772123456` and the bare national
            `772123456` are accepted, with spaces, dashes and parentheses
            ignored. The platform normalises to E.164 WITH the leading `+`
            (`+256772123456`) using the country code of the resolved corridor —
            so a missing country code is supplied, never guessed from the
            digits.

            The number is validated AGAINST the corridor: one carrying a
            different country's calling code, or the wrong national length, is
            rejected with `invalid_msisdn` rather than reinterpreted.
          example: '0772123456'
        network:
          type: string
          description: >
            Network code, a free string from the corridor table — never a closed
            enum, so a network can be added in Firestore with no schema change.
          example: MTN
        country:
          type: string
          description: >
            REQUIRED. Not inherited from the top-level `country`; the number is
            validated against it, so a mismatch is rejected.
          example: UG
    BankDestination:
      type: object
      required:
        - type
        - country
        - bank_code
        - account_number
      properties:
        type:
          type: string
          enum:
            - bank
        country:
          type: string
          example: NG
        bank_code:
          type: string
          example: '044'
        bank_name:
          type: string
        account_number:
          type: string
          example: '0011223344'
        account_name:
          type: string
          description: >
            Advisory only. The gateway RE-RESOLVES the account name at payout
            time, every time — a stale "yes, this is Jane Doe" can outlive the
            account being closed, renamed, or reassigned.
        msisdn:
          type: string
          example: '+256763494235'
          description: >
            The RECIPIENT's phone number. Not needed to reach the account, but
            the upstream provider REQUIRES a contact number on bank payouts and
            rejects an empty one — so a bank payout routed upstream without this
            is refused as `validation_failed` naming `destination.msisdn`.
            Accepted in local format (`0763494235`) and normalised to E.164
            against `destination.country`.
    GatewayErrorBody:
      type: object
      description: >
        Machine-readable error classification. EXACTLY ONE of
        terminal/retriable/ ambiguous is true.

        laces_api inferred this from the HTTP status and got it wrong, stranding
        real money twice (2026-08-15, 2026-08-16). Branch on these booleans,
        never on the status code:

          terminal  -> the provider refused. Reverse the debit and tell the user.
          retriable -> safe to retry with the SAME Idempotency-Key.
          ambiguous -> the outcome is UNKNOWN. Do NOT reverse. Poll instead.
      required:
        - code
        - message
        - terminal
        - retriable
        - ambiguous
      properties:
        code:
          type: string
          description: Stable machine code. Branch on this, never on `message`.
          example: payout_rejected_by_provider
        message:
          type: string
          description: >
            Human text from a compiled-in map. NEVER provider text — a
            stringified provider error can carry an Authorization header.
        terminal:
          type: boolean
        retriable:
          type: boolean
        ambiguous:
          type: boolean
        details:
          type: object
          additionalProperties:
            type: string
  responses:
    Unauthorized:
      description: >
        Missing, malformed, stale, replayed or invalid signature. Also returned
        when a body was sent but its raw bytes were unavailable —
        verified-as-empty must never mean processed-as-full.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIResult'
  securitySchemes:
    AptaPaySignature:
      type: apiKey
      in: header
      name: X-AptaPay-Signature
      description: >
        `v1={hex HMAC-SHA256}` over the ten-field canonical string. Sent
        alongside `X-AptaPay-Key`, `X-AptaPay-Timestamp` and `X-AptaPay-Nonce` —
        all four are required. OpenAPI can only model one header per scheme, so
        the other three are described in the Authentication section above.

````