Handle refunds
Build a support flow that gives money back safely, follows it, and reconciles it.
Issuing a refund is one API call, and Refunds documents it field by field. What that page cannot tell you is when your support agent is allowed to press the button, what the customer should see in the meantime, and what to do when the money does not come back.
That is this guide. Refunds are the operation where a mistake costs you real money, so the order of the steps matters more than usual.
A refund is a separate object with its own fate
The mental model people arrive with is wrong in a way that causes bugs. A refund is not a state the
payment moves into. It is its own record, with its own id, its own status and its own webhooks, and
it can fail while the payment it refunds stays succeeded forever.
So there are two things to track, not one. The payment answers "did they pay". The refund answers
"did we give it back", and the second question can be answered no long after you told the customer
yes.
1. Work out what is still refundable
You may refund the full amount, or part of it, or several parts of it until the original amount is exhausted. The number you are allowed to send is the payment amount minus everything already refunded.
The subtlety is that pending refunds count. A refund that has been recorded but not yet settled already reserves its share, which is what stops two support agents refunding the same money twice.
Do not compute that total from your own tables alone. Ask Wajub, which knows about refunds issued from the Dashboard too.
https://api.wajub.com/payments/{id}/refundscurl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx/refunds \
-H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"Show that figure to your agent before they type anything. Most refund incidents are not API errors, they are someone refunding 5 000 on an order that had already been refunded 3 000.
The API checks this too, and tells you why it refused
If you get the arithmetic wrong, POST /refunds answers 422 with a message naming the bound, for
example Refund amount exceeds refundable amount (2 000.00 XAF). Surface that message to the agent
verbatim rather than a generic failure.
2. Create the refund
The refund names the payment by its Wajub id. Your own order reference is not accepted here,
because the lookup runs against the payment id and nothing else.
reason is required and comes from a closed list. It is not free text, and picking the honest value
matters later when you are reading a month of refunds and trying to understand them.
| Reason | Use it when |
|---|---|
requested_by_customer | They changed their mind, nothing went wrong |
duplicate | They were charged twice for the same thing |
fraudulent | The payment was not made by the account holder |
product_not_received | Goods never arrived |
service_not_delivered | A service was paid for and not performed |
wrong_amount | You charged the wrong figure |
merchant_error | Anything else that was your mistake |
network_error | The payment succeeded but your side failed to record it |
transaction_error | A technical failure during the payment itself |
customer_complaint | Goodwill, after a complaint you accepted |
reconciliation | An accounting correction |
https://api.wajub.com/refundscurl https://api.wajub.com/refunds \
-H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
-H "Idempotency-Key: refund-order-4172-1" \
-H "Content-Type: application/json" \
-d '{
"payment": "trx_test_CSUGajfv9xh0XQ5wu2lx",
"amount": 2000,
"reason": "product_not_received",
"reference": "order-4172-partial"
}'Omit amount entirely for a full refund of whatever is left. Wajub computes the remainder itself,
which is safer than sending a figure you calculated a moment earlier.
This endpoint needs your secret key
POST /refunds sits behind the private-key guard. A public key is refused with 406 Not Acceptable
and the message Private Key Required. There is no version of this call that belongs in a browser
or a mobile app.
The idempotency key is what protects you from an agent double-clicking. Derive it from the refund attempt, not from the order, since one order can legitimately have several refunds. The same key returns the original refund for twenty-four hours; the same key with a different payload is refused.
3. Tell the customer the truth, which is "on its way"
The response comes back 201 Created with the refund pending. Nothing has moved yet.
Note the field name: the payment is under transaction, not payment. The reference is the one
you sent, so use it to find your own record again.
Crediting a Mobile Money wallet takes anywhere from a few minutes to a few hours depending on the operator. Say that. A support message promising an instant refund generates a second contact from the same customer twenty minutes later.
4. Follow it to its outcome
Three webhooks close the story, and they are the only reliable way to know. They arrive on the same endpoint you already built in Accept a payment, so there is nothing new to verify: only a branch to add.
The data of each is the refund itself. That means data.id is the refund, data.transaction is
the payment it belongs to, and data.reference is the value you set.
| Event | What your worker does with it |
|---|---|
refund.created | Log it. Useful in an audit trail, never a trigger |
refund.succeeded | Mark your record settled, close the ticket, notify the customer |
refund.failed | Read data.failure_reason, reopen the ticket, do not retry automatically |
Do not mark your own record refunded before the webhook
A failed refund that your database thinks succeeded is an amount nobody will ever chase. The only
moment a refund is real is refund.succeeded.
5. When a refund fails
A failed refund is not a retry loop. The money is still with you, the customer is still owed it, and something in the path refused. Automating another attempt usually just fails again at the same place.
Put it in front of a person, with the reason attached.
failure_reason | What it usually means |
|---|---|
insufficient_funds | The provider float could not cover it. Retry later, once |
transaction_already_reversed | Someone already refunded it. Check the Dashboard before acting |
refund_rejected_by_issuer | Card issuer refusal. The customer has to be paid another way |
network_error | Transient. One retry is reasonable |
provider_error | The provider is unhappy. Wait, then retry once |
A retry is a new refund with a new idempotency key, exactly like a retried payment. Reusing the key of the failed attempt returns that failed refund and nothing happens.
6. Reconcile at the end of the month
Two things make refunds match your books without a spreadsheet archaeology session.
The first is reference. Set it on every refund, with a value that points back at your own record,
exactly as you do on payments. It is returned on every read and it is the only field of yours that
survives the round trip.
The second is the ledger snapshot on each refund. A succeeded refund carries the balance before and after it was applied, in the balance's own currency.
Those two figures let you reconstruct the order of operations on your balance without guessing from timestamps, which is what you actually need when two refunds settle in the same second.
7. Prove your failure handling works
Sandbox has a number built for exactly this. A payment made from a number ending in 000009 succeeds
normally, and every refund against it fails.
| Number | Payment | Refund |
|---|---|---|
+237670000000 | Succeeds | Succeeds |
+237670000009 | Succeeds | Always fails |
So pay with +237670000009, refund it, and watch your refund.failed branch run for real. It is the
only reliable way to test the path that matters most, and it is the path nobody ever exercises before
production.
Related pages