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.
| Call | What it does |
|---|---|
GET /transfers | List and filter your payouts |
POST /transfers | Create one, and start it immediately |
POST /transfers/batch | Create 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).
A few of those fields do not behave the way their name suggests, and one of them is a trap.
idstringoptionalpo_ in live, po_test_ in sandbox.referencestringoptionalnull when you sent none.provider_referencestringoptionalamountnumberoptional10000 is ten thousand francs, not a hundred.statusenumoptionalprocessing, 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.completebooleanoptionalsucceeded. A failed payout is finished and still reports false, so it tells you the outcome, not whether the payout is over.sourceenumoptionalapi for anything your integration created, dashboard or recurring for a payout started from the Konsole.metadataobjectoptionalfailure_reasonstringoptionalstatus is failed, alongside failure_message. Absent on every other status, rather than null.ledger_balance_currencystringoptionalmetadata.settlement instead.beneficiaryobjectoptionalid, 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
https://api.wajub.com/transfersbeneficiarystring | objectrequiredamountnumberrequired0.01 and within the bounds set for the currency.currencystringoptionaldefault : XAF2026-10-12. Before it, a payout without currency goes out in XAF, not in your account currency: always send it.descriptionstringoptionalreasonstringoptionalreferencestringoptionalmetadataobjectoptionalThe recipient goes in beneficiary, and there are several ways to name them. Pick the case that
matches what you hold.
| You hold | Send in beneficiary | Case |
|---|---|---|
| A beneficiary you saved earlier | Its id, as a string: "ben_…" | A saved beneficiary |
| The payee's details, for a first payout | An object: name, channel, phone or account_number | A new recipient |
| Those details, for someone you already saved | The same object | Someone already saved |
| A Cameroonian number, but not its operator | The object, with channel: "cm.mobile" | A number without its operator |
| A Wajub account number | The 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.
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:
| When | error_code | Message |
|---|---|---|
No beneficiary of yours has that id (a phone number or an e-mail included), or it was deleted | beneficiary_not_found | The selected beneficiary is invalid. |
| It is deactivated | beneficiary_inactive | This beneficiary is deactivated. Reactivate it, or pay another beneficiary. |
| It was added from the Konsole | beneficiary_not_allowed | This 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.namestringrequiredbeneficiary.channelstringrequiredcm.mtn or ke.mpesa. In Cameroon only, cm.mobile resolves to the operator from the number itself.beneficiary.phonestringoptionalaccount_number.beneficiary.account_numberstringoptionalbeneficiary.emailstringoptionalbeneficiary.country_codestringoptionalbeneficiary.notestringoptionalcurl 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.
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.
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
wirepayout 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/batchnames a saved beneficiary byid; 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.
| Currency | Minimum | Maximum |
|---|---|---|
XAF XOF | 25 | 2 000 000 |
NGN | 100 | 999 999.99 |
RWF | 500 | 999 999 999 |
UGX | 1 000 | 999 999 999 |
GHS KES USD EUR | 0.50 | 999 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.
What counts as the same call, and for how long
For 24 hours, reusing a key with a different amount, currency, beneficiary, description,
reason, reference or metadata answers 422 This Idempotency-Key was already used with a different request payload. After that nothing is compared any more: the key answers 201 with
the original transfer, whatever the new body says, and nothing is sent. A key is spent for good.
Derive it from the thing you are paying for, an order line or a payroll run with its period, so
the same key can never describe two different payouts: salary-amina reused next month pays
nobody.
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 because | What it means |
|---|---|
| It is one of your first payouts | Your account's first live payouts may be held for review, so plan them |
| The amount is above a platform review threshold | Large payouts are reviewed before they leave |
| The amount crosses your own threshold | The approval amount set in Transfer security settings |
| You asked to confirm your payouts | The confirmation setting, minus whatever your auto-approve amount exempts |
| The amount is unusual for you | The amount is unusual for your account |
| Wajub restricted your account | A 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.
You cannot list what is under review, and you cannot unblock it
GET /transfers?status=review answers 422: the filter accepts the five other statuses and not
this one. A held payout is only visible by retrieving it, or by walking an unfiltered list. There is
no endpoint to approve, cancel or chase it either. You learn about a hold from transfer.created,
and about its resolution from transfer.processing or transfer.failed. Wait on those rather than
polling.
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_reason | What happened |
|---|---|
invalid_recipient | The destination could not be found, or the beneficiary lost its channel |
no_provider | No payout route was available for that channel and currency |
provider_rejected | The operator refused the payout outright, and said why in failure_message |
team_restricted | A payout restriction landed on your account between creation and execution |
admin_rejected | A held payout was refused during review |
failed, or an operator's own code | The 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.
| Number | Outcome |
|---|---|
+237670000000 | succeeded |
+237670000001 | Refused at creation, 422, the transfer is never created |
+237670000002 | failed, reason failure |
+237670000003 | failed, reason network_error |
+237670000004 | failed, 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
https://api.wajub.com/transfersstatusenumoptionalpending, 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.searchstringoptionalreason and the beneficiary's name and phone number.per_pageintegeroptionaldefault : 25cursorstringoptionalnext_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=50The 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
https://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.
Related pages
- Transfers QuickstartYour first test payout, end to end.
- Providers & channelsEvery channel slug, by country and operator.
- Statuses & webhooksThe five events, and what belongs in each handler.
- BeneficiariesSave a destination once and pay it by id.
- Balance & settlementsWhat is available to send, and what is already reserved.