> ## Documentation Index
> Fetch the complete documentation index at: https://docs.0xarchive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Signatures and payloads

> Check the 0xa-signature header on every delivery, read the event envelope and request headers, and build a receiver that does not lose events.

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:

<CodeGroup>
  ```python Python theme={"theme":"github-dark"}
  from oxarchive import WebhookSignatureError, WebhookVerifier

  verifier = WebhookVerifier(secret)
  event = verifier.verify(raw_body, headers)  # raises WebhookSignatureError when the check fails
  ```

  ```typescript TypeScript theme={"theme":"github-dark"}
  import { verifyWebhookSignature } from "@0xarchive/sdk";

  const ok = await verifyWebhookSignature({ payload: rawBody, headers, secret });
  ```

  ```rust Rust theme={"theme":"github-dark"}
  use oxarchive::webhook_signature::WebhookVerifier;

  let verifier = WebhookVerifier::new(secret);
  verifier.verify(raw_body, signature_header)?; // the 0xa-signature header value
  ```
</CodeGroup>

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](/webhooks/delivery-and-retries#rotate-a-secret), 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:

<CodeGroup>
  ```python Python theme={"theme":"github-dark"}
  import hashlib
  import hmac
  import time


  def verify_webhook(secret: str, signature_header: str, raw_body: bytes, tolerance_s: int = 300) -> bool:
      """True when signature_header authenticates raw_body for this endpoint secret."""
      if not signature_header:
          return False

      timestamp, signatures = None, []
      for part in signature_header.split(","):
          key, _, value = part.partition("=")
          key, value = key.strip(), value.strip()
          if key == "t":
              timestamp = value
          elif key == "v1":
              signatures.append(value)
      if not timestamp or not signatures:
          return False

      try:
          age_s = abs(time.time() - int(timestamp))
      except (ValueError, OverflowError):
          return False
      if age_s > tolerance_s:
          return False

      expected = hmac.new(
          secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
      ).hexdigest()
      return any(hmac.compare_digest(expected, sig) for sig in signatures)
  ```

  ```javascript Node.js theme={"theme":"github-dark"}
  const crypto = require("node:crypto");

  /** True when signatureHeader authenticates rawBody (a Buffer) for this endpoint secret. */
  function verifyWebhook(secret, signatureHeader, rawBody, toleranceS = 300) {
    if (typeof signatureHeader !== "string") return false;

    let t = null;
    const signatures = [];
    for (const part of signatureHeader.split(",")) {
      const eq = part.indexOf("=");
      if (eq === -1) continue;
      const key = part.slice(0, eq).trim();
      const value = part.slice(eq + 1).trim();
      if (key === "t") t = value;
      else if (key === "v1") signatures.push(value);
    }
    if (!t || signatures.length === 0) return false;

    const ageS = Math.abs(Date.now() / 1000 - Number(t));
    if (!Number.isFinite(ageS) || ageS > toleranceS) return false;

    const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
    return signatures.some((sig) => {
      const given = Buffer.from(sig, "hex");
      return given.length === expected.length && crypto.timingSafeEqual(expected, given);
    });
  }
  ```
</CodeGroup>

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

```javascript Node.js receiver theme={"theme":"github-dark"}
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const http = require("node:http");
const path = require("node:path");

const INBOX = path.resolve("inbox"); // recorded, not yet processed
const TMP = path.resolve("inbox-tmp"); // records being written; same disk as INBOX
const DONE = path.resolve("processed"); // processed
for (const dir of [INBOX, TMP, DONE]) fs.mkdirSync(dir, { recursive: true });

const RETRY_MS = 30_000;

async function syncDir(dir) {
  const handle = await fsp.open(dir, "r");
  try {
    await handle.sync();
  } finally {
    await handle.close();
  }
}

// Record the raw body as inbox/<id>.json. Only a complete file ever appears
// there: write and flush a temporary file, then rename it into place.
async function record(id, rawBody) {
  const name = `${id}.json`;
  if (fs.existsSync(path.join(INBOX, name))) {
    await syncDir(INBOX); // already recorded; make sure it is on disk
    return false;
  }
  if (fs.existsSync(path.join(DONE, name))) return false; // already processed

  const tmp = path.join(TMP, `${name}.${process.pid}.${Date.now()}`);
  try {
    const file = await fsp.open(tmp, "wx");
    try {
      await file.writeFile(rawBody);
      await file.sync();
    } finally {
      await file.close();
    }
    await fsp.rename(tmp, path.join(INBOX, name));
  } catch (err) {
    await fsp.rm(tmp, { force: true });
    throw err;
  }
  await syncDir(INBOX);
  return true;
}

// Your work goes here. It can run more than once for the same event, so make
// its effect idempotent, keyed by event.id.
async function handle(event) {
  console.log("handling", event.type, event.id);
}

// Process every recorded event, one at a time. An event leaves the inbox only
// after handle() has finished; one that fails stays and is retried later.
let running = null;
let again = false;
function processInbox() {
  if (running) {
    again = true;
    return running;
  }
  running = (async () => {
    do {
      again = false;
      for (const name of await fsp.readdir(INBOX)) {
        try {
          await handle(JSON.parse(await fsp.readFile(path.join(INBOX, name), "utf8")));
          await fsp.rename(path.join(INBOX, name), path.join(DONE, name));
        } catch (err) {
          console.error("processing failed, will retry", name, err);
        }
      }
    } while (again);
  })().finally(() => {
    running = null;
  });
  return running;
}

http
  .createServer((req, res) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", async () => {
      const rawBody = Buffer.concat(chunks);

      // 1. Verify.
      if (!verifyWebhook(process.env.OXARCHIVE_WEBHOOK_SECRET, req.headers["0xa-signature"], rawBody)) {
        res.writeHead(401).end();
        return;
      }
      const event = JSON.parse(rawBody.toString("utf8"));
      if (!/^[0-9a-f-]{36}$/i.test(event.id)) {
        res.writeHead(400).end();
        return;
      }

      // 2. Record, once per event id.
      try {
        await record(event.id, rawBody);
      } catch (err) {
        console.error("not recorded, the delivery will be retried", event.id, err);
        res.writeHead(500).end();
        return;
      }

      // 3. Acknowledge.
      res.writeHead(204).end();

      // 4. Process off the request.
      processInbox();
    });
  })
  .listen(8080);

processInbox(); // anything recorded before a restart
setInterval(processInbox, RETRY_MS); // retry processing that failed
```

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](/webhooks/delivery-and-retries#retries). 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.

```json theme={"theme":"github-dark"}
{
  "id": "04bade8a-659a-4609-a183-733163bc6a22",
  "type": "account.fill",
  "schema_version": 1,
  "observed_at": "2026-09-08T21:36:41.811Z",
  "late_ms": 479,
  "late": false,
  "data": {
    "venue": "hyperliquid",
    "symbol": "ETH",
    "wire_symbol": "ETH",
    "timestamp": "2026-09-08T21:36:41.332Z",
    "account": "0xbc256baa3480ec7882ac87eadc349e77291b202b",
    "side": "buy",
    "notional_usd": 563400.79,
    "buy_notional_usd": 563400.79,
    "sell_notional_usd": 0.0,
    "size": 227.0836,
    "vwap": 2481.0280914166,
    "taker": true,
    "direction": "Open Long",
    "fee": 167.611724,
    "closed_pnl": -155.12,
    "is_liquidation": false,
    "twap_id": null,
    "fill_count": 23,
    "order_count": 1,
    "order_ids": ["539700803622"],
    "min_trade_id": 17492294096692,
    "max_trade_id": 1121895567956718,
    "api_url": "/v1/hyperliquid/trades/ETH?start=1788903401332&end=1788903401333"
  }
}
```

* `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](/schemas/components/webhook-event-type)), 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](/schemas/components/webhook-delivery)).

`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

<Card title="Handle failed deliveries" icon="refresh-cw" href="/webhooks/delivery-and-retries" horizontal>
  Find out why deliveries stopped, how retries work, how to send a delivery again, and how to rotate a secret.
</Card>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.