Attestra

Webhooks

Durable, retried delivery of chain events, with an HMAC signature on every request.

A webhook delivers the same three events as the WebSocket feed: credential.revoked, did.updated, attestation.pushed. The difference is durability. A socket subscriber that falls behind loses events by design, which is the wrong behavior for a revocation. A webhook delivery is written to storage before any HTTP request is attempted, so a slow or temporarily unreachable endpoint does not lose the event, it just receives it later, on a retry schedule.

Configuring an endpoint

An endpoint needs a URL and at least one event. Chains are optional; an empty chain set means all chains.

  • Events, constrained to credential.revoked, did.updated, attestation.pushed.
  • Chains, constrained to eid, ethr, fabric. Leave this empty to receive matching events from every chain.

Both sets are CHECK-constrained at creation. A value outside them is rejected outright rather than silently matching nothing, so a typo in an event or chain name surfaces immediately instead of producing an endpoint that quietly never fires.

Verifying the signature

Every delivery carries an X-Attestra-Signature header:

X-Attestra-Signature: t=<unix seconds>,v1=<hex>

<hex> is:

v1 = HMAC-SHA256(secret, "<t>." + rawBody)

Note the literal . between the timestamp and the body: the signed message is the timestamp, then a period, then the raw request body, concatenated as one string, not the timestamp and body signed separately. secret is the endpoint's signing secret, shown once at creation and again on rotation, prefixed whsec_.

To verify, split the header on , to recover t and v1, recompute the HMAC with the same construction, and compare in constant time.

Node:

import crypto from "node:crypto";

function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=", 2))
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.`)
    .update(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);
}

Python:

import hashlib
import hmac

def verify(secret: str, raw_body: bytes, header: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, or equivalent) rather than == or ===, so a mismatched signature cannot be distinguished byte by byte through timing.

Why the timestamp is signed

t is part of the signed material, not a value alongside it. If only the body were signed, anyone who captured a single valid delivery could replay it, unchanged, at any point in the future, and it would still verify. Signing t closes that: a receiver can check the age of t against its own clock and reject anything older than a chosen tolerance window, so a captured delivery has a limited lifetime instead of an unlimited one. Do this; a tolerance window of a few minutes is a reasonable default.

Verify against the raw body, before parsing it

The payload is stored and signed as an exact byte string, and the signature is computed over those exact bytes. If your framework parses the request body into JSON before your handler sees it, and you re-serialize that object to verify, the signature will not match: key order and whitespace are not guaranteed to survive a parse-and-reencode round trip, even when the data is otherwise identical.

This is the most common integration mistake with this API. Capture the raw body explicitly, ahead of any JSON parsing middleware, and verify against that. In Express, this typically means mounting a raw body parser on the webhook route specifically, before express.json() would otherwise consume it; other frameworks have their own equivalent of "give me the bytes as received."

Retries

Delivery is attempted up to 7 times. The first attempt is immediate; if it fails, five more attempts follow on a fixed backoff, then a final attempt:

AttemptDelay after previous
1immediate
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours

That is six backoff intervals covering the second through seventh attempts. Summed, a delivery lives about 20 hours 36 minutes from first attempt to last, long enough to survive an overnight outage at your endpoint. Each POST is capped at 10 seconds; a slower response counts as a failed attempt, same as a connection error or a non-2xx status.

Automatic disabling

Ten consecutive failed deliveries disable the endpoint and send an email to the organisation. Any delivery that lands successfully, at any point, resets that counter back to zero.

Replay

Replaying a delivery re-sends the original stored bytes rather than re-serializing the payload, so a replayed delivery reproduces the same signature material as the original and verifies the same way.

Permissions

Reading webhook endpoints and their delivery history is open to any member of the organisation: debugging why an endpoint returned a 500 should not require being an admin. Creating, editing, deleting, rotating the secret, and revealing the secret are admin-only.

Retention

Delivery records are pruned after 30 days.

On this page