Skip to main content
This is the authoritative reference for integrating SandPay. If you read one page (or point your AI coding agent at one page), read this one. It is the source of truth: every other doc, starter kit, and 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 native raw payload structure.

Trust boundary

The single most important rule: the API key lives only on your server. A sp_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.
   trust boundary

 Client │  Your backend            SandPay              Operator
 ───────│  ───────────             ───────              ────────
   1. checkout ─▶  2. POST /v1/payments ─▶  3. drive operator
        │            (Bearer sp_sk_…)
        │                                   4. async settle
   7. UI update ◀─ 6. update your row ◀──── 5. POST webhook (signed)
        │              ▲
        │              └── + GET /v1/payments/{id} polling (every ~4s)

        └── the API key NEVER crosses this line ───────────────────┘

Request → webhook lifecycle

  1. Client starts checkout against your backend.
  2. Backend POST /v1/payments (Bearer key). SandPay returns a Payment with an id and an initial status (often PENDING — acceptance, not settlement).
  3. The customer confirms on their device; SandPay settles the transaction.
  4. SandPay POSTs a signed payment.completed webhook to your configured URL.
  5. Your backend verifies the signature, matches the event by tx_id, and updates its row. Polling GET /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.
MethodEndpointDescriptionAuth
POST/v1/paymentsCreate a (simulated) payment.Bearer
GET/v1/payments/{id}Retrieve one payment by its id.Bearer
GET/v1/paymentsList payments (cursor pagination: limit, country, operator, status, type, cursor).Bearer
POST/v1/payments/{id}/refundRefund all/part of a collection (payout → customer). See §5.Bearer
POST/v1/disbursementsFree-form payout (merchant → an msisdn). See §5.Bearer
GET/v1/metaAPI version + capabilities (feature-detect). See Changelog.None
GET/v1/healthHealth 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.
Authorization: Bearer sp_sk_test_a1b2c3d4e5f6...
sp_sk_test_… keys produce simulated transactions (no money moves). See Authentication for rotation and revocation.

Request body (POST /v1/payments)

{
  "amount": 25000,
  "currency": "RWF",
  "operator": "mtn",
  "country": "RW",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "application": "zana",
  "description": "Premium upgrade",
  "scenario": "success"
}
FieldTypeRequiredNotes
amountintegeryesGross amount in the currency’s smallest practical unit (integral for Mobile Money).
currencystringyesE.g. RWF, XOF.
operatorstringno*mtn / orange / moov / airtel. Auto-detected from msisdn if omitted.
countrystringno*ISO-2, e.g. RW, CI. Auto-detected from msisdn if omitted.
msisdnstringyesPayer number in E.164 (+250788123456).
referencestringyesYour external id (your idempotency key). The webhook does not echo it back.
applicationstringyesYour store’s slug — found in Settings → Stores. Every transaction is tied to a store.
order_refstringrecommendedYour 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_urlstringnoPublic URL to the order in your app — rendered as a “view order” link in the dashboard.
descriptionstringnoInformational; stored and returned, does not affect the outcome.
scenariostringnoForce 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.
Always send order_ref. It’s how SandPay groups related transactions — the original payment, every refund against it, and recurring charges for the same order all roll up under one entry on the dashboard Orders page (and GET /v1/payments?order_ref=… / the order filter). A refund automatically inherits its parent collection’s order_ref. Omit it and SandPay falls back to your reference, but a stable order id (e.g. your cart/subscription id) is the right grouping key.

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.)
{
  "id": "TX_8K3M9F",
  "amount": 25000,
  "commission": 250,
  "netAmount": 24750,
  "customerTotal": 25000,
  "merchantAbsorptionPct": 100,
  "merchantShare": 250,
  "customerShare": 0,
  "commissionMode": "merchant",
  "currency": "RWF",
  "operator": "mtn",
  "country": "RW",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "description": "Premium upgrade",
  "scenario": "success",
  "status": "SUCCESS",
  "latencyMs": 1240,
  "createdAt": "2026-05-24T10:30:45.000Z",
  "raw": {
    "_simulated": true,
    "status": "SUCCESSFUL",
    "financialTransactionId": "SIM_A1B2C3D4"
  }
}
FieldTypeMeaning
idstringThe transaction id (TX_…). Store this — the webhook matches on it.
amountintegerThe gross you requested.
commissionintegerOperator commission = round(amount × commission_bps / 10000).
netAmountintegerWhat the merchant is credited. Reconcile here. null-ish (= gross) for legacy rows; not final until terminal.
customerTotalintegerWhat the customer’s SIM is debited (= netAmount + commission).
merchantAbsorptionPctint | null% of the commission the merchant absorbed (0–100). null for legacy rows.
merchantShareintegerFee borne by the merchant (= amount − netAmount).
customerShareintegerFee borne by the customer (= commission − merchantShare).
commissionModestring | nullWho absorbed the fee for this tx: "customer" / "merchant". null for legacy rows.
currencystringAs sent.
operatorstringResolved operator.
countrystringResolved country.
msisdnstringPayer MSISDN.
referencestringYour external id.
descriptionstring | nullAs sent.
scenariostring | nullThe forced scenario, or null.
statusstringOne of the 11 statuses.
latencyMsintegerSimulated operator latency.
createdAtstringISO 8601.
rawobject | nullOperator-native payload. _simulated: true at the top level in sandbox. null for legacy rows — handle it.
Extract the id tolerantly. Read it from id, but tolerate tx_id / txId / transaction_id and one level of nesting (data.id, payment.id) so a naming difference never reads as a failure.

