Skip to content

Transfer reference

The transfer object, the four calls, and what decides whether it executes.

A transfer is money leaving your balance for someone else's Mobile Money wallet. You give Wajub an amount and a recipient, Wajub reserves the funds, picks a route, and hands the payout to an operator. Transfers & Payouts walks through the section as a whole, what a payout costs and what has to be true before one executes. This page is the reference for the object itself.

Payouts need a secret key. A publishable key gets 406 Private Key Required before any validation runs, and a browser is refused earlier still. A restricted key works too, as long as its permissions cover transfers.

The four calls

There is nothing else. A transfer cannot be updated, cancelled or deleted through the API.

CallWhat it does
GET /transfersList and filter your payouts
POST /transfersCreate one, and start it immediately
POST /transfers/batchCreate up to 500 in one call, each line started on its own
GET /transfers/{id}Retrieve one

{id} is the po_ string the API returns. In sandbox it carries a po_test_ prefix instead, which is how you tell the two environments apart in a log.

The transfer object

This is what the three single-transfer calls return, and what each accepted batch line carries under transfer. beneficiary and payment_method are loaded on every one of them, so they are always present, a beneficiary deleted since included. provider is too, except on a sandbox payout at creation: it is null until the sandbox provider takes it, then system (Wajub).

Response · a payout that went through
{
"id": "po_CSUGajfv9xh0XQ5wu2lx",
"reference": "supplier-892",
"provider_reference": "MP260912.1431.A47201",
"amount": 10000,
"currency": "XAF",
"description": "Supplier payment #892",
"reason": null,
"metadata": {
"vendor_id": "v_892",
"reservation": {
"amount": 10000,
"currency": "XAF",
"fee_estimate": 100,
"reserved_at": "2026-09-12T14:31:02+00:00"
}
},
"ledger_balance_currency": null,
"sandbox": false,
"status": "succeeded",
"complete": true,
"source": "api",
"provider": {
"slug": "mtn_momo",
"name": "MTN MoMo"
},
"beneficiary": {
"id": "ben_7Kq2mX9vL4tRb3nP8sZc",
"name": "Aminata Diallo",
"phone": "+237670000000",
"email": null
},
"payment_method": {
"id": "pm_3Nq8wR2kL5vT7yD4hB1e",
"account_number": null,
"phone": "+237670000000"
},
"succeeded_at": "2026-09-12T14:31:44+00:00",
"created_at": "2026-09-12T14:31:02+00:00",
"updated_at": "2026-09-12T14:31:44+00:00"
}

A few of those fields do not behave the way their name suggests, and one of them is a trap.

idstringoptional
The identifier for every other call. po_ in live, po_test_ in sandbox.
referencestringoptional
Your own identifier, as you sent it on creation, or the line reference on a batch. null when you sent none.
provider_referencestringoptional
The operator's own identifier, filled in once the payout reaches it. Null before that. This is what the recipient's operator support desk asks for.
amountnumberoptional
In the major unit of the currency. 10000 is ten thousand francs, not a hundred.
statusenumoptional
processing, review, succeeded, failed or cancelled in live. pending lasts only an instant in sandbox; in live, only an older Dashboard payout still awaiting your confirmation reads it, until you confirm or reject it in the Dashboard. cancelled, a payout stopped before it was sent, from API version 2026-10-12: failed before.
completebooleanoptional
True only on succeeded. A failed payout is finished and still reports false, so it tells you the outcome, not whether the payout is over.
sourceenumoptional
api for anything your integration created, dashboard or recurring for a payout started from the Konsole.
metadataobjectoptional
Yours, plus what Wajub writes into it. See below.
failure_reasonstringoptional
Present only when status is failed, alongside failure_message. Absent on every other status, rather than null.
ledger_balance_currencystringoptional
The sandbox wallet that was debited. Null on a live payout, where the wallet actually drawn from is recorded in metadata.settlement instead.
beneficiaryobjectoptional
id, name, phone, email and deleted. A beneficiary deleted after the payout stays here with deleted: true, and its statement stays readable.

reference is yours, and it is not unique

POST /transfers stores the reference you send, up to 255 characters, and returns it on every read and every webhook. Wajub never checks it for uniqueness: two payouts can carry the same reference, so match on id when you need one payout for sure. Searching the list does not look at it either, only at the po_ id and the description.

Wajub writes into metadata as well as reading it. A live payout on the Wajub rail gains a reservation object recording what was held and when, and a settlement object when the funds came out of a wallet in another currency. Read your own keys by name rather than assuming you own the whole object.

Creating one

