Skip to content

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.

RoutePublic keyPrivate key
/paymentsAcceptedAccepted
/transfers, /balance, /refunds, /disputes, /beneficiaries, /accounts406Accepted

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_reasonWhat happenedWhat to do
invalid_recipientThe beneficiary is missing, or has no payout channelFix the beneficiary, then create a new transfer
no_providerNo provider is eligible for that channel and amount right nowRetry later, or pay on another channel
provider_rejectedEvery eligible provider refused the payoutRead 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.

What a payout throttle answers
{
"code": 429,
"status": "Too Many Requests",
"message": "Too Many requests. Merchant limit : 360",
"retry_after": 24
}

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.

Ask the transfer, not the recipient
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.

What did you think of this content?