AGENTS.md defers to it. The
Integration FAQ is the quick Q&A companion; this guide is the
complete contract.
One-line summary. Keep the API key server-side, match webhooks by
tx_id, reconcile merchant proceeds on netAmount (not amount), map
all 11 statuses (USER_CANCELLED is terminal), and keep polling as your
safety net — it is your primary signal against a local SandPay.1. Overview
SandPay is a Mobile Money sandbox for francophone Africa (MTN, Orange, Moov, Airtel across Côte d’Ivoire, Bénin, Togo, Rwanda). You send one payment request to one API with one key; SandPay drives the simulation and notifies you of the outcome. Operator responses are synthesized to match the real operator shape — with realistic latencies, error codes, and the nativeraw payload structure.
Trust boundary
The single most important rule: the API key lives only on your server. Asp_sk_test_… key can charge against your account — it must
never appear in a web bundle, a mobile app, or any client-side code. Your client
calls your backend; your backend calls SandPay with the bearer key.
Request → webhook lifecycle
- Client starts checkout against your backend.
- Backend
POST /v1/payments(Bearer key). SandPay returns aPaymentwith anidand an initialstatus(oftenPENDING— acceptance, not settlement). - The customer confirms on their device; SandPay settles the transaction.
- SandPay
POSTs a signedpayment.completedwebhook to your configured URL. - Your backend verifies the signature, matches the event by
tx_id, and updates its row. PollingGET /v1/payments/{id}is the resilient fallback.
2. The contract
Endpoints
Base URL & path. At the public hostname
https://api.sandpay.dev, the
endpoints live under /v1/… (e.g. https://api.sandpay.dev/v1/payments).
When you point at a deployed or local SandPay instance (the Next.js app),
the same routes are served under /api/v1/… (e.g. ${SANDPAY_BASE_URL} /api/v1/payments). Strip any trailing slash from your base URL either way —
see the checklist.Staying current. Every
/v1/* response carries an X-SandPay-Api-Version
header, and GET /v1/meta returns the version + supported capabilities +
min_sdk_version. Feature-detect against capabilities rather than assuming
a version, and watch the Integration Changelog for
what changed and what (if anything) you must do.Authentication
Send the key as a Bearer token on every authenticated request. Server-side only.sp_sk_test_… keys produce simulated transactions (no money moves). See Authentication for rotation and
revocation.
Request body (POST /v1/payments)
*
country/operator are optional only when they’re auto-detectable from
the MSISDN prefix and the matching environment is enabled in Settings.
Response — the Payment resource
The API returns camelCase JSON. (The serializer at
frontend/lib/payments/serialize.ts is the single source of truth for this
shape.)
3. Status vocabulary
There are exactly 11 canonical statuses. Map all of them so nothing falls through to a non-terminal default.PENDING is the only non-terminal status. Every other status is final and fires
the payment.completed webhook.
Abandoned prompts auto-expire. A
PENDING payment the customer never
confirms (they didn’t answer the USSD push) is auto-closed to TIMEOUT by an
hourly cron, after ~60 minutes. You receive a payment.completed webhook with
status TIMEOUT — so a stuck prompt always resolves to a terminal outcome on
its own. Handle TIMEOUT as a terminal failure.4. Commission & settlement
SandPay applies an operator commission on every payment. Three amounts matter:amount— what you requested (the gross).customerTotal— what the customer’s SIM is debited (= netAmount + commission).netAmount— what the merchant is credited.
Formula & invariant
customerTotal === netAmount + commission.
The default: merchant absorbs everything
merchant_absorption_pct defaults to 100 — the merchant absorbs the whole
fee. The customer then pays exactly amount, and the merchant receives
amount − commission. At the other extreme (0), the customer pays
amount + commission and the merchant receives amount. Both knobs (the rate
commission_bps and the absorption split) are configurable per
environment in Settings → Countries & operators.
5. Refunds & disbursements
The endpoints above all model collections (pay-in: customer → merchant). SandPay also supports the outbound direction (payout: merchant → customer):- Refund — reverse all or part of a prior
SUCCESScollection. Linked to the original viaparentTxId. - Disbursement — a free-form payout to an msisdn, not linked to any prior collection (cashback, top-up, payout).
merchant_balance) and credit the
recipient SIM. No commission is taken on a payout — the merchant already
paid the operator’s commission when it collected the original payment; the
operator does not refund it. So a full refund leaves the merchant down by the
original commission (this matches real operator behaviour).
Payouts are persisted as transactions with a type of refund or
disbursement (collections are type: "collection"). Filter them with
GET /v1/payments?type=refund / ?type=disbursement.
Refund a payment
amount minus the sum of prior
successful refunds. Over-refunding returns 422 exceeds_refundable; refunding a
non-SUCCESS or non-collection tx returns 422. If the merchant float is too
low, the refund is recorded with status INSUFFICIENT_FUNDS (no money moves).
Create a disbursement
Payout webhooks
Payouts fire the same signed webhook as collections, with a direction-specific event name (the terminalstatus is always in the body — check it, not just the
event):
You can also refund a
SUCCESS collection straight from the dashboard
(transaction detail page → Refund this transaction), which is handy for
manual testing.
Traceability — carry the source order_ref into every payout
For investigation later, every transaction tied to one order should share the
same order_ref (the source order’s id) — so a payment, its refunds, and any
related disbursement all trace back to one order (dashboard Orders page,
GET /v1/payments?order=<order_ref>, the order_ref on the webhook):
- Refund —
order_ref(andorder_url) are inherited automatically from the parent collection. Don’t re-send them; just refund against the correct original tx id. (Persist that tx id on your order at payment time.) - Disbursement — has no parent, so it inherits nothing: pass
order_ref= the same value you used on the original collection. Omit it and the payout falls back to its ownreferenceand won’t group with the order. - Recurring / subscription — reuse one
order_ref(e.g. the subscription id) across every charge.
is_url_protected: true alongside order_url when the order page on
your app needs a specific permission to open (admin-only, not a public link).
SandPay then shows a ”⚠ protected URL” hint next to the link on the Orders +
transaction-detail pages, so whoever investigates knows they’ll need that right
first. Defaults to false. (A refund inherits the flag from its parent, like
order_ref/order_url.)
Reference format — ORIGIN-OP-TS-CODE
reference is your idempotency key (unique per org). SandPay recommends a
single canonical shape for it so every transaction is consistent and instantly
readable + sortable on the dashboard. Capability: reference_format.
The SDK (
@drwintech/sandpay ≥ 0.3.0) ships helpers so you don’t hand-roll this:
TS is the UTC YYYYMMDDHHMMSS; the CODE is
crypto.randomBytes(6).toString("hex").toUpperCase() (random) or
crypto.createHash("sha256").update(seed).digest("hex").slice(0,12).toUpperCase()
(derived). When you omit reference on a payout, SandPay auto-generates one in
this format (SP-REF-… / SP-DIS-…, inheriting the parent’s ORIGIN for a refund).
This is the format of the
reference (idempotency key) — it’s independent
of order_ref (the grouping key). Keep sending your stable order_ref
(the source order’s id) regardless of how reference is shaped. Parsing is
lenient: pre-TS references (ORIGIN-OP-CODE) are still recognized.6. Webhooks
SandPayPOSTs a payment.completed event to your configured URL the moment a
transaction reaches a terminal state. This is a server-to-server call carrying
no JWT — its authenticity comes from the HMAC signature.
Signature verification
Header:X-SandPay-Signature: sha256=<64-hex> where the digest is
HMAC-SHA256(secret, rawBody) over the exact raw bytes received (before any
JSON.parse).
- Verify constant-time. Use
crypto.timingSafeEqual(or WebCrypto + a constant-time compare) — never a===on the hex string. - Fail closed. If the secret is missing or the signature doesn’t match, reject
with
401. Never process an unverified body. - Verify before parsing. Any middleware that re-serializes the body (e.g.
Express
bodyParser.json()by default) invalidates the digest. Capture a raw body buffer on the webhook route.
crypto.subtle.importKey + sign) and a
constant-time hex compare.
Deploy receiver with --no-verify-jwt
On Supabase, deploy the webhook function with --no-verify-jwt
(supabase functions deploy sandpay-webhook --no-verify-jwt). SandPay carries no
JWT; gating the route on JWT verification would 401 every delivery. Authenticity
comes from the HMAC signature, which you verify yourself.
Webhook body
The body is snake_case and carries the same commission fields as the create response (source of truth:frontend/lib/webhooks/payload.ts):
2xx to acknowledge. SandPay retries up to 5 times with exponential
backoff; 3xx is not followed. See Webhooks for the retry
policy and the @drwintech/sandpay webhooks.verify / webhooks.parseEvent helpers.
Inngest: local vs cloud (critical for local dev)
SandPay dispatches webhooks through Inngest. Whether a webhook fires depends on where SandPay itself runs:-
Local SandPay (
next dev): the webhook fires only if SandPay’s Inngest dev server is running and synced:withINNGEST_DEV=1in SandPay’s env. Without it, events queue but no webhook is delivered. So when you test against a local SandPay, treat polling as the primary signal — the webhook is best-effort there. - Cloud SandPay (deployed + Inngest cloud): webhooks fire reliably and automatically — use them as your primary signal, with polling as the safety net.
Expose your local receiver with a stable tunnel (ngrok static domain)
Webhooks (and a cloud SandPay reaching your local SandPay) need a publicly-reachable HTTPS URL. In local dev, expose your handler with ngrok using a reserved static domain — the free tier includes one reserved domain, so the URL is stable across restarts and you setSANDPAY_BASE_URL / the webhook URL once.
7. Multi-environment
Eachoperator + country pair is one environment you configure in
Settings → Countries & operators.
-
Auto-detection. At checkout you don’t pick the operator manually — it’s
auto-detected from the payer’s MSISDN prefix (e.g.
+25078…/+25079…→ MTN Rwanda). You may still passcountry/operatorexplicitly. -
Enable the env first. If the resolved environment isn’t enabled in Settings,
the API errors (
env_not_found/ a 5xx for an unconfigured operator). Enable the operator/country/currency env before sending. -
test_clients= the SIM registry. With noscenario, the outcome is driven by the merchant’s test clients:- unknown number →
UNKNOWN_MSISDN - blocked SIM →
ACCOUNT_BLOCKED - balance below
customerTotal→INSUFFICIENT_FUNDS - otherwise →
PENDING(the customer confirms on the BigPhone screen)
/clientsfirst, or every number returnsUNKNOWN_MSISDN. - unknown number →
-
Explicit
scenarioforces a deterministic outcome (ideal for CI):success,pin_invalid,low_balance,timeout,blocked,cancelled,unknown_msisdn,limit_exceeded,maintenance,duplicate. An explicit scenario always wins over the SIM registry. See Scenarios.
8. Recommended architecture
Pay-first ordering
Create the order as adraft, call SandPay, and:
- init fails → roll the draft back (delete it) and surface the error.
- init succeeds → move the order to
initiated/pending. - terminal confirmation (webhook or polling) → promote to its final state.
Webhook + polling
Use both. The webhook is an instant push (it needs a public URL + the shared secret +--no-verify-jwt); polling GET /v1/payments/{id} every ~4s is the
resilient fallback for when the webhook is delayed, dropped, or — in local dev —
not wired at all. Against a cloud
SandPay, the webhook is your primary signal and polling is the safety net; against
a local SandPay, polling is effectively primary.
Reconciliation
When a transaction settles, credit your merchant ledger withnetAmount
(post-commission), never the gross amount. Only post the credit once the tx is
terminal and netAmount is populated.
9. Integration checklist
- Base URL has no trailing slash (
baseUrl.replace(/\/+$/, "")) so${baseUrl}/api/v1/paymentsdoesn’t double to//api/v1/payments. -
SANDPAY_API_KEY+SANDPAY_WEBHOOK_SECRETare server-side secrets, never in client code, never committed. - The operator/country/currency environment is enabled in Settings → Countries & operators (an unconfigured env errors).
- The transaction id is read tolerantly (
id, thentx_id/txId/transaction_id, then nesteddata.id/payment.id). - All 11 statuses are mapped, and
USER_CANCELLEDis terminal. - The webhook receiver is deployed with
--no-verify-jwt, verifies the HMAC signature constant-time + fail-closed, and matches bytx_id(notreference). - Webhook URL is pasted into the SandPay dashboard; the secret is identical on both sides.
- Merchant proceeds are reconciled on
netAmount, notamount, and only once terminal. - Pay-first: orders are created as
draft, rolled back on init failure, promoted on confirmation — no orphan orders. - A polling fallback (
GET /v1/payments/{id}, ~4s) backs up the webhook. - In local dev, SandPay’s Inngest dev server is running
(
inngest-cli dev -u http://localhost:3800/api/inngest+INNGEST_DEV=1) or polling is relied upon as the primary signal. - Tested end to end: success, payer refusal (
USER_CANCELLED), insufficient funds, and timeout.
10. See also
Quickstart
First payment in 10 minutes — Stack Builder or manual.
Integration FAQ & tips
The quick Q&A companion to this guide.
Webhooks
Payload shape, signature verification, retries.
Scenarios
Force any operator outcome via the
scenario field.Node SDK
@drwintech/sandpay — typed client + webhook helpers.
Integration Changelog
What changed, per API version — and what to do about it.
Upgrade & (re)install
Step-by-step runbook to install, reinstall, or upgrade from your app.