Statuses & webhooks
What a payout's status means, which events fire, and how to handle them.
201 Created is a receipt for a request, not a confirmation that anyone was paid. Between that
response and the money landing on a phone there is an operator, a queue, and occasionally a person.
The status is what tells you where a payout is. The webhook is how you learn it moved. This page covers what each status means for your money, which events fire and which do not, and what a handler has to do to still be correct after the third duplicate delivery.
The six statuses
Three of them describe a payout that is still alive and holding your money. The other three are terminal, and nothing moves a payout out of them.
| Status | What it means | Your money |
|---|---|---|
pending | In sandbox, for the instant before the simulated operator takes it. In live, only an older Dashboard payout still awaiting your confirmation, which you confirm or reject in the Dashboard | Reserved in sandbox. An older live one holds nothing 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, from API version 2026-10-12 | 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.
cancelled arrived with API version 2026-10-12. A call pinned to an earlier version reads such a
payout as failed, with the same failure_reason, and finds it with status=failed.
Which event fires, and which does not
Five events exist for transfers. They 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.cancelled, once 2026-10-12 is the current API version. transfer.failed until then |
pending, in sandbox | Nothing at all |
A webhook is shaped for Wajub's current API version, not for the one you pin. Until 2026-10-12
becomes the current version, a cancelled payout is announced as transfer.failed, with
status: failed.
transfer.created always carries processing or review, never pending.
The three sequences you will actually see
It works
Four seconds, 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 Wajub writes itself and explains why the
rest come straight from the operator. 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. Minutes, or hours. The payout
is waiting on a person at Wajub, and no event marks the wait because the payout was created inside
it.
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 seven keys, and two of them are not what their names suggest.
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. Pinning an older one changes which keys appear.pending_webhooksnumberoptionalrequestobjectoptionalOne of those seven 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.
request will disappoint you. request.id is never populated, and request.idempotency_key is an
identifier generated for the event itself, unrelated to the Idempotency-Key you sent with
POST /transfers. 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: on a live Wajub-rail payout, Wajub adds
a reservation object, and a settlement object when the payout crossed a currency. 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 queued and 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, and it is fixed. In live, it is the operator's own time, which is usually seconds and occasionally minutes.
When the operator's callback never arrives at all, Wajub keeps checking with the operator. A payout
resolved that way sends the same
transfer.succeeded or transfer.failed as any other, just later. Your handler cannot tell the
difference, and should not try.
Nothing times out on its own
A payout can stay processing for a long time, and Wajub follows it up. 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 the retry schedule.
- Receive webhooks locallyForward real events to localhost while you build the handler.