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.
| Check | How |
|---|---|
| Did the payer open the page | authorization_url is single use, and expires with the payment |
| Did the prompt arrive | The operator sends it, not Wajub. The hosted page shows the USSD code as a fallback |
| Is it simply too recent | Operators 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.
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.
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.
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.
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 suffix | Outcome |
|---|---|
000000 | Succeeds |
000001 | Fails with insufficient_funds |
000002 | Fails |
000003 | Times out |
000004 | Cancelled by the payer |
000009 | Payment succeeds, any refund on it fails |