Every call to POST /v1/payments can specify a scenario field that deterministically controls the simulation outcome: final status, latency, and error message. This is the primary lever for testing your failure paths.
Reference table
| ID | Label | Status produced | Latency (ms) | Severity |
|---|
success | Success | SUCCESS | 800–1 800 | ok |
pin_invalid | Invalid PIN | PIN_INVALID | 600–1 400 | error |
low_balance | Insufficient funds | INSUFFICIENT_FUNDS | 600–1 400 | error |
timeout | Timeout | TIMEOUT | 25 000–35 000 | warn |
blocked | Blocked account | ACCOUNT_BLOCKED | 500–1 000 | error |
cancelled | User cancelled | USER_CANCELLED | 3 000–8 000 | warn |
unknown_msisdn | Unknown number | UNKNOWN_MSISDN | 400–900 | error |
limit_exceeded | Limit exceeded | LIMIT_EXCEEDED | 500–1 000 | error |
maintenance | Service under maintenance | SERVICE_UNAVAILABLE | 200–600 | warn |
duplicate | Duplicate reference | DUPLICATE_REFERENCE | 200–500 | error |
Latency is drawn randomly within the stated range on every call — reproducing the natural variance of a real operator.
Pass an explicit scenario to force a deterministic result (ideal for tests and CI). If you omit it, the outcome depends on your test client registry — see the section below.
Without a scenario: the SIM registry (test clients)
If you omit the scenario field, SandPay behaves like a real operator and uses your test clients (/clients in the dashboard) as a SIM registry:
| Case | Status returned | Final? |
|---|
| Unknown number (no matching test client) | UNKNOWN_MSISDN | yes |
| Client found + blocked | ACCOUNT_BLOCKED | yes |
| Active client + balance < amount | INSUFFICIENT_FUNDS | yes |
| Active client + balance ≥ amount | PENDING | no — client confirms on their device |
Create at least one test client in /clients before sending a payment without a scenario, otherwise all numbers return UNKNOWN_MSISDN. For a deterministic result, pass an explicit scenario instead — it always takes precedence.
Configurable per environment: the unknownMsisdnPolicy is reject by default (unknown number → UNKNOWN_MSISDN). In passthrough mode, an unknown number is forwarded to the operator adapter (legacy behaviour). The blocked and insufficient-funds cases always remain final.
Example — triggering pin_invalid
curl -X POST https://api.sandpay.dev/v1/payments \
-H "Authorization: Bearer $SANDPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "FCFA",
"operator": "mtn",
"country": "CI",
"msisdn": "+22507123456",
"reference": "ORDER-PIN-01",
"scenario": "pin_invalid"
}'
Response:
{
"id": "TX_2L9P4R",
"status": "PIN_INVALID",
"latencyMs": 940,
"scenario": "pin_invalid"
}
Statuses and finality
The 11 canonical statuses are stable — your switch can enumerate them. PENDING is the only non-final status: it indicates a transaction is currently being processed (useful for async flows). All other statuses are final and trigger the payment.completed webhook.
Out-of-scenario: description
The description field (optional) is purely informational — it is stored on the transaction and returned in the webhook payload, but does not influence the outcome.
Raw operator response (raw)
Every payment resource (POST /v1/payments, GET /v1/payments/{id}, items in GET /v1/payments, and the payment.completed webhook) contains a raw field with the native operator shape corresponding to the scenario played.
This shape is synthesised with _simulated: true at the top level — it faithfully reproduces the native structure returned by each operator.
MTN example — scenario: "success":
{
"_simulated": true,
"amount": "25000",
"currency": "FCFA",
"externalId": "ORDER-2026-A1",
"payer": { "partyIdType": "MSISDN", "partyId": "22507123456" },
"payerMessage": "Premium upgrade",
"payeeNote": "ORDER-2026-A1",
"status": "SUCCESSFUL",
"financialTransactionId": "SIM_A1B2C3D4"
}
Moov example — scenario: "low_balance":
{
"_simulated": true,
"responseCode": "51",
"responseMessage": "Insufficient funds",
"transactionId": "MV-SIM_A1B2C3D4",
"externalReference": "ORDER-2026-A1",
"amount": "25000",
"currency": "FCFA",
"phoneNumber": "22507123456",
"timestamp": "2026-05-23T10:14:02.412Z"
}
Orange example — scenario: "success" (note: the SUCCESSFULL typo is faithfully reproduced from the real API):
{
"_simulated": true,
"status": "SUCCESSFULL",
"subscriber_msisdn": "22507123456",
"amount": 25000,
"txnid": "SIM_A1B2C3D4",
"pay_token": "ORANGE_SIM_A1B2C3D4",
"confirmtxnstatus": "200",
"confirmtxnmessage": "Transaction successfully processed",
"order_id": "ORDER-2026-A1"
}
See also