Disbursing
Sending money from your balance to a mobile money number.
POST /v1/disburse sends money from your TchokoPay balance to any mobile money number.
Your balance is funded by what you collect.
Disbursing is not withdrawing
| Withdrawal | Disbursement | |
|---|---|---|
| Money goes to | Your own account | Somebody else's number |
| Started from | The dashboard, by a person | Your backend, via the API |
| Where | Dashboard → Withdrawals | POST /v1/disburse |
Moving your own balance to your own number is a withdrawal, and it is not part of the API.
Same currency, always
You pay out of a balance you hold, in the currency you hold it in. There's no conversion at payout time — XAF in your balance goes out as XAF.
If you collect in several currencies you'll have a balance in each, and each has its own limits.
The controls
Because a payout moves real money with no human in the loop, several things bound it. Every one is checked before your balance moves.
These are risk controls, not a budget — they exist so that a mistake, a bug in your own code, or a stolen key stays small.
The key must be allowed to
Disbursing is a separate permission from collecting, chosen when a key is made and never widened afterwards. See Scopes.
The amount must fit your payout limits
A per-payout ceiling, and optionally a rolling daily and weekly one. The per-payout ceiling is the currency's own maximum — the same one that bounds a collection — so there's nothing to set up before your first payout. Daily and weekly ceilings exist only where support has agreed them with you.
Check what is left with
GET /v1/account/disburse-limits/:currency, and
read enabled rather than the status code. High-volume accounts get ceilings to match
their volume.
You must actually have the money
Checked against your live balance at the moment of the write, not a moment before — two payouts racing each other can never both spend the same funds.
Large amounts wait for a person
Above your review threshold, a payout is funded and held rather than dispatched. It
arrives with awaitingReview: true and stays PENDING until somebody releases it.
Idempotency is mandatory
Idempotency-Key is required on POST /v1/disburse, unlike on payments.
The two are not symmetrical. A duplicated charge creates an invoice nobody has to pay — a tidy-up. A duplicated payout sends real money twice and cannot be recalled.
// Derive the key from something stable in YOUR system, not a random value
// per attempt — a retry has to produce the same key or it isn't a retry.
const idempotencyKey = `payout-${yourPayoutInstructionId}`;Send the same key again and you get the original payout back, with its original reference. Nothing moves twice.
When a payout fails
A FAILED payout keeps your money reserved. It does not return automatically.
When a dispatch fails we frequently cannot tell whether the network took the money. Returning funds that may already have gone is the one mistake with no way back — so the money stays put and a person resolves it, either by retrying the rail or returning it explicitly.
You'll get a disburse.failed webhook, and the disbursement will show why in statusReason.
A full example
// `instruction` is whatever your system calls a payment it owes somebody:
// a seller settlement, a user withdrawal, a loan disbursement, a refund.
async function sendPayout(instruction) {
// Fail early rather than half way through a batch.
const limits = await get('/v1/account/disburse-limits/XAF');
if (!limits.enabled) throw new Error(limits.message);
if (limits.remainingToday !== null && limits.remainingToday < instruction.amount) {
throw new Error('Would exceed the daily payout limit');
}
const payout = await post('/v1/disburse', {
amount: instruction.amount,
currency: 'XAF',
phone: instruction.phone,
recipientName: instruction.recipientName,
// Your own id, so you can reconcile without storing ours.
merchantReference: instruction.id,
}, {
// Derived from your id, not random per attempt — a retry has to produce
// the same key or it is not a retry.
'Idempotency-Key': `payout-${instruction.id}`,
});
if (payout.awaitingReview) {
// Funded and queued, but a person has to release it. Do not retry —
// the same key returns this same payout.
await markAwaitingApproval(instruction.id, payout.reference);
return;
}
await markSent(instruction.id, payout.reference);
}Then handle disburse.succeeded and disburse.failed in your webhook endpoint rather than polling
— see Webhooks.