Environments & modes
How 0Gate separates test (sandbox) and live (production) — encoded in your key prefix, bound to dedicated API and widget hosts, with isolated data, webhook secrets, and sessions.
0Gate runs in two modes: test (sandbox) and live (production). The mode is not a flag you toggle at request time — it is baked into the prefix of the API key you authenticate with (pk_test_… / sk_test_… vs pk_live_… / sk_live_…) and reinforced by the host you send the request to. A test key talks to the sandbox; a live key talks to production. There is no third "staging" — localhost is simply the live/sandbox software running on your own machine during development.
The point of this split is simple: test exhaustively without moving real money, then flip to live by swapping one credential. Both modes run the same API surface, the same GateSession lifecycle, and the same webhook contract — so anything you build against sandbox behaves identically in production.
Test vs live
| Mode | Key prefixes | What it does |
|---|---|---|
| test (sandbox) | pk_test_…, sk_test_… | Simulated end-to-end. Test cards and test bank flows, no real funds, no real KYC, no on-chain settlement. A sandbox test helper can force-complete a session to fire gate_session.completed on demand. |
| live (production) | pk_live_…, sk_live_… | The real ramp. Real fiat rails, real KYC and sanctions screening, real chargeback handling, and real on-chain settlement. Every payment moves money. |
The mode is read from the key, server-side. You never pass a mode parameter — using a sk_test_ key is test mode, and the GateSession it creates carries mode: "test". To go live, you replace the test key with the matching live key and point at the live hosts. Nothing else in your code changes.
Mode is decided by the key prefix
Both key types carry the mode in the prefix itself: sk_<test|live>_<…> and pk_<test|live>_<…>. The backend reads it off the credential before it touches your request body — there is no mode field on POST /v1/gate_sessions, and you cannot ask a test key to create a live session (or vice versa). The mode then propagates: the session it mints carries mode: "test" or mode: "live", and the embed JWT issued at bootstrap stamps the same mode claim onto every downstream iframe call.
What sandbox simulates
In test mode the same non-custodial machinery runs, but its external effects are simulated rather than real:
- No real funds move on any fiat rail.
- No real KYC — identity verification and AML/sanctions screening are simulated, so you never need a real document or a verified wallet to exercise the flow.
- No on-chain settlement — crypto delivery is simulated, so
gate_session.completedfires without a real chain transaction. - Test helpers let you force-complete a session on demand instead of waiting for a real settlement (see below).
Forcing terminal states in sandbox
Sandbox exposes two helper endpoints (controller prefix v1/test_helpers) so you can drive your integration without performing a real payment. These exist only in test mode and are not part of the OpenAPI spec. They are guarded by PartnerSecretKeyGuard, so call them server-side with your sk_test_ key — a live key is rejected with a 403:
| Endpoint | Effect |
|---|---|
POST /v1/test_helpers/sessions/{id}/complete | Marks the session completed and fires gate_session.completed. |
POST /v1/test_helpers/sessions/{id}/fail | Marks the underlying intent failed; the parent session stays open and no webhook is emitted. Useful for exercising your in-widget error UX, not a webhook branch. |
The complete helper is how you exercise the gate_session.completed webhook branch in CI before a single real payment exists.
Hosts & base URLs
Each mode has its own API base URL and its own widget iframe origin. Use the host that matches your key. These are the servers declared in the OpenAPI contract (0gate-v1.yaml, version 2026-05-25):
| Surface | test (sandbox) | live (production) | local (development) |
|---|---|---|---|
| API base | https://gate-api-sandbox.0bit.app/v1 | https://gate-api.0bit.app/v1 | http://localhost:4000/v1 |
| Widget iframe | https://gate-sandbox.0bit.app | https://gate.0bit.app | http://localhost:3000 |
| Partner Hub | https://portal.0bit.app | https://portal.0bit.app | — |
The Partner Hub is a single console for both modes — you switch between your test and live keys, webhook endpoints, and analytics inside it.
The OpenAPI base URLs include /v1, while the SDK constructors take the API origin only. If you override baseUrl /
base_url in the SDK, use https://gate-api-sandbox.0bit.app or https://gate-api.0bit.app, not the /v1 URL.
SDK base URL shape
The audited Node and Python SDKs default to the production API origin. Resource methods append /v1/... internally.
Override the origin when testing against sandbox or local development.
| SDK | Constant | Default value |
|---|---|---|
@0bit/gate | DEFAULT_BASE_URL (src/node/client.ts) | https://gate-api.0bit.app |
0bit-gate (Python) | _DEFAULT_BASE_URL (python/zerobit/gate/client.py) | https://gate-api.0bit.app |
There is no auto-detection from the key prefix. A sk_test_ key should use the sandbox origin; a sk_live_ key should use the production origin.
Overriding baseUrl on the server SDKs
Set the option to the origin for the mode you're using: https://gate-api.0bit.app for live or https://gate-api-sandbox.0bit.app for sandbox. The SDKs strip any trailing slash for you (.replace(/\/+$/, '')).
import { GateClient } from '@0bit/gate';
const gate = new GateClient({
apiKey: process.env.GATE_KEY, // sk_test_…
baseUrl: 'https://gate-api-sandbox.0bit.app',
// timeoutMs?: number — default 30_000 (30s)
// maxRetries?: number — default 2 (up to 3 total attempts; 0 disables)
});from zerobit.gate import GateClient
gate = GateClient(
api_key=os.environ["GATE_KEY"], # sk_test_…
base_url="https://gate-api-sandbox.0bit.app",
# timeout_seconds=30.0, max_retries=2 — same defaults as Node
)The example env var GATE_KEY is a convention used in the SDK examples only — the SDKs never read environment variables themselves. The API key and base URL are always passed explicitly to the constructor.
Browser SDK: environment, apiBaseUrl, iframeUrl
The browser SDK (@0bit/gate/browser, class GateRamp) resolves hosts differently from the server SDKs. Instead of one baseUrl, it takes an environment label and looks up a pair of hosts — the API origin and the iframe origin — from a built-in table (src/browser/environments.ts):
environment | apiBaseUrl | iframeUrl |
|---|---|---|
'production' (default) | https://gate-api.0bit.app | https://gate.0bit.app |
'sandbox' | https://gate-api-sandbox.0bit.app | https://gate-sandbox.0bit.app |
'development' | http://localhost:3000 | http://localhost:3001 |
Override them with explicit apiBaseUrl and iframeUrl options only for local development or a reviewed environment override:
import { GateRamp } from '@0bit/gate/browser';
const ramp = new GateRamp({
publishableKey: 'pk_test_…',
clientSecret: session.client_secret, // recommended (session-bound)
apiBaseUrl: 'https://gate-api-sandbox.0bit.app', // bare origin — see note
iframeUrl: 'https://gate-sandbox.0bit.app',
});
await ramp.mount('#ramp', {
onSuccess: ({ txId, sessionId }) => { /* … */ },
onError: ({ message }) => { /* … */ },
onClose: () => { /* … */ },
});The browser apiBaseUrl is a bare origin, not a /v1 URL. The browser SDK builds its only API call as `${apiBaseUrl}/v1/embed/bootstrap`, so it appends /v1 itself — pass https://gate-api-sandbox.0bit.app, not …/v1. This is the opposite of the server SDKs, whose baseUrl includes /v1. The iframeUrl is always a bare origin (it is parsed with new URL(...).origin for the postMessage targetOrigin check).
Hosts feed the postMessage origin check
The resolved iframeUrl is not cosmetic — GateRamp derives iframeOrigin = originOf(iframeUrl) and rejects any message event whose event.origin doesn't match (and whose event.source isn't the iframe it created). A wrong iframeUrl therefore doesn't just load the wrong widget; it silently drops every WIDGET_READY / PAYMENT_* message, so mount() rejects after its 15s WIDGET_READY timeout. Hosted-redirect mode (GateRamp.redirectToCheckout) uses the same resolution to build the redirect URL.
Key–mode binding
The mode lives in the credential, and the credential is enforced at three layers.
One prefix, one mode, one host
A sk_test_ / pk_test_ key only authenticates against the sandbox host; a sk_live_ / pk_live_ key only against production. The prefix and the host enforce this on both ends — there is no request-time switch, no shared "staging" key, and no way to mix a test key with the live host. If a request's key prefix and target host disagree on the mode, it fails.
Publishable vs secret — by mode
Each mode issues both kinds of key, and the kind dictates where it may be used and what it may do — independently of the mode:
| Key | Prefix (per mode) | Where it lives | Wire usage |
|---|---|---|---|
| Secret | sk_test_… / sk_live_… | Server only — never in browser/mobile | Authorization: Bearer <key> (or X-Secret-Key); never accepted via query string. Creates/retrieves/cancels sessions. |
| Publishable | pk_test_… / pk_live_… | Browser-safe | Authorization: Bearer <key> (or X-Publishable-Key; pk_ may also go in a query string). Bootstrap only — POST /v1/embed/bootstrap. |
The browser SDK enforces this client-side too: GateRamp throws SecretKeyInBrowserError if you hand it a key matching ^sk_, and ConfigError if the key doesn't match ^pk_(test|live)_.
The embed token inherits the mode
When the browser exchanges a pk_ (plus an optional clientSecret) at POST /v1/embed/bootstrap, it gets back a short-lived HS256 JWT (default ~1h TTL) sent as X-Embed-Token on subsequent iframe calls. That JWT carries a mode claim copied straight from the publishable key, so the mode flows all the way through the iframe runtime — a test bootstrap can never produce a live-mode embed token.
Never put a secret key (sk_test_ or sk_live_) in browser, mobile, or any client-side code. Secret keys create and manage sessions server-side; the browser only ever sees a publishable key (pk_…, used solely for POST /v1/embed/bootstrap) and the one-time client_secret. This rule applies in both modes — a leaked sk_test_ key is still a credential leak.
What's shared vs separate across modes
This is the most important mental model: the two modes share your identity but share nothing else.
How a request resolves to a mode
The client_secret (gsec_<id>_<rand>) minted in one mode only works against that mode's widget host — a sandbox session cannot be opened in the production widget, and vice versa.
What stays identical across modes
Because both modes run the same software, the things you actually integrate against do not change between test and live:
- The endpoints —
POST /v1/gate_sessions,GET /v1/gate_sessions/{id},POST /v1/gate_sessions/{id}/cancel,POST /v1/embed/bootstrap. - The
GateSessionshape and its lifecycle statuses (open→completed/expired/cancelled; failure is modeled at the intent level / surfaced via webhooks, not as a session status). - The webhook verification scheme —
Gate-Signatureover the raw request body. Event names and payload fields should match the current webhook reference for your contract. - The non-custodial model — 0Bit runs KYC, AML/sanctions screening, chargebacks, fiat rails, and on-chain settlement in both modes — sandbox simulates these, live performs them for real, but you never rebuild the ramp UI or the compliance stack either way.
This is what makes the test → live cutover a one-line credential swap rather than a re-integration.
Going from sandbox to live
Because the surface is identical, the cutover is a small, mechanical set of swaps — not a re-integration.
The cutover checklist
- Swap the server key. Replace
sk_test_…withsk_live_…in your server environment. - Repoint the server SDK origin. Change
baseUrl/base_urlfromhttps://gate-api-sandbox.0bit.apptohttps://gate-api.0bit.app. - Swap the publishable key. Replace
pk_test_…withpk_live_…wherever the browser SDK is constructed. - Repoint the browser SDK. Set
environment: 'production', or explicitly setapiBaseUrltohttps://gate-api.0bit.appandiframeUrltohttps://gate.0bit.appfor a reviewed override. - Swap the webhook secret. Verify live deliveries with your live
whsec_…, not the test one. - Allowlist your live domain. Make sure the page that embeds the widget is in your partner
allowed_domains(exact match — no wildcards), orPOST /v1/embed/bootstrapwill reject the live Origin.
Before you flip: drive the completed webhook path in sandbox
Use the sandbox complete test helper (POST /v1/test_helpers/sessions/{id}/complete, called server-side with your sk_test_ key) to force a completion and confirm your webhook handler correctly verifies the Gate-Signature and reacts to gate_session.completed. The fail helper marks the test intent failed while the session stays open and emits no webhook — use it to exercise your in-widget error UX, not a webhook branch. Settlement truth is the webhook, not the browser PAYMENT_SUCCESS postMessage — so this is the path that must be solid before live money moves.
Don't forget the live-only events. Some events you will only ever observe in live: real kyc.required (sandbox KYC is simulated) and the partner.quota.warning / partner.quota.exhausted quota signals. The browser SDK surfaces quota exhaustion to your UI via the onUnavailable mount callback (driven by the bootstrap response's available: false / unavailable_reason), so handle that state before launch.
There is nothing else to change. The endpoints, the GateSession object, the idempotency contract (Idempotency-Key on writes), and the postMessage protocol are byte-for-byte the same between modes. If it worked in sandbox, it works in live.