> ## 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 a payout

> Pay a person from your float — mobile money, bank or wallet — and budget for the provider's fee.

A payout is the money-out entry point: what your app calls to pay a person. A seller payout, a withdrawal to a user's mobile money wallet, a payroll run, a refund you are settling manually. You name an amount and a destination, and the gateway debits **your float** and credits them.

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

<Warning>
  Payouts are asynchronous and irreversible. `POST /v1/payouts` returns `201` with `status: "pending"`, which means the transfer was accepted, not that it landed. The webhook, or `GET /v1/payouts/{reference}`, tells you when it actually arrived.
</Warning>

## Before you start

* **`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. See [Idempotency](/concepts/idempotency).
* **Amounts are integer minor units.** UGX, RWF, XAF and XOF are zero-decimal. See [Money and minor units](/concepts/money).
* **Check the corridor first.** Capability is per `(country, currency, direction, method)`, never per country. Nigeria takes bank payouts but no mobile money, Rwanda mobile money 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.

## Fees come out of your float, on top of the amount

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 roughly UGX 15,400 at current rates — bank fees are far higher than mobile money, around UGX 5,400 against UGX 1,000 on a comparable mobile money transfer.

`fees_minor` on the response carries the provider's own figure. Your float is debited both the amount and the fee when the payout settles.

<Note>
  Budget for **amount + fee**, not the amount alone. 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. Your float therefore can never go negative.
</Note>

## The flow

<Steps>
  <Step title="Quote the payout (optional)">
    `POST /v1/payouts/quote` is the confirm-screen endpoint: "you send 50,000 UGX, they receive 1,340 KES, fee 500" — priced by the real provider, with no money moved and nothing recorded as a transaction.

    It is optional. `POST /v1/payouts` re-quotes internally, so you never have to call this first; it exists purely so a UI can show a rate before the user taps send. Quoting is free and works even when the amount is unaffordable.

    ```bash Quote a cross-currency payout theme={null}
    curl -sS https://api.pay.aptahq.com/v1/payouts/quote \
      -H "X-AptaPay-Key: $APP_ID.$KEY_ID.$API_KEY" \
      -H "X-AptaPay-Timestamp: $TS" \
      -H "X-AptaPay-Nonce: $NONCE" \
      -H "X-AptaPay-Signature: v1=$SIG" \
      -H "Content-Type: application/json" \
      -d '{
        "amount_minor": 10000,
        "currency": "USD",
        "destination_currency": "KES",
        "amount_type": "DESTINATION",
        "country": "KE",
        "method": "momo",
        "destination": { "type": "momo", "msisdn": "+254712345678", "country": "KE" }
      }'
    ```

    `total_amount_minor` is what leaves your float. Show that figure.

    The response carries **our** `quote_id` plus the human-meaningful fields. The provider's own quotation token never leaves the gateway — holding it would let a caller go straight to the provider, bypassing our limits, idempotency and transaction records.

    <Warning>
      `expires_at` is absolute and provider-reported. Do not assume a fixed TTL, and do not hold a quote across a slow confirm screen. Re-quote instead, or just send the payout and let the gateway price it.
    </Warning>
  </Step>

  <Step title="Send the payout">
    ```bash Same-currency mobile money payout theme={null}
    curl -sS https://api.pay.aptahq.com/v1/payouts \
      -H "X-AptaPay-Key: $APP_ID.$KEY_ID.$API_KEY" \
      -H "X-AptaPay-Timestamp: $TS" \
      -H "X-AptaPay-Nonce: $NONCE" \
      -H "X-AptaPay-Signature: v1=$SIG" \
      -H "Idempotency-Key: 4f2b9d1a-7c30-4e88-a1f6-93b0c2d5e714" \
      -H "Content-Type: application/json" \
      -d '{
        "amount_minor": 50000,
        "currency": "UGX",
        "country": "UG",
        "method": "momo",
        "destination": {
          "type": "momo",
          "msisdn": "+256781234567",
          "network": "MTN",
          "country": "UG"
        },
        "metadata": { "withdrawal_id": "wd_5521" }
      }'
    ```

    Returns `201` with a gateway `reference`. Persist it.

    On a **bank** destination, `destination.msisdn` is the recipient's phone number. It is not needed to reach the account, but the provider requires a contact number on bank payouts and rejects an empty one — a bank payout without it is refused as `validation_failed` naming `destination.msisdn`.

    `destination.account_name` is advisory only. The gateway re-resolves the account name at payout time, every time, because a stale "yes, this is Jane Doe" can outlive the account being closed, renamed or reassigned.
  </Step>

  <Step title="Wait for the real answer">
    The webhook is the primary signal. `GET /v1/payouts/{reference}` is the fallback that a "sending your money…" screen polls, and what a reconciliation job sweeps for payouts that never produced a webhook. It is a read of the gateway's own ledger — cheap, and safe to poll.

    | `status`     | Meaning                                |
    | ------------ | -------------------------------------- |
    | `pending`    | Still in flight.                       |
    | `successful` | Terminal. It landed.                   |
    | `failed`     | Terminal. The float has been returned. |

    <Warning>
      A payout reported `failed` has had its float returned. Do not re-send blindly — resolve why it failed first.
    </Warning>

    A `404` means "not found **for this tenant**". Another tenant's reference looks exactly like one that never existed, so this endpoint cannot be used to probe whether a reference belongs to somebody else.
  </Step>
</Steps>

## Cross-currency payouts

When `currency` differs from `destination_currency`, **`amount_type` is required**. It decides whether the sender pays a fixed amount or the recipient receives one — a materially different product, so there is no silent default.

| `amount_type` | `amount_minor` means                                                              |
| ------------- | --------------------------------------------------------------------------------- |
| `SOURCE`      | What leaves your float, before fees. The recipient's amount floats with the rate. |
| `DESTINATION` | What the recipient receives. What leaves your float floats with the rate.         |

`destination_currency` defaults to `currency` for same-currency payouts, where `amount_type` is not needed.

<Note>
  `destination.country` is **required** on a mobile money payout 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, so the field deciding which country's subscriber is paid must be stated explicitly. Collections are deliberately more lenient here.
</Note>

## Withdrawing 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 and nothing is stranded. A UGX 10,000 mobile money payout then delivers 9,000 with a 1,000 fee, and the full 10,000 leaves your float.

The same flag works on `/quote`, so a confirm screen can show the reduced `destination_amount_minor` the recipient will actually receive.

<Warning>
  The reduction happens **before** the minimum is checked. A UGX 10,000 withdraw-all by bank nets 4,600 and is refused against bank's own UGX 10,000 floor. By mobile money the same 10,000 nets 9,000 and succeeds. `error.details.suggested_method` points at the rail that fits.
</Warning>

## 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 **mobile money** 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. Read it there rather than hardcoding the figure — floors are provider-set and change without a release.

## Refusals are safe

A payout is refused **before any provider call, with no money moved**, when the amount is over your float, outside a corridor's min/max window, over your velocity cap, or over the test ceiling in test mode.

A `422` is therefore always safe: nothing was sent.

On an insufficient-float refusal, `error.details` carries the figures you need to explain it:

```json Insufficient float theme={null}
{
  "code": 422,
  "message": "Insufficient float",
  "error": {
    "code": "insufficient_float",
    "details": {
      "amount_minor": 10000,
      "estimated_fee_minor": 5400,
      "shortfall_minor": 3200,
      "suggested_method": "momo",
      "suggested_total_minor": 11000
    }
  }
}
```

When a cheaper rail would fit, `suggested_method` and `suggested_total_minor` are present, so an unaffordable bank payout can point you at mobile money rather than leaving you to guess.

## A complete Node example

```javascript payout.js theme={null}
import { randomUUID } from "node:crypto";
import { signedFetch } from "./aptapay-client.js";

