Skip to content

B2C disbursements

Your backend owns customer wallets and their accounting. Malipo stores approved beneficiaries and reserves your available merchant balance for a disbursement; a beneficiary has no Malipo wallet or balance.

Malipo runs two separate environments. Each has its own merchant portal, accounts and API keys, and a key only works against the API of the portal that issued it.

Production Staging (pre-release testing)
API base URL https://api.malipo.dev/v1 https://api-staging.malipo.dev/v1
Where to create keys malipo.dev staging.malipo.dev (separate account)
B2C with an sk_test_ key Available: simulated, no real money Available: simulated, receives new versions first
B2C with an sk_live_ key Not enabled yet Not used

One sk_test_ key covers both collections (C2B charges) and B2C beneficiaries and disbursements in its environment; you do not need a separate B2C key. A malipo.dev key sent to api-staging.malipo.dev (or the reverse) is rejected with 401 unauthorized. If you already integrate payments with a malipo.dev sandbox key, keep using it with https://api.malipo.dev/v1.

The integration version is malipo-node@1.3.0-beta.2, published on npm. Pin this exact version to use beneficiaries, disbursements and testing. The npm latest tag currently points to 1.2.5, which does not include these resources. See the Node.js SDK reference.

Use your regular Malipo server API key, stored as MALIPO_API_KEY: a sandbox key (sk_test_) from the portal of the environment you call. The same key authenticates payments, balances, beneficiaries and disbursements. The example below uses the production API; replace baseUrl with https://api-staging.malipo.dev/v1 only with a staging key. Enable its B2C write permission in the merchant portal under Finance → Disbursements. Existing keys do not gain this permission automatically. Keep the key on your backend.

Install the SDK, then save the complete example below as b2c.mjs. Use Node.js 20.6 or later for the --env-file command.

Terminal window
npm install malipo-node@1.3.0-beta.2
node --env-file=.env b2c.mjs

Set MALIPO_API_KEY in your private .env. Never commit it. The example creates synthetic sandbox data, including a USD 30 charge and USD 20 withdrawal. Charge fees reduce the pending credit; only released funds can be withdrawn. Repeated runs create separate operations.

The special test phone forces a successful charge. No operator receives this sandbox request. The example then releases pending funds, approves a beneficiary and simulates a timeout followed by a late success.

import Malipo from 'malipo-node';
import { randomUUID, randomInt } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
const apiKey = process.env.MALIPO_API_KEY;
if (!apiKey?.startsWith('sk_test_')) throw new Error('A sandbox MALIPO_API_KEY is required');
const client = new Malipo({ apiKey, baseUrl: 'https://api.malipo.dev/v1' });
const run = randomUUID();
const testRecipientPhone = '24384' + String(randomInt(0, 10000000)).padStart(7, '0');
await client.testing.setLimits({ minimum_minor: 1, maximum_minor: 1000000, daily_minor: 5000000, monthly_minor: 100000000 });
// Sandbox test phone: no request is sent to a real operator.
let charge = await client.charges.create({ amount: 30, currency: 'USD', phone: '+243000000001', network: 'ORANGE_MONEY' }, { idempotencyKey: `b2c-funding-${run}` });
for (let attempt = 0; charge.status === 'pending' && attempt < 30; attempt++) {
await delay(1000);
charge = await client.transactions.retrieve(charge.id);
}
if (charge.status !== 'succeeded') throw new Error(`Funding charge ${charge.id}: ${charge.status}`);
await client.testing.release();
await client.testing.screenMerchant('cleared');
const beneficiary = await client.beneficiaries.create({ reference: `user-${run}`, name: 'Sandbox recipient', network: 'ORANGE_MONEY', msisdn: testRecipientPhone });
await client.testing.approveBeneficiary(beneficiary.id);
const params = { beneficiary_id: beneficiary.id, amount: '20.00', currency: 'USD', reference: `withdrawal-${run}` };
await client.testing.setDefaultScenario('timeout');
const payout = await client.disbursements.create(params, { idempotencyKey: `withdrawal-${run}` });
// Replay returns the same operation, not another debit.
const replay = await client.disbursements.create(params, { idempotencyKey: `withdrawal-${run}` });
if (replay.id !== payout.id) throw new Error('Replay returned a different operation');
await client.testing.run();
console.log(await client.disbursements.retrieve(payout.id)); // needs_review; funds remain reserved
await client.testing.result(payout.id, 'succeeded');
console.log(await client.disbursements.list({ reference: params.reference }));
console.log(await client.balance.retrieve());
await client.testing.setDefaultScenario('success');

Persist the reference, request body and idempotency key in your own database before sending a withdrawal. The random identifiers above are for isolated test runs. Reuse the saved identifiers when retrying the same customer withdrawal.

const found = await client.disbursements.list({ reference: savedReference });
const payout = found.data[0];
// If absent, resend the saved request with its original idempotency key.
// If present, retrieve its current status; never create a replacement withdrawal.

An identical key or reference and content returns the existing operation. Different content returns 409 idempotency_conflict. Create amounts are decimal strings such as "20.00" for USD or integer strings such as "1000" for CDF, and idempotency is required. References and keys are at most 128 characters.

