Zeno 文档
获取 API 密钥

服务器

Webhooks

Get every new event POSTed to your server, signed.

Add an endpoint url on the dashboard's Webhooks page and every new event is POSTed there within seconds of the identification, as JSON in the same shape as GET /events/{requestId} without velocity. The page also makes the signing secret (whsec_...), shown once.

The endpoint url

The url has to be https://. Each delivery carries a visitor's IP, location, device and your external ID, which shouldn't cross the internet readable to anyone on the way, and https also proves to us that we're talking to your server before we hand an event over. The host has to be public too: a private or local IP address, or a name like localhost, is refused, and a name that resolves to one fails its deliveries without a request. A url that answers with a redirect fails, since deliveries don't follow them. (A local copy of the service also takes http://localhost; the hosted one doesn't.)

Testing on your own machine

Deliveries come from the internet, so they can't reach localhost, and a self-signed certificate fails them (the attempt says so). Put a tunnel in front of your local server, which gives it a public https url with a trusted certificate:

Shell
# Cloudflare's, no account needed for a quick tunnel:
cloudflared tunnel --url http://localhost:3000

# or ngrok:
ngrok http 3000

Use test events only through a tunnel: real deliveries carry personal data through the tunnel's provider. Add the https url it prints, with your receiver's path (like https://random-words.trycloudflare.com/webhooks/fingerprint), as the endpoint, then press Send test event on the Webhooks page: the delivery shows up in the log with your server's answer, so you can fix the receiver before real traffic arrives.

Headers

HeaderValue
Content-Typeapplication/json
User-Agentzeno-webhooks/1
Fingerprint-SignatureThe signature, below.
Fingerprint-Delivery-IdThe delivery's id, the same on every retry of it.
Fingerprint-TriggerWhy it was sent: event (a new event), update (an external ID or action attached later), redeliver (someone pressed Redeliver) or test (Send test event).

Verify the signature

Text
Fingerprint-Signature: t=1792578565,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t is when it was signed, in Unix seconds, and v1 the HMAC-SHA256 of <t>.<raw body> as 64 lowercase hex digits, keyed with your webhook secret as it's shown, whsec_ included, in UTF-8 (not base64-decoded, unlike some other services). Check both, so nobody else can send you events and a captured delivery can't be replayed:

  1. Split the header on commas, and each part at its first =; a part may have spaces around it. t comes once, v1 once or more, in any order; skip names you don't know.
  2. Collect every v1 in a list. For 24 hours after a secret rotation there are two, one per secret, both named v1, so a name to value map, like PHP's parse_str, keeps only one of them and may refuse every delivery. The current secret's comes first today; don't rely on the order.
  3. Refuse a t more than 5 minutes from your clock, either way.
  4. Compute the HMAC with each secret you hold and accept the delivery when any of them equals any v1, compared in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'

// header: "t=1792578565,v1=5257a8...", with a second v1 for a day after a
// secret rotation. Any one v1 that matches will do.
export const verify = (body: string, header: string, secret: string) => {
	const parts = header.split(',').map(part => part.trim().split('='))
	const t = Number(parts.find(([name]) => name === 't')?.[1])
	const signatures = parts.filter(([name]) => name === 'v1').map(([, value]) => value ?? '')

	if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) {
		return false
	}

	const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${body}`).digest('hex'))

	return signatures.some(signature => {
		const given = Buffer.from(signature)
		return given.length === expected.length && timingSafeEqual(given, expected)
	})
}

Test vector

Check your code with these before real deliveries arrive (with the time check left out, as t is in the past):

Secretwhsec_TestVectorSecretDoNotUseInProduction0123
Raw body{"requestId":"1792578564800.yU4Ety","visitorId":"q1yLOPpzvOstEQCFQQxE","externalId":"user-2077"}
Headert=1792578565,v1=f00a46196269863e862c4533891af491a2267ae136c023413b501653da6b3558
During a rotation, with the previous secret whsec_PreviousSecretOnlyForTheDocsTestVector00t=1792578565,v1=f00a46196269863e862c4533891af491a2267ae136c023413b501653da6b3558,v1=e2425c59e6414d171e89cc8857bbd6bb9ccd3529d370e2c05dd75860c0db4443: valid for either secret

In a shell: printf '%s' '1792578565.<raw body>' | openssl dgst -sha256 -hmac '<secret>' prints the first v1.

Use the raw body

The signature covers the exact bytes we sent. A framework that parses JSON first and hands you an object has thrown those bytes away, and re-serialising the object won't give them back. Read the raw body, verify, then parse:

// Express: the raw body as a string, for this route only.
app.post('/webhooks/fingerprint', express.text({ type: 'application/json' }), (request, response) => {
	if (!verify(request.body, request.get('fingerprint-signature') ?? '', process.env.FP_WEBHOOK_SECRET!)) {
		return response.sendStatus(400)
	}

	const event = JSON.parse(request.body)
	response.sendStatus(204)
})

PHP runs a request to its end before answering, so verify, note the Fingerprint-Delivery-Id and queue the work (dispatch() in Laravel) rather than doing it in the request.

Retries

Answer with any 2xx within 5 seconds; 204 No Content is fine. A project's deliveries go out at most five at a time, so a slow receiver gets them later rather than all at once. Anything else but a redirect (which fails at once, see The endpoint url), or no answer, is tried again after 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up on: the last try is about 8 hours and 40 minutes after the first. A delivery can arrive more than once: skip one whose Fingerprint-Delivery-Id you've handled, and see Updated events for an event delivered again.

Updated events

When your server attaches an external ID or action afterwards with PATCH /events/{requestId}, like after a signup, the event is delivered again in full with updatedAt set and the trigger update; the log calls it Event updated. Keep the delivery with the latest updatedAt for each requestId; a first delivery has none.

Rotating the secret

Rotate on the Webhooks page makes a new secret and shows it once. For the next 24 hours every delivery is signed with both, the new one and the old one, each as its own v1, so receivers still on the old secret keep accepting deliveries:

  1. Rotate, and copy the new secret.
  2. Put it in your receiver's secrets and deploy, within 24 hours.
  3. After 24 hours only the new secret signs.

A receiver that takes a list of secrets can hold both during the switch. Rotate after a leak right away; the old secret still verifies for those 24 hours, so a leaked one is worth a quick deploy.

Deliveries log

The dashboard's Webhooks page lists every delivery of the project, newest first and refreshed every few seconds: when it was sent, the event, why it was sent, its status, how many attempts it took, the last answer's status code and how long it took. Filter it by status.

Open a delivery to see:

  • the request: the url, the headers with the Fingerprint-Signature of the latest attempt, and the JSON body;
  • every attempt: when, the status code or that no answer came, how long it took, why a request failed (like a self-signed certificate, a refused connection or a timeout), and the receiver's response headers and the first 2 KB of its body;
  • the next attempt, for a delivery that's still being retried.

Deliveries and their attempts are kept for 30 days, whatever the project's retention, then removed. Only content-type, content-length, date, server, x-request-id and retry-after of a response's headers are kept.

Redeliver

Redeliver on a delivery sends its event again, now, as a new delivery with its own id and the trigger redeliver, signed with the current secret and sent to the current url. The first attempt runs right away and the dashboard shows its answer; if it fails, it's retried like any delivery. Use it after fixing your endpoint, or to replay one event.

Test events

Send test event sends the project's latest event, or a made up one for a project without events, to the webhook right away, so you can try a receiver before real traffic arrives. It's signed like every other delivery and shows up in the log with the trigger Test.

The body of a test delivery is a real event. A receiver that acts on events (blocks a user, holds an order) should check Fingerprint-Trigger and skip test.

Turn webhooks off

Clear the endpoint url on the Webhooks page. Deliveries still waiting for a retry fail at their next attempt, with the error Webhooks were turned off.