Developers

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

EventFires when
gate_session.createdPOST /v1/gate_sessions succeeded
gate_session.completedAt least one intent against the session succeeded — the user actually paid. Fulfill here.
gate_session.cancelledYou called POST /v1/gate_sessions/{id}/cancel
gate_session.expiredLazy 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

HeaderExampleUse
Gate-Signaturet=1700000000,v1=<sha256-hex>HMAC for verification
X-0bit-Timestamp1700000000Same value as t= (convenience)
X-0bit-Event-Id<uuid>Dedupe key — equals the body id
X-0bit-Event-Typegate_session.completedRoute without parsing the body first
User-Agent0bit-webhooks/1.0Identify the source
Content-Typeapplication/jsonThe 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"
  }
}
FieldTypeDescription
idstring (UUID)Unique per delivery; retries reuse it. Your dedupe key.
typestringWhich event fired, e.g. gate_session.completed.
created_atintegerUnix epoch seconds the event was generated.
dataobjectThe 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:

  1. Parse the header — split on commas, key=value, extract t and v1.
  2. Check the timestamp — reject if |now - t| > 5 minutes. This neutralizes replay of a captured request while tolerating mild clock skew.
  3. Compute the expected HMACHMAC-SHA256(your_webhook_secret, "{t}.{raw_body}"), hex.
  4. Constant-time compare the computed hex against v1. Plain == / === leaks the correct-prefix length via timing.
  5. 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' }), Fastify addContentTypeParser, Nest app.useBodyParser('json', { rawBody: true }).
  • Wrong secret after a mode switch. test and live have 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:

  1. Dedupe by event.id. Record every processed event id and skip repeats. A Redis SET with a multi-day TTL (e.g. 7 days, comfortably longer than the 2h max retry horizon) is plenty; a unique constraint in your database works too.
  2. 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) or data.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:

  1. 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).
  2. For each, call GET /v1/gate_sessions/{id} and read status.
  3. 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:

SignalWhy it mattersAlert when
Webhook 2xx rateNon-2xx responses are being retried and head toward dead-letterSustained dip below ~100%
Signature-failure rateA spike often means a stale/rotated secret or a forgery attemptAny sustained non-zero rate
Handler latencyApproaching the ~10s timeout causes redelivery stormsp99 ack time above a few hundred ms
Queue depth / ageBacked-up fulfillment is stalled state, even with healthy acksOldest job older than your SLA
Reconciliation driftSessions terminal at 0Gate but unfulfilled locally = missed eventsAny non-zero count after the grace window
Dead-letter countEvents exhausted all 5 attempts and need manual replayAny 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.
  • 401 on signature failure; never log unverified bodies or signatures.
  • Distinct test vs live webhook secrets, each in your secret manager.
  • Dedupe on event.id; fulfillment keyed and idempotent on data.id / tx_refid.
  • Fast 2xx ack; heavy work on a background queue with its own retries.
  • Idempotency-Key (UUID) on every outbound POST; 409 handled.
  • 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).

Next steps

On this page