POSThttps://api.wajub.com/transfers
beneficiarystring | objectrequired
A saved beneficiary's id, or the recipient's details inline. The two forms are detailed below.
amountnumberrequired
In the major unit, at least 0.01 and within the bounds set for the currency.
currencystringoptionaldefault : XAF
Three letters, and an active currency. Required from API version 2026-10-12. Before it, a payout without currency goes out in XAF, not in your account currency: always send it.
descriptionstringoptional
Free text, 255 characters. Searchable in both environments.
reasonstringoptional
A second free-text field, 255 characters. Stored and returned, used for nothing else.
referencestringoptional
Up to 255 characters. Stored and returned, never checked for uniqueness.
metadataobjectoptional
Free-form keys of your own, returned on every read.

The recipient goes in beneficiary, and there are several ways to name them. Pick the case that matches what you hold.

You holdSend in beneficiaryCase
A beneficiary you saved earlierIts id, as a string: "ben_…"A saved beneficiary
The payee's details, for a first payoutAn object: name, channel, phone or account_numberA new recipient
Those details, for someone you already savedThe same objectSomeone already saved
A Cameroonian number, but not its operatorThe object, with channel: "cm.mobile"A number without its operator
A Wajub account numberThe object, with channel: "wajub"Another Wajub account

Whatever the case, every payout needs its own Idempotency-Key: a saved beneficiary is a destination, not a guard against paying twice.

A saved beneficiary

Send the id as a string. Nothing else about the payout changes.

Pay a saved beneficiary
{
"beneficiary": "ben_7Kq2mX9vL4tRb3nP8sZc",
"amount": 15000,
"currency": "XAF",
"description": "September retainer"
}

Only the id is matched, in the environment of your key: a sandbox beneficiary does not exist for a live key. A phone number or an e-mail address is not matched, even though they identify the beneficiary elsewhere on the API. Each refusal is a 422 with an error_code:

Whenerror_codeMessage
No beneficiary of yours has that id (a phone number or an e-mail included), or it was deletedbeneficiary_not_foundThe selected beneficiary is invalid.
It is deactivatedbeneficiary_inactiveThis beneficiary is deactivated. Reactivate it, or pay another beneficiary.
It was added from the Konsolebeneficiary_not_allowedThis beneficiary was added from the Konsole dashboard and cannot be paid out via the API.

The last one is deliberate: a leaked key must not be able to pay a destination your integration never created.

A new recipient, created by the payout

Send the payee's details as an object, and the beneficiary is created as part of the call. That is what you want for a first payout, or for someone you pay once: one call instead of two. The fields are the beneficiary's own, and Beneficiaries documents them in full.

beneficiary.namestringrequired
The payee, as it should appear on the payout.
beneficiary.channelstringrequired
The channel of the operator that serves the number, such as cm.mtn or ke.mpesa. In Cameroon only, cm.mobile resolves to the operator from the number itself.
beneficiary.phonestringoptional
Required unless you send account_number.
beneficiary.account_numberstringoptional
The other half of that pair. One of the two has to be there.
beneficiary.emailstringoptional
Contact address. Nothing is sent to it.
beneficiary.country_codestringoptional
Two letters. Defaults to the first two of the channel slug.
beneficiary.notestringoptional
Free text, 500 characters.
curl https://api.wajub.com/transfers \
  -H "Authorization: sk.xxxxxxxxxxxxxxxx…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: payout-892" \
  -d '{
    "amount": 10000,
    "currency": "XAF",
    "description": "Supplier payment #892",
    "metadata": { "vendor_id": "v_892" },
    "beneficiary": {
      "name": "Aminata Diallo",
      "channel": "cm.mtn",
      "phone": "+237670000000"
    }
  }'

The answer is 201 Created, and the transfer's beneficiary.id is the new beneficiary's. It is a normal one: it shows up in GET /beneficiaries, beneficiary.created is sent for it, and the next payout to that person can send this id instead of the details.

A recipient you already saved, sent inline again

Sending the details of someone you already saved does not create a second beneficiary. When an active beneficiary of yours already uses that number on that channel, the payout goes to it: the transfer's beneficiary.id is the existing one, and the name, email and note you just sent are ignored, not written over the saved ones. To change them, use PUT /beneficiaries/{id}.

A number without its operator

A payout names its channel, and Cameroon is the one exception: when you hold a Cameroonian number but not its operator, send cm.mobile with phone. The operator, cm.mtn or cm.orange, is worked out from the number: in live from the country's numbering plan, in sandbox from the test prefixes. A number that matches neither is refused with a 422 on channel and phone, rather than sent to a guess.

Let the number pick the operator
{
"amount": 10000,
"currency": "XAF",
"beneficiary": {
"name": "Aminata Diallo",
"channel": "cm.mobile",
"phone": "+237670000000"
}
}

