Simulator · settlement rail
PESI simulator
Local PESI B2B endpoints — account-name lookup, paybill and till disbursement, transfer status and account balance — with signed asynchronous callbacks and failure injection. Test merchant payouts on the PESI rail without PESI credentials.
What it is
PESI is the second settlement rail Vuka Bridge can use to pay merchants, alongside M-Pesa. When the PESI rail resolves to the simulator environment, the bridge sends its PESI calls to /v1/_sim/pesi/* on the same deployment. The simulator keeps a request ledger
(sim_pesi_b2b_requests) and a wallet balance (sim_pesi_config), signs
its callbacks the way PESI does, and — unlike the M-Pesa simulator — can be told to fail.
Whether a payout goes over PESI or M-Pesa is decided per payout, first match wins:
- the financier's rail override, set on
/admin/financiers/[id]; - the default rail set on /admin/settlement-gateway;
EDOMX_DISBURSE_API(MPESAorPESI);- M-Pesa.
Without a financier override, the gateway's routing criteria may prefer the other rail for a given payout (for example when one rail's balance is low).
Quick start
- Apply migrations with
npm run migrate. - Set
PESI_HMAC_SECRETin.envto any non-empty value. The bridge signs every PESI request and the simulator signs every callback with it — the simulator needs it too. - Leave
PESI_MODEunset or set it tosimulator, and check the resolved environment on /admin/simulators/pesi. - On the same page, set the simulator wallet balance and run Test connectivity.
- Make PESI the rail: set the default rail on /admin/settlement-gateway, or override it for one financier.
- Run a credit checkout on the merchant
simulator. The callback arrives about 1.5 seconds after the dispatch, and the merchant
receives
order.paid.
Environments
The PESI environment is resolved the same way as M-Pesa's — first match wins, cached for about 30 seconds:
- the PESI rail override, set on /admin/simulators/pesi (choosing live needs you to type
LIVE); - the global settlement environment on /admin/disbursement-accounts;
PESI_MODE;simulator.
| Environment | Where the bridge sends PESI calls |
|---|---|
simulator | The base URL saved on the admin page, if any; otherwise <this deployment>/v1/_sim/pesi. |
sandbox / live | The base URL, source account, callback URL and HMAC secret saved on that environment's tab of the admin page; then the rail-level credentials custody; then .env. |
Environment variables
| Variable | What it's for | Default |
|---|---|---|
PESI_MODE | simulator, sandbox or live. The starting environment when no admin setting overrides it. It also switches on the simulator's stalled-callback recovery. | simulator |
PESI_HMAC_SECRET | Signing secret for requests and callbacks. Required in every environment, including the simulator; with it empty, dispatches fail as rail_unreachable. | — |
PESI_DISBURSEMENT_URL | Fallback base URL for sandbox/live when the admin page has none. | — |
PESI_SOURCE_ACCOUNT | Fallback source account when neither the environment settings nor the PESI system account has one. | — |
PESI_CALLBACK_URL | Overrides the callback URL sent with each disbursement. | /v1/_callbacks/pesi/disbursement-result |
PESI_SIM_CALLBACK_DELAY_MS | Delay before the simulator's callback. | 1500 |
PESI_SIM_MAX_CALLBACK_ATTEMPTS | How many times the recovery sweep re-fires a stalled callback before leaving it for an admin. | 5 |
EDOMX_DISBURSE_API | Default rail when no admin setting chooses one. | MPESA |
Copy-paste block for your .env
# ─── PESI rail ─────────────────────────────────────────────────
PESI_MODE="simulator"
PESI_HMAC_SECRET="any-non-empty-dev-value"
PESI_SIM_CALLBACK_DELAY_MS="1500"
# Sandbox / live fallbacks (prefer the admin page)
PESI_DISBURSEMENT_URL=""
PESI_SOURCE_ACCOUNT=""
PESI_CALLBACK_URL="" Simulator endpoints
| Endpoint | Behaviour |
|---|---|
POST /v1/_sim/pesi/hakikisha | Account-name lookup. Body { accountType, accountNumber, bankCode? },
where accountType is BK, MB, PL, TL or PP (bankCode required for BK). Returns a made-up but stable accountName such as PAYBILL MERCHANT 5006001. The bridge uses it when a merchant settlement
account is set up. |
POST /v1/_sim/pesi/disburse/b2b/paybillPOST /v1/_sim/pesi/disburse/b2b/till | B2B disbursement. Requires an integer KES sendAmount, receiverFullName, the paybill or till and account, partnerTransactionId, convId, sourceAccount and callback_url. Returns { traceId, transactionId, statusCode: "AC500", success: true } and schedules the callback. A partnerTransactionId that was already used
is rejected with HTTP 409 and statusCode "AC409", as real PESI does —
every retry must use a new id. |
GET /v1/_sim/pesi/queryTransferStatus?partnerTransactionId=… | Transfer status: statusCode "404" when unknown, otherwise "200" with COMPLETED, FAILED or UPLOADED. The bridge uses it to check a payout before any retry. |
GET /v1/_sim/pesi/accountDetails | The simulator wallet's available balance, in whole KES. |
POST /v1/_sim/pesi/replay-stalled-callbacks | Admin-only: re-fires callbacks that never landed, ignoring the attempt cap. |
Callbacks and signing
- Every POST the bridge sends to PESI carries
x-pesi-signature: hex(HMAC-SHA256(PESI_HMAC_SECRET, raw body)). GET requests are unsigned. - After
PESI_SIM_CALLBACK_DELAY_MSthe simulator posts the result to the request'scallback_url, signed the same way:COMPLETEDwithStatus_code "AC100"and anExternal_transaction_idon success, orAC400on an injected failure. APROCESSINGcallback comes first only if the request asked for it. - The bridge's receiver,
/v1/_callbacks/pesi/disbursement-result, rejects a missing or wrong signature with 401. On success it marks the payout delivered and sends the merchantorder.paid; on failure the payout stays in transit for a retry — it is never reversed back to the financier. - The first successful callback for a request debits the simulator wallet; the wallet never goes below zero and the simulator does not refuse for insufficient funds.
- While
PESI_MODE=simulator, callbacks that failed to deliver are re-fired at boot and every 30 seconds, up toPESI_SIM_MAX_CALLBACK_ATTEMPTStimes.
Failure injection
Prefix the id to force a failure branch:
| Prefix | On partnerTransactionId (disburse) | On accountNumber (hakikisha) |
|---|---|---|
FAIL_400_ | HTTP 400 bad request | HTTP 400 bad request |
FAIL_NAME_ | HTTP 422 — receiver name does not match | HTTP 200, success: false ("No match found") |
FAIL_500_ | HTTP 500 server error | HTTP 500 server error |
FAIL_CALLBACK_ | Accepted; the callback never fires | — |
FAIL_AC_ | Accepted; the callback reports AC400 | — |
The bridge sets partnerTransactionId itself, so disbursement prefixes are reached by
calling the simulator directly (for example with curl), not through a checkout.
Testing recovery
Lost callback
Restart the dev server inside the callback delay, or call the simulator directly with a FAIL_CALLBACK_ id. The payout stays in transit; the recovery sweep re-fires stalled
callbacks, and an admin can replay them or check the transfer status from /admin/settlement-gateway.
Retry after a failure
From /admin/settlement-gateway, check the payout's
status first, then retry. A retry always gets a new partnerTransactionId;
re-sending the old one is rejected with 409.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
Every PESI dispatch fails with rail_unreachable | PESI_HMAC_SECRET is empty — set it, even for the simulator. |
| Callbacks are rejected with 401 | In the simulator both sides use the same secret (the rail's credentials custody, then PESI_HMAC_SECRET), so a 401 usually means the PESI environment was switched to sandbox or live while simulator payouts were still in flight — the receiver then verifies with that environment's saved secret. Switch back, or let the payouts settle before switching. |
| Payouts still go over M-Pesa | Check, in order: the financier's rail override, the default rail on /admin/settlement-gateway, then EDOMX_DISBURSE_API. |
A retry is rejected with AC409 | The retry reused a partnerTransactionId. Use the gateway's retry action, which issues a new one. |
| Callbacks stall and never recover | The recovery sweep runs only while PESI_MODE=simulator and stops after PESI_SIM_MAX_CALLBACK_ATTEMPTS. Replay them from the admin side. |
Reference
- /admin/simulators/pesi — resolved environment and its source, environment override, simulator base URL and wallet balance, sandbox/live settings and secrets, connectivity test.
- /admin/settlement-gateway — default rail, provider balances, payout status checks, retries.
src/lib/server/pesi-b2b.ts— the PESI rail adapter;pesi-hmac.ts— signing;pesi-env-config.ts— per-environment settings;sim-pesi-helpers.ts— simulator callbacks and failure injection.documentation/requirements/pesi_disbursement/— the PESI disbursement guide set.