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
Why isn't my webhook firing in local dev?
Why isn't my webhook firing in local dev?
SandPay dispatches webhooks through Inngest. The behaviour differs by
where SandPay itself is running:Claim the free static domain once at
dashboard.ngrok.com/cloud-edge/domains.
Plain
-
Local SandPay (
next dev): the webhook only fires if SandPay’s Inngest dev server is running and synced:withINNGEST_DEV=1set 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.
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).The amount debited differs from what I sent — why?
The amount debited differs from what I sent — why?
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).
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.Where do I put my API key?
Where do I put my API key?
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.How do I support multiple operators / countries?
How do I support multiple operators / countries?
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.How do I test a specific failure (insufficient funds, cancelled, …)?
How do I test a specific failure (insufficient funds, cancelled, …)?
Two ways:
- Force an outcome — pass an explicit
scenarioon 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
scenarioand let the merchant’s test_clients drive the outcome: an unknown number →UNKNOWN_MSISDN, a blocked SIM →ACCOUNT_BLOCKED, a SIM whose balance is belowcustomer_total→INSUFFICIENT_FUNDS, otherwisePENDING(the customer confirms on the BigPhone screen). Create test_clients in/clientsfirst, or every number returnsUNKNOWN_MSISDN.
How do I reconcile merchant proceeds?
How do I reconcile merchant proceeds?
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.What status values should I handle?
What status values should I handle?
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.What happens to a PENDING payment the customer never confirms?
What happens to a PENDING payment the customer never confirms?
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.Webhook vs polling — which should I use?
Webhook vs polling — which should I use?
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.How do I refund a customer, or send a payout?
How do I refund a customer, or send a payout?
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.Can I refund more than the original amount?
Can I refund more than the original amount?
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
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.