Transfers
Refused before sending, failed on the operator side, or throttled in a payout loop.
A transfer moves your money out, so it is guarded more tightly than a payment on every axis: the key that may call it, the rate at which you may call it, and the balance behind it. Most transfer problems are one of those three guards doing its job.
406 Private Key Required
You sent a public key. Payouts, along with balance, refunds, disputes, beneficiaries and Sync connections, accept a private key only.
| Route | Public key | Private key |
|---|---|---|
/payments | Accepted | Accepted |
/transfers, /balance, /refunds, /disputes, /beneficiaries, /accounts | 406 | Accepted |
Note the code. It is 406, not 403, so a branch written against 403 to catch a wrong key never
fires here.
403 This API key does not have permission to write transfer
You sent a restricted key without the scope. Payouts need transfer.write, and the read routes need
transfer.read. Scopes freeze when the key is created, so widening one means creating a new key.
The transfer failed immediately
Read failure_reason on the transfer object, not reason: reason is the label you supplied.
Three codes cover most of it.
failure_reason | What happened | What to do |
|---|---|---|
invalid_recipient | The beneficiary is missing, or has no payout channel | Fix the beneficiary, then create a new transfer |
no_provider | No provider is eligible for that channel and amount right now | Retry later, or pay on another channel |
provider_rejected | Every eligible provider refused the payout | Read failure_message, fix, then create a new one |
When a provider refuses, check first that the number belongs to the operator of the channel you
named. A Cameroonian number starting +23767 is MTN and +23769 is Orange.
Phone numbers has the prefix table, and
Failure reasons the other codes.
A balance too low for the payout is not a failed transfer: POST /transfers answers 422 with
error_code: insufficient_balance and nothing is created. Its message reads Insufficient available balance for this payout. (and 1 more error): the sentence is set on both amount and
balance, and the suffix counts the second one. The same suffix ends a refusal for want of a
provider and the sandbox insufficient-funds number. Compare error_code, or read errors, never the
whole message. Timing is usually the
cause. A collection that reads succeeded is not yet spendable: it becomes available after
settlement. Your balance has an available figure and a pending figure, and a payout draws on
available only.
I cannot cancel a transfer
There is no cancel endpoint. POST /transfers creates it, GET /transfers and
GET /transfers/{id} read it, and that is the whole surface. Once created, a transfer runs to
succeeded or failed, or cancelled when it is cancelled before it was sent (API version
2026-10-12 on; earlier versions read it failed). A payout the operator cancels on its side ends
failed.
This is deliberate: a payout executes with no human in the loop, and a cancel window would be a window for taking money back after the recipient has been credited. Validate before you send.
429 in a payout loop
Payouts carry a ceiling of their own, on top of the four general ones: 20 per minute per team and
environment, flat, independent of your plan, shared by payouts, batches, Sync payouts and
beneficiary creation. A loop over a list of beneficiaries will meet it well before it meets the team
quota. A live payout that waited more than ten seconds behind another payout of the account answers
429 too, with error_code: payout_queue_busy and Retry-After: nothing was created, send it
again.
The 429 for that ceiling arrives without the type field that the general ceilings set, so a
handler that reads type blindly will fail on the very response it needs to read.
Space the loop, honour retry_after, and keep batch payouts away from any hour where a person might
be waiting on one. Rate limiting covers the pattern.
Paying many people at once
POST /transfers/batch sends up to 500 payouts in one call, and counts once
against the 20 per minute ceiling whatever its size. It also counts once in the
hourly velocity count of payout calls, and a new
batch is refused whole with 422 when the hour has no place left. Each line adds its amount to the hourly
amount and fails on its own: the response lists every line with its status, and a rejected line
carries a message.
After a timeout or a partial failure, send the whole batch again with the same Idempotency-Key.
Only the lines that did not go through leave, the others come back as they already are, even when
the hour has no place left: then only the lines still to send are rejected, with the velocity
message, and the same key sends them later. Never resend under a new key a batch you are not sure
failed: every line would be paid again. A line whose reference was already used for a different
payout is rejected rather than sent. The Dashboard has
no CSV upload.
The recipient says they received nothing
Before suspecting the transfer, check what it reads.
curl https://api.wajub.com/transfers/po_9xh0XQ5wu2lxCSUGajfv \
-H "Authorization: sk.xxxxxxxxxxxxxxxx…" \
| jq '.transfer | { status, failure_reason, amount, currency, created_at }'succeeded means the operator confirmed the credit. At that point the money is on the recipient's
side and the question belongs to them or to their operator, not to Wajub. processing means it is
still in flight, which on Mobile Money is usually under two minutes but can stretch when a network
is congested.