Skip to content
Crixaa
← All posts

Verifying a Crixaa webhook

The Crixaa team4 min read

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:

HeaderContents
X-Crixaa-EventThe event name, e.g. document.generated
X-Crixaa-DeliveryA unique id for this delivery attempt's parent delivery
X-Crixaa-Signaturet=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 console
  • document.expiring — a document is approaching its expiration date
  • document.expired — a document reached that date and moved to Expired
  • template.version.committed — a new template version was committed
  • review.requested — someone requested review of a template version
  • review.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.

Try it on your own document

Design a template in the browser and generate a real PDF — free, no card.