3. Status vocabulary

There are exactly 11 canonical statuses. Map all of them so nothing falls through to a non-terminal default.
StatusTerminal?Meaning
SUCCESS✅ yesPayment completed.
PIN_INVALID✅ yesWrong PIN.
INSUFFICIENT_FUNDS✅ yesBalance below customerTotal.
TIMEOUT✅ yesOperator timed out — or an abandoned PENDING prompt the hourly cron auto-expired.
ACCOUNT_BLOCKED✅ yesSIM/account blocked.
USER_CANCELLED✅ yesPayer refused the prompt. Terminal.
UNKNOWN_MSISDN✅ yesNumber not in the SIM registry.
LIMIT_EXCEEDED✅ yesOperator transaction limit exceeded.
SERVICE_UNAVAILABLE✅ yesOperator in maintenance.
DUPLICATE_REFERENCE✅ yesDuplicate reference.
PENDING❌ noIn flight — keep polling / await the webhook.
USER_CANCELLED is terminal — and it is the single most common integrator pitfall. A payer refusing the prompt is a final outcome, not a retry. If you leave it mapped to “pending”, your waiting screen polls until it times out. Map it to a final “cancelled” state.
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

commission     = round(amount × commission_bps / 10000)
merchantShare  = round(commission × merchantAbsorptionPct / 100)   (the fee the merchant eats)
customerShare  = commission − merchantShare                         (the fee the customer eats)
netAmount      = amount − merchantShare
customerTotal  = netAmount + commission
Invariant (always holds): 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.
Reconcile on netAmount, never amount. amount is the gross the customer was quoted; netAmount is what actually lands with the merchant after the commission. Crediting your merchant ledger on amount over-credits by the commission. netAmount isn’t final while a tx is PENDING — only post the credit once the tx is terminal.

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 SUCCESS collection. Linked to the original via parentTxId.
  • Disbursement — a free-form payout to an msisdn, not linked to any prior collection (cashback, top-up, payout).
