Refunds
Return funds on a succeeded payment, and follow the refund to its own outcome.
A refund is its own object, with its own id, its own status and its own webhooks. It is not a state
the payment moves into. That distinction matters more than it sounds: the payment you refund stays
succeeded for the rest of its life, and everything you want to know about the money coming back
lives on the refund instead.
You can return the full amount or part of it, and stack several partial refunds until the original amount is exhausted.
Refunds require the secret key
POST /refunds requires a secret key. A call carrying a public key is refused with
406 Not Acceptable and the message Private Key Required. This endpoint belongs on your
server, never in a browser or a mobile app.
Two pages go further:
- Create a refund, the call, its parameters and its answer.
- Track a refund, its webhooks, why one fails, how to test a failure, and the list of refunds.
What can be refunded
Four conditions are checked before a refund is recorded, and each one fails with its own 422.
| Condition | What you get back when it fails |
|---|---|
The payment is succeeded | Transaction must be completed to be refunded. Current status: … |
| Something is still refundable | Transaction has already been fully refunded. |
| The amount fits what is left | Refund amount exceeds refundable amount (…) |
| The currency matches the payment | Refunds must be in the transaction currency (XAF). |
The refundable amount is the payment amount minus everything already refunded, counting refunds
that are still pending as well as those that succeeded. A pending refund therefore reserves its
share straight away, which is what stops two concurrent calls from refunding the same money twice.
A refund cannot convert a currency
currency is accepted, but the only value it may hold is the payment's own currency. It exists
so you can be explicit, not so you can refund a XAF payment in XOF. Omit it and Wajub uses the
payment's currency anyway.
The refund has its own lifecycle
Five states, three of them terminal. A refund never returns to an earlier one.
How long that takes is not up to Wajub. On a card the issuer decides, and several days is normal. On Mobile Money the credit usually lands in minutes, sometimes hours. Nothing about your integration changes that, so tell the customer a range rather than a time.
A refund starts in a different state depending on the environment
In production a refund is created pending and moves to processing when it reaches the
provider. In sandbox there is no provider, so it is created processing directly and resolves a
couple of seconds later. Write your handler against the terminal states and you will not notice
the difference; branch on pending and your sandbox tests will never hit that branch.
Who sends a live refund
In production, a refund is sent one of three ways. execution_mode on the refund says which, once
Wajub has planned it, and manual_reason says why when it is not sent automatically.
execution_mode | Who sends it |
|---|---|
automatic | Wajub, back to the payer's mobile money number, within minutes |
merchant | You: the payment was collected on your own provider account, which Wajub cannot refund from |
manual | Wajub support |
manual_reason is an object with a stable code to branch on and a message you can show.
code | What it means |
|---|---|
refund_from_your_provider | Collected on your own provider account: refund it there. It updates here once the provider confirms |
card_or_bank_payment | Automatic refunds cover mobile money. The Wajub team sends this one |
insufficient_balance | Your balance did not cover the refund when it was due to be sent |
payer_number_missing | The payer's mobile money number is not on record |
requested_over_24h | Requested more than a day ago: the Wajub team checks it was not refunded another way first |
outcome_being_confirmed | Sent, and the operator's answer is not final: the Wajub team confirms whether the money arrived |
automatic_attempt_failed | The operator refused it and no money left: Wajub support processes it |
wajub_review | Anything else the Wajub team has to look at first |
A refund can change lane without changing status. One not yet sent stays pending when the Wajub
team takes it over. One already handed to the operator stays processing when the operator refuses
it and Wajub support processes it (automatic_attempt_failed): it does not go back to
pending. Either way it ends succeeded or failed. Each change of lane sends refund.updated
with the refund as it now stands. Sandbox refunds have neither field.
What the payment's status says
It depends on your API version, and this is the part that catches people.
From version 2026-10-11, a payment reads partially_refunded once refunds that succeeded
cover part of its amount, and refunded once they cover all of it. A refund that is pending,
processing or failed changes nothing. On an earlier version the payment keeps reading
succeeded, however much was refunded. Versioning says which one your account
is pinned to.
The status is a reading and nothing else moves with it: no payment event is sent, and the payment
is otherwise untouched. To know what is still refundable, on any version, list the payment's
refunds and add up the ones that are pending, processing or succeeded.
https://api.wajub.com/payments/{id}/refundsSubtract that total from the payment amount and you have what is still refundable, which is exactly the figure the API checks against when you post the next refund.
The status filter follows your version
From 2026-10-11, GET /payments?status=refunded and ?status=partially_refunded list those
payments, and ?status=succeeded no longer includes them. On an earlier version the two filters
are accepted and come back empty, and succeeded lists every payment that succeeded, refunded or
not.
Who bears the fee
Wajub keeps the fee of a payment you refund. What you decide is who ends up bearing it, for the
share of the fee that matches the refunded amount: you, or the customer. That is fees_bearer.
| The fee was paid by | fees_bearer: merchant | fees_bearer: customer |
|---|---|---|
You (bearer: merchant on the payment) | The customer gets the amount back. The fee you paid is not returned to you | The fee share is deducted from what the customer gets back, and you are debited that much less |
The customer, on top (bearer: customer) | The customer gets the amount and the fee share back, and you are debited both | The customer gets the amount back, not the fee |
Left out, the fee stays with whoever paid it, which is the first and the last cell of the table and
how refunds have always worked. To change that for every refund, set it once in Settings,
Refunds; fees_bearer on one refund overrides it.
The refund tells you what was applied: fee_amount is the fee share, and payer_amount what the
customer receives. Your balance is debited payer_amount, never more and never less. A partial
refund takes the same proportion of the fee, and the refund that completes the payment takes what
is left of it, so the shares add up exactly. In a currency with no subunit, such as XAF, a share is
a whole number, so payer_amount is too.
A payment collected on your own provider account is different. Wajub holds none of its money, so
nothing is debited from your balance: you send the refund yourself, from that provider, and the
amount to send is payer_amount. With bearer: merchant Wajub charged no fee on it, so
fee_amount is 0 and payer_amount equals amount. With bearer: customer the payer paid the
fee on top to your provider, and the table above applies to it.
The money moves on success, not on creation
Creating a refund reserves the amount against what is refundable, but touches no balance. The debit
happens when the refund reaches succeeded, and only then, for payer_amount: the amount, give or
take the fee share.
Two consequences worth planning around. In sandbox, a refund that succeeds debits your sandbox balance by the amount refunded, as a live one debits your balance. A refund that pays back a dispute you accepted debits it even when it fails, since the dispute is lost either way. And a refund issued on a payment you already lost to a dispute is capped at whatever principal has not already been reversed, so the same money cannot leave twice through two different doors.
Where the balance goes afterwards, and how a negative one behaves, is covered in Balance and settlements.
In the Dashboard
A refund can be issued without any code. Open a payment under Payments, use its refund action,
and the result is the same object the API creates. The form asks who bears the fee when the payment
carried one, and shows what the customer will receive. The action needs the refund_payments
permission, which is separate from manage_payments, so a teammate can manage orders without being
able to give money back.
Refunds lists them all with their status, and exports to CSV or Excel in production.
Refund requests is the inbound side. A payer using Pocket can ask for a refund on a payment they made, and the request waits there until someone approves or rejects it. Nothing is refunded until it is approved, and approving creates the refund exactly as the API would.
Related pages
- Handling refundsThe full journey, reconciliation included.
- DisputesWhen the money is taken back rather than given back.
- Balance and settlementsWhere a refund lands once it has succeeded.
- Payment lifecycleWhy the payment stays succeeded through all of this.
- Sandbox modeTest numbers and cards, and what each one triggers.
- Refunds API referenceEvery field on the resource, and the three endpoints.