Skip to content

Payments

Stuck on pending, failed without a reason, charged twice, or a 404 on a real id.

Seven symptoms cover almost every payment question. Find yours, and read the cause before the fix: three of these are the same misunderstanding wearing different clothes.

The payment stays pending and nothing happens

A Mobile Money payment waits on a person. Wajub has asked the operator, the operator has pushed a prompt to a phone, and until someone types a PIN there is nothing to report. pending is not a stuck state, it is a waiting state.

What to check, in this order.

CheckHow
Did the payer open the pageauthorization_url is single use, and expires with the payment
Did the prompt arriveThe operator sends it, not Wajub. The hosted page shows the USSD code as a fallback
Is it simply too recentOperators take up to a minute to push a prompt at peak

It will not stay pending forever. Every payment expires on its own and moves to expired.

A payment that lives 30 minutes instead of a day
curl https://api.wajub.com/payments \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "currency": "XAF",
    "email": "buyer@example.com",
    "expires": { "in": 30 }
  }'

The field is expires.in, a nested object, and it counts minutes: from 5 to 43200, which is 30 days, and 1440 by default. Writing expires_in at the top level does nothing at all, the value is ignored and you keep the 24 hour default.

The payment failed and I cannot tell why

The object carries a reason, a stable machine code you can branch on. The human message beside it is the provider's wording and changes without notice.

Read the reason, not the message
curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
  | jq '.transaction | { status, reason, channel, amount }'

Failure reasons lists every code and says which ones a retry can fix. Roughly: insufficient_funds, wrong_pin and authorization_timeout are the payer's to resolve, channel_unavailable and operator_declined are worth one retry, and invalid_number or fraud_blocked will fail identically every time until something changes.

My call timed out, and I do not know if the payment exists

This is the dangerous one, because both answers are plausible and only one of them is safe to act on. The SDK gives up after 30 seconds by default and raises a connection error, which tells you nothing about what the server did.

Do not create a second payment to find out. Send the same request again with the same Idempotency-Key, and Wajub answers with the original payment if there was one, or creates it if there was not.

The retry that cannot double charge
const payment = await wajub.payments.create(params, {
  idempotencyKey: `ORDER-${order.id}`,
});

Without that key the retry is a second charge. Idempotency covers how to build one that survives your process restarting.

The customer was charged twice

Almost always one of two causes.

The first is a retry without an idempotency key, covered just above. The second is a reader who believed reference deduplicates. It does not: reference is a free string with no uniqueness rule anywhere in the API, and sending the same one twice creates two payments that both charge.

If it already happened, refund the duplicate rather than cancelling it. A succeeded payment cannot be cancelled, only refunded, and Refunds is where that lives.

GET /payments/{something} returns 404 on an id I have

Three causes, in descending order of frequency.

You passed your own reference. The endpoint resolves the Wajub uid and nothing else. A payment created with "reference": "ORDER-4172" is not reachable at /payments/ORDER-4172, and answers 404 Payment Not Found. Store the id from the create response next to your order, and look it up with that.

What to keep, and what not to look up with
{
"code": 201,
"status": "Created",
"message": "Payment initiated",
"authorization_url": "https://pay.wajub.com/…",
"transaction": {
"id": "trx_test_CSUGajfv9xh0XQ5wu2lx",
"reference": "ORDER-4172"
}
}

id is the one that resolves. reference is yours, for your own searching.

You are in the wrong environment. A sandbox key cannot see a live payment, and a live key cannot see a sandbox one. They are separate databases, so the answer is 404, not a permission error. The test_ in the id tells you which one you are holding.

The payment belongs to another team. Ids do not cross teams, and the response is the same 404 rather than a 403, on purpose.

The amount is refused with a 422

Three rules, and people trip on the first one coming from another platform.

Amounts are in major units. 5000 means five thousand francs, not fifty. There are no centimes to convert, and XAF and XOF have no subunit at all.

Each currency has bounds. They are enforced server side and the 422 names the field, so read errors.amount rather than guessing. For XAF and XOF the floor is low and the ceiling is high enough that you will normally only meet it by sending a value in the wrong unit.

Mobile Money has its own ceiling, and it does not reject. Above 500 000 XAF or XOF the hosted page collects the amount in tranches instead of one charge. The payment then sits in partial while some but not all of it is captured, and only reaches succeeded when the last tranche lands. Treat partial as "not paid yet", never as a partial fulfilment trigger.

Everything works in sandbox and nothing works live

Two candidates.

Your live keys may exist while your account is not yet activated. Live payments need the KYC review to have passed, and Going live has the checklist.

Or you are using sandbox test numbers against real operators. In sandbox the last six digits of the phone number choose the outcome, and in live they choose a person. +237670000002 fails on demand in sandbox and is somebody's number in production.

Sandbox suffixOutcome
000000Succeeds
000001Fails with insufficient_funds
000002Fails
000003Times out
000004Cancelled by the payer
000009Payment succeeds, any refund on it fails

What did you think of this content?