Skip to content

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.

GEThttps://api.wajub.com/payments/{id}/refunds
curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx/refunds \
  -H "Authorization: sk_test.xxxxxxxxxxxxxxxx…"

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 XAF). (the amount is written with a comma for thousands and the currency's own decimals: 2,000.00 EUR). 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.

ReasonUse it when
requested_by_customerThey changed their mind, nothing went wrong
duplicateThey were charged twice for the same thing
fraudulentThe payment was not made by the account holder
product_not_receivedGoods never arrived
service_not_deliveredA service was paid for and not performed
wrong_amountYou charged the wrong figure
merchant_errorAnything else that was your mistake
network_errorThe payment succeeded but your side failed to record it
transaction_errorA technical failure during the payment itself
customer_complaintGoodwill, after a complaint you accepted
reconciliationAn accounting correction
POSThttps://api.wajub.com/refunds
curl https://api.wajub.com/refunds \
  -H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
  -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.

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 always returns the original refund, a year later as well, and the same key with another amount is refused. It never comes free again, so number the refunds of an order: refund-4172-1, refund-4172-2.

3. Tell the customer the truth, which is "on its way"

The response comes back 201 Created. A live refund is born pending; a sandbox refund, like the one below, is born processing. Nothing has moved yet.

Response · 201 Created
{
"code": 201,
"status": "Created",
"message": "Refund created successfully",
"refund": {
"id": "rfd_test_9xh0XQ5wu2lxCSUGajfv",
"transaction": "trx_test_CSUGajfv9xh0XQ5wu2lx",
"reference": "order-4172-partial",
"amount": 2000,
"currency": "XAF",
"status": "processing",
"reason": "product_not_received",
"sandbox": true,
"created_at": "2026-09-13T09:12:00+00:00"
}
}

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.

EventWhat your worker does with it
refund.createdLog it. Useful in an audit trail, never a trigger
refund.succeededMark your record settled, close the ticket, notify the customer
refund.failedRead data.failure_reason, reopen the ticket, do not retry automatically

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. A failed refund always carries one, from the same list in production and in sandbox.

failure_reasonWhat it usually means
insufficient_fundsThe provider float could not cover it. Retry later, once
transaction_already_reversedSomeone already refunded it. Check the Dashboard before acting
refund_rejected_by_issuerCard issuer refusal. The customer has to be paid another way
invalid_numberThe payer's number or account cannot receive money. Ask the customer for another way to pay them back
limit_exceededA limit on the receiving account or at the provider. Retry later, once, or pay them another way
network_errorTransient. One retry is reasonable
provider_errorThe provider is unhappy. Wait, then retry once
provider_declinedThe provider refused it without saying why. Read failure_message, then decide
ops_failedWajub could not complete the refund, for no known reason. A refund the provider refused ends with the provider's reason instead. failure_message says why

invalid_number, limit_exceeded and ops_failed only happen in production. Sandbox draws its failures from the others.

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. Once a succeeded refund has taken its amount from your balance, it carries the balance before and after, in the balance's own currency: the difference is what the refund took, the payment fee's share included when it moved with it. refund.succeeded is sent once that is done, so the webhook carries the snapshot as a read does. When the balance write is retried, the webhook can come a few minutes after the refund's status turned succeeded. In sandbox it is your sandbox balance's.

What a settled refund carries
{
"id": "rfd_9xh0XQ5wu2lxCSUGajfv",
"transaction": "trx_CSUGajfv9xh0XQ5wu2lx",
"reference": "order-4172-partial",
"amount": 2000,
"currency": "XAF",
"status": "succeeded",
"ledger_balance_currency": "XAF",
"ledger_balance_before": 412500,
"ledger_balance_after": 410500
}

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.

NumberPaymentRefund
+237670000000SucceedsSucceeds
+237670000009SucceedsAlways 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.

What did you think of this content?