A webhook endpoint is a URL on the open internet that accepts POSTs and does something consequential with them. Anyone who learns the address can send you whatever they like.
That is why every delivery we make is signed, and why verifying that signature is not an optional hardening step you get to later. It is the entire security model.
What arrives
Three headers come with every delivery:
| Header | Contents |
|---|---|
X-Crixaa-Event | The event name, e.g. document.generated |
X-Crixaa-Delivery | A unique id for this delivery attempt's parent delivery |
X-Crixaa-Signature | t=1769510400,v1=9f86d0… |
The signature has two parts. t is the Unix timestamp at which we signed, and v1 is an
HMAC-SHA256, hex encoded, of this exact string:
{timestamp}.{raw request body}
signed with the whsec_… secret shown once when you created the webhook.
Sign the bytes you received
The single most common way to get this wrong is to parse the JSON, re-serialise it, and
sign that. Do not. JSON.parse followed by JSON.stringify is not an identity function —
key order, whitespace and number formatting can all shift, and the HMAC changes completely
when a single byte does.
Read the raw body first, verify against it, and only then parse.
import crypto from 'node:crypto'
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
)
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
// Constant-time: a plain === leaks how much of the digest matched, one
// character at a time, to anyone willing to send enough requests.
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
)
}
In Express that means reaching for express.raw({ type: 'application/json' }) on the
webhook route specifically, rather than the express.json() you have mounted globally.
Two details worth the extra lines. Compare digests in constant time — timingSafeEqual
rather than === — because a short-circuiting comparison tells an attacker how many
leading characters they got right. And reject a timestamp that is far from now, a few
minutes either way, so a delivery captured off the wire cannot be replayed at leisure.
Assume every delivery arrives twice
Networks fail in the least convenient way possible: your handler runs, does its work, and the 200 gets lost on the way back to us. From where we sit that is indistinguishable from your server never having received anything, so we retry — and you process the same event twice.
X-Crixaa-Delivery is the fix. Record it, and make a repeat a no-op:
if (await seen(req.headers['x-crixaa-delivery'])) {
return res.sendStatus(200)
}
This matters more than it sounds. If the event triggers an email, a duplicate is an apology. If it triggers a payment, it is a refund.
What we do when you are down
Any 2xx is success. Anything else — including a timeout, and we give up on a request
after 10 seconds — is a failure, and we retry with exponential backoff: up to 8 attempts,
doubling from 2 seconds to about 2 minutes, over a window of roughly 4 minutes. After
that the delivery is abandoned and we stop.
That window is deliberately short, and it is worth being honest about what it means for you. Four minutes covers a deploy or a brief blip. It does not cover an outage. If your endpoint is down for an hour, those events are gone, and the recovery path is to reconcile against the API rather than to wait for us to try again.
So treat webhooks as a fast path, not as your only path. Anything you cannot afford to
miss should also be reachable by polling GET /api/v1/documents.
The events
Six, and the names say what they mean:
document.generated— a document finished generating, from the API or the consoledocument.expiring— a document is approaching its expiration datedocument.expired— a document reached that date and moved to Expiredtemplate.version.committed— a new template version was committedreview.requested— someone requested review of a template versionreview.decided— a reviewer approved or requested changes
Subscribe only to what you handle. An endpoint that receives events it ignores is an endpoint whose logs are harder to read on the day something breaks.
Before you go live
Send yourself a delivery and check three things: that a tampered body fails verification, that a replayed delivery id is ignored, and that your handler returns in well under ten seconds. If it needs to do real work, enqueue it and return 200 immediately — a handler that does the work inline is a handler that will time out on the day the queue is long.