Sandbox
Test keys, simulated rails, and amounts that fail on purpose.
Sandbox is not a separate deployment. It is the same API, the same code paths
and the same validation, reached with a key that begins sk_test_.
The two worlds do not touch
A test key sees test data. A live key sees live data. Neither can see the other, and that includes:
- charges, payouts and webhook endpoints — filtered by which key asked
- idempotency keys — separate namespaces
That last one matters more than it sounds. If they shared a namespace, the order references you used while building would collide with your real ones the first time you re-ran your integration tests against production — and we would hand you back the test charge and tell you it had succeeded.
Amounts that fail on purpose
amount_minor | Outcome |
|---|---|
999999 | declined |
999998 | timed out |
999997 | succeeded, then reversed |
999996 | pending review |
Anything else succeeds.
An amount works for a charge and for a payout, needs no setup, and survives
being pasted into a form. The values are far enough from anything real that
nobody trips one by accident — 999999 minor units is 9,999.99, and a business
testing with round numbers will not land on it.
Destinations that fail on purpose
An amount is not always yours to choose — a payout against a saved beneficiary is for whatever the invoice says. So there are recipients that ask for an outcome too:
| Destination | Outcome |
|---|---|
+250788000001 | succeeds |
+250788000002 | declined |
+250788000003 | timed out |
+250788000004 | succeeded, then reversed |
+250788000005 | pending review |
These are inside Rwanda's reserved test range, so they cannot ring.
The destination wins where both are magic. Somebody who has gone to the trouble of naming a failing recipient means it more than they mean whatever amount they happened to type.
What a failure says
The simulated rail reports a fixed reason per outcome, so you can assert on it:
| Outcome | failure_reason |
|---|---|
| declined | insufficient_funds |
| timed out | upstream_timeout |
| reversed | returned_by_beneficiary |
| pending review | manual_review_required |
Match on the outcome and the reason, never on the message. Reasons from a real
rail are normalised into one vocabulary — insufficient_funds,
invalid_account, cancelled_by_payer, timeout, rejected_by_provider,
account_unavailable, unknown — so you never learn four partners'
vocabularies, and the human-readable text beside them may change at any time.
Balances do not move
Sandbox charges and payouts do not affect any real balance, including the test one. Nothing here reconciles against money.