curl --request POST \
--url https://api.pay.aptahq.com/v1/payouts \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--header 'X-AptaPay-Signature: <api-key>' \
--data '
{
"amount_minor": 50000,
"currency": "UGX",
"country": "UG",
"method": "momo",
"destination": {
"type": "momo",
"msisdn": "+256781234567",
"network": "MTN",
"country": "UG"
}
}
'{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}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.
curl --request POST \
--url https://api.pay.aptahq.com/v1/payouts \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--header 'X-AptaPay-Signature: <api-key>' \
--data '
{
"amount_minor": 50000,
"currency": "UGX",
"country": "UG",
"method": "momo",
"destination": {
"type": "momo",
"msisdn": "+256781234567",
"network": "MTN",
"country": "UG"
}
}
'{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}Authorizations
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.
Headers
Required. A missing key is a 400 — never a server-generated one.
255Body
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_currencyrather than assuming 2, because a silent wrong exponent is worse than a loud failure. - A non-exact amount is rejected, never rounded. KES
19.999fails withinexact_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.
2000
UGX, RWF, XAF, XOF, KES, GHS, NGN, TZS, ZMW, USD, EUR, GBP "UG"
momo, bank, eversend 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.
- Option 1
- Option 2
Show child attributes
Show child attributes
Defaults to currency for same-currency payouts.
UGX, RWF, XAF, XOF, KES, GHS, NGN, TZS, ZMW, USD, EUR, GBP REQUIRED when currency != destination_currency.
SOURCE, DESTINATION 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.
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.
Response
Submitted to the provider.
The single response envelope, used by EVERY endpoint so a consuming app parses one shape everywhere.
200
"OK"
Present on success. Shape varies per endpoint.
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.
Show child attributes
Show child attributes