Both debit the merchant float (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

POST /v1/payments/{id}/refund
{ "amount": 500 }          // omit `amount` for a full refund of the remaining refundable
// Full refund
await sandpay.payments.refund("TX_8K3M9F");
// Partial refund (minor units)
await sandpay.payments.refund("TX_8K3M9F", {
  amount: 500,
  reference: "RFND-ORDER-42",
});
The refundable ceiling is the original gross 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

POST /v1/disbursements
{ "amount": 5000, "currency": "XOF", "operator": "mtn", "country": "CI", "msisdn": "+22507000000" }
await sandpay.disbursements.create({
  amount: 5000,
  currency: "XOF",
  operator: "mtn",
  country: "CI",
  msisdn: "+22507000000",
});
The recipient is resolved against the env’s test-client registry + its unknown-msisdn policy, exactly like collections:
RecipientOutcome
Active test_clientSUCCESS — SIM credited
Blocked test_clientACCOUNT_BLOCKED
Unknown msisdn (reject policy)UNKNOWN_MSISDN
Unknown msisdn (passthrough)SUCCESS — no SIM credited
Merchant float < amountINSUFFICIENT_FUNDS

Payout webhooks

Payouts fire the same signed webhook as collections, with a direction-specific event name (the terminal status is always in the body — check it, not just the event):
Transaction typeWebhook eventX-SandPay-Event header
collectionpayment.completedpayment.completed
refundpayment.refundedpayment.refunded
disbursementdisbursement.completeddisbursement.completed
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):
  • Refundorder_ref (and order_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 own reference and won’t group with the order.
  • Recurring / subscription — reuse one order_ref (e.g. the subscription id) across every charge.
Pass 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.)
Never use a fresh refund-… / payout-… string as the grouping key — that’s fine as the idempotency reference, but order_ref must stay the source order’s id, or the payout floats unlinked from its order.

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.
ORIGIN-OP-TS-CODE     e.g.  ZANA-PAY-20260531221015-8E2884A47B1C
SegmentRule
ORIGINYour app’s slug — uppercase, 2–12 chars (e.g. ZANA). SandPay itself uses SP.
OP3-letter operation: PAY (collection), REF (refund), DIS (disbursement), ABO (subscription).
TSCompact UTC datetime YYYYMMDDHHMMSS — when the reference was minted (sortable).
CODE12-char uppercase hex. Random for a fresh attempt, or derived from a stable id for idempotent retries.
Idempotency: the WHOLE string must be reproducible. For a NEW attempt each call (e.g. a fresh payment attempt for an order), use a random CODE + current TS. For an operation that must dedupe on retry (e.g. a specific refund), make it fully reproducible: a CODE derived from a stable id (sha256(id) → first 12 hex) and a stable TS (e.g. the source entity’s timestamp) — otherwise a retried refund whose TS/CODE drift would refund twice.
The SDK (@sandpay/node ≥ 0.2.1) ships helpers so you don’t hand-roll this:
import { buildReference } from "@sandpay/node";

// Fresh payment attempt → random CODE + current TS
const reference = buildReference({ origin: "ZANA", operation: "PAY" });
// Idempotent refund → derived CODE + stable TS (the original payment's date)
const refundRef = buildReference({
  origin: "ZANA",
  operation: "REF",
  seed: refundId,
  at: originalPaymentCreatedAt, // Date | epoch-ms — keeps the TS stable on retry
});
// Subscription charge
const subRef = buildReference({ origin: "ZANA", operation: "ABO", seed: `${subId}:${period}` });
Hand-rolling (no SDK)? 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

SandPay POSTs 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.
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  if (!header || !secret) return false; // fail closed
  const expected =
    "sha256=" +
    createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  if (expected.length !== header.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
On Deno / Supabase Edge, use WebCrypto (crypto.subtle.importKey + sign) and a constant-time hex compare.
Match the event by tx_id, not your reference. The webhook does not echo the reference you sent. Relate the event to your row by tx_id — the id you stored at creation.

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):
{
  "event": "payment.completed",
  "tx_id": "TX_8K3M9F",
  "org_id": "org_123",
  "env_id": "env_456",
  "country": "RW",
  "operator": "mtn",
  "amount": "25000",
  "commission": "250.00",
  "net_amount": "24750.00",
  "customer_total": "25000.00",
  "merchant_absorption_pct": 100,
  "merchant_share": "250.00",
  "customer_share": "0.00",
  "commission_mode": "merchant",
  "currency": "RWF",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "status": "SUCCESS",
  "latency_ms": 1240,
  "created_at": "2026-05-24T10:30:45.000Z",
  "completed_at": "2026-05-24T10:30:46.652Z",
  "scenario": "success",
  "provider_tx_id": "SIM_A1B2C3D4",
  "description": "Premium upgrade",
  "raw": {
    "_simulated": true,
    "status": "SUCCESSFUL",
    "financialTransactionId": "SIM_A1B2C3D4"
  }
}
Return any 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:
    npx inngest-cli@latest dev -u http://localhost:3800/api/inngest
    
    with INNGEST_DEV=1 in 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 set SANDPAY_BASE_URL / the webhook URL once.
