Developers
Core Concepts

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

FieldTypeDescription
idstring (UUID)Unique per delivery. This is your dedupe key. Retries of the same logical event reuse the same id.
typestringThe event type, e.g. gate_session.completed. See the catalogue below.
created_atintegerUnix epoch seconds when the event was generated. (Distinct from the session's own created_at.)
dataobjectThe 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.

EventMeaningdata payload essentials
gate_session.createdA session was created (POST /v1/gate_sessions succeeded). Optional bookkeeping; not a settlement signal.Full GateSession.
gate_session.completedThe user paid and the transaction settled — authoritative.Full GateSession plus tx_refid (refid of the underlying crypto transaction).
gate_session.expiredThe 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.cancelledThe 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.failedThe flow failed (rejected payment, compliance hold, etc.). Surface the failure and allow a retry.Full GateSession.
gate_session.kyc_package_acceptedA kyc_trusted partner's pre-submitted kyc_package was accepted for the session. Audit/correlation only.Redacted KycPackageAcceptancenever raw KYC.
kyc.requiredThe session is blocked pending identity verification. Prompt the user to finish KYC.{ gate_session_id, … }not a full GateSession.
partner.quota.warningYour org is approaching its usage quota (fired once per period at the warning threshold). Operational — no user impact yet.Quota/usage details.
partner.quota.exhaustedYour 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:

FieldTypeDescription
data.tx_refidstringRefid 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).

FieldTypeDescription
session_idstringThe session the package was accepted for.
partner_idstringYour partner id.
modestringtest or live.
providerstring | nullFrom your submitted kyc_package.provider (e.g. "<your-kyc-vendor>"). Null if unspecified.
accepted_atstring (date-time)When the package was accepted.
user_referencestring | nullEchoed 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 periodwarning 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:

  1. Parse the header into t and v1 (t must be a positive integer; reject a malformed or duplicated header).
  2. Reject if |now − t| > 300s (replay / clock-skew defence).
  3. Recompute HMAC-SHA256(secret, "<t>.<rawBody>") over the raw body.
  4. 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 failure

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

HeaderPurpose
Gate-SignatureThe signed value (t=…,v1=…). This is what authenticates the event.
X-0bit-Event-IdThe event's unique id — handy for fast dedupe and log correlation. Mirrors event.id.
X-0bit-Event-TypeThe event type. Mirrors event.type in the body.
X-0bit-TimestampThe signing timestamp. The same value as the t= in the signature.
Content-TypeAlways application/json.
User-AgentAlways 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 2xx in 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 resultWhat happens
2xxsucceeded — terminal. No further attempts.
4xxDead-lettered immediately — a client error is treated as a deterministic problem in your handler, so it is not retried.
5xx, timeout, DNS/connection failureRetried 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_url does not redirect deliveries already in flight; new events go to the new URL.
  • Manual redelivery. Dead-lettered deliveries can be inspected and replayedGET /portal/webhook-deliveries lists deliveries (with last_response_status and last_error for debugging), and POST /portal/webhook-deliveries/{id}/replay requeues a dead-lettered one. Replay is cross-tenant safe and only works on dead_lettered rows.

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, or INSERT … ON CONFLICT DO NOTHING). If you have seen it, ack with 200 and do nothing else. Retries reuse the same event.id, so this is exact.
  • Make the side effect itself idempotent. Key fulfillment off the session id (data.id) and tx_refid so that "credit this order" run twice still credits once. Dedupe on event.id is your first line; an idempotent side effect is your safety net if dedupe ever races.
  • Acknowledge fast, work async. Return a 2xx quickly (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 status rather 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 current status (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-time client_secret.)
  • Join on tx_refid. Persist tx_refid from gate_session.completed against 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, then POST /portal/webhook-deliveries/{id}/replay the 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

On this page