Skip to content

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.

StatusWhat it meansYour money
pendingA Dashboard payout awaiting your confirmation, which you confirm or reject in the DashboardNothing held 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 sentReleased, 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 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.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

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

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,
"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 these keys.

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.
pending_webhooksnumberoptional
How many of your endpoints this event is delivered to. Not a retry counter.

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

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 mistakeWhat it costs you
Trusting the order events arrive inDeliveries are 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. 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.

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?