Simulator · settlement rail
M-Pesa simulator
Daraja-shaped local endpoints for B2B payment, Account Balance, C2B confirmation and
account validation. Run the whole merchant-payout loop — FIN virtual account debit,
B2B send from the float paybill, asynchronous result callback, order.paid —
without a Daraja account or a real shortcode.
What it is
A local mock of the M-Pesa Daraja API, one of the two settlement rails Vuka Bridge uses to pay
merchants (the other is PESI). When the M-Pesa rail
resolves to the simulator environment, the bridge sends its Daraja calls to /v1/_sim/mpesa/* on the same deployment instead of sandbox.safaricom.co.ke or api.safaricom.co.ke.
The simulator keeps its own "M-Pesa universe" in the database:
- Paybills with balances — the Vuka Bridge float paybill (
400200by default) plus any merchant settlement shortcodes you register. - B2B requests and their callback status.
- Transactions — the money that moved between sim paybills.
- C2B events — simulated top-ups into the float paybill.
The simulator is happy-path only: every accepted B2B succeeds. It does not check Initiator or SecurityCredential and needs no OAuth token, so a
successful sim run proves the bridge's payout logic, not Daraja sandbox or production
connectivity.
Quick start
- Apply migrations with
npm run migrate. They create thesim_mpesa_*tables and seed the float paybill400200. On a fresh database, also runnpm run bootstrap:adminso you can sign in to/admin. - Leave
MPESA_MODEunset or set it tosimulator. Confirm the active environment on /admin/disbursement-accounts — it shows what the bridge actually uses (see Environments). - Fund a financier: on /admin/disbursement-accounts, open the FIN virtual accounts and Top up the lender you will test with. In simulator mode the top-up is mirrored onto the sim float paybill.
- Open /sample/mpesa. Check the float paybill's balance and make sure the merchant's settlement shortcode exists as a paybill (create it there if not).
- Run a credit checkout on the merchant simulator with a Vuka Bridge-settled lender such as Shop na Pesi. Watch the B2B request appear in the console's Requests tab and the callback complete about two seconds later.
Environments
The M-Pesa rail runs in one of three environments:
| Environment | Daraja base URL the bridge calls |
|---|---|
simulator | <this deployment>/v1/_sim/mpesa |
sandbox | https://sandbox.safaricom.co.ke/mpesa |
live | https://api.safaricom.co.ke/mpesa |
The environment is resolved at runtime, first match wins (cached for about 30 seconds):
- a per-rail environment override for M-Pesa, if one is set;
- the global settlement environment set on /admin/disbursement-accounts (switching to live needs a second admin's approval);
MPESA_MODEin.env;simulator.
"This deployment" is the bridge's own origin, taken from EDOMX_API_INTERNAL_ORIGIN,
then PUBLIC_APP_ORIGIN, then the request origin.
Note. The sim-paybill mirror (top-ups, payouts and the aggregate reconciliation
check) follows MPESA_MODE only. If you switch environments from the admin console,
keep MPESA_MODE consistent with it on simulator boxes.
Environment variables
| Variable | What it's for | Default / example |
|---|---|---|
MPESA_MODE | simulator, sandbox or live. The starting environment when no admin setting overrides it; unset means simulator. A production box must use live or it refuses to boot. | simulator |
EDOMX_FLOAT_PAYBILL_NUMBER | The Vuka Bridge float paybill: PartyA on every B2B send, and the shortcode that holds every FIN virtual account (see Float-paybill reconciliation). | 400200 |
DARAJA_INITIATOR_NAME | Initiator username sent on B2B requests. The value stored in the credentials custody on /admin/disbursement-accounts wins over this; the simulator ignores it. | (empty in sim mode) |
DARAJA_SECURITY_CREDENTIAL | Pre-encrypted security credential sent on B2B requests. Custody value first, then this; the simulator ignores it. | (empty in sim mode) |
DARAJA_CONSUMER_KEY / DARAJA_CONSUMER_SECRET | Daraja app credentials, kept in custody for sandbox/live. Not used by the simulator. | (empty in sim mode) |
CRON_SECRET | Shared secret every /api/_cron/* endpoint checks against the X-Cron-Secret header. Unset means the crons are open — acceptable on a laptop only. Production refuses to boot without it. | (empty in dev) |
DARAJA_INITIATOR_PASSWORD and RECONCILER_USER_ID may still appear in
older .env files; nothing reads them now.
Copy-paste block for your .env
# ─── M-Pesa rail (Daraja) ──────────────────────────────────────
MPESA_MODE="simulator"
EDOMX_FLOAT_PAYBILL_NUMBER="400200"
# Sandbox / live only — the simulator ignores these.
DARAJA_INITIATOR_NAME=""
DARAJA_SECURITY_CREDENTIAL=""
DARAJA_CONSUMER_KEY=""
DARAJA_CONSUMER_SECRET=""
# ─── Cron auth ─────────────────────────────────────────────────
CRON_SECRET="" Simulator endpoints
| Endpoint | Maps to | Behaviour |
|---|---|---|
POST /v1/_sim/mpesa/b2b/v1/paymentrequest | Daraja B2B | Accepts CommandID BusinessPayBill or BusinessBuyGoods; requires PartyA, PartyB, ResultURL, QueueTimeOutURL and a positive Amount. Returns OriginatorConversationID, ConversationID and ResponseCode "0" at once, then sends the
result callback after the sender paybill's callback_delay_ms (2000 ms by
default). The sender (PartyA) must be an active sim paybill allowed to
send B2B. |
POST /v1/_sim/mpesa/accountbalance/v1/query | Daraja Account Balance | Requires CommandID=AccountBalance, PartyA and ResultURL. Acknowledges at once, then calls back with the paybill's
balance in Daraja's AccountBalance format
(Working Account|KES|…). The bridge uses it for scheduled provider
balance refreshes. |
POST /v1/_sim/mpesa/validate | Account validation (sim-only shape) | Body { shortcode, expected_name_match }. Returns { ok: true, registered_name } for a known shortcode. An unknown
shortcode is registered automatically only when expected_name_match is
given; otherwise the reply is { ok: false, error }. |
POST /v1/_sim/mpesa/c2b/simulate | Simulated C2B payment | Needs a signed-in platform admin (call it with your admin session cookie; no page
calls it). Body { shortcode, amount_cents, sender_phone, sender_name?, account_reference }.
Credits the sim paybill, records the event, and posts a Daraja C2B confirmation to /v1/_callbacks/mpesa/c2b-confirmation, which credits the FIN whose
paybill account reference matches account_reference. |
There is no health route, no C2B validation URL, no transaction-status query and no reversal endpoint — Daraja B2B has no status query, which is why the bridge relies on the result callback.
Callback receivers on the bridge
These are the bridge's own endpoints. M-Pesa (or the simulator) calls them:
| Endpoint | What it does |
|---|---|
POST /v1/_callbacks/mpesa/b2b-result | The B2B result. Always acknowledges with ResultCode 0. On success it
marks the payout delivered and sends the merchant order.paid (for a batch payout, one order.paid per order in
the batch). On failure the payout stays in transit and is retried —
a rail failure is never reversed back to the financier. If the callback beats the
bridge's own bookkeeping it retries the lookup (4 × 250 ms). |
POST /v1/_callbacks/mpesa/b2b-timeout | Daraja queue timeout. The simulator never calls it. |
POST /v1/_callbacks/mpesa/account-balance | Completes a pending provider balance refresh. |
POST /v1/_callbacks/mpesa/c2b-confirmation | Matches BusinessShortCode + BillRefNumber to a financier's
paybill account reference and credits that FIN virtual account. Idempotent on the
M-Pesa receipt; an unknown reference is logged and acknowledged. |
Daraja callbacks are not signed, so the simulator does not sign them either.
Float-paybill reconciliation
One physical paybill — the float paybill — holds the money for every account the bridge keeps on it: each financier's virtual account, the M-Pesa and PESI system accounts, and the Vuka Bridge revenue account. The per-account balances are a virtual ledger on top of that paybill.
The nightly /api/_cron/reconcile-floats run checks two things:
- Per account: each account's cached balance equals the sum of its ledger
rows. Any difference is corrected to the ledger value and raises a
sync_driftalert (critical for the M-Pesa system account, warning otherwise). - Aggregate (simulator only): the sum of every virtual account on the float paybill against the sim paybill's balance:
SUM(fin_float_accounts.cached_balance_cents
WHERE edomx_paybill_number = <float paybill>)
vs sim_mpesa_paybills.balance_cents (shortcode = <float paybill>) The aggregate figure is reported, not corrected. Top-ups move both sides together. A payout moves the virtual side when the financier is debited and the paybill side only when the B2B completes, so payouts in transit — or parked for a batch run — show as aggregate drift until they are delivered. Read it when nothing is in flight.
End-to-end payout flow
An express checkout (Vuka Bridge-settled lender, two-leg express) through to the merchant being paid, with the default settings:
T0 Shopper on the Soko checkout picks Shop na Pesi
T1 Bridge → FIN express_loan/request FIN sends the shopper an OTP
T2 Shopper enters the OTP (222222 on the sims)
T3 Bridge → FIN express_loan/complete FIN verifies the OTP — no loan yet
T4 Bridge checks the FIN virtual account can fund
principal + estimated rail toll + commission
→ otherwise the checkout stops with fin_insufficient_funds; no money moves
T5 Checkout commits; the merchant receives order.confirmed
T6 After the commit (off the shopper's path):
a. reserve the FIN virtual account
b. debit it and open the payout as "in transit"
c. Bridge → FIN express_loan/settle FIN books and cements the loan
d. realtime: dispatch the B2B now batch: park it for the T+1 run
T7 B2B → /v1/_sim/mpesa/b2b/v1/paymentrequest (PartyA = float paybill)
sim replies with a ConversationID at once
T8 After callback_delay_ms the sim:
a. debits the float paybill by amount + charge
b. credits the receiver — only if it is a known sim paybill
c. records a sim_mpesa_transactions row
d. POSTs the Result to /v1/_callbacks/mpesa/b2b-result
T9 b2b-result marks the payout delivered → the merchant receives order.paid - Pay on delivery (m1): the loan is booked at checkout and the payout runs when the delivery is confirmed with the delivery OTP (333333 on the sims); steps T6–T9 then follow.
- Lender-settled (m3): the lender pays the merchant itself; no B2B is sent.
- Batch T+1: merchants on batch settlement (with
MERCHANT_BULK_SETTLEMENT_ENABLEDon) get one aggregate B2B per run from/api/_cron/settlement-run; see /admin/batch-settlements. - Which rail: a per-financier rail override, else the default rail set on /admin/settlement-gateway, else
EDOMX_DISBURSE_API, else M-Pesa.
Testing failure paths
The M-Pesa simulator has no failure injection: every accepted B2B succeeds. The failure rate and response delay fields on /sample/mpesa are stored but have no effect. To exercise rail failures, use the PESI simulator's failure prefixes.
What you can test here:
Insufficient financier funds
Leave a financier's virtual account below the order amount plus toll and commission, then run an
express checkout with that lender. The checkout stops with fin_insufficient_funds before any money moves. Top up the account on /admin/disbursement-accounts and retry.
Callback race
On /sample/mpesa, set the float paybill's callback_delay_ms to 50. The result can now arrive before the bridge
has recorded the dispatch, and b2b-result exercises its retry loop.
Lost callback
Set callback_delay_ms high (for example 60000), run a checkout and
restart the dev server before the timer fires. Sim timers do not survive a restart, so the
payout stays in transit. /api/_cron/sweep-stuck-disbursements flags it, and it
can be inspected and re-processed from /admin/settlement-gateway or /admin/transactions.
Aggregate drift
Run UPDATE sim_mpesa_paybills SET balance_cents = balance_cents - 100000 WHERE shortcode
= '400200' to simulate money leaving the paybill outside the bridge. With nothing in
transit, the next /api/_cron/reconcile-floats reports a non-zero aggregate_drift_cents while every per-account drift stays zero.
Cron endpoints
Each takes the X-Cron-Secret header matched against CRON_SECRET.
Schedule them on whatever runs your jobs; the full list and schedule is in documentation/operations/cron-scheduling.md. The ones that matter for payouts:
| Endpoint | Frequency | What it does |
|---|---|---|
/api/_cron/sweep-float-reservations | Every minute | Expires pending float reservations past their expiry and releases the held balance. |
/api/_cron/sweep-stuck-disbursements | Every 5 min | Flags Vuka Bridge-settled payouts that are stuck. |
/api/_cron/sweep-retryable-settlements | Every 5 min | Retries transient settlement failures. |
/api/_cron/settlement-run | Every 15–30 min from midnight | The T+1 batch loop. Does nothing unless batch settlement is enabled and a merchant is on batch. |
/api/_cron/refresh-3pp-balances | Periodic | Refreshes the rails' provider balances (M-Pesa Account Balance, PESI account details). |
/api/_cron/dispatch-float-alerts | Every 1–5 min | Sends pending float alerts (low balance, drift). |
/api/_cron/reconcile-floats | Nightly | The reconciliation described above. |
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| The B2B call fails with HTTP 500 | The sender paybill (PartyA) is missing, inactive or not allowed to send B2B. Check the float paybill on /sample/mpesa; npm run migrate seeds it, and a top-up in simulator mode re-creates it if it is missing. |
Checkout stops with fin_insufficient_funds | The financier's virtual account cannot fund principal + toll + commission. Top it up on /admin/disbursement-accounts. |
The payout stays in transit; order.paid never arrives | Check the request's callback status in the Requests tab on /sample/mpesa (sim_mpesa_b2b_requests.callback_dispatch_status). If the dev server restarted before the timer fired, the callback is lost — re-process the payout from /admin/settlement-gateway. If the merchant is on batch settlement, the payout waits for the next settlement run. |
| The merchant's paybill balance does not change | The receiver shortcode is not a sim paybill; the sim logs funds lost from sim universe. Create the merchant's settlement shortcode as a paybill on /sample/mpesa. |
| The bridge calls Safaricom instead of the simulator (or the reverse) | An admin setting has overridden MPESA_MODE. Check the active environment on /admin/disbursement-accounts (see Environments); changes take up to 30 seconds to apply. |
| Aggregate drift is never zero | Payouts are in transit or parked for a batch run. Let them deliver, then re-run /api/_cron/reconcile-floats. |
Reference
Consoles
- /sample/mpesa — the simulator console: paybills (balance, callback delay, new paybill), B2B requests, transactions.
- /admin/simulators/mpesa — M-Pesa environment values and the sim float-paybill balance.
- /admin/disbursement-accounts — FIN virtual accounts, top-ups, system rail accounts, credentials custody, the global environment.
- /admin/settlement-gateway — default rail, provider balances, routing, stuck payouts and re-processing.
- /admin/transactions — per-order journey trace.
Schema
Tables sim_mpesa_paybills, sim_mpesa_b2b_requests, sim_mpesa_transactions and sim_mpesa_c2b_events, plus the function mpesa_b2b_charge_cents() for the published B2B tariff. The bridge's side lives in fin_float_accounts, fin_float_reservations, fin_float_transactions and disbursement_delivery (the in-transit
payouts).
Helper libraries
src/lib/server/mpesa.ts— Daraja client and base-URL selectionsrc/lib/server/mpesa-rail.ts— the M-Pesa rail adaptersrc/lib/server/mpesa-tariffs.ts— B2B tariff calculatorsrc/lib/server/disburse-rail.ts— rail selectionsrc/lib/server/settlement-global-config.ts— environment resolutionsrc/lib/server/settlement-method-custody.ts— Daraja credentials custodysrc/lib/server/disbursement-initiator.ts— reserve, debit, cement and dispatchsrc/lib/server/disbursement-float.ts— virtual accounts, ledger, top-ups, reconciliation
Specifications
documentation/requirements/disbursement_float.md— the float schemadocumentation/requirements/disbursement_float_solution.md— the float design