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 stays valid until the payment ends, and can be reopened: treat it as a secret
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.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.

Read the status
curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
  -H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
  | jq '.transaction | { status, channel, amount }'
statusWhat happened
failedThe operator refused the charge
expiredThe payer never confirmed in time
cancelledYou 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.

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.

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.

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. 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 suffixOutcome
000000Succeeds
000001The charge answers 422 for insufficient funds, the payment stays pending
000002Fails
000003Times out
000004Cancelled by the payer
000009Payment succeeds, any refund on it fails

What did you think of this content?