Skip to content

Webhooks

Tell other software that a site published. Signed, retried, and logged.

Publishing is the moment other systems care about: rebuild the marketing site, post to a channel, invalidate a cache, start a job. A webhook tells them, so nothing has to poll.

EventWhen it fires
publish.succeededA merge built and the site is serving it.
publish.failedA build failed. The payload says why.
webhook.testYou pressed Send test.

The test event goes through the same queue and carries the same signature as a real one, so a receiver that accepts the test accepts the real thing.

Every request carries a staleless-signature header:

staleless-signature: t=1788446621,v1=<hex hmac>

t is the Unix time in seconds. v1 is an HMAC-SHA256, keyed with your endpoint secret, over the timestamp and the body together:

import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(header, body, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t))
if (!Number.isFinite(age) || age > toleranceSeconds) return false
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${body}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.v1 ?? '', 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}

Danger

Verify the signature over the timestamp and the body, then reject a timestamp outside your tolerance. A signature over the body alone is a permanent licence to replay: an attacker rewrites t to now and a body-only signature still verifies.

Verify against the raw bytes you received. A body your framework parsed and re-serialised is a signature over bytes nobody sent.

A delivery gets five attempts. The delay doubles from a base and is capped at one hour, so five attempts span about four minutes on the default schedule. That covers an ordinary rolling deploy at your end.

Five rather than three, because a delivery fails when somebody else’s service is restarting, and the point of the backoff is to still be trying when it comes back.

Three failed deliveries in a row disable the endpoint.

A failed delivery is already five refused requests over four minutes, so three of them is fifteen requests to a receiver that has said no every time, over at least ten minutes. Nothing transient looks like that.

You are notified when an endpoint is disabled. Re-enable it on the same screen once the receiver is fixed.

Every attempt is recorded: the event, the response status, the duration, and the error where there was one. A failure says why in two registers: one line for the person reading the screen, and the underlying response for whoever is debugging the receiver.

A target is checked before it is saved. An address that resolves to a private network is refused, so a webhook cannot be used to reach inside the network Staleless runs in.

Webhooks are a Pro capability. See Plans.