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 (400200 by 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

  1. Apply migrations with npm run migrate. They create the sim_mpesa_* tables and seed the float paybill 400200. On a fresh database, also run npm run bootstrap:admin so you can sign in to /admin.
  2. Leave MPESA_MODE unset or set it to simulator. Confirm the active environment on /admin/disbursement-accounts — it shows what the bridge actually uses (see Environments).
  3. 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.
  4. 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).
  5. 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:

EnvironmentDaraja base URL the bridge calls
simulator<this deployment>/v1/_sim/mpesa
sandboxhttps://sandbox.safaricom.co.ke/mpesa
livehttps://api.safaricom.co.ke/mpesa

The environment is resolved at runtime, first match wins (cached for about 30 seconds):

  1. a per-rail environment override for M-Pesa, if one is set;
  2. the global settlement environment set on /admin/disbursement-accounts (switching to live needs a second admin's approval);
  3. MPESA_MODE in .env;
  4. 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

VariableWhat it's forDefault / example
MPESA_MODEsimulator, 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_NUMBERThe 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_NAMEInitiator 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_CREDENTIALPre-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_SECRETDaraja app credentials, kept in custody for sandbox/live. Not used by the simulator.(empty in sim mode)
CRON_SECRETShared 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

EndpointMaps toBehaviour
POST /v1/_sim/mpesa/b2b/v1/paymentrequestDaraja B2BAccepts 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/queryDaraja Account BalanceRequires 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/validateAccount 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/simulateSimulated C2B paymentNeeds 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:

EndpointWhat it does
POST /v1/_callbacks/mpesa/b2b-resultThe 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-timeoutDaraja queue timeout. The simulator never calls it.
POST /v1/_callbacks/mpesa/account-balanceCompletes a pending provider balance refresh.
POST /v1/_callbacks/mpesa/c2b-confirmationMatches 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:

  1. 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_drift alert (critical for the M-Pesa system account, warning otherwise).
  2. 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_ENABLED on) 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:

EndpointFrequencyWhat it does
/api/_cron/sweep-float-reservationsEvery minuteExpires pending float reservations past their expiry and releases the held balance.
/api/_cron/sweep-stuck-disbursementsEvery 5 minFlags Vuka Bridge-settled payouts that are stuck.
/api/_cron/sweep-retryable-settlementsEvery 5 minRetries transient settlement failures.
/api/_cron/settlement-runEvery 15–30 min from midnightThe T+1 batch loop. Does nothing unless batch settlement is enabled and a merchant is on batch.
/api/_cron/refresh-3pp-balancesPeriodicRefreshes the rails' provider balances (M-Pesa Account Balance, PESI account details).
/api/_cron/dispatch-float-alertsEvery 1–5 minSends pending float alerts (low balance, drift).
/api/_cron/reconcile-floatsNightlyThe reconciliation described above.

Troubleshooting

SymptomCause / fix
The B2B call fails with HTTP 500The 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_fundsThe financier's virtual account cannot fund principal + toll + commission. Top it up on /admin/disbursement-accounts.
The payout stays in transit; order.paid never arrivesCheck 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 changeThe 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 zeroPayouts are in transit or parked for a batch run. Let them deliver, then re-run /api/_cron/reconcile-floats.

Reference

Consoles

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 selection
  • src/lib/server/mpesa-rail.ts — the M-Pesa rail adapter
  • src/lib/server/mpesa-tariffs.ts — B2B tariff calculator
  • src/lib/server/disburse-rail.ts — rail selection
  • src/lib/server/settlement-global-config.ts — environment resolution
  • src/lib/server/settlement-method-custody.ts — Daraja credentials custody
  • src/lib/server/disbursement-initiator.ts — reserve, debit, cement and dispatch
  • src/lib/server/disbursement-float.ts — virtual accounts, ledger, top-ups, reconciliation

Specifications

  • documentation/requirements/disbursement_float.md — the float schema
  • documentation/requirements/disbursement_float_solution.md — the float design