How a payment actually works
Mobile money is asynchronous, and pretending otherwise is how integrations lose money. Five minutes here will save you a bad week later.
The sequence
- You create the payment. One POST with an amount and a phone number. We answer in well under a second with a transaction in
pending. - The customer gets a prompt. MTN or Airtel pushes a PIN prompt to their handset. This is out of our hands and out of yours: they might approve it in four seconds, or notice it after lunch, or never.
- They approve or they do not. The network tells us which.
- We tell you, by webhook. A signed
payment.succeededorpayment.failedhits your endpoint, usually within a few seconds of the customer tapping. - You fulfil the order. On the webhook. Not before.
Pending is not paid
Read this one twice
The response to POST /v1/payments is a 201 with status pending. It means we accepted the instruction and the network has been asked. It does not mean anybody paid anything.
If you ship a customer’s goods on that 201, you will ship goods to everyone who declines a prompt, and the people who work that out will tell their friends.
There is exactly one signal that money moved: status === "succeeded", delivered by a webhook you verified or read back from GET /v1/payments/:id. Nothing else counts: not a 201, not a 200, not a provider reference appearing on the object.
What the statuses mean
| Status | What happened | What you do |
|---|---|---|
pending | We asked the network. The customer has a prompt, or is about to. | Wait. Show the customer a “confirm on your phone” screen. |
succeeded | The customer approved and the money is ours to owe you. | Fulfil the order. This is final. |
failed | Declined, timed out, wrong PIN, insufficient balance, or the network refused. | Tell the customer, offer to try again. You are charged nothing. |
cancelled | The customer actively dismissed the prompt. | Same as failed, but worth different copy, because they chose this. |
Statuses only move one way
pending becomes succeeded, failed or cancelled, and then never changes again. A succeeded payment does not later become failed. If you need to undo one, that is a refund, which is a new transaction pointing at the old one.How long it takes
| Typical | Worst case | |
|---|---|---|
| API response | under 500ms | 30s timeout |
| Customer approves | 5–40 seconds | They never do |
| Webhook reaches you | within 5s of approval | retried for ~24h |
| Funds withdrawable | 24 hours after success | 24 hours after success |
A prompt a customer ignores does not stay open forever: the network expires it, and we mark the payment failed when it does. You do not have to time anything out yourself.
If you never hear back
Webhooks are the fast path, not the only path. If your endpoint was down, or you are reconciling this morning’s orders, ask directly:
GET /v1/payments/0f8c2b7e-1a45-4c31-9d02-6e8f1b2c3d44
{
"success": true,
"payload": {
"id": "0f8c2b7e-1a45-4c31-9d02-6e8f1b2c3d44",
"status": "succeeded",
"amount": 5000,
"fee": 195,
"currency": "RWF",
"network": "mtn",
"completed_at": "2026-09-12T09:14:37Z"
},
"timestamp": "2026-09-12T09:20:00.000Z"
}The record is authoritative and permanent. Poll it if you must: once every few seconds while a customer is watching a spinner is fine, every 200ms for an hour is not. And a payment stuck in pending long after the prompt should have expired is something we chase on our side too: a background reconciler re-asks the network about every open transaction, so nothing is left unresolved because a single callback went missing.
The one-paragraph version
succeeded as the only good news, make your webhook handler idempotent, and you have a correct integration.