Run Workers
Build, verify, and operate the background workers that consume 0Gate webhooks and keep your production state in sync.
A 0Gate integration has two halves: the user-facing widget, and the background workers on your server that consume our webhooks and keep your own records in sync. The widget moves the user through buy / sell / swap; the worker is what actually marks an order paid, credits a balance, or releases goods.
This page is about that worker — how to build one that is reliable under retries,
how to verify the Gate-Signature before trusting an event, how to dedupe and
process idempotently, and how to reconcile against the API as a backstop.
Webhooks are the source of truth
The browser onSuccess callback is UX only — it tells you the user
reached the "done" screen, not that money settled. A tab can close, a callback
can drop, a device can die mid-flow. The webhook (or a reconciling poll of
GET /v1/gate_sessions/{id}) is the only authoritative settlement signal.
Never grant value, release crypto, or fulfill an order off a browser callback.
How delivery works
When a ramp session changes state, 0Gate POSTs a signed JSON event to your
configured webhook_url. The contract your worker is built against:
- At-least-once delivery. We retry on any non-2xx response. The same event can arrive more than once, so your handler must be idempotent.
- Ordered per session. Events for one session arrive in the order they happened. Events across sessions are not ordered — do not assume global ordering.
- Bounded retries. Up to 5 attempts, then the event is dead-lettered.
The backoff schedule is
1m → 5m → 30m → 2h. - 10-second per-attempt timeout. If you don't return a 2xx within ~10s we treat the attempt as failed and retry. Acknowledge fast; do heavy work async.
Retry-on-4xx is intentional in v1
Any non-2xx — 4xx, 5xx, timeout, or connection refused — triggers a retry
per the backoff schedule, then dead-letters after 5 attempts. We retry on
4xx on purpose: partners often mishandle a newly added event type, and this
gives you time to fix the handler without losing the event. This may become
stop-on-4xx in a future version.
Events you'll receive
| Event | Fires when |
|---|---|
gate_session.created | POST /v1/gate_sessions succeeded |
gate_session.completed | At least one intent against the session succeeded — the user actually paid. Fulfill here. |
gate_session.cancelled | You called POST /v1/gate_sessions/{id}/cancel |
gate_session.expired | Lazy expiry past expires_at without the session completing |
A few events (e.g. gate_session.processing and gate_session.failed) also
carry a tx_refid plus a PII-filtered transaction object. Event names and
payload fields are illustrative — treat the live type string and the
event payload reference as canonical, and write your
router to ignore event types it doesn't recognize (return 200, don't 400).
Headers we send
| Header | Example | Use |
|---|---|---|
Gate-Signature | t=1700000000,v1=<sha256-hex> | HMAC for verification |
X-0bit-Timestamp | 1700000000 | Same value as t= (convenience) |
X-0bit-Event-Id | <uuid> | Dedupe key — equals the body id |
X-0bit-Event-Type | gate_session.completed | Route without parsing the body first |
User-Agent | 0bit-webhooks/1.0 | Identify the source |
Content-Type | application/json | The body is JSON |
The event envelope
Every event shares one envelope; type says which event, data carries the
resource.
{
"id": "a1b2c3d4-5e6f-7890-abcd-ef0123456789",
"type": "gate_session.completed",
"created_at": 1700000000,
"data": {
"id": "67a1f3b9e4b0c10001234567",
"object": "gate_session",
"partner_id": "507f1f77bcf86cd799439011",
"mode": "live",
"amount": "100.00",
"currency": "EUR",
"status": "completed",
"expires_at": "2026-05-26T13:45:00Z",
"created_at": "2026-05-25T13:45:00Z",
"tx_refid": "0g_txn_abc123"
}
}| Field | Type | Description |
|---|---|---|
id | string (UUID) | Unique per delivery; retries reuse it. Your dedupe key. |
type | string | Which event fired, e.g. gate_session.completed. |
created_at | integer | Unix epoch seconds the event was generated. |
data | object | The full GateSession. On completed events, includes tx_refid to join to the underlying transaction / receipt. |
Anatomy of a reliable worker
A correct worker does four things, in this order, on every request:
1. Verify the signature before trusting anything
Unverified webhooks can be forged by anyone who learns your URL. Verify the
Gate-Signature HMAC first — before parsing, routing, logging, or
side-effecting.
2. Acknowledge fast with a 2xx
Persist the raw event (or enqueue it) and return 200 immediately. Don't run
fulfillment inline — you have ~10 seconds before the attempt times out and we
retry, which under load turns one slow handler into a redelivery storm.
3. Dedupe on event.id, then process idempotently
Because delivery is at-least-once, the same event.id will sometimes arrive
twice. Record processed IDs and make the side effect safe to run more than once.
4. Reconcile as a backstop
Webhooks are highly reliable but not infallible — a deploy window, a bad gateway, or a dead-lettered event can leave a gap. A periodic poll of session state closes it. See Reconciliation below.
Verifying the Gate-Signature
The header is Stripe-format:
Gate-Signature: t=1700000000,v1=a1b2c3d4e5f6...t=<unix-ts>— the timestamp we signed at (seconds since epoch).v1=<hex>—HMAC-SHA256(webhook_secret, "<t>.<raw-request-body>"), hex-encoded.
The signed payload is <timestamp>.<raw-body>, computed over the raw
bytes of the request body. Re-serializing parsed JSON produces a different hash
and will never verify. The algorithm:
- Parse the header — split on commas,
key=value, extracttandv1. - Check the timestamp — reject if
|now - t| > 5 minutes. This neutralizes replay of a captured request while tolerating mild clock skew. - Compute the expected HMAC —
HMAC-SHA256(your_webhook_secret, "{t}.{raw_body}"), hex. - Constant-time compare the computed hex against
v1. Plain==/===leaks the correct-prefix length via timing. - Reject on mismatch with
401, and do not log the body or signature — logging an unverified payload is itself an attack vector.
const crypto = require('node:crypto');
function verifyWebhook(rawBody, signatureHeader, secret, skewSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=')),
);
const t = parseInt(parts.t, 10);
if (!Number.isInteger(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > skewSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// constant-time compare; mismatched lengths would throw, so guard
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify_webhook(raw_body: bytes, signature_header: str,
secret: str, skew_seconds: int = 300) -> bool:
parts = dict(p.split("=") for p in signature_header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > skew_seconds:
return False
payload = f"{t}.{raw_body.decode()}".encode()
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Verification is a server-side step, not a request — there is no cURL form. To exercise your endpoint locally, expose it with a tunnel and trigger test sessions:
# Expose localhost so sandbox webhooks can reach your worker
cloudflared tunnel --url http://localhost:3000
# or: ngrok http 3000
# Set the resulting HTTPS URL as your test webhook_url (via support / portal),
# then create + complete a sandbox session to fire real signed events.For one-off inspection without a handler, point your test webhook_url at a
webhook.site URL and watch payloads + headers arrive.
The three verification killers
- Parsing JSON before verifying. Frameworks that auto-parse the body
destroy the raw bytes. Configure the raw body: Express
express.raw({ type: 'application/json' }), FastifyaddContentTypeParser, Nestapp.useBodyParser('json', { rawBody: true }). - Wrong secret after a mode switch.
testandlivehave separate webhook secrets. 100% failures right after promoting to production is almost always this. - Non-constant-time comparison. Always use
crypto.timingSafeEqual/hmac.compare_digest/ your language's equivalent — never==.
If you use an official SDK, gate.webhooks.constructEvent(rawBody, sigHeader, secret)
(Node @0bit/gate) and gate.webhooks.construct_event(payload=..., sig_header=..., secret=...)
(Python 0bit-gate, import zerobit.gate) wrap this algorithm and throw
WebhookSignatureError on a bad signature. The Python helper takes raw bytes
(request.body, not request.json).
Idempotent event handling
At-least-once delivery means the same event.id will be delivered more than
once. Two layers protect you:
- Dedupe by
event.id. Record every processed event id and skip repeats. A RedisSETwith a multi-day TTL (e.g. 7 days, comfortably longer than the2hmax retry horizon) is plenty; a unique constraint in your database works too. - Make the side effect idempotent. Even with a dedupe table, concurrency and
crash-after-side-effect-before-commit windows exist. Key your own writes on a
stable identifier —
data.id(the session id) ordata.tx_refid— with an upsert / "fulfill once" guard, so re-processing is a no-op.
The same discipline applies to the outbound calls your worker makes back to
0Gate. Send an Idempotency-Key (a UUID) on every POST — the key is stable
across retries of the same logical operation but unique per operation.
0Gate replays the original response for 24 hours, so a timed-out create never
produces two sessions. A replay with a matching body re-serves the original
response (within ~24h). Reusing the same key with a different body is
rejected with 422 and code idempotency_key_payload_mismatch — it does
not silently return the original; that's a bug in your retry logic, not a
feature. A concurrent in-flight request with the same key may return a conflict;
re-issue or fall back to a GET.
A worker sketch (Node)
This Express worker verifies, dedupes, acknowledges fast, and hands fulfillment to an async queue. It is deliberately small — the load-bearing parts are the order of operations.
const express = require('express');
const crypto = require('node:crypto');
const app = express();
const WEBHOOK_SECRET = process.env.OBIT_WEBHOOK_SECRET;
const SECRET_KEY = process.env.OBIT_SECRET_KEY;
const API = 'https://gate-api.0bit.app/v1'; // sandbox: gate-api-sandbox.0bit.app/v1
// 1. Raw body is REQUIRED — verification runs over the exact bytes we signed.
app.post(
'/webhooks/0bit',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body.toString('utf8');
const sig = req.headers['gate-signature'];
// 2. Verify before trusting, parsing, or logging anything.
if (!verifyWebhook(rawBody, sig, WEBHOOK_SECRET)) {
return res.status(401).end(); // do NOT log the body/signature
}
const event = JSON.parse(rawBody);
// 3. Dedupe on event.id (retries reuse the same id).
if (await seenBefore(event.id)) {
return res.status(200).end(); // already handled — ack and move on
}
// 4. Persist + enqueue, then ACK fast. Heavy work happens off the request.
await persistEvent(event); // store raw event keyed by event.id
await enqueue(event); // background job does fulfillment
return res.status(200).end(); // well under the ~10s timeout
},
);
// Background job — runs outside the 10s window, retried by YOUR queue.
async function handleEvent(event) {
switch (event.type) {
case 'gate_session.completed': {
const session = event.data;
// Idempotent fulfillment keyed on the session id / tx_refid.
await fulfillOnce(session.id, async () => {
await creditUser({
sessionId: session.id,
txRefId: session.tx_refid, // join key to the transaction record
amount: session.amount,
currency: session.currency,
});
});
break;
}
case 'gate_session.cancelled':
case 'gate_session.expired':
await releaseHold(event.data.id);
break;
default:
// Unknown/future event type: ack, don't fail. Log for follow-up.
break;
}
await markProcessed(event.id);
}
function verifyWebhook(rawBody, signatureHeader, secret, skewSeconds = 300) {
if (!signatureHeader) return false;
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=')),
);
const t = parseInt(parts.t, 10);
if (!Number.isInteger(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > skewSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Why the queue matters
Returning 200 after fulfillment risks the 10s timeout and a redelivery
storm; it also couples your downstream (a slow DB, a flaky email service) to
0Gate's retry policy. Persist + enqueue + ack, then let your own job runner
apply its own retries and backoff to the heavy work.
Reconciliation: the backstop
Webhooks are the source of truth, but a resilient integration never relies on a single channel. Run a reconciliation worker that periodically reconciles your records against 0Gate's state:
- Find sessions you consider "in flight" — created/processing locally but not yet terminal — older than a small grace window (e.g. a few minutes past their last expected transition).
- For each, call
GET /v1/gate_sessions/{id}and readstatus. - If 0Gate reports a terminal state your worker never recorded (a missed or dead-lettered event), run the same idempotent fulfillment path the webhook handler uses — never a separate code path. Because fulfillment is keyed on the session id, a later webhook for the same session is a safe no-op.
curl https://gate-api.0bit.app/v1/gate_sessions/67a1f3b9e4b0c10001234567 \
-H "Authorization: Bearer $OBIT_SECRET_KEY"{
"id": "67a1f3b9e4b0c10001234567",
"object": "gate_session",
"amount": "100.00",
"currency": "EUR",
"status": "completed",
"tx_refid": "0g_txn_abc123",
"expires_at": "2026-05-26T13:45:00Z"
}This poll is a backstop, not the primary path — keep its frequency modest and
scoped to in-flight sessions so you don't hammer the API. If you need an event
re-sent, replay is currently handled by support: email
[email protected] with the event.id and we'll
re-enqueue it (self-service replay from the delivery log is on the roadmap).
Monitoring and alerting
Treat the worker as production infrastructure. The signals worth watching:
| Signal | Why it matters | Alert when |
|---|---|---|
| Webhook 2xx rate | Non-2xx responses are being retried and head toward dead-letter | Sustained dip below ~100% |
| Signature-failure rate | A spike often means a stale/rotated secret or a forgery attempt | Any sustained non-zero rate |
| Handler latency | Approaching the ~10s timeout causes redelivery storms | p99 ack time above a few hundred ms |
| Queue depth / age | Backed-up fulfillment is stalled state, even with healthy acks | Oldest job older than your SLA |
| Reconciliation drift | Sessions terminal at 0Gate but unfulfilled locally = missed events | Any non-zero count after the grace window |
| Dead-letter count | Events exhausted all 5 attempts and need manual replay | Any non-zero count |
Rotate webhook secrets without downtime
Rotate signing secrets on a schedule (e.g. quarterly) and immediately after any suspected leak. 0Gate does not do gradual rollover for you — during the rotation window, verify against both the old and new secret, then drop the old once you confirm traffic is signed with the new one.
Production checklist
- Raw body preserved on the webhook route; signature verified before parsing.
401on signature failure; never log unverified bodies or signatures.- Distinct
testvslivewebhook secrets, each in your secret manager. - Dedupe on
event.id; fulfillment keyed and idempotent ondata.id/tx_refid. - Fast
2xxack; heavy work on a background queue with its own retries. Idempotency-Key(UUID) on every outboundPOST;409handled.- Reconciliation worker polling
GET /v1/gate_sessions/{id}for in-flight sessions. - Health check, structured logs, and alerts on the signals above.
- A documented runbook for dead-lettered events (request replay from support).