Modes (test, sandbox, live)
The most important concept to internalize before integrating.
One axis, three values
Section titled “One axis, three values”Every app on the gateway has mode = test | sandbox | live:
app.mode | Routes to | Use when |
|---|---|---|
test | Mock provider — gateway returns a /mock/checkout/... URL and never touches a real PSP. | Wiring up the integration locally with no PSP setup and no real money. |
sandbox | Real PSP sandbox credentials and hosts (NICEPAY’s dev.nicepay.co.id, Polar’s sandbox-api.polar.sh, PayPal’s api-m.sandbox.paypal.com). | Integrating against the actual PSP without real money — full webhook round-trip, real signature verification, real edge cases. |
live | Real PSP live credentials and hosts. | Production. Real charges. |
Mode is per-app, not per-request, not gateway-global
Section titled “Mode is per-app, not per-request, not gateway-global”The gateway loads both sandbox and live credentials for every registered PSP at startup. POST /v1/payments does NOT carry a mode field — mode is implicit in which (public_key, secret) pair signed the request, because each app has exactly one mode set at creation time.
Two products at different modes can run side-by-side on the same gateway: book on live, fotoyu on sandbox, Quay on test. No env flip needed to switch any single product.
Recommended pattern: one app per (product, mode)
Section titled “Recommended pattern: one app per (product, mode)”A typical uncle-z product creates two apps:
<product>-sandbox # mode=sandbox<product>-live # mode=liveStores both credential sets side by side. The deployment env (PAYMENT_GATEWAY_PUBLIC_KEY / PAYMENT_GATEWAY_SECRET / PAYMENT_GATEWAY_WEBHOOK_SECRET) carries one triple matching the active app. Flipping sandbox→live in production = swap three env vars + restart the product.
The hard credential separation is the point: a leaked sandbox key cannot make a live charge because the row in the gateway’s apps table has mode=sandbox and the registry routes accordingly.
Per-PSP availability
Section titled “Per-PSP availability”Not every PSP has every mode wired in production today. As of last update:
| PSP | test | sandbox | live |
|---|---|---|---|
| Mock | ✓ | ✓ | ✓ |
| Polar | (n/a) | ✓ | ✓ |
| NICEPAY | (n/a) | ✓ | (pending merchant approval) |
| PayPal | (n/a) | ✓ | (pending KYC) |
When a live-mode app picks a PSP that has no live credentials loaded, the gateway falls back to the mock provider — surfacing the misconfiguration as a mock/checkout/... URL rather than a 500. Operators can spot this on the /info endpoint.
Switching modes
Section titled “Switching modes”There’s no special “promotion” workflow. To take a product live:
- Operator confirms the PSP’s live credentials are loaded on the gateway (
/infoshows theliveinstance for that PSP). - Product team swaps their three env vars from
<product>-sandboxkeys to<product>-livekeys. - Restart the product’s deployment.
The gateway’s behavior changes per-app, not per-deploy. Other products running concurrently are unaffected.
Why not just one set of keys with a mode field on each request?
Section titled “Why not just one set of keys with a mode field on each request?”Because that would let a leaked key make charges in any mode the gateway supports. Hard credential separation is the security property. The slight extra ergonomic overhead (two app rows per product) buys it.