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:

  1. the financier's rail override, set on /admin/financiers/[id];
  2. the default rail set on /admin/settlement-gateway;
  3. EDOMX_DISBURSE_API (MPESA or PESI);
  4. 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

  1. Apply migrations with npm run migrate.
  2. Set PESI_HMAC_SECRET in .env to any non-empty value. The bridge signs every PESI request and the simulator signs every callback with it — the simulator needs it too.
  3. Leave PESI_MODE unset or set it to simulator, and check the resolved environment on /admin/simulators/pesi.
  4. On the same page, set the simulator wallet balance and run Test connectivity.
  5. Make PESI the rail: set the default rail on /admin/settlement-gateway, or override it for one financier.
  6. 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:

  1. the PESI rail override, set on /admin/simulators/pesi (choosing live needs you to type LIVE);
  2. the global settlement environment on /admin/disbursement-accounts;
  3. PESI_MODE;
  4. simulator.
EnvironmentWhere the bridge sends PESI calls
simulatorThe base URL saved on the admin page, if any; otherwise <this deployment>/v1/_sim/pesi.
sandbox / liveThe 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

VariableWhat it's forDefault
PESI_MODEsimulator, 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_SECRETSigning secret for requests and callbacks. Required in every environment, including the simulator; with it empty, dispatches fail as rail_unreachable.—
PESI_DISBURSEMENT_URLFallback base URL for sandbox/live when the admin page has none.—
PESI_SOURCE_ACCOUNTFallback source account when neither the environment settings nor the PESI system account has one.—
PESI_CALLBACK_URLOverrides the callback URL sent with each disbursement./v1/_callbacks/pesi/disbursement-result
PESI_SIM_CALLBACK_DELAY_MSDelay before the simulator's callback.1500
PESI_SIM_MAX_CALLBACK_ATTEMPTSHow many times the recovery sweep re-fires a stalled callback before leaving it for an admin.5
EDOMX_DISBURSE_APIDefault 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

EndpointBehaviour
POST /v1/_sim/pesi/hakikishaAccount-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/paybill
POST /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/accountDetailsThe simulator wallet's available balance, in whole KES.
POST /v1/_sim/pesi/replay-stalled-callbacksAdmin-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_MS the simulator posts the result to the request's callback_url, signed the same way: COMPLETED with Status_code "AC100" and an External_transaction_id on success, or AC400 on an injected failure. A PROCESSING callback 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 merchant order.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 to PESI_SIM_MAX_CALLBACK_ATTEMPTS times.

Failure injection

Prefix the id to force a failure branch:

PrefixOn partnerTransactionId (disburse)On accountNumber (hakikisha)
FAIL_400_HTTP 400 bad requestHTTP 400 bad request
FAIL_NAME_HTTP 422 — receiver name does not matchHTTP 200, success: false ("No match found")
FAIL_500_HTTP 500 server errorHTTP 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

SymptomCause / fix
Every PESI dispatch fails with rail_unreachablePESI_HMAC_SECRET is empty — set it, even for the simulator.
Callbacks are rejected with 401In 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-PesaCheck, in order: the financier's rail override, the default rail on /admin/settlement-gateway, then EDOMX_DISBURSE_API.
A retry is rejected with AC409The retry reused a partnerTransactionId. Use the gateway's retry action, which issues a new one.
Callbacks stall and never recoverThe 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.