Webhooks
A webhook is an event your application pushes to a url someone else gave it. Everybody writes this by hand, and everybody gets two parts of it wrong: the signature — which is what makes the receiver able to believe the request — and the url, which is a server-side request forgery with a registration form in front of it.
npm install @usehenri/webhooks @usehenri/jobs # a delivery is a jobhenri webhooks:add https://acme.example/hooks --events 'invoice.*'henri jobs # a worker performs the deliveriesawait henri.webhooks.emit('invoice.paid', { id: invoice.externalId, total: 4200,});That is the whole surface. One row per subscribed endpoint is written to the queue and the request answers; a runner delivers, signs, retries with a backoff, and what runs out of attempts is in the dead letter queue with its reason.
The package ships a henri module, so depending on it is all there is to do: it is in the boot as henri.webhooks, at level 4, after the queue. henri new does not install it. It needs @usehenri/jobs as well — an application that has the endpoints and no running queue can register and inspect them, and the first emit() says what is missing.
Registering an endpoint
Section titled “Registering an endpoint”An endpoint is a url, what it subscribes to, and a signing secret henri generates. From the command line:
henri webhooks:add https://acme.example/hooks \ --events 'invoice.paid,invoice.void' \ --owner tenant_42 \ --description 'Acme production' \ --header 'X-Acme-Env: production'or from the application, which is what a settings page does:
const endpoint = await henri.webhooks.register({ url: 'https://acme.example/hooks', events: ['invoice.*'], owner: tenant.externalId, description: 'Acme production',});
endpoint.secret; // 'whsec_…' — show it once, it is not readable in the UI laterevents is an event name, a family (invoice.* takes invoice.paid and invoice.payment.failed), or *. There is no glob language beyond that: a fan-out policy richer than a subscription is the application’s, and a second pattern syntax is a second thing to get wrong.
headers are the receiver’s own — a routing tag, a token its gateway expects. henri refuses the ones it owns (webhook-id, webhook-timestamp, webhook-signature, content-type, user-agent) and the hop-by-hop ones, because a header a receiver could set to shadow the signature would be a hole with a form in front of it.
The url’s shape is checked here — https, no credentials in it, a host. Where it resolves is not, and that is deliberate: see what a hostile url cannot reach.
Many tenants
Section titled “Many tenants”owner is the tenant an endpoint belongs to, and it is what keeps one customer’s events out of another’s:
await henri.webhooks.emit('invoice.paid', payload, { owner: tenant.externalId,});An emit() without an owner reaches the endpoints that have none, and never a tenant’s. henri will not fan an event out across every tenant for you: that is one typo away from sending customer A’s invoice to customer B, and iterating your own tenants is the version of it you can read.
The lookup loads the enabled endpoints of one owner and matches the pattern in the process, because the four SQL dialects do not agree on how to ask that of a JSON column. It is cached in henri.cache for ten seconds — without the secrets, which never reach a cache — and invalidated whenever an endpoint changes. So a busy event costs one query per owner per ten seconds, not one per event. The ceiling is webhooks.maxFanout (a thousand): past it emit() refuses rather than writing ten thousand rows inside a request, and an application that really has that many endpoints on one event emits from a job.
Where the endpoints live
Section titled “Where the endpoints live”In a table this package owns, henri_webhooks, reached through the store adapter the way the queue reaches henri_jobs — no henri model is involved. The three candidates were the configuration, a model you write, and this:
- the configuration cannot hold a secret per tenant, cannot rotate one, and is not writable by a settings page;
- a model of yours means every application carries a migration, a validation and a secret column for a table it did not want to design — and gets the storage of the secret wrong, which is the part this feature exists for;
- so: a table henri owns, indexed on
(owner, disabled_at), on every adapter (postgresql,mysql,mssql,drizzle,mongoose,disk).
The cost, said plainly: an endpoint is a row, not a model. It has none of your validations, no hooks, no paranoid, and henri.webhooks.register() is the only way in.
henri webhooks:install # creates the table and its index; idempotentThe table is also created when the application boots, so development needs nothing. In production, run henri webhooks:install in the deploy and set "install": false.
Signing
Section titled “Signing”This is the part to get right, so here is exactly what henri sends and why.
POST /hooks HTTP/1.1content-type: application/json; charset=utf-8user-agent: henri-webhooks/1.1.0webhook-id: 018f9c3e-6a2d-7b41-9f0e-2c1d5a7b3e90webhook-timestamp: 1789045200webhook-signature: v1,g0hM9SXGj0WhV0mm8ZeUR4a3lz1qWfBIvyGqUvxvXhg=
{"data":{"total":4200},"id":"018f9c3e-…","timestamp":"2026-09-06T12:00:00.000Z","type":"invoice.paid"}The scheme is Standard Webhooks, followed to the byte: three headers, the signed content is id.timestamp.body, HMAC-SHA256, base64, with a v1, scheme prefix, and the secret is whsec_ followed by the base64 of the key. A receiver that already has a Standard Webhooks library verifies henri with nothing written.
What everyone else sends, and what it leaves out
Section titled “What everyone else sends, and what it leaves out”| Sender | What it sends | What is signed |
|---|---|---|
| GitHub | X-Hub-Signature-256: sha256=<hex> |
the body |
| Shopify | X-Shopify-Hmac-Sha256: <base64> |
the body |
| Stripe | Stripe-Signature: t=<seconds>,v1=<hex> |
timestamp.body |
| henri | webhook-id, webhook-timestamp, webhook-signature |
id.timestamp.body |
GitHub and Shopify sign the body and nothing else. There is no timestamp, so a captured request replays for as long as the secret lives, and no delivery id, so a receiver cannot tell a retry from a duplicate. They also disagree with each other on the encoding of the same algorithm — hex against base64 — which is the single most common reason a hand-written verification fails on a genuine request.
Stripe is the one that gets it right, and henri differs from it in three ways, on purpose:
- the timestamp is its own header, not a field inside the signature header, so a receiver can do the recency check without parsing the signature first (Slack does the same);
- the delivery id is part of the signed content. Stripe re-signs a retry with a fresh timestamp and puts the event id only in the body, so deduplicating means trusting the body before verifying it. henri sends the same
webhook-idon every attempt of one delivery, signed, so “have I already processed this?” is answered from bytes that were authenticated; - base64, not hex, because the specification says so.
One more thing henri does not do: it sends no unsigned header a receiver could route on. The event type is in the signed body and nowhere else. A X-Event-Type header outside the signature is a switch statement an attacker can steer.
The timestamp, and replay
Section titled “The timestamp, and replay”Replay protection is not an extra on top of a signature, it is part of one. The timestamp is inside the signed content, so it cannot be moved without the key, and it is the moment of this attempt — a delivery retried six hours later carries a fresh timestamp and a fresh signature, and stays inside the receiver’s window. The event’s own moment is in the body, as timestamp.
A receiver needs all three of these, and the snippet below has all three:
- refuse a
webhook-timestampmore than five minutes from now (Stripe’s default, and what henri documents); - compare with a constant-time comparison, never
===on strings; - refuse a
webhook-idyou have already answered. The window bounds a replay; the id makes a retry safe. Neither is enough alone.
Verifying a delivery
Section titled “Verifying a delivery”Paste this. It is the whole thing, and packages/webhooks/__tests__/signature.spec.js runs this exact transcription against what the package signs, so this page cannot drift from the code.
const { createHmac, timingSafeEqual } = require('crypto');
const TOLERANCE = 300; // seconds
/** * Whether a request really came from the sender that holds this secret * * @param {string} body the RAW body, exactly as it arrived * @param {object} headers the request headers, lowercased * @param {string} secret the endpoint secret, `whsec_…` * @returns {boolean} whether to act on it */function verify(body, headers, secret) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) { return false; }
// 1. recency: a signature that is valid and old is a replay if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE) { return false; }
// 2. the key is the base64 INSIDE the secret, not the secret string const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key) .update(`${id}.${timestamp}.${body}`, 'utf8') .digest();
// 3. every `v1,` signature in the header; ignore the schemes you do not // know, so a future one cannot downgrade you return signatures .split(' ') .filter((entry) => entry.startsWith('v1,')) .map((entry) => Buffer.from(entry.slice(3), 'base64')) .some( (candidate) => candidate.length === expected.length && timingSafeEqual(candidate, expected) );}In Express, with the raw body — a signature is over bytes, and express.json() has already thrown them away:
app.post('/hooks', express.raw({ type: 'application/json' }), (req, res) => { const body = req.body.toString('utf8');
if (!verify(body, req.headers, process.env.ACME_WEBHOOK_SECRET)) { return res.sendStatus(400); }
const event = JSON.parse(body);
if (alreadyProcessed(req.headers['webhook-id'])) { return res.sendStatus(200); // a retry, not a new event }
res.sendStatus(200); // answer first handle(event).catch(report); // then do the work});In Ruby, in Python, in Go: the same four lines. The only step people skip is the second one — whsec_ is a label, not key material, and the key is what the base64 after it decodes to.
An application that receives its own webhooks (a test, another service of the same codebase) can skip the transcription: require('@usehenri/webhooks').verify({ body, headers, secret }) is the counterpart of what signed it.
Rotating
Section titled “Rotating”Two things rotate, and the v1 prefix is what makes the second one possible.
The key. An endpoint may hold several secrets at once, and every one of them signs, so a receiver has a window to install the new key without dropping a delivery:
henri webhooks:rotate <id> --grace 24hconst { secret } = await henri.webhooks.rotate(id, { grace: 86400000 });The header then carries two v1, signatures, and a receiver that has either key accepts. Without a --grace the old secret retires at once, which is what a leak calls for. henri webhooks:show <id> --reveal prints the secrets that still sign, for the receiver that lost one.
The scheme. v1 is HMAC-SHA256. A receiver that follows the snippet above — ignore every scheme you do not know — keeps working while a second one is being sent alongside it.
The secrets at rest
Section titled “The secrets at rest”A signing secret is a bearer credential in both directions: whoever holds it can forge a delivery the receiver will believe. It cannot be hashed the way a password is, because henri signs with it on every attempt, so it is encrypted: AES-256-GCM under a key derived from config.secret (HKDF-SHA256). A database dump on its own is then not a set of forged deliveries.
The row carries the first bytes of the key’s digest, so this is worth writing down:
Rotating
HENRI_SECRETmakes every stored webhook secret unreadable. henri says exactly that (HENRI_WEBHOOK_SECRET_UNREADABLE) instead of failing to decrypt. The fix is not to decrypt them — it is to rotate the endpoints’ own secrets and hand the new ones to the receivers. Rotate one, or rotate both in that order.
An application with no secret at all — there is one as soon as it has users — stores them as they are, and the boot says so once.
Delivery
Section titled “Delivery”A delivery is one henri/webhook job. That is the whole mechanism: the retries, the exponential backoff, the dead letter queue, the recovery of a runner that died mid-flight and the operator’s view of all of it are the queue’s, already written and already covered on four databases. There is no second thing to learn, no second table to prune and no second answer to “what happened to it”.
The default policy is eight attempts, ten seconds tripling up to six hours — about three days of trying, which is what a receiver that is down for a weekend needs.
What is a failure
Section titled “What is a failure”| The receiver answered | henri does |
|---|---|
2xx |
delivered. |
3xx |
fails, and does not retry. The redirect is not followed. |
410 Gone |
fails, does not retry, and disables the endpoint. |
any other 4xx |
fails, retried. |
5xx |
fails, retried. |
| nothing, in time | fails, retried (webhooks.timeout, ten seconds, the whole exchange). |
| a connection error | fails, retried. |
| a refused address | fails, and does not retry (see below). |
Two decisions in there are worth their reasons.
A redirect is not followed. A 3xx is the receiver choosing the next url, after henri checked the one it was given; following it hands back the hole the address check just closed. Stripe treats a redirect as a failure too; henri goes one step further and does not retry it, because three days of retrying a 301 is noise and the fix is a registration change. The error names the Location, so the operator can henri webhooks:update <id> --url <the new one>.
Other 4xx are retried. A 401 is a token that was rotated a minute ago and a 404 is a deploy that is half done; the backoff is patient enough to outlive both, and what really is permanent ends in the dead letter queue anyway. 410 Gone is the exception because it means “stop”, and henri stops: the endpoint is disabled and nothing else is queued for it.
henri does not honour Retry-After. The backoff is the queue’s, and a second schedule on top of it would be a second thing to reason about.
What an operator sees
Section titled “What an operator sees”The queue, because the queue already has this shape:
henri webhooks:status # the endpoints, and the counts belowhenri jobs:list --queue webhooks # what is pending, running, donehenri jobs:dead --queue webhooks # what ran out of attemptshenri jobs show <id> # the endpoint, the event, the error, every attempthenri jobs:retry <id> # send it againhenri jobs show prints the arguments of the job, which are the delivery id, the endpoint id, the event and the signed body — so a dead delivery says which endpoint, which event, and the receiver’s own words in the error.
Trying one by hand
Section titled “Trying one by hand”henri webhooks:send <id> invoice.paid --data '{"total":4200}'henri jobs --oncesend goes to that one endpoint whether or not it subscribed to the event, through the queue like everything else — so a runner still has to perform it.
What a hostile url cannot reach
Section titled “What a hostile url cannot reach”A webhook url is a string someone else typed into your application, and the process that opens it sits inside your network. That is server-side request forgery, and it is the reason http://169.254.169.254/latest/meta-data/ (the cloud instance’s credentials) and http://localhost:6379/ (the Redis holding your sessions) are the two examples in every write-up.
henri refuses, when the request is made:
- anything that is not
https— andhttptoo, unlesswebhooks.allowHttpsays otherwise; - a url carrying credentials (
https://user:pass@host/); - an address in any of these, whichever of the name’s answers it is:
| Refused | Because |
|---|---|
127.0.0.0/8, ::1 |
the loopback: everything you run beside this |
169.254.0.0/16, fe80::/10 |
link-local, where the metadata service lives |
10/8, 172.16/12, 192.168/16, fc00::/7 |
the private network |
100.64.0.0/10 |
carrier-grade NAT |
0.0.0.0/8, 224/4, 240/4, ::, ff00::/8 |
this network, multicast, reserved |
192.0.2/24, 198.51.100/24, 203.0.113/24 |
documentation ranges |
198.18/15 |
benchmarking |
::ffff:127.0.0.1, 2002::/16, 64:ff9b::/96 |
an IPv4 address wearing an IPv6 costume |
Three details that are usually what is missing:
- at request time, not at registration. DNS answers differently later: a name that resolved publicly when it was registered resolves to
169.254.169.254when the delivery goes out. Checking at registration proves nothing about the request. - the checked address is the address the socket connects to. Checking the name and then letting the HTTP client resolve it again re-opens the same hole through the back door, half a millisecond wide — DNS rebinding. henri hands the agent a
lookupthat answers the one address it checked and never asks a resolver again. TLS still validates against the name, so pinning costs no certificate warning. - one bad answer refuses the name. A host with an A record on a public address and an AAAA record on
::1is an attack, not a mistake.
A refused address is a failure that is not retried: it goes straight to the dead letter queue with the address it resolved to, so an operator sees it now rather than in three days.
The answer is read up to 64kb and then the socket is dropped, so a receiver that streams forever holds a runner for one timeout and not a byte more. Nothing in it is parsed; a short excerpt is kept for the operator to read.
webhooks.allowPrivate and webhooks.allowHttp lift the first two rules. They exist because a development configuration delivers to http://127.0.0.1:9099, and henri audit reports either of them in a production configuration — webhooks.private-addresses-allowed is a high finding.
This is the floor, not the ceiling. An application that knows the hosts it sends to should also say so at its egress; a blocklist inside the process cannot see a route the network has.
Testing
Section titled “Testing”@usehenri/testing boots the application, so henri.webhooks is there. Stand up a receiver, emit, drain the queue, verify:
const http = require('http');const { henri, setup } = require('@usehenri/testing');const { Runner } = require('@usehenri/jobs/src/runner');const { verify } = require('@usehenri/webhooks');
test('paying an invoice tells the endpoints', async () => { await setup();
const got = []; const server = http.createServer((request, response) => { const chunks = [];
request.on('data', (chunk) => chunks.push(chunk)); request.on('end', () => { got.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers, }); response.end('ok'); }); });
await new Promise((done) => server.listen(0, '127.0.0.1', done));
const endpoint = await henri().webhooks.register({ url: `http://127.0.0.1:${server.address().port}/hooks`, events: ['invoice.*'], });
await henri().webhooks.emit('invoice.paid', { total: 4200 }); // A delivery is a job: perform what is due instead of running a worker await new Runner(henri().jobs.queue).once();
expect(verify({ ...got[0], secret: endpoint.secret }).ok).toBe(true);
server.close();});A test configuration needs "webhooks": { "allowHttp": true, "allowPrivate": true }, since a receiver on the loopback is exactly what the address rules refuse.
Configuration
Section titled “Configuration”Everything below has a default; the webhooks block only says what differs.
{ "webhooks": { "store": "default", "table": "henri_webhooks", "queue": "webhooks", "maxAttempts": 8, "timeout": "10s", "backoff": { "base": "10s", "factor": 3, "max": "6h", "jitter": 0.2 }, "maxFanout": 1000, "install": true, "allowPrivate": false, "allowHttp": false }}See the configuration reference for what each key does.
What this does not do
Section titled “What this does not do”Deliberately, and each with its reason:
- Receiving webhooks — verifying somebody else’s signature. Every sender has a different scheme, so the useful thing is not a framework abstraction over all of them but the four lines above, written against their documentation. henri exports
verify()for its own scheme and stops there. - A UI. The surface is
henri.webhooks.*andhenri webhooks:*; a settings page is your application’s, in your design, with your authorization. Building it on the API takes an afternoon and building it into the framework takes it away from you. - A richer subscription policy than an event name, a family and
*. Transformations, filters on the payload, per-event retry policies and fan-out rules are a product, not a framework feature, and the ones who need them know it. - Anything that needs a message broker. The queue is a table in the database the application already runs. That bounds the throughput — a few thousand deliveries a minute, not a million — and it means there is nothing else to deploy, nothing else to monitor and nothing else to lose messages in. If you outgrow it, you have outgrown this on purpose.
Retry-After. See above: the backoff is the queue’s.- Ordering guarantees. Deliveries are jobs, performed concurrently by however many runners are up;
invoice.paidmay arrive beforeinvoice.created. That is true of every webhook system worth trusting, which is why the event carries what the receiver needs rather than a diff. Say so in your own documentation.