# 1. Install ngrok
#    macOS:    brew install ngrok
#    Linux:    sudo snap install ngrok   (or download from ngrok.com/download)
#    Windows:  scoop install ngrok       (or download the .exe)
#    npm:      npm install -g ngrok

# 2. Authenticate (one time) — copy your token from
#    https://dashboard.ngrok.com/get-started/your-authtoken
ngrok config add-authtoken <YOUR_AUTHTOKEN>

# 3. Claim your free static domain (one time) at
#    https://dashboard.ngrok.com/cloud-edge/domains  → "New Domain"
#    You get one free reserved domain like:  your-name.ngrok-free.app

# 4. Start the tunnel with your STATIC domain (URL never changes)
ngrok http 3800 --domain=your-name.ngrok-free.app
#    (use the port of whatever you're exposing: 3800 SandPay, 4000 Express/Hono,
#     8000 FastAPI, 3000 Next.js, 54321 Supabase functions)

# 5. Set the base URL / webhook URL ONCE — it stays valid across restarts:
#    SANDPAY_BASE_URL=https://your-name.ngrok-free.app
The --domain=<static> flag is the whole point: the URL is the same every time, so you set SANDPAY_BASE_URL / the webhook URL once and never touch it again. Plain ngrok http (no --domain) and cloudflared quick-tunnels rotate the subdomain on every restart — forcing you to re-set the secret and re-paste the webhook URL each time. Avoid them for repeated local testing.

7. Multi-environment

Each operator + 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 pass country / operator explicitly.
  • 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 no scenario, the outcome is driven by the merchant’s test clients:
    • unknown number → UNKNOWN_MSISDN
    • blocked SIM → ACCOUNT_BLOCKED
    • balance below customerTotalINSUFFICIENT_FUNDS
    • otherwise → PENDING (the customer confirms on the BigPhone screen)
    Create test clients in /clients first, or every number returns UNKNOWN_MSISDN.
  • Explicit scenario forces 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.

Pay-first ordering

Create the order as a draft, call SandPay, and:
  • init failsroll 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.
You never want an order lingering for a payment that never started. Surface synchronous init failures to the UI (error message + back to the payment screen), not just a silent rollback.

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.
Client ──(operator, phone, amount)──▶ YOUR BACKEND  (initiate-payment)
                                        │ 1. create order as `draft`
                                        │ 2. POST /v1/payments (Bearer key)
                                        │ 3a. fail → rollback (delete draft) → error
                                        │ 3b. ok   → tx "initiated" → return id to client
Client (waiting screen) ◀───────────────┘
   ├─ webhook resolution (instant)            ── SandPay ──(signed webhook)──▶ YOUR BACKEND
   └─ polling GET /v1/payments/{id} (~4s)        (payment-webhook): verify HMAC,
                                                  match by tx_id, update row,
                                                  reconcile merchant ledger on netAmount
   terminal = SUCCESS / a failure / USER_CANCELLED → waiting screen resolves
   everything else (PENDING) → keep polling

Reconciliation

When a transaction settles, credit your merchant ledger with netAmount (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/payments doesn’t double to //api/v1/payments.
  • SANDPAY_API_KEY + SANDPAY_WEBHOOK_SECRET are 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, then tx_id / txId / transaction_id, then nested data.id / payment.id).
  • All 11 statuses are mapped, and USER_CANCELLED is terminal.
  • The webhook receiver is deployed with --no-verify-jwt, verifies the HMAC signature constant-time + fail-closed, and matches by tx_id (not reference).
  • Webhook URL is pasted into the SandPay dashboard; the secret is identical on both sides.
  • Merchant proceeds are reconciled on netAmount, not amount, 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.