Webhooks & events
The async event model behind 0Gate — why webhooks are the source of truth for settlement, the full event catalogue, the signed Gate-Signature delivery model, at-least-once retries and dead-lettering, idempotent handling, and reconciliation.
A webhook is the signed, server-to-server message 0Bit POSTs to your backend when something happens to a session — most importantly, when money has actually settled. 0Gate is an asynchronous system: a buy, sell, or swap moves through KYC, a payment rail, and an on-chain leg over seconds to minutes, none of which the browser can witness. The webhook is how your server learns the real outcome. Webhooks are the source of truth for settlement; the browser onSuccess callback is UX only.
This page is the mental model: which events exist, how they are signed, how they are delivered and retried, and how to consume them safely. The step-by-step verifier code lives in Authentication, and the operational worker pattern lives in Run workers.
The event model
0Gate emits events the moment a session crosses a meaningful state boundary on the server. Each event is enqueued as a durable delivery row, signed, and POSTed to your webhook_url by a background worker — independent of whatever the user's browser is doing. This decoupling is the whole point: the truth of a transaction is established server-side and pushed to you, not inferred client-side.
The event envelope
Every event shares one envelope shape. For session events, data is the full GateSession object (with extra fields on some types — see the catalogue).
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Unique per delivery. This is your dedupe key. Retries of the same logical event reuse the same id. |
type | string | The event type, e.g. gate_session.completed. See the catalogue below. |
created_at | integer | Unix epoch seconds when the event was generated. (Distinct from the session's own created_at.) |
data | object | The payload. For most session events this is a full GateSession; completed adds tx_refid; kyc.required and kyc_package_accepted carry smaller, purpose-built objects. |
event.id vs session id
event.id identifies the delivery (a UUID, unique across the deliveries collection). The session it concerns lives at data.id (a Mongo ObjectId hex). Dedupe on event.id; key your fulfillment side effect on data.id / data.tx_refid.
Browser callback vs webhook
When a user finishes the widget flow, the browser SDK fires onSuccess. That callback means "the user reached the done screen" — not "the money settled." Settlement happens after the iframe closes: the fiat rail clears, the on-chain transfer confirms, compliance checks finalize. A tab can close, a phone can die, a network can drop, and a determined user can call your front-end "success" handler by hand. None of that should ever grant value.
The webhook is delivered by 0Bit's backend directly to yours, carries a verifiable signature, and reflects the server-side truth of the session. Treat the two signals with very different trust:
Never grant value on the browser callback
The onSuccess callback is a cue to show a friendly "processing…" state — nothing more. Only a verified gate_session.completed webhook (or a reconciling poll of GET /v1/gate_sessions/{id}) authorizes you to fulfill an order, credit a balance, or release crypto. Build for the case where onSuccess never fires at all; the webhook still will.
The event catalogue
Subscribe to the ones your integration acts on; ignore the rest. The session lifecycle events carry the full GateSession in data; the KYC and quota events carry smaller payloads described below.
| Event | Meaning | data payload essentials |
|---|---|---|
gate_session.created | A session was created (POST /v1/gate_sessions succeeded). Optional bookkeeping; not a settlement signal. | Full GateSession. |
gate_session.completed | The user paid and the transaction settled — authoritative. | Full GateSession plus tx_refid (refid of the underlying crypto transaction). |
gate_session.expired | The session passed its expires_at (default 24h) without completing. Fired lazily on the first read past expiry. Clean up the pending order. | Full GateSession (status: expired). |
gate_session.cancelled | The session was cancelled — by you via POST /v1/gate_sessions/{id}/cancel, or by the user. Release the order. | Full GateSession (status: cancelled). |
gate_session.failed | The flow failed (rejected payment, compliance hold, etc.). Surface the failure and allow a retry. | Full GateSession. |
gate_session.kyc_package_accepted | A kyc_trusted partner's pre-submitted kyc_package was accepted for the session. Audit/correlation only. | Redacted KycPackageAcceptance — never raw KYC. |
kyc.required | The session is blocked pending identity verification. Prompt the user to finish KYC. | { gate_session_id, … } — not a full GateSession. |
partner.quota.warning | Your org is approaching its usage quota (fired once per period at the warning threshold). Operational — no user impact yet. | Quota/usage details. |
partner.quota.exhausted | Your org has hit its quota; sessions may be refused (fired once per period at the limit). Operational — escalate. | Quota/usage details. |
The spec documents fewer events than the backend emits
The OpenAPI spec only formally documents gate_session.created, gate_session.completed, gate_session.expired, gate_session.cancelled, and gate_session.kyc_package_accepted. The backend also emits gate_session.failed, kyc.required, partner.quota.warning, and partner.quota.exhausted. Write your handler with a default branch so an unrecognized type is acknowledged and ignored rather than erroring.
Terminal session events
The terminal session events are completed, expired, cancelled, and failed — once a session reaches one of these, it never moves again. Of the four, only gate_session.completed means money settled.
gate_session.completed — the only value-moving event
completed is the single event that authorizes fulfillment. Its data is the full session plus one extra field:
| Field | Type | Description |
|---|---|---|
data.tx_refid | string | Refid of the underlying user_crypto_transaction. Use it to deep-link to the on-chain receipt and to join the event back to your own order record for reconciliation. |
Key your fulfillment off data.id (the session) and data.tx_refid so that crediting the same completion twice is a no-op.
KYC events
gate_session.kyc_package_accepted
Delivered once per session when a kyc_trusted partner submitted a kyc_package on session-create and it was accepted. The payload is a deliberately minimal KycPackageAcceptance audit envelope — it gives you correlation without re-sending PII (the raw KYC body is never echoed in any webhook; you already hold your own copy).
| Field | Type | Description |
|---|---|---|
session_id | string | The session the package was accepted for. |
partner_id | string | Your partner id. |
mode | string | test or live. |
provider | string | null | From your submitted kyc_package.provider (e.g. "<your-kyc-vendor>"). Null if unspecified. |
accepted_at | string (date-time) | When the package was accepted. |
user_reference | string | null | Echoed from the session for partner-side correlation. |
kyc.required
Fired when a session is blocked pending KYC. Unlike the lifecycle events, its data is not a full GateSession — it carries { gate_session_id, … }. Treat it as a prompt to route the user back into the verification step, not as a state change to fulfill against.
Quota events
partner.quota.warning and partner.quota.exhausted are operational, not per-user. Each fires once per usage period — warning when usage crosses the configured threshold, exhausted when it hits the limit (at which point new sessions may be refused). Route these to your ops/alerting channel, not your fulfillment path.
Signature verification
Every webhook is signed so you can prove it genuinely came from 0Bit and was not tampered with in transit. The signature rides in a single header:
Gate-Signature: t=1700000000,v1=a1b2c3d4e5f6...t=<unix>— the time we signed the event, in seconds since the epoch.v1=<hex>—HMAC-SHA256(webhook_secret, "<t>.<raw-request-body>"), hex-encoded.
The signed string is the timestamp, a literal ., and the raw request body bytes — the signer hashes the exact bytes that are sent on the wire, so you must verify against the exact bytes you received, before any JSON parser re-serializes them. The shared webhook_secret (shape whsec_*, returned once when you rotate it via POST /portal/webhook-secret/rotate in Partner Hub) is the only thing that proves authenticity. The accepted clock-skew tolerance is 300 seconds, which neutralizes replay while tolerating mild clock drift.
The header is Gate-Signature — never x-0bit-signature
0Bit emits Gate-Signature (a constant locked in the backend), and that is what the official SDKs parse. Some older snippets show x-0bit-signature; that string is a bug that will never match a real signature. Always read Gate-Signature.
The verification algorithm
Conceptually, verification is four steps — done in this order, rejecting on any failure:
- Parse the header into
tandv1(tmust be a positive integer; reject a malformed or duplicated header). - Reject if
|now − t| > 300s(replay / clock-skew defence). - Recompute
HMAC-SHA256(secret, "<t>.<rawBody>")over the raw body. - Constant-time compare the recomputed hex against
v1. The lengths must match exactly before the timing-safe comparison runs.
This is a security boundary, so do not improvise it. The worked verifier — with raw-body capture for Express / Flask / Nest / Django and the SDK helpers — is in Authentication → Webhook signature verification.
Use the SDK helper
Both official SDKs wrap the exact algorithm above. They throw on any failure (malformed header, missing parts, stale timestamp, signature mismatch, non-JSON body); catch and return 400.
// @0bit/gate — pass the RAW body, not JSON.stringify(req.body)
const event = client.webhooks.constructEvent(
req.rawBody, // Buffer or string — raw request body
req.headers['gate-signature'], // header value as-is
process.env.WEBHOOK_SECRET, // whsec_… from POST /portal/webhook-secret/rotate
);
// throws WebhookSignatureError on any failureExpress silently re-encodes parsed objects, which breaks verification — use express.raw() (or a raw-body parser) on the webhook route so constructEvent sees the original bytes. An optional { tolerance } overrides the default 300s.
# 0gate (import gate) — pass the raw body bytes/str
event = client.webhooks.construct_event(
payload, # raw request body (do not re-serialize)
sig_header, # Gate-Signature header value
secret, # whsec_…
)Capture the raw body before any framework JSON parsing (e.g. Flask request.get_data(), Django request.body).
Companion headers are informational
Alongside the signature, each delivery carries convenience headers. They make logging and routing easier, but they are not part of the signed payload — never trust them in place of verifying Gate-Signature.
| Header | Purpose |
|---|---|
Gate-Signature | The signed value (t=…,v1=…). This is what authenticates the event. |
X-0bit-Event-Id | The event's unique id — handy for fast dedupe and log correlation. Mirrors event.id. |
X-0bit-Event-Type | The event type. Mirrors event.type in the body. |
X-0bit-Timestamp | The signing timestamp. The same value as the t= in the signature. |
Content-Type | Always application/json. |
User-Agent | Always 0bit-webhooks/1.0. |
The authoritative event id and type live in the JSON body (event.id, event.type); the X-0bit-* headers mirror them for convenience.
Delivery, retries & dead-letter
Delivery is decoupled from whatever triggered the event. When 0Gate emits an event it writes a durable delivery row, and a background worker drains the queue, signs each payload, and POSTs it — retrying with backoff until the partner acks or the attempts are exhausted.
The delivery flow
The delivery worker
- Leader-elected, periodic pump. A single elected replica runs a tick every 30 seconds, claiming up to 20 ready deliveries per tick and attempting them sequentially. Other replicas stand down so they don't race for the same rows.
- Opt-in. The worker only runs where
WEBHOOK_WORKER_ENABLED=true; otherwise no outbound HTTP is made. (This is a 0Bit-side operational flag, noted only so you understand why dev/sandbox timing can differ from prod.) - Per-attempt timeout. Each POST is given roughly 10 seconds. If you don't return a
2xxin that window, the attempt is aborted and counts as failed. - Enqueue rate limit. Events are enqueued at up to 100 per partner per minute; bursts beyond that are dropped at enqueue time rather than queued. Quota/warning events are designed to fire at most once per period precisely to avoid this.
Retry & dead-letter contract
Delivery is at-least-once with bounded retries. The behaviour depends on how an attempt fails:
| Attempt result | What happens |
|---|---|
2xx | succeeded — terminal. No further attempts. |
4xx | Dead-lettered immediately — a client error is treated as a deterministic problem in your handler, so it is not retried. |
5xx, timeout, DNS/connection failure | Retried with backoff. |
- Up to 5 attempts total, then dead-lettered. The backoff schedule between attempts is
1m → 5m → 30m → 2h(≈6h total budget), so a 1–2 hour partner outage heals naturally without losing events. - The target URL is snapshotted at enqueue time. Changing your
webhook_urldoes not redirect deliveries already in flight; new events go to the new URL. - Manual redelivery. Dead-lettered deliveries can be inspected and replayed —
GET /portal/webhook-deliverieslists deliveries (withlast_response_statusandlast_errorfor debugging), andPOST /portal/webhook-deliveries/{id}/replayrequeues a dead-lettered one. Replay is cross-tenant safe and only works ondead_letteredrows.
A 4xx is not a retry signal
Because 4xx responses dead-letter on the first attempt, returning 400 from a handler that merely choked on a transient issue will permanently drop that delivery (until you replay it). Return 4xx only when the event is genuinely unprocessable; return 5xx (or simply fail/time out) when you want a retry.
Return 2xx only after you've safely accepted the event
"Safely accepted" means verified and durably recorded (e.g. enqueued or written), not fully processed. Acknowledging a webhook you failed to verify or persist silently drops the event. Conversely, a slow handler that blows the ~10s budget is counted as a failed attempt — so ack fast and do heavy work async.
Delivery row lifecycle
Each delivery is one row that doubles as queue, retry scheduler, and audit log. Its status walks a fixed path:
pending -> in_flight -> succeeded (2xx; terminal)
-> dead_lettered (5 attempts exhausted, or a 4xx; terminal)pending rows (new or awaiting a scheduled retry) are claimed into in_flight, then resolved to succeeded or dead_lettered. The row also records attempts, last_response_status, last_error (a truncated response body or network error), and delivered_at — which is what the Partner Hub delivery log surfaces.
Idempotent handling & dedupe
Because delivery is at-least-once, the same event can arrive more than once (a retry firing after your 200 was already sent, a redelivery, a replay). Your handler must therefore be idempotent — processing the same event twice must have the same effect as processing it once.
The discipline is built around the stable, per-delivery event id:
- Dedupe on
event.id. Before acting, record the id (a unique constraint, orINSERT … ON CONFLICT DO NOTHING). If you have seen it, ack with200and do nothing else. Retries reuse the sameevent.id, so this is exact. - Make the side effect itself idempotent. Key fulfillment off the session id (
data.id) andtx_refidso that "credit this order" run twice still credits once. Dedupe onevent.idis your first line; an idempotent side effect is your safety net if dedupe ever races. - Acknowledge fast, work async. Return a
2xxquickly (well within the ~10s budget) and push heavy work to a queue. A slow handler is treated as a failed attempt and retried. - Don't assume global ordering. There is no cross-session ordering guarantee, and a retried event can land out of order relative to newer ones. Reconcile against the session's current
statusrather than assuming the last event you saw is the latest state.
Settlement is terminal — guard against late or stale events
Once a session is completed (or expired / cancelled / failed), that is final. If a later or duplicate event arrives, your dedupe and status checks should make re-handling a no-op. When in doubt, treat GET /v1/gate_sessions/{id} as the tie-breaker — the API always reflects the current authoritative state.
Reconciliation
Webhooks are reliable but not infallible from your side: an outage can dead-letter events, a deploy can drop a few, a bug can mis-handle one. Reconciliation is the backstop that makes the system self-healing — the API is always authoritative, so you can always recover the truth by reading it.
- The API is the tie-breaker.
GET /v1/gate_sessions/{id}always returns the session's currentstatus(open/completed/expired/cancelled). When a webhook is missing, ambiguous, or arrives out of order, the API state wins. (The retrieve response does not include the one-timeclient_secret.) - Join on
tx_refid. Persisttx_refidfromgate_session.completedagainst your order record so completions can be matched to your ledger and to the on-chain receipt, both during live handling and during a sweep. - Sweep your pending orders. For any order still pending past a sane window (e.g. the session's
expires_at, default 24h), poll the session and act on the API status. This catches anything a webhook outage dropped. - Replay dead-letters. After resolving an outage, list deliveries via
GET /portal/webhook-deliveries, thenPOST /portal/webhook-deliveries/{id}/replaythe dead-lettered ones. Because your handler is idempotent, replaying an event you already processed is harmless.
Treat webhooks + reconciliation as one system
Webhooks give you low-latency, push-based settlement signals; reconciliation guarantees eventual correctness regardless of delivery hiccups. Ship both. A webhook-only integration is one outage away from stuck orders; a poll-only integration is needlessly slow. Together they are robust.
See also
Authentication
The HMAC-SHA256 verifier against Gate-Signature, raw-body capture per framework, and the constructEvent / construct_event SDK helpers.
Run workers
Build and operate the background worker that consumes these events — reliable under retries, idempotent, and reconciled against the API.
Core concepts
Sessions, client secrets, the non-custodial settlement model, and why the browser callback is never the source of truth.
0Gate
The on/off-ramp product these events describe — Buy, Sell, and Swap.