Webhooks

Stop polling. Take the signed push instead.

When a proof is created, verified, spent, anchored in the audit tree or when a key is revoked, AffixIO posts a small JSON document to an HTTPS endpoint you control. Each delivery is signed with a secret that belongs to that endpoint alone. Everything you need to check a delivery is on this page, including the exact string that gets hashed.

Last checked against the running implementation on . Written for the engineer who has to build the receiver.

Bundled optical fibre strands lit from one end, a single path carrying many separate signals
One channel out, many events across it. Photograph by Gringer, CC BY-SA 3.0, cropped and desaturated.

What arrives at your endpoint

A single JSON POST with four AffixIO headers and a body containing a delivery id, the event name, an ISO-8601 timestamp and a data object. The body is never chunked across requests and never exceeds a few hundred bytes in practice.

Headers on every delivery
HeaderValue
X-Affix-EventThe event name, matching one of the five below.
X-Affix-TimestampUnix seconds at the moment of the attempt. Part of the signed message.
X-Affix-Delivery-IdIdentifier of the form dlv_ plus 24 hex characters. Constant across retries of the same event.
X-Affix-Signaturehmac-sha256= followed by a lowercase hex digest.
Content-Typeapplication/json
User-AgentAffixIO-Webhooks/1.0

Envelope

POST /affix/webhook HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: AffixIO-Webhooks/1.0
X-Affix-Event: proof.verified
X-Affix-Timestamp: 1786780800
X-Affix-Delivery-Id: dlv_4f1c9a02b7e35d81c6a4f0d2
X-Affix-Signature: hmac-sha256=6b1f0c...

{
  "delivery_id": "dlv_4f1c9a02b7e35d81c6a4f0d2",
  "event": "proof.verified",
  "created_at": "2026-08-14T21:00:00.000Z",
  "data": {
    "digest": "aeec81911a1cb288c24133ec48abe9f067302b5ac14badeed2c0a609d3c7c5f2",
    "circuit_id": "yesno",
    "proof_id": "prf_9c2d18a6",
    "merkle_root": "1f0b7c...",
    "merkle_leaf_hash": "8d41e2..."
  }
}

The four envelope keys are the same for every event. Only data changes shape, and it only ever carries identifiers and hashes.

Five events, and what each one means

An endpoint subscribes to at least one event and can subscribe to all five. Anything outside this list is rejected at registration rather than silently accepted.

proof.created

Proof generated

A proof was generated and its digest anchored. Useful for reconciling what your own systems produced against what reached AffixIO.

  • digest, circuit_id, proof_id
  • merkle_root, merkle_leaf_hash

proof.verified

Outcome signed

A verification completed and the outcome was signed. This is the event most receivers act on, because it marks the point where a decision became durable.

  • digest, circuit_id, proof_id
  • merkle_root, merkle_leaf_hash

proof.spent

Single use consumed

A digest was consumed and cannot be presented again. Presenting it a second time returns 409 with reason code DOUBLE_SPEND. Watch this event if you run a gate or a turnstile.

  • digest, circuit_id, proof_id
  • merkle_root, merkle_leaf_hash

merkle.leaf_appended

Audit tree grew

A leaf was appended to the audit tree. It fires alongside the proof events above, and it is the one to subscribe to if you keep your own copy of the audit trail.

  • digest, circuit_id, proof_id
  • audit_event, leaf_hash
  • merkle_index, merkle_root

key.revoked

Credential withdrawn

An API key was revoked, either by an account owner or by an administrator. Route this one to whoever handles access, not to the queue that processes proofs.

  • key_id, label

The signature scheme, in full

The message is {timestamp}.{raw_body}, the key is the endpoint secret, the function is HMAC-SHA256 and the header carries the lowercase hex digest behind an hmac-sha256= prefix. There is no canonicalisation step and no header list to assemble.

  1. Capture the raw body Read the bytes before any JSON parsing. If you re-serialise a parsed object you will change whitespace or key order and the digest will not match.
  2. Check the timestamp first Read X-Affix-Timestamp and drop the delivery if it is more than 300 seconds from your own clock. Doing this before the MAC keeps a captured delivery from being replayed at leisure.
  3. Recompute the MAC Join the timestamp, a full stop and the raw body, then run HMAC-SHA256 with the endpoint secret as the key.
  4. Compare in constant time Strip the prefix from X-Affix-Signature and use a constant time comparison. A plain string equality leaks timing.
  5. Deduplicate, then respond fast Treat X-Affix-Delivery-Id as your idempotency key. Return 2xx quickly and do the real work afterwards, because each attempt times out after eight seconds.

Node.js receiver

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.AFFIX_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

const app = express();

app.post(
  "/affix/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts = Number(req.get("X-Affix-Timestamp"));
    const provided = (req.get("X-Affix-Signature") ?? "").replace(/^hmac-sha256=/i, "");
    const body = req.body.toString("utf8");

    if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
      return res.status(400).send("stale");
    }

    const expected = createHmac("sha256", SECRET).update(`${ts}.${body}`).digest("hex");
    const a = Buffer.from(expected, "utf8");
    const b = Buffer.from(provided, "utf8");
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).send("bad signature");
    }

    res.status(204).end();
    queue.push(req.get("X-Affix-Delivery-Id"), JSON.parse(body));
  }
);

