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.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.

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.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.

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.

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

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.

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
network_errorTransient. One retry is reasonable
provider_errorThe 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.

What a settled refund carries
{
"id": "ref_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?