Before you start
- Amounts are integer minor units. Every amount field ends in
_minor. UGX, RWF, XAF and XOF are zero-decimal, so 2,000 UGX is2000, not200000. Getting this wrong is a 100x error. See Money and minor units. - Check the corridor. Capability is per
(country, currency, direction, method). CallGET /v1/reference/corridorsand render your UI against it. Idempotency-Keyis required. This call moves money. See Idempotency.
The flow
1
Quote the charge (optional)
POST /v1/collections/quote returns the fee and the total the customer must pay, so a confirm screen can show real numbers before anyone commits.This is a standalone call that holds no state. It moves no money, needs no Idempotency-Key, and returns no token to redeem — quote as often as you like, or skip it entirely. A quote is never “used up” and can never expire out from under a customer.Show total_to_pay_minor rather than adding amount_minor and fee_minor yourself. That figure comes from the provider and is what the customer is actually billed.Quote a collection
momo and bank can be quoted. card cannot, and answers 501.2
Charge the customer
One call. Send the amount, the corridor and the payer’s number.The response carries a gateway The customer now sees a prompt on their handset and approves it there.
Charge a Uganda mobile money wallet
reference. Persist it: it is your handle for polling, for the webhook you will receive, and for a later refund.Response
3
Branch on next_action
Most charges go straight to the provider and come back
pending. Some corridors need a second step from the customer first, and those come back status: "requires_action" with a next_action object.Always branch on the presence of next_action rather than assuming it is absent. That way your integration keeps working if a corridor changes, or if you deploy against a provider that does require a step.Ghana (GHS) requires
redirect_url on the request and answers requires_action with a next_action.redirect_url. Send the user there to finish.4
Wait for the real answer
Your webhook handler is the primary signal and the source of truth for settlement. See Webhooks.As a fallback, poll A live check never changes the response shape and never fails the read. An unreachable provider, an unlookupable transaction, an already-terminal record or a caller inside the cooldown all return the stored record with a
GET /v1/collections/{reference}. By default that reads the gateway’s own ledger — a database read, not a provider call — so polling is cheap and safe.Pass live=true when a customer is watching a payment screen. The gateway then asks the provider what it currently holds and applies the answer before responding, so a charge the customer just approved is reported as successful on that very poll rather than a minute later. Upstream calls are collapsed to at most one per reference every few seconds however many callers ask, so polling on a timer and from several tabs at once is safe.Poll with a live upstream check
200. The only difference is that the status may be fresher.A complete Node example
collect.js
Correlate on metadata, not on the reference
Put your own order id inmetadata at charge time. It is stored on the transaction and echoed back on every webhook delivery as data.metadata, which is how you map a gateway reference to one of your own.
Our reference is opaque. It looks like APT-LCS-oRUZWq8fyn9DttNb3SN3B, and while that shape is APT-{prefix}-{nanoid} today, it carries no field you are meant to parse.
OTP is not required
This account is whitelisted for phone verification, so no corridor currently requires an OTP.requires_otp is false for every corridor. A mobile money charge goes to the provider on the single POST /v1/collections call and the customer approves it on their handset prompt alone — Kenya, Rwanda, Uganda and Ghana alike. There is nothing to collect and nothing to post back.
The ladder behind it is retained, and can be re-enabled per corridor through configuration. If a corridor is configured to demand a code, 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 a pin_id.
Finishing a charge that came back requires_action
Finishing a charge that came back requires_action
Collect the code in your UI and submit it against the transaction’s reference. This call validates the code and charges. There is no separate authorize step upstream: authorizing re-sends the charge with the code attached, so a correct code debits the customer immediately. Expect
pin_id comes from the next_action on the charge; pin is what the customer typed.Authorize with the customer's code
pending here too, and treat the webhook as the answer. A wrong code fails this call without failing the transaction — prompt again and retry.You must resupply source. Re-sending the charge needs the payer’s full number, and the gateway never stores one — only its last four digits, so a leaked database cannot become a list of customer phone numbers. The last4 of what you send is checked against the transaction, so a reference cannot be redirected to a different payer. Amount, currency and country come from the stored transaction and cannot be changed here.Requesting an OTP yourself
Requesting an OTP yourself
You almost certainly do not need this.
POST /v1/collections/otp triggers an SMS one-time PIN for a mobile money collection and is kept for corridors configured to demand a code, and for deployments on an un-whitelisted account.When you do need it: call it first, show the user an “enter the code we texted you” field, then pass what they type back on the charge. It returns a pin_id; send it with the code as otp: {pin_id, pin} — on POST /v1/collections for a fresh charge, or on the authorize endpoint for one already sitting at requires_action.No money moves, so no Idempotency-Key. Calling it again simply sends another code.The double-charge guard
Before charging, the gateway checks whether a mobile money charge to this same number is already live for your tenant within the last 15 minutes. If one is, it asks the provider what actually happened.
This is separate from
Idempotency-Key. That protects a retried API call; this protects a retried customer action, which arrives with a brand-new key.
Phone numbers
Send the number in whatever form the customer typed. All of0772123456, 256772123456, +256772123456 and the bare national 772123456 are accepted, with spaces, dashes and parentheses ignored.
The gateway normalises to E.164 with a leading + using the country code of the resolved corridor, so a missing country code is supplied rather than guessed from the digits. On a collection, source.country is optional and defaults to the request’s top-level country.
Errors worth handling
Full list and response shape: Errors.
Next steps
Send a payout
The money-out side, and how float and fees work.
Test your integration
Test keys, the amount ceiling, and the Postman collection.