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:
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 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.