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.| Method | Endpoint | Description | Auth |
|---|---|---|---|
POST | /v1/payments | Create a (simulated) payment. | Bearer |
GET | /v1/payments/{id} | Retrieve one payment by its id. | Bearer |
GET | /v1/payments | List payments (cursor pagination: limit, country, operator, status, type, cursor). | Bearer |
POST | /v1/payments/{id}/refund | Refund all/part of a collection (payout → customer). See §5. | Bearer |
POST | /v1/disbursements | Free-form payout (merchant → an msisdn). See §5. | Bearer |
GET | /v1/meta | API version + capabilities (feature-detect). See Changelog. | None |
GET | /v1/health | Health probe. | None |
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)
| Field | Type | Required | Notes |
|---|---|---|---|
amount | integer | yes | Gross amount in the currency’s smallest practical unit (integral for Mobile Money). |
currency | string | yes | E.g. RWF, XOF. |
operator | string | no* | mtn / orange / moov / airtel. Auto-detected from msisdn if omitted. |
country | string | no* | ISO-2, e.g. RW, CI. Auto-detected from msisdn if omitted. |
msisdn | string | yes | Payer number in E.164 (+250788123456). |
reference | string | yes | Your external id (your idempotency key). The webhook does not echo it back. |
application | string | yes | Your store’s slug — found in Settings → Stores. Every transaction is tied to a store. |
order_ref | string | recommended | Your order / commande id — the key that links a payment, its refunds, and any recurring charges together. Falls back to reference when omitted. Shows up on the dashboard Orders page. |
order_url | string | no | Public URL to the order in your app — rendered as a “view order” link in the dashboard. |
description | string | no | Informational; stored and returned, does not affect the outcome. |
scenario | string | no | Force a deterministic outcome (see Multi-environment). |
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.)
| Field | Type | Meaning |
|---|---|---|
id | string | The transaction id (TX_…). Store this — the webhook matches on it. |
amount | integer | The gross you requested. |
commission | integer | Operator commission = round(amount × commission_bps / 10000). |
netAmount | integer | What the merchant is credited. Reconcile here. null-ish (= gross) for legacy rows; not final until terminal. |
customerTotal | integer | What the customer’s SIM is debited (= netAmount + commission). |
merchantAbsorptionPct | int | null | % of the commission the merchant absorbed (0–100). null for legacy rows. |
merchantShare | integer | Fee borne by the merchant (= amount − netAmount). |
customerShare | integer | Fee borne by the customer (= commission − merchantShare). |
commissionMode | string | null | Who absorbed the fee for this tx: "customer" / "merchant". null for legacy rows. |
currency | string | As sent. |
operator | string | Resolved operator. |
country | string | Resolved country. |
msisdn | string | Payer MSISDN. |
reference | string | Your external id. |
description | string | null | As sent. |
scenario | string | null | The forced scenario, or null. |
status | string | One of the 11 statuses. |
latencyMs | integer | Simulated operator latency. |
createdAt | string | ISO 8601. |
raw | object | null | Operator-native payload. _simulated: true at the top level in sandbox. null for legacy rows — handle it. |
3. Status vocabulary
There are exactly 11 canonical statuses. Map all of them so nothing falls through to a non-terminal default.| Status | Terminal? | Meaning |
|---|---|---|
SUCCESS | ✅ yes | Payment completed. |
PIN_INVALID | ✅ yes | Wrong PIN. |
INSUFFICIENT_FUNDS | ✅ yes | Balance below customerTotal. |
TIMEOUT | ✅ yes | Operator timed out — or an abandoned PENDING prompt the hourly cron auto-expired. |
ACCOUNT_BLOCKED | ✅ yes | SIM/account blocked. |
USER_CANCELLED | ✅ yes | Payer refused the prompt. Terminal. |
UNKNOWN_MSISDN | ✅ yes | Number not in the SIM registry. |
LIMIT_EXCEEDED | ✅ yes | Operator transaction limit exceeded. |
SERVICE_UNAVAILABLE | ✅ yes | Operator in maintenance. |
DUPLICATE_REFERENCE | ✅ yes | Duplicate reference. |
PENDING | ❌ no | In flight — keep polling / await the webhook. |
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
| Recipient | Outcome |
|---|---|
| Active test_client | SUCCESS — SIM credited |
| Blocked test_client | ACCOUNT_BLOCKED |
Unknown msisdn (reject policy) | UNKNOWN_MSISDN |
Unknown msisdn (passthrough) | SUCCESS — no SIM credited |
| Merchant float < amount | INSUFFICIENT_FUNDS |
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):
Transaction type | Webhook event | X-SandPay-Event header |
|---|---|---|
collection | payment.completed | payment.completed |
refund | payment.refunded | payment.refunded |
disbursement | disbursement.completed | disbursement.completed |
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.
| Segment | Rule |
|---|---|
ORIGIN | Your app’s slug — uppercase, 2–12 chars (e.g. ZANA). SandPay itself uses SP. |
OP | 3-letter operation: PAY (collection), REF (refund), DIS (disbursement), ABO (subscription). |
TS | Compact UTC datetime YYYYMMDDHHMMSS — when the reference was minted (sortable). |
CODE | 12-char uppercase hex. Random for a fresh attempt, or derived from a stable id for idempotent retries. |
@sandpay/node ≥ 0.2.1) 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 @sandpay/node 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
@sandpay/node — 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.