No other country has a generic channel: ke.mobile, eg.mobile or any other {country}.mobile is refused as an unknown channel. Name the operator's channel, such as ke.mpesa or eg.vodafone; Payout providers lists them. In Cameroon too, name the operator when you know it: cm.mobile is for a number and nothing else.

Another Wajub account

To pay someone who has a Wajub account, send channel: "wajub" and their 11-digit account number in account_number. The money goes from your balance to theirs at once, with no network in between, and the payout is free. The account has to exist, cannot be your own, and cannot be one Wajub has frozen. Paying another Wajub account has the details.

Pay another Wajub account
{
"amount": 25000,
"currency": "XAF",
"beneficiary": {
"name": "Boutique Ada",
"channel": "wajub",
"account_number": "12345678901"
}
}

Create first, pay later

You can also create the beneficiary on its own with POST /beneficiaries, then pay it by id. That is two calls instead of one, and it is worth it when you want to:

  • check the name the operator holds for a wallet before any money moves, with POST /beneficiaries/{id}/verify;
  • show the payee in your own interface, or have someone approve them, before the first payout.

What the API cannot pay

  • A beneficiary added from the Konsole, a payout account included. Pay it from the Dashboard.
  • A bank account. The API creates Mobile Money, phone-addressed wallet (such as Djamo) and Wajub account beneficiaries only. A bank payout goes to a wire payout account, created and paid from the Dashboard.
  • Another platform's or the merchant's own beneficiary, through X-Sync. A platform paying for a connected account pays only the beneficiaries of that connection.
  • An inline recipient in a batch. Each line of POST /transfers/batch names a saved beneficiary by id; the object form is for single payouts.

Amounts are in the major unit

A transfer's amount is in the currency's major unit and accepts decimals: 10000 sends ten thousand francs. This is the opposite of an invoice, whose lines are whole units of the currency, so do not carry a conversion from one to the other.

Each currency has a floor and a ceiling, checked before anything else happens. Cross either one and the answer is 422 naming both bounds.

CurrencyMinimumMaximum
XAF XOF252 000 000
NGN100999 999.99
RWF500999 999 999
UGX1 000999 999 999
GHS KES USD EUR0.50999 999.99

These are the platform's bounds for a single call, and the rest of the ceilings are yours: the daily cap you set yourself, the limit tied to your KYC tier, and the operator's own per-transfer ceiling. Transfers & Payouts lists the whole sequence.

Idempotency is optional, and it is your only protection

Send an Idempotency-Key header and a retry of the same call returns the original transfer instead of sending the money twice. Leave it out and nothing stops a duplicate: there is no window, no deduplication on amount, and no cancel to fall back on.

The key is at most 128 characters of letters, digits, and . _ : -. Anything else answers 422 on the format alone. It is remembered for good, per account and per environment: a key that created a transfer always returns that transfer, and never sends a second one.

Payout creation carries its own rate limit on top of the one your plan sets: 20 calls a minute per account and environment, regardless of plan, shared by POST /transfers, POST /transfers/batch, POST /accounts/{id}/payouts, POST /beneficiaries, POST /beneficiaries/{id}/receivables and POST /beneficiaries/{id}/receivables/{receivable}/settle. A loop that creates a beneficiary and then pays it counts twice.

Under heavy concurrent load, a live payout can be refused with 429, error_code: payout_queue_busy and a Retry-After header. Nothing was created: send it again after that delay, with the same Idempotency-Key. A batch line in that case is rejected with the same code and can be sent again with the batch.

What comes back, and in what state

A live payout is created as processing and is already on its way, or as review when a safeguard holds it, never as pending: in live, only an older Dashboard payout still awaiting your confirmation reads pending. Confirmed, it is checked like a new payout, its funds are held, and it may be held for review (review) on the same grounds as a new payout, or leaves as processing; rejected, it ends cancelled (failed before API version 2026-10-12). In sandbox it lasts only an instant, so the response you get back already says processing, and so does the transfer.created webhook. A payout you create is never pending when you read it.

transfer.created fires on every payout at creation, including one held for review. Statuses & webhooks covers the five events and what to do in each handler.

A payout stopped before anything was sent, rejected in the Dashboard or cancelled by Wajub, ends cancelled, with merchant_rejected or cancelled_by_wajub as its failure_reason. That status came with API version 2026-10-12: earlier versions read it as failed. A payout Wajub refuses after a review ends failed, with admin_rejected.

Why a live payout is held

A live payout that trips any of these is created as review instead of processing. Its funds are reserved, no operator has seen it, and it waits for Wajub to release or refuse it.

