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 stays valid until the payment ends, and can be reopened: treat it as a secret |
| 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.xxxxxxxxxxxxxxxx…" \
-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
A payment carries no failure code today: failure_reason is absent from the payment and from the
payment.failed webhook. What tells the cases apart is status.
curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
-H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
| jq '.transaction | { status, channel, amount }'status | What happened |
|---|---|
failed | The operator refused the charge |
expired | The payer never confirmed in time |
cancelled | You called DELETE /payments/{id}, or the payer pressed Cancel on the hosted page |
When the refusal comes at the moment of the charge, the code is in the answer to
POST /payments/{id} instead: a 402 whose error_code names it, insufficient_funds for
instance, and the payment goes back to pending, ready to be charged again. For the operator's
own words after the fact, open the payment in Konsole:
its timeline shows each attempt and the raw provider response.
Failure reasons covers the codes transfers and refunds do carry.
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. By default the SDK waits 30 seconds per attempt and retries up to three times with the same key, so the connection error comes after about two minutes, and it 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.
If the retry arrives while the first request is still running, the API answers 409 with
errors.idempotency_key: "A request with this Idempotency-Key is still being processed." Nothing
failed: the first request's answer is on its way. The server SDKs wait and ask again with the same
key, after 2 then 4 seconds (then 8 in Node.js), and return the original payment (from Node.js
and C# 2.0.0, PHP 2.0.1, Ruby 1.3.0 and 1.2.1 of the others). With raw HTTP, wait a few seconds and send it again. Any
other 409 is a real refusal.
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. The two share no data, 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 a single charge above it is refused. Above 500 000 XAF or
XOF, or the equivalent in another currency, the charge answers 422, in live and in sandbox. Pay it
in tranches with POST /payments/{id}/splits: the payment then sits in
partial while some but not all of it is captured, and only reaches succeeded when the last
tranche lands or when you close it. Treat partial as "not paid yet", never as a 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 | The charge answers 422 for insufficient funds, the payment stays pending |
000002 | Fails |
000003 | Times out |
000004 | Cancelled by the payer |
000009 | Payment succeeds, any refund on it fails |