Statuses & webhooks
What a payout's status means, which events fire, and how to handle them.
A payout's status tells you where it is, and its webhooks tell you when it moved: 201 Created is a
receipt for a request, not a confirmation that anyone was paid. This page covers what each status
means for your money, which events fire, and what a handler has to do.
The six statuses
Three of them describe a payout that is still alive. The other three are terminal, and nothing moves a payout out of them.
| Status | What it means | Your money |
|---|---|---|
pending | A Dashboard payout awaiting your confirmation, which you confirm or reject in the Dashboard | Nothing held until you confirm it |
review | Held by Wajub before execution while a safeguard is checked | Reserved |
processing | Handed to the operator. Where an API payout starts | Reserved |
succeeded | The recipient has the money, and complete becomes true | Debited, fee charged |
failed | It did not arrive, and failure_reason says why | Released, nothing charged |
cancelled | Stopped before anything was sent | Released, nothing charged |
POST /transfers creates a payout as processing, or as review when a safeguard holds it, and
nothing else. A payout stopped before anything was sent ends cancelled: rejected in the
Dashboard, its failure_reason is merchant_rejected; cancelled by Wajub, cancelled_by_wajub.
A payout Wajub's review refuses (admin_rejected) or the operator declines ends failed.
Which event fires, and which does not
The transfer events do not cover all six statuses, and the gaps are the part worth knowing.
| The payout reaches | You receive |
|---|---|
processing, at creation | transfer.created, then transfer.processing |
review | transfer.created, and then nothing |
processing, after a hold is approved | transfer.processing |
succeeded | transfer.succeeded |
failed | transfer.failed |
cancelled | transfer.failed, with status: failed |
A webhook is shaped for Wajub's current API version, not for the one you pin, which is why a cancelled
payout is announced as transfer.failed, with status: failed and the same failure_reason.
transfer.created always carries processing or review, never pending.
The three sequences you will actually see
It works
Three events, and only the last one means anything moved.
A payout that succeeds
- 14:31:02
transfer.createdstatus: processingThe payout is recorded and your balance is reserved. Nothing has left the account yet.
- 14:31:02
transfer.processingstatus: processingMTN MoMoHanded to MTN MoMo. The same status as the event before it, because nothing about the payout changed, only who is holding it.
- 14:31:06
transfer.succeededstatus: succeededThe recipient has the money. The reservation settles, the fee is charged, and three keys appear on the object.
- complete
- true
- succeeded_at
- 2026-09-12T14:31:06+00:00
- provider_reference
- MP260912.1431.B84213
The first two tell you nothing your 201 did not already say. Act on transfer.succeeded and
ignore the rest, unless you are drawing a timeline for a support agent.
It fails
The same two events, then a different third. A failure that happened inside Wajub before any operator saw the payout and a failure the operator itself returned look identical on the wire: same sequence, same timing envelope, same event name.
A payout that fails
- 14:31:02
transfer.createdstatus: processingIdentical to the successful payout. Nothing here predicts the outcome.
- 14:31:02
transfer.processingstatus: processingMTN MoMoStill identical. The operator has it and has not answered yet.
- 14:31:09
transfer.failedstatus: failedThe operator refused it. Your reservation is released, the fee is never charged, and two keys appear that exist on no other status.
Those two keys are the whole of what a failure tells you, and they are worth reading behind a status check rather than assumed present.
failure_reason is a short code to branch on and log, failure_message a sentence to show a human.
Transfer reference lists the codes. On a Wajub-rail payout, transfer.failed is also the moment
your reservation is released, so the principal and the fee estimate return to available before
your handler finishes running.
It is held
transfer.created arrives with status: "review", and then nothing, for minutes or hours, until
Wajub's review ends.
When the hold resolves you get one of two things: transfer.processing, meaning it was approved and
is now running normally, or transfer.failed with failure_reason: admin_rejected.
A review hold is silence, not a delay
A handler that treats "no transfer.processing within a minute" as a fault will page someone at
three in the morning for a process working exactly as designed. Branch on
data.status === 'review' inside your transfer.created handler, tell your own operations team,
and stop the clock.
What an event looks like
Every event, for every product, arrives in the same envelope. The transfer itself is the whole of
data, in the same shape GET /transfers/{uid} returns.
The envelope carries these keys.
idstringoptionalevt_ in live and evt_test_ in sandbox. Your deduplication key, see below.eventstringoptionalevent, not type.dataobjectoptionallivemodebooleanoptionalfalse for a sandbox payout. The one field that tells the two apart.api_versionstringoptionaldata.pending_webhooksnumberoptionalOne of them is worth its own warning, because getting it wrong produces no error anywhere.
Read event, not type
The field naming the event is event. There is no type key on a Wajub envelope. Reaching for
event.type out of habit gives you undefined, a switch that matches nothing, and a handler
that returns 200 while doing absolutely nothing. It is the single most common way this
integration fails silently.
To tie an event back to your own call, use data.id, or send your own reference when you create
the payout: it comes back as data.reference on every event.
Two things about data are worth knowing before you write against it. The beneficiary, the payment
method and the provider are always expanded, so a transfer event tells you where the money went
without a second API call. And metadata is not only yours: Wajub adds keys of its own, such as
reservation. Read the keys you put there, and never assert the exact shape of the whole.
The handler
Three things, in this order: prove the event came from Wajub, answer, then work. Answering before you work is not an optimisation, it is what keeps a slow database from turning into a retry storm.
import express from 'express';
const app = express();
// The client verifies with the secret it was built with: webhookSecret,
// or WAJUB_WEBHOOK_SECRET when you pass none.
app.post('/webhooks/wajub', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = wajub.webhooks.constructEvent(
req.body,
req.headers['x-wajub-signature'],
req.headers['x-wajub-timestamp'],
);
} catch {
return res.status(400).send('invalid signature');
}
res.status(200).end();
queue.push(event);
});Returning 400 on a bad signature is deliberate: a replay of the same bytes would fail the same
way, so there is nothing to gain from a retry. Signature verification
covers the algorithm, the raw-body requirement and the five-minute timestamp window.
The work itself belongs off the request, and it branches on the event name and then on the status inside it.
async function handleTransferEvent(event) {
const transfer = event.data;
if (await alreadyProcessed(event.id)) {
return;
}
switch (event.event) {
case 'transfer.succeeded':
await markSupplierPaid(transfer.id, transfer.amount, transfer.currency);
break;
case 'transfer.failed':
await flagPayoutFailure(transfer.id, transfer.failure_reason);
break;
case 'transfer.created':
if (transfer.status === 'review') {
await notifyOpsPayoutHeld(transfer.id);
}
break;
}
await recordProcessed(event.id);
}transfer.processing is absent on purpose. There is nothing to do on it that is not premature: the
money has not moved, and a handler that ships an order on processing ships it before the payout
was ever attempted.
The four ways this goes wrong
Every one of these produces a handler that passes review, works in staging, and pays somebody twice in production.
| The mistake | What it costs you |
|---|---|
| Trusting the order events arrive in | Deliveries are retried independently. transfer.succeeded can land before transfer.processing. Read the state from data.status, never from the sequence |
| Not deduplicating | The same event can be delivered more than once. Store event.id and check it first |
Returning 410 when you meant "retry me" | 410 Gone is the one answer that ends the delivery permanently. Every other failure, 4xx included, is retried for up to 72 hours |
| Working before answering | The timeout is 10 seconds. A slow handler turns one event into repeated deliveries, and your retries pile up behind your own latency |
Subscribing to everything gives you more than five events
An endpoint subscribed to * sees balance.updated when the reservation settles and
fee.charged when Wajub takes its fee, on top of the five transfer events. That is correct
behaviour, not a duplicate. Subscribe to the five you need unless you have a use for the rest.
How long before you worry
In sandbox, about two seconds. In live, usually seconds and occasionally minutes, and a payout that
takes longer still ends with the same transfer.succeeded or transfer.failed, just later.
Nothing times out on its own
A payout can stay processing for a long time, with nothing for you to do, and your reservation
stays held. There is no status meaning "we gave up", and there is no expiry. Do not
build a timeout that marks the payout failed on your side: a processing payout may still land
on the recipient's phone, and you will have paid them while your books say you did not.
When to read instead of listen
Webhooks are the answer for a payout you are waiting on. Reading is the answer for a payout you are checking up on.
https://api.wajub.com/transfers/{uid}Use it to reconcile on a schedule, to answer a support question, and while you are still building
and have nowhere public for Wajub to reach. Do not use it as a loop around one payout waiting for
succeeded: reading a transfer counts against your team's general limit, 120 requests a minute
on the default plan, and not against the share kept for sending payouts, but a loop still burns it
for every other call your integration needs to make.
For an end-of-day reconciliation, list by period and compare with the events you received. Anything
you hold as processing that Wajub reports terminal is a delivery you missed, and
Konsole shows you every attempt that was made to reach you.
If you are a platform
A payout made on behalf of a connected account is delivered twice: once to that account's own
endpoints, and once to yours. Your copy carries an extra account object naming the merchant it
belongs to, so one endpoint can serve every connected account you have. Sync covers how
those accounts are set up.
Related pages
- Transfer referenceThe object, the statuses in detail, and the failure codes.
- Signature verificationThe algorithm, the raw body, and the timestamp window.
- Retries & orderingWhat counts as a failed delivery, and why an event arrives twice.
- Receive webhooks locallyForward real events to localhost while you build the handler.