Simulator
Merchant simulator
"Soko Demo Store" — a working online storefront living at /sample/mer.
Category browsing, product pages, cart, checkout on credit (redirect or embedded), webhooks
and settlement — the full Vuka Bridge merchant integration, without standing up a real
merchant.
What it is
A self-contained merchant — catalogue, customers, carts, orders, webhook events and a payments ledger — that plugs into the real Vuka Bridge checkout. Use it to test:
- Shopper journeys through the hosted checkout, in redirect or embedded (iframe) mode
- Whole-basket and asset financing ("Lipa pole pole") with a cart manifest
- Webhook delivery to a merchant endpoint (signed
Vuka-*headers, retries, the event log) - Settlement to the merchant's settlement account (M-Pesa or PESI B2B; realtime or batch T+1) and the merchant's payments ledger
- The catalog API that the Vuka Bridge marketplace and asset financing read
Quick start
- Make sure migrations are applied — the simulator needs the
sim_mer_*tables. Runnpm run migrateif you haven't. - Open /sample/mer. It is the storefront home: the category tree and
featured products. Browse categories (
/sample/mer/c/…) and product pages (/sample/mer/item/…) without signing in. - Click Sign up (a modal on the storefront) — enter any email, phone
(
+254XXXXXXXXX) and password. A customer record is created insim_mer_customers; the session travels as a?sid=URL parameter. - Add products to the cart and click Checkout. Choose Pay with
credit. The sim calls the real
POST /v1/checkout/orderswithSAMPLE_MER_API_KEYas the bearer token, then either redirects to the hosted checkout page or frames it in the page (embedded mode). The default checkout mode and delivery mode — and which pickers the shopper sees — are set in Admin → Integration. - The checkout form has Send cart manifest to credit checkout and Send delivery address to credit checkout toggles (both default ON). With the manifest on, each line's SKU, GTIN, name, quantity and unit price is sent with the order; because the sim's key is a test key, the hosted checkout shows the cart back on a test-mode panel so you can verify what landed. See Cart manifest below.
- Visit /sample/mer/admin to see the order land, the webhook events recorded, and the payment once it settles.
Environment setup
The merchant simulator reads the following values from .env (or from the database —
see the note below). The defaults in .env.example are fine for local development.
| Variable | What it's for | Default / example |
|---|---|---|
SAMPLE_MER_MERCHANT_CHECKOUT_ID | The merchant's stable Vuka Bridge identifier (think PayPal's merchant ID). The display name is resolved from this id via GET /sample/_api/merchant-details — see below. | mch_8f2e91d7c63b4081f5e9c2a3d6b8f0 |
SAMPLE_MER_API_KEY | Server-side API key the sim merchant uses to call Vuka Bridge (create orders, read status, refunds). The simulator is always a test merchant, so its key is a test key. | ek_test_... |
SAMPLE_MER_ADMIN_EMAIL | Bootstrap admin email for /sample/mer/admin/sign-in. | admin@soko.demo |
SAMPLE_MER_ADMIN_PASSWORD | Hashed onto the seeded admin row on first sign-in attempt. Change this for any environment beyond your laptop. | changeme-dev-only |
Resolved from the DB: display_name
The storefront's display name is resolved from the database using merchant_checkout_id as the lookup key (it lives in accounts.display_name), so it doesn't need its own env var:
GET /sample/_api/merchant-details?merchant_checkout_id=mch_8f2e91d7c63b4081f5e9c2a3d6b8f0
200 OK
{
"merchant_checkout_id": "mch_8f2e91d7c63b4081f5e9c2a3d6b8f0",
"display_name": "Soko Demo Store",
"public_key": "pk_test_…",
"activation_status": "submitted"
} public_key is legacy: it was the publishable key for the former JS SDK, which is
retired. Embedded checkout is an iframe of hosted_url and needs no client
key; the field is still returned but nothing uses it.
Copy-paste block for your .env
# ─── Merchant simulator (/sample/mer) ──────────────────────────
# display_name is resolved at runtime from the merchant_checkout_id
# via /sample/_api/merchant-details.
SAMPLE_MER_MERCHANT_CHECKOUT_ID="mch_8f2e91d7c63b4081f5e9c2a3d6b8f0"
SAMPLE_MER_API_KEY="ek_test_..."
SAMPLE_MER_ADMIN_EMAIL="admin@soko.demo"
SAMPLE_MER_ADMIN_PASSWORD="changeme-dev-only" Database-backed sim config. When SAMPLE_MER_CONFIG_SOURCE=db the four values above are read from sim_mer_settings instead of .env; secrets are AES-256-GCM-encrypted at rest. Manage them from /admin/simulators/merchant with one-click mint flows (the minted API key is always an ek_test_… key). Default is env.
Route map
Customer surface (?sid= session)
| Path | Purpose |
|---|---|
/sample/mer | Storefront home — category tree, featured products; sign in / sign up in a modal |
/sample/mer/c/[category] · /sample/mer/c/[category]/[subcategory] | Category and sub-category listings |
/sample/mer/item/[slug] | Product page — for items in credit-enabled categories, the "Lipa pole pole" credit entry |
/sample/mer/shop | Signed-in shop view — products grouped by main category |
/sample/mer/auth/sign-up · /sample/mer/auth/sign-in | Full-page sign-up / sign-in (the storefront modal uses the same actions) |
/sample/mer/cart | Cart contents + checkout button |
/sample/mer/checkout | Begin a checkout — calls POST /v1/checkout/orders, then redirects to the hosted checkout page or frames it (embedded mode) |
/sample/mer/checkout/success | Return landing (the shopper's return_url) |
/sample/mer/checkout/cancel | Cancel landing (the shopper's cancel_url) |
/sample/mer/orders | Customer order history |
/sample/mer/deliveries | Delivery state of the customer's orders (pay-on-delivery captures, express orders) |
/sample/mer/embedded/checkout | Retired — redirects to /sample/mer. The embedded flow runs in /sample/mer/checkout in embedded mode. |
Admin surface (?sid= session)
| Path | Purpose |
|---|---|
/sample/mer/admin/sign-in | Admin sign-in |
/sample/mer/admin | Admin dashboard |
/sample/mer/admin/orders · /sample/mer/admin/orders/[id] | All orders; order detail with line items, status, webhook events, status pull and refund/delivery actions |
/sample/mer/admin/payments | Payments ledger — what Vuka Bridge paid the merchant, per order or per capture (sim_mer_payments) |
/sample/mer/admin/integration | Checkout integration config — default checkout mode, delivery mode, paypoint; can pin a policy by hiding pickers (sim_mer_checkout_config) |
/sample/mer/admin/webhooks | Webhook event log — every event received, with handler outcome |
/sample/mer/admin/customers | Customer list |
/sample/mer/admin/products · /sample/mer/admin/categories | Catalogue and category taxonomy management |
Catalog API (read by Vuka Bridge)
The sim exposes the merchant catalog API that the Vuka Bridge marketplace and asset financing
synchronise from: GET /sample/mer/api/catalog/categories, …/categories/[category]/subcategories, …/brands, …/products and …/products/[sku]. Requests authenticate with the
merchant's API key (X-Api-Key or Authorization: Bearer).
Webhook handling
Vuka Bridge posts JSON events to the sim's receiver at /sample/mer/api/webhooks/edomx (the merchant's registered webhook endpoint). Every inbound POST is logged to sim_mer_webhook_events (audit-first), de-duplicated on its event id, applied to the
sim's orders and payments ledger, and surfaced on the admin webhook log.
Signature shape
Vuka Bridge includes a header Vuka-Signature (and, during the header-name transition, the same value under the former name EDOMX-Signature) formatted as:
t=<unix-timestamp>,v1=<hmac-sha256-hex> The HMAC is computed over "t=<timestamp>.<raw-body>" using each merchant's signing secret from mer_webhook_endpoints.signing_secret_encrypted (decrypted via EDOMX_DATA_ENCRYPTION_KEY). The event id arrives in Vuka-Event-Id (and in the body as event_id). The sim's receiver reads Vuka-Signature (falling back to the former name) and records it; it does not enforce it (dev-only) — a real merchant must verify every request.
Event types handled
The receiver routes on type:
order.confirmed— the customer completed checkout; the order is confirmed (fulfil on this)order.paid— the money has landed; the order is marked paid and a row is written to the payments ledgercapture.committed— a pay-on-delivery capture settled; one ledger row per capturecancellation.applied— a cancellation was applied to the orderrefund.applied— a refund reversed the customer's loan; the order is marked refundedorder.expired— the order expired without being confirmedsettlement.pending,delivery.completed,delivery.failed,capture.failed,webhook.test— acknowledged (informational for the sim)- Legacy
credit_intent.*events are still accepted for older flows
Event shape (example)
{
"type": "order.confirmed",
"event_id": "a968da91-064f-4561-a43e-a29e61bc1e22",
"occurred_at": "2026-10-06T01:29:09.368Z",
"data": {
"order_id": "0e42983e-7629-4fcb-b503-e2f8893b7d31",
"merchant_order_id": "SOKO-202610-9C934E",
"archetype": "m2",
"credit_intent_id": "979a7ddc-998d-4421-b631-21043fe8396f",
"amount_cents": 66500,
"currency": "KES",
"confirmed_at": "2026-10-06T01:29:09.368Z"
}
} archetype on the merchant wire is m1 (pay on delivery), m2 (express, Vuka Bridge-settled) or m3 (express, provider-settled).
Optional: cart manifest
On the sim's checkout page the Send cart manifest to credit checkout toggle
(default ON) attaches each cart line's sku, gtin (when known), name, quantity and unit_amount_cents to the create-order
call, plus shipping_cents, tax_cents and discount_cents as
sibling fields. The manifest is what makes a single item eligible for asset financing.
Vuka Bridge stores the manifest in a dedicated sibling table (mer_checkout_order_carts,
1:1 with the order) so the hot order row stays lean. For orders created with a test key — like the
sim's — the manifest is shown back to the shopper on the hosted checkout page (both redirect and
embedded modes); this is the verification surface. Live orders store it silently.
A few Soko products carry real GS1-valid GTINs (Anker PowerLine III USB-C Cable, Samsung Galaxy A54 5G, Golden Fry Cooking Oil 1L); the rest have none, so the test-mode panel shows a realistic mix of "has GTIN" and "GTIN — not provided" lines.
Untick the toggle to see the omit-cart behaviour — the sim's placeOrder action drops
the cart field entirely (the order is then offered whole-basket financing only).
Full contract: documentation/requirements/cart_manifest.md.
Pairing with other simulators
| Combo | What it exercises |
|---|---|
| Merchant sim + financier sims (Shop na Pesi, Ngao Credit) | End-to-end credit flow — customer at Soko checkout → Vuka Bridge → the financier sim approves → order.confirmed to the merchant |
| Merchant sim + M-Pesa / PESI rail | Settlement — the merchant's M-Pesa settlement account (paybill or till) receives the B2B payout, realtime or in a batch T+1 deposit → order.paid |
| All of them | The full pipeline: customer browses → checks out → financier approves → Vuka Bridge pays the merchant → webhooks delivered → payments ledger updated. |
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| "Something went wrong" 500 on first page load | Migrations haven't run. Run npm run migrate and reload. |
| Sign-up succeeds but I land back on sign-in | Sessions are URL-param (?sid=). Check the redirect URL in your browser bar — if sid isn't appended, it was lost across the redirect. Refresh and try again. |
| Admin sign-in rejects the seed password | The seed row uses a scrypt$bootstrap$bootstrap sentinel. The helper writes a real hash on first sign-in attempt using SAMPLE_MER_ADMIN_PASSWORD. If that isn't set, set it and restart the server. |
| Webhook events don't show in the admin log | Check the merchant's webhook endpoint in mer_webhook_endpoints: it must be enabled and its URL must point at /sample/mer/api/webhooks/edomx on this deployment. Delivery attempts and failures are in the server logs ([worker:attempt]). The sim does not enforce signatures, so a signature mismatch never drops an event. |
| Order is confirmed but never marked paid | Paid follows order.paid, which arrives only when the payout lands (realtime) or with the batch T+1 deposit. Check the payout status under /admin (settlement gateway / transactions). |
| I want to wipe sim state and start fresh | Truncate the order-side sim_mer_* tables (Postgres: TRUNCATE sim_mer_customers, sim_mer_customer_locations, sim_mer_carts, sim_mer_cart_items, sim_mer_orders, sim_mer_order_items, sim_mer_payments, sim_mer_webhook_events CASCADE). Keep sim_mer_products, sim_mer_categories, sim_mer_settings and sim_mer_checkout_config. Sessions reset too — sign in again. |
Reference
Schema
Fifteen tables prefixed sim_mer_ (base migration migrations/20260529120000_sim_merchant.sql, extended by later migrations):
sim_mer_customers·sim_mer_customer_locations·sim_mer_sessionssim_mer_admin_users·sim_mer_admin_sessionssim_mer_categories·sim_mer_products·sim_mer_carts·sim_mer_cart_itemssim_mer_orders·sim_mer_order_items·sim_mer_payments·sim_mer_webhook_eventssim_mer_checkout_config·sim_mer_settings
Helper libraries
src/lib/server/sim-mer.ts— sessions, passwords, URL helpers, cart actionssrc/lib/server/sim-mer-catalog.ts·sim-mer-catalog-api.ts— catalogue + the catalog APIsrc/lib/server/sim-mer-checkout-config.ts— checkout integration configsrc/lib/server/sim-mer-reconciler.ts·sim-mer-payments.ts— applying webhook events and status pulls; the payments ledger
Specification
Canonical behaviour spec: documentation/requirements/merchant_simulator.md.
Merchant integration contract: documentation/requirements/merchant_webhooks.md and documentation/requirements/merchant_integration_handbook.md.