const BASE = "https://api.pay.aptahq.com";

const destination = {
  type: "momo",
  msisdn: "+256781234567",
  network: "MTN",
  country: "UG", // required on a payout; never inherited
};

// 1. Quote for the confirm screen. total_amount_minor is what leaves your float.
const quote = await signedFetch(`${BASE}/v1/payouts/quote`, {
  method: "POST",
  body: { amount_minor: 50000, currency: "UGX", country: "UG", method: "momo", destination },
});
await showConfirmScreen({
  leavesYourFloat: quote.data.total_amount_minor,
  recipientReceives: quote.data.destination_amount_minor,
  expiresAt: quote.data.expires_at, // absolute; do not hold it across a slow screen
});

// 2. Send. One key per user intent, reused across retries.
const idempotencyKey = randomUUID();

try {
  const payout = await signedFetch(`${BASE}/v1/payouts`, {
    method: "POST",
    headers: { "Idempotency-Key": idempotencyKey },
    body: {
      amount_minor: 50000,
      currency: "UGX",
      country: "UG",
      method: "momo",
      destination,
      metadata: { withdrawal_id: "wd_5521" },
    },
  });

  await saveWithdrawalReference("wd_5521", payout.data.reference);
  // `pending` is normal. The webhook settles it.
} catch (err) {
  if (err.code === "insufficient_float") {
    // Nothing was sent. Suggest the cheaper rail if the gateway named one.
    const { shortfall_minor: shortfall, suggested_method: cheaper } = err.details;
    return offerAlternative({ shortfall, cheaper });
  }
  if (err.code === "amount_below_minimum") {
    return showMinimum(err.details.minimum_minor);
  }
  throw err;
}
```

## Correlate on metadata, not on the reference

Put your own withdrawal id in `metadata`. It is stored on the transaction and echoed back on every webhook delivery as `data.metadata`.

Our `reference` is opaque. It looks like `APT-LCS-oRUZWq8fyn9DttNb3SN3B` — the shape is `APT-{prefix}-{nanoid}` — but it carries no field you are meant to parse.

<Warning>
  Never parse the `reference` to recover your own identifiers. Match on `data.metadata` instead, which must serialize to at most 4096 bytes.
</Warning>

## Errors worth handling

| Code                     | Meaning                                                                                                                 |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `insufficient_float`     | Amount + estimated fee exceeds your float. Nothing was sent.                                                            |
| `amount_below_minimum`   | Under the floor for that method. Read `error.details.minimum_minor`.                                                    |
| `corridor_not_supported` | No route for that `(country, currency, direction, method)`.                                                             |
| `validation_failed`      | A required field is missing — commonly `destination.msisdn` on a bank payout, or `amount_type` on a cross-currency one. |
| `invalid_msisdn`         | The number is not valid for the stated destination country.                                                             |

Full list and response shape: [Errors](/concepts/errors).

## Next steps

<Columns cols={2}>
  <Card title="Collect a payment" icon="arrow-down-to-line" href="/guides/collections">
    The money-in side.
  </Card>

  <Card title="Test your integration" icon="flask-conical" href="/guides/testing">
    Test keys, the amount ceiling, and the Postman collection.
  </Card>
</Columns>
