Skip to content

Modes (test, sandbox, live)

The most important concept to internalize before integrating.

Every app on the gateway has mode = test | sandbox | live:

app.modeRoutes toUse when
testMock 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.
sandboxReal 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.
liveReal 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.

Section titled “Recommended pattern: one app per (product, mode)”

A typical uncle-z product creates two apps:

<product>-sandbox # mode=sandbox
<product>-live # mode=live

Stores 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.

Not every PSP has every mode wired in production today. As of last update:

PSPtestsandboxlive
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.

There’s no special “promotion” workflow. To take a product live:

  1. Operator confirms the PSP’s live credentials are loaded on the gateway (/info shows the live instance for that PSP).
  2. Product team swaps their three env vars from <product>-sandbox keys to <product>-live keys.
  3. 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.