Skip to main content
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

IDLabelStatus producedLatency (ms)Severity
successSuccessSUCCESS800–1 800ok
pin_invalidInvalid PINPIN_INVALID600–1 400error
low_balanceInsufficient fundsINSUFFICIENT_FUNDS600–1 400error
timeoutTimeoutTIMEOUT25 000–35 000warn
blockedBlocked accountACCOUNT_BLOCKED500–1 000error
cancelledUser cancelledUSER_CANCELLED3 000–8 000warn
unknown_msisdnUnknown numberUNKNOWN_MSISDN400–900error
limit_exceededLimit exceededLIMIT_EXCEEDED500–1 000error
maintenanceService under maintenanceSERVICE_UNAVAILABLE200–600warn
duplicateDuplicate referenceDUPLICATE_REFERENCE200–500error
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:
CaseStatus returnedFinal?
Unknown number (no matching test client)UNKNOWN_MSISDNyes
Client found + blockedACCOUNT_BLOCKEDyes
Active client + balance < amountINSUFFICIENT_FUNDSyes
Active client + balance ≥ amountPENDINGno — 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