Webhooks

A webhook is the durable form of the WebSocket feed. Both carry the same three chain events. A socket subscriber that falls behind loses events by design; a webhook delivery is written down before any HTTP is attempted, and retried for the best part of a day.

The events

Three, and the list is fixed. Which chains a given event can arrive from depends on what that ledger actually emits, not on what you subscribe to.

EventFires whenChains
credential.revokedA credential or attestation is revoked or removedeIDChain, Fabric
did.updatedA DID document is created, changed, or deletedeIDChain, Fabric
attestation.pushedAn attestation is written on-chainEthereum, Fabric

Ethereum never sends credential.revoked or did.updated: the watcher there follows one contract log, AttestationRecorded, so revocation on that chain arrives as an attestation.pushed carrying status. On Fabric all three come from the block write set, keyed by prefix (did::, status::, attestation::), so any write to a status key produces credential.revoked whatever the new status says.

Events are global to the chain, not scoped to you. Deliveries are not filtered to your own DIDs, your own credentials, or anything else you own. A webhook subscribed to credential.revoked on eIDChain receives every revocation on that ledger, including revocations that have nothing to do with your organisation.

The only filters are the event list and the chain list you set on the endpoint. There is no subject filter. Your handler has to match the incoming DID or credential id against your own records and drop the rest, and your endpoint has to be sized for ledger-wide volume rather than your share of it. Treat the payload as public ledger data, because that is what it is.

The payload

One POST per event per matching endpoint. The body is a JSON object with four keys, and the headers carry the delivery id, the event name, and the signature.

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Attestra-Webhooks/1
X-Attestra-Delivery-Id: 40218
X-Attestra-Event: credential.revoked
X-Attestra-Signature: t=1786704173,v1=300fd6684e857f10…

{"chain":"eid","created_at":"2026-08-14T09:12:44.318Z","data":{"block":"0x9c3f2a","fields":{"attester":"did:eid:4pZq","claim_hash":"0x5e1c"},"runtime_event":"Attestation.AttestationRevoked"},"event":"credential.revoked"}

chain is eid, ethr or fabric. event is one of the three above. created_at is when the portal queued the delivery, in UTC, and it is your ordering hint: delivery order is not the order things happened. data is chain-shaped and passed through as the watcher produced it.

Chaindata keys
eIDChainruntime_event, block, fields
Ethereumkey, did, credential_id, status, block, tx
Fabrickey, id, block

Read the body as bytes, not as re-serialised JSON. Object keys are emitted in the order shown, but the signature covers the exact bytes sent, so a handler that parses and re-encodes before verifying will fail on whitespace alone.

Verifying the signature

Every delivery carries X-Attestra-Signature, formatted t=<unix seconds>,v1=<hex>. The v1 value is HMAC-SHA256 over the string <t>. followed by the raw body, keyed with the endpoint signing secret (the whsec_ value shown once at creation and at rotation).

The timestamp is inside the signed material, not merely alongside it. Signing the body alone would let anyone who captured one delivery replay it forever. Attestra does not enforce an age limit at your end, so the tolerance window is yours to pick; five minutes is a reasonable default.

import crypto from 'node:crypto'

const TOLERANCE = 300 // seconds

function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
  const t = Number(parts.t)
  if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${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)
}

In Express, mount express.raw({type: 'application/json'}) on the route and pass req.body straight in, before any JSON parser touches it. Compare with timingSafeEqual rather than ===, and answer 401 on a mismatch. Rotating the secret invalidates the old one immediately, including for deliveries already in flight.

Delivery semantics

Delivery is at-least-once. A worker that dies mid-POST, or a timeout that arrives after your handler committed, produces a second POST of the same event. Deduplicate on X-Attestra-Delivery-Id, which is stable across every attempt of one delivery, and make your handler idempotent.

Any 2xx is success. Anything else, including a connection error or a response slower than the 10-second budget, is a failed attempt. Seven attempts total, the first immediate and six retried on a fixed schedule.

AttemptDelay after previousElapsed
1immediately0
21 minute1m
35 minutes6m
430 minutes36m
52 hours2h 36m
66 hours8h 36m
712 hours20h 36m

A delivery therefore lives 20 hours 36 minutes, long enough to cover an overnight outage. After the seventh attempt the delivery is marked failed and never sent again on its own. It stays in the deliveries tab with the status code and the first 4 KB of your response body, and you can replay it by hand.

Ten consecutive deliveries that exhaust all seven attempts pause the endpoint. Owners and admins get an email, nothing further is queued, and everything still waiting is retired as failed rather than being POSTed at a broken endpoint for the remaining hours. Retired deliveries stay visible and can be replayed once you re-enable the endpoint, which also resets the counter, as does any single successful delivery.

History is kept 30 days. Settled deliveries older than that are pruned; anything still pending is left alone, because a wedged delivery is worth seeing. Ordering is not guaranteed in any direction: not across chains, whose watchers run independently, and not within one chain, because a retried delivery lands after the ones queued behind it. Use created_at and the block number in data, never arrival order.