Skip to main content
Every delivery to a JSON endpoint is a POST signed with your endpoint’s secret. Treat a request as coming from 0xArchive only after its signature checks out, and answer with a 2xx within 10 seconds.

Check a delivery with the SDK

The SDKs (version 1.12.0 and later) check the signature for you. Pass the raw request body, exactly as it arrived, the request headers, and the endpoint’s signing secret:
Each one accepts either signature during a secret rotation and rejects timestamps more than 5 minutes from your clock. Python also offers oxarchive.verify_webhook(body, headers, secret). The rest of this page is the protocol behind them, for checking signatures without the SDK.

Request headers

  • 0xa-signature: the signature, t=<unix seconds>,v1=<hex digest>. For 24 hours after a secret rotation, a second ,v1=<hex digest> follows the first.
  • 0xa-event-id: the event identifier, the same value as id in the body.
  • 0xa-event-type: the event type string, the same value as type in the body.
  • content-type: application/json.
  • user-agent: 0xArchive-Webhooks/1.0.

Verifying signatures

The digest in v1 is HMAC-SHA256, in lowercase hex, over the string <t>.<raw body>, keyed with your endpoint’s secret: the whole string you were given, whsec_ prefix included. The raw body is the exact bytes that arrived. Recompute the digest, compare it in constant time, and reject anything that does not match. Two rules decide whether a receiver is safe:
  • Sign the bytes you received, not the object you parsed. Serializing a parsed body back to JSON changes key order, spacing and number formatting, and the digest stops matching. The body’s key order and spacing are not the ones shown on this page, so read the raw body first, check it, then parse. In Express that means express.raw({ type: "application/json" }) on the webhook route rather than express.json(); in Flask, request.get_data().
  • Reject old timestamps. t is the time of each attempt, so a retry carries a fresh one. Compare it with your own clock and refuse anything outside a tolerance, 5 minutes being a reasonable default, so a captured delivery cannot be replayed at you later.
The header can carry more than one v1 value. For the 24 hours after a secret rotation, each delivery is signed with the new secret and with the previous one, in that order, and both digests are sent. Compute your digest with the secret you hold and accept the request when it matches any v1 value. That is what lets you roll the secret on your receiver without dropping deliveries. The same check without a dependency:

A receiver that does not lose events

Delivery is at least once: the same event can arrive more than once, and a delivery your receiver did not answer with a 2xx is retried. A receiver that keeps every event does four things in this order:
  1. Verify the signature on the raw body.
  2. Record the event durably, once per event id, before answering. If recording fails, answer with a non-2xx so the delivery is retried.
  3. Acknowledge with a 2xx, within 10 seconds.
  4. Process the recorded event after the response, off the request, and retry processing that fails.
Answering before the event is recorded loses it if the process stops in between, because a 2xx tells 0xArchive not to retry. Processing is at least once too: if the process stops after your work ran and before the event is marked done, the work runs again for the same event. Make its effect idempotent, keyed by the event id, for example by writing results under that id. The receiver below uses the verifyWebhook function above. It records each event as inbox/<id>.json: the body is written to a temporary file, flushed to disk, then renamed into the inbox, and the inbox directory is flushed too, so a file in the inbox is always complete, and an event counts as recorded only once that file exists. A retried or redelivered event that is already recorded is acknowledged without being recorded again. A worker processes the inbox after each delivery and every 30 seconds, and moves each event to processed/ once handle has finished. Replace handle with your own work.
Node.js receiver
Only the status code of your response is read. Any 2xx is a success; anything else, a redirect included, is a failure that is retried. Deliveries are not ordered: a retry can arrive after a newer event, so order by the event’s own data.timestamp, not by arrival.

Event envelope

Every delivery body to a JSON endpoint has this shape.
  • id: the event identifier, deterministic per occurrence. Dedupe on it.
  • type: the event type string.
  • schema_version: the payload schema version, 1 today.
  • observed_at: when the engine produced the delivery.
  • late_ms: the occurrence’s age at that moment, observed_at minus data.timestamp. null when the event has no data.timestamp.
  • late: true when late_ms is over 10 minutes. Both detection paths are normally far under that, so late: true means the engine or a feed was behind; do not treat the event as one that just happened.
  • data: the event payload. Its fields depend on the event type. Each type’s entry in GET /v1/webhooks/event-types declares the fields a condition can test as metrics, with types and units (event type schema), and a payload can carry more, such as api_url. There is no separate schema for each event’s payload.
The delivery log returns this body as each entry’s payload (delivery schema). id is the same on every retry and every manual redelivery, and one occurrence matched by two rules on the same endpoint is delivered once. Rules with different parameters, such as two burst windows, describe different occurrences, so their deliveries carry different ids. Each test delivery gets a new id. late_ms has a value on events that carry data.timestamp: liquidations, fills, transfers, order rejections, TWAP changes, priority gas auction payments, oracle jumps and HIP-4 settlements. On other market, chain and billing events it is null and late is false. Export events, webhook.test and some archive and ingest health events carry neither field, so read both as optional. Payloads are pointers, not documents: they identify what happened and where to fetch the full resource. api_url is a path on the REST API. Call it with your normal authentication for the fills behind a fill or liquidation event, or for the job details and download links behind an export event. Download links themselves are never included in webhook payloads. Events with two edges (oracle.stall, chain.block_stall, chain.block_time_degraded, chain.upgrade_detected) put the edge in data.state, and paired events (ingest.stall and ingest.recovered) share a data.incident_id.

Destination policy

Endpoint URLs must use HTTPS with a valid TLS certificate and resolve only to public addresses. A URL that resolves to a private, loopback, link-local, carrier-grade NAT or cloud metadata address is refused when the endpoint is created and checked again before every delivery attempt. Redirects are not followed. Endpoint secrets are shown once, when the endpoint is created or its secret is rotated, and are never returned by list or read routes. A Slack or Discord incoming-webhook URL also works as an endpoint. Those endpoints receive a message card instead of the JSON envelope above. The endpoint’s format field (json, discord or slack) shows which one was detected from the URL, and format on create overrides it.

Next step

Handle failed deliveries

Find out why deliveries stopped, how retries work, how to send a delivery again, and how to rotate a secret.
Last modified on October 6, 2026