Sandbox
Every account gets a full sandbox. Sandbox traffic runs against a payment simulator: it never touches real providers and no real money can move, while behaving exactly like production, including fees, webhooks and reconciliation.
Get your test keys
Create a free account, then open Developers > API keys in the dashboard. Sandbox keys start with sk_test_ and are shown once, so store the key the moment it appears. Sandbox keys never touch a real provider and never move real money.
Sandbox keys
Use your sk_test_ secret key. The same endpoints, envelopes and error catalog apply in both environments; only the key decides where you are.
Authorization: Bearer sk_test_XXXXXXXXXXXX # sandbox Authorization: Bearer sk_live_XXXXXXXXXXXX # production
Try it
Price a 5,000 XAF mobile money collection in Cameroon. This is safe to run as often as you like:
curl "https://api.capayglobal.com/v1/pricing?direction=collection&country=CM¤cy=XAF&method=mobile_money&amount=5000" \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
Choosing an outcome
The simulator picks a scenario in this order. The X-Scenario-Key header wins whenever it is present; otherwise the last four digits of the MSISDN are looked up; otherwise you get success.
X-Scenario-Key: failed.insufficient_funds # 1. wins whenever it is present +237650000002 # 2. magic suffix 0002, used when no header was sent success # 3. the default for every other number
The header is strict: an unknown key, or a key that does not apply to the resource you are creating, answers 422 with a validation_errors entry for X-Scenario-Key. It is ignored entirely on an sk_live_ key, so leaving it in production code changes nothing.
curl -X POST https://api.capayglobal.com/v1/collections \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Idempotency-Key: col-order-10483-d4e5f6" \
-H "X-Scenario-Key: failed.insufficient_funds" \
-H "Content-Type: application/json" \
-d '{
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"merchant_reference": "order-10483"
}'Test scenarios
A scenario scripts the whole lifecycle of one transaction: which statuses it passes through, after how many seconds and with which reason_code. The timings are what the simulator waits before each step, so a run is deterministic without being instant. Direction says which resources a scenario applies to.
| Scenario key | Direction | Timeline | What it is for |
|---|---|---|---|
| success | any | processing 4s, completed 8s | The happy path, and the default when nothing else matches. |
| success.late | any | processing 4s, completed 90s | Slow finality: exercises your pending-state screens and your patience with requery. |
| failed.insufficient_funds | any | processing 4s, failed 8s (insufficient_funds) | The payer's balance was too low. |
| failed.invalid_customer | any | failed 4s (invalid_customer), no processing phase | An unknown or unregistered number, rejected before any processing phase. |
| failed.provider_error | any | processing 4s, failed 8s (provider_error) | An unrecoverable provider-side error. |
| failed.payer_timeout | collection | pending until failed 30s (payer_timeout) | The payer never answered the prompt. Collections only. |
| cancelled | any | cancelled 10s (cancelled_by_payer) | The payer declined the prompt. |
| expired | any | expired 60s (expired) | Nothing happened before the transaction expired. |
| enqueued.then_success | payout | enqueued 4s (provider_unavailable), processing 24s, completed 28s | The provider is down or the breaker is tripped: the payout is held with the reserve kept, then drains on recovery. Payouts only. |
| in_reconciliation.then_resolved | any | processing 4s, in_reconciliation 8s (amount_mismatch), completed 30s | The amount came back different, a reconciliation item opened, and a human resolved it. Never treat in_reconciliation as failed. |
| webhook.duplicate | any | processing 4s, completed 8s, completed again 12s (duplicate) | completed is delivered twice. Your receiver must make the second one a no-op. |
| webhook.out_of_order | any | completed 4s, processing 8s | processing arrives after completed. Your receiver must not let it regress the order. |
Magic phone numbers
When you send no X-Scenario-Key, the last four digits of the payer or recipient number choose the scenario. This is the easiest way to test a checkout form, where you cannot set a header.
| Last 4 digits | Example number | Scenario key |
|---|---|---|
| 0001 | +237650000001 | success |
| 0002 | +237650000002 | failed.insufficient_funds |
| 0003 | +237650000003 | failed.invalid_customer |
| 0004 | +237650000004 | failed.payer_timeout |
| 0005 | +237650000005 | expired |
| 0006 | +237650000006 | enqueued.then_success |
| 0007 | +237650000007 | in_reconciliation.then_resolved |
| 0008 | +237650000008 | webhook.duplicate |
| 0009 | +237650000009 | success.late |
Any other number resolves to success. The suffix is a convenience, not a contract: when a suffix maps to a scenario that does not apply to the resource you are creating, the simulator falls back to success rather than failing your request.
Sandbox helpers
POST /v1/test/fund credits your sandbox available balance so payouts and refunds have something to spend. There is a cap per call; over it the request answers 422.
curl -X POST https://api.capayglobal.com/v1/test/fund \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{ "amount": "100000", "currency": "XAF" }'POST /v1/test/advance makes every pending simulator step for one resource due immediately, so you do not wait 90 seconds for success.late. It answers how many steps it advanced, which is 0 once the resource is terminal. With resource_type subscription it is the subscription test clock: the next payment prompt falls due now and goes out.
curl -X POST https://api.capayglobal.com/v1/test/advance \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{ "resource_type": "collection", "resource_id": "col_7Qm3k1Xa9Zr4" }'Both helpers exist only in the sandbox. Called with an sk_live_ key they answer 403 sandbox_only: production money is never minted or fast-forwarded.
Test your webhook receiver
Duplicate and out-of-order deliveries are normal in production, so two scenarios reproduce them on demand: webhook.duplicate delivers completed twice, and webhook.out_of_order delivers processing after completed. A receiver that survives both is a receiver that survives production.
The delivery contract and a worked idempotent receiver are in the webhooks guide, and the CLI forwards sandbox events to localhost: Webhooks, CLI.
Handle every status
The sandbox produces every status the production simulator can produce, including enqueued and in_reconciliation. Handle each one explicitly and treat anything you do not recognise as undetermined: keep polling or wait for the webhook, never conclude the payment failed.