{
"code": 201,
"message": "OK",
"data": {
"reference": "APT-LCS-V1StGXR8Z5jdHi6BmyT",
"provider": "eversend",
"status": "requires_action",
"observed_amount_minor": 2000,
"observed_currency": "UGX",
"next_action": {
"type": "otp",
"pin_id": "a435fdbc-a0b3-4412-848c-2dbc0956e5c6",
"message": "Enter the code sent to the number ending 4235, then POST it to /v1/collections/APT-LCS-V1StGXR8Z5jdHi6BmyT/authorize."
}
}
}Charge — money in
Charge a customer — the money-in entry point. This is what your app calls when a user taps “Pay”: you hand over an amount, a country, a method and the payer’s identifier, and the gateway asks the routed provider to debit them. Typical use is a checkout or top-up flow — a race entry fee, a wallet load, an order payment.
ONE call. The customer-facing prompt (momo PIN, redirect page, OTP) is the provider’s own; the gateway hands you back a canonical status you branch on instead of a provider-shaped payload. A charge does not settle synchronously — expect pending and treat the webhook (or a poll of GET /v1/collections/{reference}) as the real answer.
Returns 201 with a gateway reference — persist it. It is your handle for polling, for the webhook you will receive, and for a later refund.
Idempotency-Key is required; this moves money. Replaying a completed key returns the original response verbatim with no second charge, so a retry after a network timeout is always safe.
Ghana (GHS) requires redirect_url and answers requires_action with a next_action.redirect_url — send the user there to finish.
No OTP is required. A momo charge goes to the provider on this one call and the customer approves it on their handset prompt alone — Kenya, Rwanda, Uganda and Ghana alike. Nothing to collect, nothing to post back.
The otp field remains accepted, and so does the ladder behind it, for the case where a corridor is configured to demand a code: there a charge sent without an otp texts the customer one, records the transaction, and answers 201 with status: "requires_action" and a next_action of type otp carrying the pin_id — collect the code and post it to POST /v1/collections/{reference}/authorize, which validates it AND charges. Always branch on next_action rather than assuming its absence, so your integration is unaffected either way and keeps working on a provider that does require one.
Amounts are integer minor units, and UGX/RWF/XAF/XOF are zero-decimal: 2000 UGX is 2000, not 200000. Getting this wrong is a 100x error.
Double-charge guard. Before charging, the gateway checks whether a momo charge to this same number is already live for your tenant (last 15 minutes). If one is, it asks the provider what actually happened:
-
The earlier charge succeeded (its webhook was lost) — this request is refused
409 duplicate_charge_in_flightwithalready_succeeded: "true"and the earlierreference. Show that as a receipt; do not retry. -
The earlier charge failed or expired — it is settled and THIS charge proceeds normally.
-
The earlier charge is still live, or the provider cannot be reached — refused
409 duplicate_charge_in_flight. Wait and pollGET /v1/collections/{reference}.
This is separate from Idempotency-Key, which protects a retried API call. This protects a retried CUSTOMER action, which arrives with a brand-new key.
{
"code": 201,
"message": "OK",
"data": {
"reference": "APT-LCS-V1StGXR8Z5jdHi6BmyT",
"provider": "eversend",
"status": "requires_action",
"observed_amount_minor": 2000,
"observed_currency": "UGX",
"next_action": {
"type": "otp",
"pin_id": "a435fdbc-a0b3-4412-848c-2dbc0956e5c6",
"message": "Enter the code sent to the number ending 4235, then POST it to /v1/collections/APT-LCS-V1StGXR8Z5jdHi6BmyT/authorize."
}
}
}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, card A momo source on a COLLECTION. country is optional here and defaults to the request's top-level country. Collections pull money: a wrong corridor fails or produces a refundable charge, so inheriting is safe. The payout side is deliberately stricter — see PayoutMomoDestination.
- Option 1
- Option 2
Show child attributes
Show child attributes
REQUIRED for GHS collections.
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.
Optional payer details for the PROVIDER's records and receipts. Never used for routing or matching, and not persisted on the transaction — it is passed straight through, so send only what you are willing to share with the provider.
Show child attributes
Show child attributes
Optional — this account is whitelisted for phone verification, so no corridor requires an OTP. Supply it only if you already drove POST /v1/collections/otp yourself: pin_id from that call, pin from what the customer typed. On a corridor configured to require a code, omitting this starts the OTP ladder rather than failing.
Show child attributes
Show child attributes
Response
Accepted. Normally the charge is already with the provider — no OTP is required on any corridor. status is requires_action only where a corridor still demands a code or a redirect; branch on next_action.type and finish at /authorize.
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