Skip to content

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.

StatusWhat it meansYour money
pendingIn 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 DashboardReserved in sandbox. An older live one holds nothing until you confirm it
reviewHeld by Wajub before execution while a safeguard is checkedReserved
processingHanded to the operator. Where an API payout startsReserved
succeededThe recipient has the money, and complete becomes trueDebited, fee charged
failedIt did not arrive, and failure_reason says whyReleased, nothing charged
cancelledStopped before anything was sent, from API version 2026-10-12Released, 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 reachesYou receive
processing, at creationtransfer.created, then transfer.processing
reviewtransfer.created, and then nothing
processing, after a hold is approvedtransfer.processing
succeededtransfer.succeeded
failedtransfer.failed
cancelledtransfer.cancelled, once 2026-10-12 is the current API version. transfer.failed until then
pending, in sandboxNothing 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

  1. transfer.createdstatus: processing
    14:31:02

    The payout is recorded and your balance is reserved. Nothing has left the account yet.

  2. transfer.processingstatus: processingMTN MoMo
    14:31:02

    Handed to MTN MoMo. The same status as the event before it, because nothing about the payout changed, only who is holding it.

  3. transfer.succeededstatus: succeeded
    14:31:06

    The 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

  1. transfer.createdstatus: processing
    14:31:02

    Identical to the successful payout. Nothing here predicts the outcome.

  2. transfer.processingstatus: processingMTN MoMo
    14:31:02

    Still identical. The operator has it and has not answered yet.

  3. transfer.failedstatus: failed
    14:31:09

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

The failure keys, inside data
{
"status": "failed",
"complete": false,
"failure_reason": "provider_rejected",
"failure_message": "Recipient account is barred for incoming transfers."
}

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.

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.

transfer.succeeded
{
"id": "evt_9Lm4tQ7wRc2vZ8kN5pXbT3jH",
"event": "transfer.succeeded",
"livemode": true,
"created": "2026-09-12T14:31:06+00:00",
"api_version": "2026-10-11",
"pending_webhooks": 1,
"request": {
"id": null,
"idempotency_key": "idem_zR4mK8vQ2pL7tB3nY6hC9dF1jS5aG0eU"
},
"data": {
"id": "po_9Kdm2LpXv4tRb7nQ3sZc",
"reference": "supplier-892",
"provider_reference": "MP260912.1431.B84213",
"amount": 10000,
"currency": "XAF",
"description": "Supplier payment #892",
"reason": null,
"metadata": {
"invoice": "INV-2026-0892",
"reservation": {
"amount": 10000,
"currency": "XAF",
"fee_estimate": 100,
"reserved_at": "2026-09-12T14:31:02+00:00"
}
},
"sandbox": false,
"status": "succeeded",
"complete": true,
"source": "api",
"succeeded_at": "2026-09-12T14:31:06+00:00",
"created_at": "2026-09-12T14:31:02+00:00",
"updated_at": "2026-09-12T14:31:06+00:00",
"provider": {
"slug": "mtn_momo",
"name": "MTN MoMo"
},
"beneficiary": {
"id": "ben_7Kq2mX9vL4tRb3nP8sZc",
"name": "Aminata Diallo",
"phone": "+237670000000",
"email": null
},
"payment_method": {
"id": "pm_3Nq8wR2kL5vT7bY4hC6d",
"account_number": null,
"phone": "+237670000000"
}
}
}

The envelope carries seven keys, and two of them are not what their names suggest.

idstringoptional
The event id, evt_ in live and evt_test_ in sandbox. Your deduplication key, see below.
eventstringoptional
The event name. This field is called event, not type.
dataobjectoptional
The transfer object itself, not wrapped in anything.
livemodebooleanoptional
false for a sandbox payout. The one field that tells the two apart.
api_versionstringoptional
The version that shaped data. Pinning an older one changes which keys appear.
pending_webhooksnumberoptional
How many of your endpoints this event was queued for. Not a retry counter.
requestobjectoptional
Present for shape only. Both keys are useless to you, see below.

One of those seven is worth its own warning, because getting it wrong produces no error anywhere.

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 mistakeWhat it costs you
Trusting the order events arrive inDeliveries are queued and retried independently. transfer.succeeded can land before transfer.processing. Read the state from data.status, never from the sequence
Not deduplicatingThe 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 answeringThe 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.

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.

GEThttps://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.

What did you think of this content?