Transfer reference
The transfer object, the four calls, and what decides whether it executes.
A transfer sends money from your balance to someone else's wallet. This page is the reference for the object and its calls; Transfers & Payouts covers what a payout costs and what has to be true before one executes.
Payouts need a secret key, or a restricted key whose permissions cover transfers. A publishable key
gets 406 Private Key Required. A secret key used from a browser is refused; a restricted key used
from one sets off a security alert, and will be refused the same way later
(private keys in the browser).
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 always present, a beneficiary deleted since
included.
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, or pending for a Dashboard payout awaiting your confirmation, until you confirm or reject it in the Dashboard. cancelled is a payout stopped before it was sent.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.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 adds keys of its own to metadata, such as reservation. 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.currencystringrequireddescriptionstringoptionalreasonstringoptionalreferencestringoptionalmetadataobjectoptionalThe 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. 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, and there is no cancel to
fall back on.
The key is at most 128 characters of letters, digits, and . _ : -; anything else answers 422. It
is spent for good, per account and per environment: a key that created a transfer always returns
that transfer, and never sends a second one.
One key, one payout
Reusing a key with a different body answers 422 This Idempotency-Key was already used with a different request payload., or returns the original transfer without sending anything. Derive
the key 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 payout is created as processing and is already on its way, or as review when a safeguard holds
it. A payout you create is never pending when you read it: only a Dashboard payout awaiting your
confirmation reads pending. Confirmed, it is checked like a new payout and may be
held for review (review) or leave as processing; rejected, it ends
cancelled.
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. 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 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 |
| Wajub's own checks | Wajub can hold any payout for review before it leaves |
| 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
The list cannot be filtered on review: 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.
Once a payout has reached an operator, the code can be the operator's own. 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.
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.searchstringoptionalper_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.