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

  1. 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.
  2. 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.
  3. They approve or they do not. The network tells us which.
  4. We tell you, by webhook. A signed payment.succeeded or payment.failed hits your endpoint, usually within a few seconds of the customer tapping.
  5. 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

StatusWhat happenedWhat you do
pendingWe asked the network. The customer has a prompt, or is about to.Wait. Show the customer a “confirm on your phone” screen.
succeededThe customer approved and the money is ours to owe you.Fulfil the order. This is final.
failedDeclined, timed out, wrong PIN, insufficient balance, or the network refused.Tell the customer, offer to try again. You are charged nothing.
cancelledThe 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

TypicalWorst case
API responseunder 500ms30s timeout
Customer approves5–40 secondsThey never do
Webhook reaches youwithin 5s of approvalretried for ~24h
Funds withdrawable24 hours after success24 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

Create the payment, store the id against your order, show the customer a waiting screen, and let the webhook decide what happens next. Treat succeeded as the only good news, make your webhook handler idempotent, and you have a correct integration.