SDK method Route
beneficiaries.create/list POST/GET /beneficiaries
beneficiaries.retrieve/update GET/PATCH /beneficiaries/{id}
disbursements.create/list POST/GET /disbursements
disbursements.retrieve GET /disbursements/{id}
disbursements.cancel POST /disbursements/{id}/cancel

Lists support starting_after, page_size (maximum 100) and optional include_total. Disbursements also accept reference and status. Explicit legacy page requests remain supported. Responses contain data and pagination; reference filters stay within the authenticated merchant/key context. Unknown or foreign resources return 404; missing write permission returns 403.

pending reserves funds once. processing means the request was taken up. needs_review retains the reservation while the outcome is uncertain. succeeded never debits twice. Certain failed results or cancelled operations restore funds once. Cancellation succeeds only before worker pickup. Never release a customer reservation just because a request times out.

Test SDK call
Future payout outcome testing.setDefaultScenario('success' | 'rejected' | 'accepted' | 'timeout')
Process this key’s jobs now testing.run()
Late provider confirmation testing.result(id, 'succeeded')
Resolve with simulated evidence testing.resolve(id, 'failed', 'synthetic-proof')
Release pending USD/CDF testing.release()
Merchant or recipient hold testing.holdMerchant(true) / testing.holdBeneficiary(id, true)
Advance business time testing.advanceTime(86400)
Simulate a screening outcome testing.screenMerchant('cleared' | 'blocked') / testing.screenBeneficiary(id, 'cleared' | 'blocked')
Replay an existing event testing.replayWebhook(id, 'payout.succeeded')

Configure the default scenario before creating a withdrawal. accepted stays processing; timeout becomes needs_review. A number change creates a pending beneficiary version; after approval it activates exactly 24 UTC hours later. The old version stays active until then, and existing withdrawals retain their recipient snapshot. sanctions_blocked on a sandbox disbursement means you set the simulated merchant screen to blocked or held the merchant; call testing.screenMerchant('cleared') or testing.holdMerchant(false) and retry. A simulated screen does not expire: screening is a manual compliance review. Business time does not change authentication, webhook timestamps or worker leases.

Balances, beneficiaries, limits, jobs and events are isolated by sandbox key. /testing/* rejects live keys. Sandbox supports Orange Money and M-Pesa simulation in USD; CDF, Airtel and other SDKs are outside this release.

B2C uses payout.*. Select data.object.payout_kind === 'b2c', the expected environment, and data.object.api_key_id for your sandbox key: endpoint subscriptions are merchant-wide.

Require both X-Webhook-Timestamp and X-Webhook-Signature. Call client.webhooks.constructEvent(rawBody, signature, endpointSecret, timestamp) with the unmodified body; signatures expire after five real minutes. See signature verification.

Persist each event ID with a unique constraint and acknowledge only after durable storage. Apply customer wallet effects atomically with deduplication in your database. Replayed events keep their ID. Events can arrive twice or out of order: reconcile with disbursements.retrieve(id), and never replace a terminal status with an older pending or processing event. Return a retryable failure if storage or reconciliation fails. Malipo retries delivery five times with backoff; request a replay after exhaustion.

See the Node.js webhook integration for signature verification and durable event handling. Your application owns event storage and customer wallet accounting.

Live needs a separate approval and operator acceptance: merchant and beneficiary verification, residence, fresh sanctions screening, holds, available balance and shared payout limits all apply. Orange Money is qualified first, then M-Pesa separately. An operator acknowledgement is not proof of payment. Keep your B2C integration on sandbox keys until Malipo enables live disbursements for your merchant.

A published policy is required in both environments. For the remediation sandbox, testing.setLimits(...) explicitly publishes simulated zero-fee policy versions and never changes live policies. Disbursements expose the frozen policy version and the action screening state.

The staging-accepted SDK, 1.3.0-beta.2, adds separate USD/CDF balances, fee quotes and frozen total debits, stable cursor pagination, batches of at most 500 JSON/CSV rows, beneficiary archiving and screening states. The new routes passed staging acceptance on 7 October 2026. Version 1.3.0-beta.2 is published under the npm beta tag; latest remains 1.2.5. Existing routes remain compatible.

Every new live approval, reservation and submission authorization requires a fresh favorable beneficiary screening. A historic clearance never replaces that action’s check. A service outage blocks the new action. Exact replays, reads, operator status queries and late confirmations remain available; uncertain submissions keep funds reserved.

Routes available on staging: POST /v1/disbursements/quote, GET /v1/b2c-balance, POST /v1/disbursements/batches, GET /v1/disbursements/batches/{id} and POST /v1/beneficiaries/{id}/archive. Lists accept starting_after, optional include_total and return pagination.next_cursor/has_more. Existing routes and explicit legacy page remain supported.

USD uses decimal strings; CDF requires integer strings. No currency conversion occurs. Fees and total debit are fixed at reservation and completely returned on admissible cancellation or certain failure. CSV headers are beneficiary_id,reference,amount,currency plus optional idempotency_key. Batches return 202 after persistence and expose per-line results; each line has its own required screening.

Archiving blocks new requests and preserves existing operations. Reads show masked numbers. The two-owner review includes KYC, residence and operator account holder evidence. Live remains disabled until real operator qualification for each merchant/network/currency combination. M-Pesa requires separate qualification.