Test & go live
Run your 0Gate integration end to end in the sandbox with test keys and the 4242 test card, drive terminal states with the real test-helper routes, verify webhooks locally over a tunnel, then promote to production with a concrete go-live checklist.
Every 0Gate integration is built and proven in the sandbox first, then promoted to production by swapping credentials and hosts — same routes, same response shapes, no flow changes. This page covers both halves: testing safely in the sandbox (where no real money moves), and the exact checklist for going live.
0Gate is 0Bit's embeddable buy / sell / swap ramp. You create a session on your server, mount the widget in the browser, and treat the signed webhook as the authoritative settlement signal. If you have not built that flow yet, start with the 0Gate quickstart.
The whole point of sandbox: it moves no real money
pk_test_ / sk_test_ keys target the sandbox host https://gate-api-sandbox.0bit.app/v1 and the sandbox widget origin https://gate-sandbox.0bit.app. In sandbox, no fiat is charged, no crypto is settled, no payout is wired, and KYC is skipped — it runs on isolated test data so you can exercise the full lifecycle in seconds. Going live is a credentials-and-host swap, nothing more.
Use the right SDK base URL shape
Raw REST examples use the /v1 OpenAPI base URL. SDK constructors take the API origin and append /v1/... inside each resource method. Use https://gate-api-sandbox.0bit.app while testing and https://gate-api.0bit.app in production.
Part 1 — Test in the sandbox
Environments at a glance
| Surface | Sandbox (test) | Production (live) |
|---|---|---|
| Raw REST/OpenAPI base | https://gate-api-sandbox.0bit.app/v1 | https://gate-api.0bit.app/v1 |
SDK API origin (baseUrl) | https://gate-api-sandbox.0bit.app | https://gate-api.0bit.app |
| Widget iframe origin | https://gate-sandbox.0bit.app | https://gate.0bit.app |
| Secret key | sk_test_… | sk_live_… |
| Publishable key | pk_test_… | pk_live_… |
| Webhook secret | whsec_… (test) | whsec_… (live) |
| Money | Fake — nothing settles | Real fiat & on-chain settlement |
| KYC | Skipped | Real (0Bit's verification partner) |
| Partner Hub | portal.0bit.app | portal.0bit.app |
Local development can point baseUrl at http://localhost:4000/v1 if you run the backend yourself.
How a key chooses its environment
There is no separate "test mode" flag — the key prefix is the mode. A *_test_ key is test-mode; a *_live_ key is live-mode. The mode is baked into the session at create time (mode: "test" | "live" on the gate_session) and inherited by everything downstream: the embed token, the webhook event, and which test-helper routes will touch it. You cannot promote a test session to live or vice versa — you recreate it with the other key.
SDK maturity
The 0Gate SDKs (@0bit/gate, 0gate on PyPI, @0bit/gate/browser, @0bit/gate/react) are v0.1.0 — alpha / preview. The install commands below are the intended commands; pin versions and expect surface changes before 1.0.
1. Get sandbox keys
Sign in to Partner Hub (or receive them from the team during private beta) and grab your test pair plus the test-mode webhook signing secret:
GATE_KEY=sk_test_XyZwQrStuvwxyz0987654321zyxwvuts # server-only — creates sessions
VITE_GATE_PUBLISHABLE_KEY=pk_test_AbCdEfGh1234567890abcdefghijklmn # browser-safe — widget bootstrap
WEBHOOK_SECRET=whsec_... # verifies inbound webhook signaturesKeys are not read from env automatically
The SDKs do not read process.env themselves. These variable names are a convention — you always pass the value to the constructor (apiKey / api_key= / publishableKey). And the sk_ key never belongs in the browser: the browser SDK refuses an sk_ passed as publishableKey.
Where each key may travel
| Key | Sent how | Used for | Never |
|---|---|---|---|
sk_test_ (secret) | Authorization: Bearer or X-Secret-Key header — never a query string | Create / retrieve / cancel sessions; drive test helpers | The browser, a query string, a client bundle |
pk_test_ (publishable) | Authorization: Bearer, X-Publishable-Key, or query string | POST /v1/embed/bootstrap only | Anything beyond bootstrap |
Both are accepted in the canonical Authorization: Bearer <key> form (checked first), or via the explicit X-Secret-Key / X-Publishable-Key headers. The publishable key is the only one that may appear in a URL — the secret-key guard refuses query-string keys outright.
2. Create a session against the sandbox host
Call POST /v1/gate_sessions with your sk_test_ key. Bind amount, currency, and return_url server-side so the browser cannot tamper with them. The response includes a one-time client_secret (gsec_<sessionId>_<random>) that is safe to hand to your page.
curl -X POST https://gate-api-sandbox.0bit.app/v1/gate_sessions \
-H "Authorization: Bearer $GATE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amount": "100.00",
"currency": "EUR",
"return_url": "https://yourapp.example/checkout/done"
}'import { GateClient } from '@0bit/gate';
const client = new GateClient({
apiKey: process.env.GATE_KEY, // sk_test_...
baseUrl: 'https://gate-api-sandbox.0bit.app', // SDK origin; resource paths append /v1
});
const session = await client.sessions.create({
amount: '100.00',
currency: 'EUR',
return_url: 'https://yourapp.example/checkout/done',
});
// Hand this to your front-end. Never send your sk_ key to the browser.
console.log(session.client_secret); // gsec_..._...The SDK auto-attaches an Idempotency-Key (UUID v4) on writes and retries 429/5xx with backoff.
import os
from zerobit.gate import GateClient # PyPI dist is "0bit-gate"
client = GateClient(
api_key=os.environ["GATE_KEY"], # sk_test_...
base_url="https://gate-api-sandbox.0bit.app", # SDK origin; resource paths append /v1
)
session = client.sessions.create({
"amount": "100.00",
"currency": "EUR",
"return_url": "https://yourapp.example/checkout/done",
})
print(session["client_secret"]) # gsec_..._...You get back a gate_session:
{
"id": "67a1f3b9e4b0c10001234567",
"object": "gate_session",
"client_secret": "gsec_67a1f3b9e4b0c10001234567_AbCdEfGhIjKlMnOpQrStUvWxYz012345",
"amount": "100.00",
"currency": "EUR",
"status": "open",
"expires_at": "2026-05-26T13:45:00Z"
}Session create parameters
Only amount, currency, and return_url are required; the rest tune the flow. Set the ones you depend on server-side — the browser never sees them until bootstrap echoes them back.
| Field | Required | Notes |
|---|---|---|
amount | yes | Decimal string, pattern ^[0-9]+(\.[0-9]{1,8})?$, must be > 0. Strings avoid float drift. |
currency | yes | 3-letter ISO 4217 (e.g. EUR). |
return_url | yes | Must be HTTPS (or localhost / 127.0.0.1) and its origin must already be in your allowed_domains — otherwise create fails with 400. |
cancel_url | no | Where the hosted redirect sends an abandoned flow. |
target_token / target_network | no | Pin the asset and chain delivered (e.g. USDC / arbitrum). |
flow | no | on_ramp | off_ramp | swap — locks the widget to one kit block. |
wallet_address | no | Pre-fill the destination address. |
user_reference | no | Your own user id; echoed back, useful for reconciliation. |
kyc_package | no | Pass pre-verified KYC — only honoured for kyc_trusted partners. |
metadata | no | Arbitrary key/value bag, returned on the session and in webhooks. |
Session statuses and lifetime
A gate_session has exactly four statuses: open (live, mountable), completed (the user paid — terminal), expired (passed expires_at without completing — terminal, fired lazily on the first read past expiry), and cancelled (terminal). There is no failed session status — failure is an intent-level concept that surfaces as a webhook, never as a session field. Sessions default to a 24-hour expires_at; cancelling an already-terminal session returns 409.
3. Mount the sandbox widget
Install the browser SDK and mount the widget, pointing it at the sandbox environment. The SDK performs the embed handshake for you: it calls POST /v1/embed/bootstrap with your pk_test_ key, receives a short-lived embed token, and renders the iframe from https://gate-sandbox.0bit.app.
npm install @0bit/gate/browserimport { GateRamp } from '@0bit/gate/browser';
const ramp = new GateRamp({
publishableKey: import.meta.env.VITE_GATE_PUBLISHABLE_KEY, // pk_test_...
clientSecret: 'gsec_..._...', // from step 2, fetched from your server
environment: 'sandbox', // routes to gate-sandbox.0bit.app
});
await ramp.mount('#ramp', {
onReady: () => console.log('widget ready'),
onSuccess: ({ sessionId }) => {
// UX only — do NOT grant value here. Wait for the webhook.
window.location.href = '/checkout/done';
},
onError: ({ code, message }) => console.warn(code, message),
onClose: () => console.log('widget closed'),
});Or drop it in with a CDN tag and no build step — the IIFE bundle exposes the global GateJS:
<div id="ramp"></div>
<script src="https://cdn.jsdelivr.net/npm/@0bit/gate/browser"></script>
<script>
const ramp = new GateJS.GateRamp({
publishableKey: 'pk_test_...',
clientSecret: 'gsec_..._...',
environment: 'sandbox',
});
ramp.mount('#ramp', {
onSuccess: () => { window.location.href = '/checkout/done'; },
});
</script>What mount resolves to and how callbacks map
ramp.mount(target, callbacks) returns a Promise<void> that resolves on WIDGET_READY (15-second timeout, surfaced as a mount error). The widget speaks a typed postMessage protocol back to the host; the SDK translates each message into a callback:
| postMessage | Callback | Meaning |
|---|---|---|
WIDGET_READY | onReady | Iframe mounted and bootstrapped — mount() resolves here |
PAYMENT_SUCCESS | onSuccess | User finished the flow — UX only, payload { txId, sessionId? } |
PAYMENT_ERROR | onError | Flow errored — payload { code, message } |
PAYMENT_CLOSE | onClose | User dismissed the widget |
(bootstrap available: false) | onUnavailable | Corridor/quota unavailable — render a neutral state, do not throw |
The onSuccess payload is { txId, sessionId? } — there is no refid field on the browser callback. Treat all of these as UX signals; the webhook is the settlement record. Companion methods: ramp.unmount(), ramp.setTheme('light' | 'dark'), ramp.updateConfig({…}), and the static GateRamp.redirectToCheckout({ publishableKey, clientSecret, theme }) for hosted-redirect mode (where clientSecret is required).
React
Using React? @0bit/gate/react ships <RampCheckout publishableKey clientSecret environment="sandbox" onSuccess /> (alias <GateCheckout>) plus the flow-locked <GateOnRamp>, <GateOffRamp>, and <GateSwap> wrappers. It re-exports the gate-js error surface (SecretKeyInBrowserError, ConfigError, MountTargetError, EmbedBootstrapError, …).
Origin gating in the sandbox
bootstrap checks the request Origin (with Referer as a fallback) against your allowed_domains — exact match, no wildcards. While testing locally you must add the exact origin you load the page from (e.g. http://localhost:5173) or bootstrap returns 403. The one exception is hosted-redirect mode: when the page loads top-level on the widget origin itself, the allowlist is skipped — but a clientSecret is then required (anonymous hosted access is disabled).
4. Run a test purchase with the 4242 card
- Open your page with the mounted widget.
- Click through, pick a payment method, and enter the test card
4242 4242 4242 4242with any future expiry and any CVC. Complete any 3DS prompt. - Watch your server logs — a
gate_session.completedwebhook should arrive within a few seconds. - The browser
onSuccessfires at roughly the same time. Treat it as UX confirmation only; the webhook is what you act on.
Because nothing settles and KYC is skipped in sandbox, the flow completes quickly.
5. Drive terminal states with the test-helper routes
Instead of clicking the card flow, you can fast-forward a session straight to a terminal/intent state — ideal for exercising your webhook handler's branches in CI. These are the real backend routes under v1/test_helpers:
# Force a session to completed (sandbox only) — fires gate_session.completed
curl -X POST https://gate-api-sandbox.0bit.app/v1/test_helpers/sessions/<id>/complete \
-H "Authorization: Bearer $GATE_KEY" \
-H "Idempotency-Key: $(uuidgen)"
# Mark the session's intent failed (sandbox only) — session STAYS open, NO webhook
curl -X POST https://gate-api-sandbox.0bit.app/v1/test_helpers/sessions/<id>/fail \
-H "Authorization: Bearer $GATE_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "code": "provider_declined", "message": "Test-induced failure" }'complete vs fail — they are not symmetric
This asymmetry mirrors real life: a successful intent settles the session, but a failed intent does not kill the session — the user can retry inside the iframe.
| Helper | What it does to the session | Webhook emitted |
|---|---|---|
POST …/complete | Bumps the session open → completed and attaches a synthetic tx_refid (test_tx_…) | gate_session.completed (with data.tx_refid) |
POST …/fail | Marks the underlying intent failed; the session stays open | None — emits no webhook at all |
So complete is the way to test your fulfilment path end to end; fail is the way to assert that an intent failure on its own does not terminate the session or fire an event. If you want to test your gate_session.cancelled / gate_session.expired branches, use POST /v1/gate_sessions/{id}/cancel (cancel) or let the session pass expires_at (expiry).
Response shapes
complete returns the updated session:
{ "session": { "id": "…", "object": "gate_session", "status": "completed", "amount": "100.00", "currency": "EUR", "…": "…" } }fail returns the still-open session plus the failed intent (note status: "open" on the session):
{
"session": { "id": "…", "object": "gate_session", "status": "open", "…": "…" },
"intent": {
"id": "…",
"tx_refid": "test_tx_…",
"status": "failed",
"failure_code": "provider_declined",
"failure_message": "Test-induced failure"
}
}Both fields on the fail body are optional — omit the body entirely and the backend defaults to code: "provider_declined", message: "Test-induced failure". Pass a stable failure code (provider_declined, kyc_failed, insufficient_funds, user_cancelled, …) so your handler can branch on it.
Test helpers require an sk_test_ key and refuse live keys
Both routes sit behind PartnerSecretKeyGuard (any sk_ is accepted there) and a controller-level mode check: if the key's mode is not test, the request is rejected with 403 { "code": "live_keys_not_allowed" }. A sk_live_ key cannot drive them — the helpers are physically inert against production traffic. The session itself must also be test-mode and owned by your partner (otherwise 404/403). Both routes are idempotent (re-calling complete on a completed session, or fail twice, does not create duplicate intents) and throttled to 30 req/min per key; pass an Idempotency-Key like any other write.
6. Receive and verify webhooks locally
Webhooks are the source of truth — so test them locally before you ship. Your laptop is not reachable from the internet, so put a tunnel in front of your handler and register that public URL as your test-mode webhook_url.
Start a tunnel to your local port:
# ngrok
ngrok http 3000
# → forwards https://<random>.ngrok-free.app → http://localhost:3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Register the resulting HTTPS URL (e.g. https://<random>.ngrok-free.app/webhooks/0bit) as your test webhook_url in Partner Hub (or via support during private beta). Then verify the signature on every inbound event.
The Gate-Signature scheme
Use the Gate-Signature header — never x-0bit-signature
The header 0Bit sends is Gate-Signature, value t=<unix>,v1=<hex>, where v1 is HMAC-SHA256(webhook_secret, "<t>.<raw-request-body>") with a 300-second tolerance. Verify against the raw request bytes — re-serializing parsed JSON changes the hash and every check fails. Any snippet reading x-0bit-signature is wrong and will silently never match.
The SDK helper (constructEvent / construct_event) does the parse, timestamp-tolerance check, and constant-time compare for you — you only hand it the raw body, the header value, and the secret. It throws WebhookSignatureError on any failure.
import express from 'express';
import { GateClient, WebhookSignatureError } from '@0bit/gate';
const app = express();
const client = new GateClient({
apiKey: process.env.GATE_KEY,
baseUrl: 'https://gate-api-sandbox.0bit.app',
});
app.post(
'/webhooks/0bit',
express.raw({ type: 'application/json' }), // RAW body required for verification
(req, res) => {
let event;
try {
event = client.webhooks.constructEvent(
req.body, // raw Buffer/bytes
req.headers['gate-signature'], // Gate-Signature header value
process.env.WEBHOOK_SECRET,
);
} catch (err) {
if (err instanceof WebhookSignatureError) return res.status(400).end();
throw err;
}
// Dedupe on event.id — delivery is at-least-once.
switch (event.type) {
case 'gate_session.completed':
// authoritative settlement signal
fulfillOrder(event.data.id, event.data.tx_refid);
break;
case 'gate_session.failed':
case 'gate_session.cancelled':
case 'gate_session.expired':
releaseOrder(event.data.id);
break;
}
res.status(200).end(); // return 2xx fast; queue heavy work async
},
);import os
from flask import Flask, request
from zerobit.gate import GateClient, WebhookSignatureError
app = Flask(__name__)
client = GateClient(
api_key=os.environ["GATE_KEY"],
base_url="https://gate-api-sandbox.0bit.app",
)
@app.route("/webhooks/0bit", methods=["POST"])
def obit_webhook():
try:
event = client.webhooks.construct_event(
payload=request.get_data(), # RAW bytes — not request.json
sig_header=request.headers.get("Gate-Signature", ""),
secret=os.environ["WEBHOOK_SECRET"],
)
except WebhookSignatureError:
return "", 400
# Dedupe on event["id"] — delivery is at-least-once.
if event["type"] == "gate_session.completed":
fulfill_order(event["data"]["id"], event["data"]["tx_refid"]) # authoritative
elif event["type"] in ("gate_session.failed",
"gate_session.cancelled",
"gate_session.expired"):
release_order(event["data"]["id"])
return "", 200Trigger an event with the 4242 card flow or a complete helper call, and confirm your handler verifies and 2xx-es. If verification fails, check that you captured the raw body and that you are loading the test-mode whsec_. Because the fail helper emits no webhook, drive completed/cancelled/expired to exercise the rest of your branches.
Lifecycle events
| Event | Fires when | Act on it |
|---|---|---|
gate_session.created | Session was created | Optional bookkeeping |
gate_session.completed | The user actually paid — authoritative | Fulfill using data.id + data.tx_refid |
gate_session.failed | An intent failed (note: not from the test fail helper) | Surface the failure / release the order |
gate_session.cancelled | The session was cancelled | Release / cancel the order |
gate_session.expired | The session passed expires_at (default 24h) | Clean up the pending order |
The platform also emits gate_session.kyc_package_accepted, kyc.required, partner.quota.warning, and partner.quota.exhausted. The OpenAPI spec documents only created/completed/expired/cancelled/kyc_package_accepted — the failed, kyc.required, and partner.quota.* events are emitted by the backend beyond the spec, so handle the ones you depend on and ignore the rest rather than asserting an exhaustive list.
Part 2 — Go live
Promotion is a credentials-and-host swap — no flow changes. Work the checklist top to bottom.
| # | Step | What to do |
|---|---|---|
| 1 | Swap to live keys | Get pk_live_ + sk_live_ from Partner Hub. Keep sk_live_ server-only. |
| 2 | Point the API host | Set the server SDK baseUrl (or your raw fetch host) to https://gate-api.0bit.app/v1. |
| 3 | Point the widget | Set the browser SDK environment: 'production' so the iframe serves from https://gate.0bit.app. |
| 4 | Register a production webhook | Add a live webhook_url (a public HTTPS endpoint, separate from your test one) in Partner Hub. |
| 5 | Load the live webhook secret | Switch your verifier to the live whsec_ and confirm it verifies Gate-Signature (not x-0bit-signature). |
| 6 | Pin allowed_domains | Add every production origin you load the widget from to your pk_'s allowed_domains (exact match, no wildcards). |
| 7 | Handle completed/failed idempotently | Dedupe on event.id; grant value only on gate_session.completed; release on failed/cancelled/expired. |
| 8 | Monitor | Watch delivery success, signature-failure rate, and partner.quota.* events; alert on dead-lettered webhooks. |
Swap credentials and hosts
Production environment
GATE_KEY=sk_live_... # server-only
VITE_GATE_PUBLISHABLE_KEY=pk_live_... # browser-safe
WEBHOOK_SECRET=whsec_... # the LIVE webhook secretimport { GateClient } from '@0bit/gate';
const client = new GateClient({
apiKey: process.env.GATE_KEY, // sk_live_...
baseUrl: 'https://gate-api.0bit.app', // production SDK origin
});const ramp = new GateRamp({
publishableKey: import.meta.env.VITE_GATE_PUBLISHABLE_KEY, // pk_live_...
clientSecret,
environment: 'production', // serves the iframe from gate.0bit.app
});A key's mode must match the host
A *_test_ key against the live host (or a *_live_ key against the sandbox host) is rejected with 403. In live mode, real KYC runs (0Bit's verification partner) and real funds move — verify the corridor, asset, and payment-method coverage you depend on before launch rather than hard-coding lists.
Register and verify the production webhook
Pin allowed_domains exactly
POST /v1/embed/bootstrap matches the browser's Origin (falling back to Referer) against allowed_domains with exact string equality — no wildcards, no subdomain matching. Add every production origin you embed from (apex and www, plus any preview/marketing hosts). A missing origin returns 403 at bootstrap and the widget never renders. Note that a quota-exhausted partner is the one case that returns 200 with available: false instead — wire onUnavailable to show a neutral state rather than treating that as an error.
Confirm signatures against the live secret
Live and test webhook secrets are independent. After registering the live webhook_url, switch your verifier to the live whsec_, send one real (small) event, and confirm the handler verifies and returns 2xx. 100% signature failures immediately after go-live almost always means the verifier is still loaded with the test secret — or is reading x-0bit-signature instead of Gate-Signature.
Idempotency and the settlement contract
Webhooks are the source of truth
The browser onSuccess (and a hosted-redirect return_url arrival) means "the user finished the flow," not "the payment settled." Closed tabs, dropped callbacks, and crashes happen. The signed gate_session.completed webhook is the only reliable settlement signal — grant value only there, make the handler idempotent (dedupe on event.id), verify Gate-Signature against the raw body, and return 2xx quickly. Never release crypto or fulfill an order off the browser callback.
Two layers of idempotency keep you safe under at-least-once delivery and client retries:
- Inbound — dedupe handler work on
event.id(mirrored inX-0bit-Event-Id). Replays and retries carry the same id; a unique constraint on it makes double-fulfilment impossible. - Outbound — the SDKs auto-attach an
Idempotency-Key(UUID v4) on every write, so a retriedsessions.createreturns the original session instead of creating a duplicate. Keep that behaviour; do not strip the header.
Monitor in production
Watch these signals from day one:
- Delivery success rate and dead-lettered webhooks (5 failed attempts → dead-letter; replay from the portal once the endpoint is healthy).
- Signature-failure rate — a spike means a wrong/rotated secret or a body-parsing regression (re-serializing JSON before verification).
partner.quota.warning/partner.quota.exhausted— exhaustion flips bootstrap toavailable: false, so the widget goes unavailable for end users; alert before you hit it.
Pre-launch verification
A short list catches most launch-day incidents:
- A live
sk_live_session creates successfully againsthttps://gate-api.0bit.app/v1. - The widget loads from
https://gate.0bit.appon each production origin (no403— origin is inallowed_domains). - A real (small) purchase delivers a signed
gate_session.completedto your live endpoint and your handler verifies it and returns2xx. - Replaying the same webhook does not double-fulfill (idempotency works).
sk_live_appears nowhere in your client bundle (audit SSR boundaries and any/api/configendpoint).
Next steps
Quickstart
Build the buy flow end to end — session, widget, and webhook.
Authentication
Keys, embed tokens, modes, idempotency, and signature verification in depth.
0Gate
The full ramp: kit blocks, hosted redirect, enterprise inputs, and webhooks.
API reference
Every endpoint, parameter, and response shape.