Held becauseWhat it means
It is one of your first payoutsYour account's first live payouts may be held for review, so plan them
The amount is above a platform review thresholdLarge payouts are reviewed before they leave
The amount crosses your own thresholdThe approval amount set in Transfer security settings
You asked to confirm your payoutsThe confirmation setting, minus whatever your auto-approve amount exempts
The amount is unusual for youThe amount is unusual for your account
Wajub restricted your accountA restriction such as manual approval or a payout delay holds every live payout, whatever its amount, until it is lifted

A held payout eventually leaves review in one of two directions. Released, it moves to processing and continues as normal. Refused, it becomes failed with failure_reason: admin_rejected and the reservation goes back to your available balance.

Sandbox payouts are never held. This is the one behaviour you cannot rehearse before going live, which is why your first live payout deserves a person watching it rather than an unattended script.

When it fails

A failed payout carries failure_reason, a short code, and failure_message, a sentence. Both are absent on any other status, so read them behind a status check rather than assuming the keys exist.

Wajub writes the code itself when the payout never reached an operator. Once it has, the code is whatever that operator returned, or the literal failed when it returned nothing usable. Treat the field as a label to log and to show a human, not as an enum to branch on.

failure_reasonWhat happened
invalid_recipientThe destination could not be found, or the beneficiary lost its channel
no_providerNo payout route was available for that channel and currency
provider_rejectedThe operator refused the payout outright, and said why in failure_message
team_restrictedA payout restriction landed on your account between creation and execution
admin_rejectedA held payout was refused during review
failed, or an operator's own codeThe payout reached an operator and came back failed

Sandbox is the exception: its vocabulary is fixed at failure, network_error and invalid_recipient, one per test number.

On a Wajub-rail payout, a failure releases the reservation: the principal and the fee estimate both return to available, and nothing was charged. You are free to create the payout again, under a new idempotency key.

Testing it

Sandbox payouts draw on your sandbox balance and resolve themselves about two seconds later. The outcome is not random: it is decided by the recipient's number, which must be a recognised test number or the call is refused outright.

NumberOutcome
+237670000000succeeded
+237670000001Refused at creation, 422, the transfer is never created
+237670000002failed, reason failure
+237670000003failed, reason network_error
+237670000004failed, reason invalid_recipient

The suffix is what matters, not the prefix, so the same five endings work behind any operator prefix the sandbox knows: +2250700000002 fails the same way +237670000002 does. Leading zeros are tolerated between the prefix and the suffix.

Insufficient funds is not a failed payout

…0001 models a recipient whose wallet cannot receive the money, and the sandbox answers 422 before creating anything. There is no transfer to retrieve and no webhook. If you are testing a failure handler, use …0002 instead.

Sandbox payouts are charged a 2 % platform fee on top of the amount, and it is not given back when the payout fails. That rate is the sandbox's own and does not match live pricing.

Listing

GEThttps://api.wajub.com/transfers
statusenumoptional
pending, processing, succeeded, failed or cancelled. Not review. Before API version 2026-10-12, failed also returns the cancelled payouts, which that version reads as failed.
searchstringoptional
Matches the id and the description. In live mode it also reaches reason and the beneficiary's name and phone number.
per_pageintegeroptionaldefault : 25
Between 1 and 100.
cursorstringoptional
The next_cursor of a previous page. Sending it switches the whole response to cursor mode.

There is no date filter and no amount filter, so a reconciliation job walks the list and does its own narrowing on created_at.

curl -G https://api.wajub.com/transfers \
  -H "Authorization: sk.xxxxxxxxxxxxxxxx…" \
  -d status=failed \
  -d per_page=50

The list is sorted by creation date, newest first, and you cannot change that. It is paginated by page number by default, with meta carrying current_page, last_page, per_page and total, plus the next_cursor and has_more that let you go on with a cursor. Pass that next_cursor as cursor and meta changes shape, to per_page, next_cursor, prev_cursor and has_more. Pick one of the two and write your loop against it, because a handler that reads last_page breaks the moment someone adds a cursor.

Retrieving one

GEThttps://api.wajub.com/transfers/{id}

Only the po_ id resolves. A transfer that belongs to another account, or to the other environment, answers 404 Transfer Not Found rather than telling you it exists somewhere else.

curl https://api.wajub.com/transfers/po_CSUGajfv9xh0XQ5wu2lx \
  -H "Authorization: sk.xxxxxxxxxxxxxxxx…"

Retrieving is for reconciliation and for support, not for following a payout. Wait on the webhook instead: a payout that reaches an operator can sit there for minutes, and polling it teaches you nothing the event would not have told you first.

What did you think of this content?