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

  1. Make sure migrations are applied — the simulator needs the sim_mer_* tables. Run npm run migrate if you haven't.
  2. 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.
  3. Click Sign up (a modal on the storefront) — enter any email, phone (+254XXXXXXXXX) and password. A customer record is created in sim_mer_customers; the session travels as a ?sid= URL parameter.
  4. Add products to the cart and click Checkout. Choose Pay with credit. The sim calls the real POST /v1/checkout/orders with SAMPLE_MER_API_KEY as 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.
  5. 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.
  6. 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.

VariableWhat it's forDefault / example
SAMPLE_MER_MERCHANT_CHECKOUT_IDThe 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_KEYServer-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_EMAILBootstrap admin email for /sample/mer/admin/sign-in.admin@soko.demo
SAMPLE_MER_ADMIN_PASSWORDHashed 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)

PathPurpose
/sample/merStorefront 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/shopSigned-in shop view — products grouped by main category
/sample/mer/auth/sign-up · /sample/mer/auth/sign-inFull-page sign-up / sign-in (the storefront modal uses the same actions)
/sample/mer/cartCart contents + checkout button
/sample/mer/checkoutBegin a checkout — calls POST /v1/checkout/orders, then redirects to the hosted checkout page or frames it (embedded mode)
/sample/mer/checkout/successReturn landing (the shopper's return_url)
/sample/mer/checkout/cancelCancel landing (the shopper's cancel_url)
/sample/mer/ordersCustomer order history
/sample/mer/deliveriesDelivery state of the customer's orders (pay-on-delivery captures, express orders)
/sample/mer/embedded/checkoutRetired — redirects to /sample/mer. The embedded flow runs in /sample/mer/checkout in embedded mode.

Admin surface (?sid= session)

PathPurpose
/sample/mer/admin/sign-inAdmin sign-in
/sample/mer/adminAdmin 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/paymentsPayments ledger — what Vuka Bridge paid the merchant, per order or per capture (sim_mer_payments)
/sample/mer/admin/integrationCheckout integration config — default checkout mode, delivery mode, paypoint; can pin a policy by hiding pickers (sim_mer_checkout_config)
/sample/mer/admin/webhooksWebhook event log — every event received, with handler outcome
/sample/mer/admin/customersCustomer list
/sample/mer/admin/products · /sample/mer/admin/categoriesCatalogue 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 ledger
  • capture.committed — a pay-on-delivery capture settled; one ledger row per capture
  • cancellation.applied — a cancellation was applied to the order
  • refund.applied — a refund reversed the customer's loan; the order is marked refunded
  • order.expired — the order expired without being confirmed
  • settlement.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

ComboWhat 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 railSettlement — 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 themThe full pipeline: customer browses → checks out → financier approves → Vuka Bridge pays the merchant → webhooks delivered → payments ledger updated.

Troubleshooting

SymptomCause / fix
"Something went wrong" 500 on first page loadMigrations haven't run. Run npm run migrate and reload.
Sign-up succeeds but I land back on sign-inSessions 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 passwordThe 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 logCheck 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 paidPaid 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 freshTruncate 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_sessions
  • sim_mer_admin_users · sim_mer_admin_sessions
  • sim_mer_categories · sim_mer_products · sim_mer_carts · sim_mer_cart_items
  • sim_mer_orders · sim_mer_order_items · sim_mer_payments · sim_mer_webhook_events
  • sim_mer_checkout_config · sim_mer_settings

Helper libraries

  • src/lib/server/sim-mer.ts — sessions, passwords, URL helpers, cart actions
  • src/lib/server/sim-mer-catalog.ts · sim-mer-catalog-api.ts — catalogue + the catalog API
  • src/lib/server/sim-mer-checkout-config.ts — checkout integration config
  • src/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.