Skip to main content
The short, battle-tested answers to the questions developers run into while wiring SandPay into a real app. If you only read one thing: reconcile on net_amount, match webhooks by tx_id, and treat polling as your safety net.
This page is the quick Q&A companion. The complete, authoritative contract — endpoints, statuses, commission, webhooks, and the recommended architecture — lives in the Integration Guide. When in doubt, the guide is the source of truth.

FAQ

SandPay dispatches webhooks through Inngest. The behaviour differs by where SandPay itself is running:
  • Local SandPay (next dev): the webhook only fires 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 set 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 in that setup.
  • Cloud SandPay (deployed + Inngest cloud): webhooks fire reliably and automatically — use them as your primary signal.
Separately, SandPay also needs a publicly-reachable URL for your receiver. In local dev, expose your handler with ngrok using a reserved static domain so the URL is stable and you set the webhook URL once:
ngrok http 4000 --domain=your-name.ngrok-free.app   # use your backend's port
Claim the free static domain once at dashboard.ngrok.com/cloud-edge/domains. Plain ngrok http and cloudflared quick-tunnels rotate the URL on every restart — avoid them for repeated local testing.Either way, keep a polling fallback (see “Webhook vs polling” below).
SandPay applies an operator commission on every payment. Three amounts matter:
  • amount — what you requested.
  • customer_total — what the customer’s SIM is debited (= net_amount + commission).
  • net_amount — what the merchant is credited (= amount − merchant_share).
By default (merchant_absorption_pct = 100) the merchant absorbs the whole fee: the customer pays exactly amount, and the merchant receives amount − commission. The rate (commission_bps) and absorption split are configurable per environment in Settings → Countries & operators.See Reconcile merchant proceeds for what to record.
On your backend, only. The sp_sk_test_… key must never appear in a web bundle, a mobile app, or any client-side code — anyone who sees it can charge against your account. Your client calls your backend; your backend calls SandPay with the bearer key. See Authentication for the full contract.
SandPay is multi-environment. Each operator+country pair is one environment you configure in Settings → Countries & operators. At checkout you don’t pick the operator manually — it’s auto-detected from the payer’s MSISDN (e.g. +25078…/+25079… → MTN Rwanda). To add a new operator: enable its environment in Settings, then add the matching entry to your integration’s env table (the starter kit calls this ENABLED_ENVS) and restart the backend.
Two ways:
  • Force an outcome — pass an explicit scenario on the create request (success, pin_invalid, low_balance, timeout, blocked, cancelled, unknown_msisdn, limit_exceeded, maintenance, duplicate). Deterministic, no setup. See Scenarios.
  • Use test_clients as a SIM registry — omit scenario and let the merchant’s test_clients drive the outcome: an unknown number → UNKNOWN_MSISDN, a blocked SIM → ACCOUNT_BLOCKED, a SIM whose balance is below customer_totalINSUFFICIENT_FUNDS, otherwise PENDING (the customer confirms on the BigPhone screen). Create test_clients in /clients first, or every number returns UNKNOWN_MSISDN.
Use net_amount from the webhook / Payment, not amount. amount is the gross the customer was quoted; net_amount is what actually lands with the merchant after the operator commission. Crediting your merchant ledger on amount over-credits by the commission. net_amount is null while a tx is PENDING — only post the credit once the tx is terminal and net_amount is populated.
Map all eleven so nothing falls through to a non-terminal default:SUCCESS, PIN_INVALID, INSUFFICIENT_FUNDS, TIMEOUT, ACCOUNT_BLOCKED, USER_CANCELLED, UNKNOWN_MSISDN, LIMIT_EXCEEDED, SERVICE_UNAVAILABLE, DUPLICATE_REFERENCE, PENDING.
Common pitfall: USER_CANCELLED is terminal. 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.
An hourly cron closes it as TIMEOUT (after ~60 min) and fires the payment.completed webhook. So an abandoned USSD prompt always resolves to a terminal outcome on its own — you don’t have to expire stale orders yourself. Handle TIMEOUT as terminal.
Both. The webhook gives you an instant push the moment a tx reaches its final state; 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.
SandPay models both directions. To reverse a prior SUCCESS collection, call POST /v1/payments/{id}/refund (sandpay.payments.refund(id, { amount }) — omit amount for a full refund). To send money to an msisdn that isn’t tied to a prior payment (cashback, top-up, payout), call POST /v1/disbursements (sandpay.disbursements.create({ … })).Both debit the merchant float and credit the recipient SIM. No commission is taken on a payout — the merchant already paid it on the original collection, and the operator doesn’t refund it (so a full refund leaves the merchant down by the original commission). Refunds fire a payment.refunded webhook; disbursements fire disbursement.completed. List them with GET /v1/payments?type=refund / ?type=disbursement. See §5 of the Integration Guide.
No. The refundable ceiling is the original gross amount minus the sum of prior successful refunds. Over-refunding returns 422 exceeds_refundable; a fully-refunded tx returns 422 fully_refunded. You can issue several partial refunds until the remaining refundable reaches zero. If the merchant float is too low, the refund is recorded as INSUFFICIENT_FUNDS and no money moves.

Tips

Pay-first ordering. Create the order as a draft, call SandPay, and roll the draft back (delete) if init fails. You never want an order lingering for a payment that never started.
Extract the id tolerantly. Read the transaction id 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.
Match the webhook 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).
Strip the trailing slash from your base URL. ${baseUrl}/api/v1/payments doubles to //api/v1/payments if SANDPAY_BASE_URL ends in /. Normalize with baseUrl.replace(/\/+$/, "").
USER_CANCELLED is terminal. Map it to a final “cancelled” state, or your waiting screen will poll to timeout when a payer refuses.
Reconcile on net_amount. Credit the merchant ledger with net_amount (post-commission), never the gross amount.
In local dev, expose your receiver or rely on polling. SandPay needs a reachable URL to deliver a webhook; in local dev either expose your handler with ngrok + a reserved static domain (ngrok http <port> --domain=your-name.ngrok-free.app — stable URL, set the webhook URL once) and run SandPay’s Inngest dev server, or just lean on polling.

See also

Integration Guide

The canonical, end-to-end contract — the source of truth.

Quickstart

First payment in 10 minutes — Stack Builder or manual.

Webhooks

Payload shape, signature verification, retries.

Scenarios

Force any operator outcome via the scenario field.