Python receiver

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(secret: str, headers, raw_body: bytes) -> bool:
    try:
        ts = int(headers["X-Affix-Timestamp"])
    except (KeyError, ValueError):
        return False

    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False

    provided = headers.get("X-Affix-Signature", "")
    provided = provided.split("=", 1)[-1].strip().lower()

    message = f"{ts}.".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)

Both examples appear on the Python page and the browser page in the wider context of calling the API. The rule that matters is the same in every language: hash the timestamp and the bytes you received, not the object you parsed.

Retries, ordering and what counts as success

Delivery behaviour
PropertyBehaviour
AttemptsThree per event. A short pause separates them, growing with each attempt.
TimeoutEight seconds per attempt. A slow receiver is treated as a failed one.
SuccessAny 2xx response. The body is ignored.
Delivery idIdentical across all three attempts, so deduplication is straightforward.
After three failuresThe event is dropped. The endpoint records the failure count and stays active.
OrderingNot guaranteed. Deliveries to different endpoints run concurrently, and a verification produces more than one event.
TransportHTTPS only. A plain HTTP URL is rejected at registration.

One verification produces both proof.verified and merkle.leaf_appended. If your receiver subscribes to both, expect two deliveries with different delivery ids describing the same underlying moment, and reconcile on digest rather than on arrival order.

Endpoints are never disabled automatically. A receiver that has been failing for a week keeps being called, which is deliberate: an endpoint that silently switches itself off is worse than one that keeps knocking. Pause it yourself when you need to, and the configuration survives.

Registering and rotating

The Hub webhooks page is the shortest path: sign in, add a label, paste an HTTPS URL, tick the events. The signing secret is shown once, at creation, in the form whsec_ followed by 48 hex characters. Copy it into your receiver's environment then.

The same operations are available over the API when you would rather manage endpoints from code. Authenticate with a Hub session or an API key.

Management endpoints on api.affix-io.com
CallPurpose
GET /api/webhooksList your endpoints with masked secrets, last delivery status and failure counts.
POST /api/webhooksRegister an endpoint. Body takes label, url and events. Returns the secret once.
PATCH /api/webhooks/{id}Change the label, URL or event set, or set active to false to pause deliveries.
DELETE /api/webhooks/{id}Remove the endpoint.
POST /api/webhooks/{id}/rotate-secretIssue a new secret and invalidate the previous one immediately. The id is unchanged.
curl -X POST https://api.affix-io.com/api/webhooks \
  -H "Authorization: Bearer $AFFIX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "billing-events",
    "url": "https://example.com/affix/webhook",
    "events": ["proof.verified", "proof.spent"]
  }'

Scope follows ownership. An endpoint registered from a Hub session receives events triggered by any key on that account. An endpoint registered with an API key and no session receives events triggered by that key alone. Rotation does not change scope.

What webhooks do not do

Stated plainly, so nobody designs around a guarantee that is not there.

  • There is no dead letter queue. After three failed attempts the event is gone from the delivery path. If you need a complete record, pull the evidence export on a schedule and use webhooks for latency, not for completeness.
  • There is no delivery history API. Each endpoint keeps its last delivery timestamp, last HTTP status and a running failure count, which is what the Hub page and the export show.
  • Ordering is not guaranteed, and the same moment can produce two events.
  • Payloads are not encrypted beyond TLS. They are signed, which proves origin and integrity, not confidentiality.
  • An endpoint cannot filter below the event level. You get every event of a subscribed type within your scope, and filtering on circuit_id happens in your code.
  • Signing secrets are not recoverable. If you lose one, rotate it.

Questions from people building receivers

The six that come up before the first delivery lands.

What signature does AffixIO send on a webhook?

X-Affix-Signature carries hmac-sha256= followed by a lowercase hex digest. The digest is HMAC-SHA256 with the per-endpoint signing secret as the key and the string {timestamp}.{raw_body} as the message, where the timestamp is the value in X-Affix-Timestamp.

How long is a delivery valid?

Reject deliveries where the timestamp is more than 300 seconds from your own clock. That is the window the AffixIO reference verifier applies, and it stops a captured delivery being replayed later.

How many times will a failed delivery be retried?

Up to three attempts per event, with a short pause between them and an eight second timeout each. Every attempt carries the same delivery id, so deduplicate on it. After the third failure the event is dropped.

Which events can an endpoint subscribe to?

proof.created, proof.verified, proof.spent, merkle.leaf_appended and key.revoked. At least one is required. Unknown names are rejected at registration.

Do payloads contain personal data?

No. They carry proof digests, circuit identifiers, proof identifiers, Merkle roots and leaf hashes. The verification model never puts the underlying attribute on the wire, so there is nothing personal in the envelope to leak.

What if a signing secret leaks?

Rotate it from the Hub page or with POST /api/webhooks/{id}/rotate-secret. The new secret is shown once and the previous one stops working immediately. The endpoint id is unchanged, so nothing else needs updating.

Next

Register an endpoint in Hub, or read how the same events reach a SIEM as a complete file rather than a stream of pushes.