Your server

Verify a webhook signature

Each webhook call is signed with your secret: check the signature before handling the event.

Updated October 28, 2026

Each call carries the Zean-Signature header, shaped like t=1760515200,v1=5f2b…. Check it to be sure the call comes from Zean and was not changed.

  • t: when the call was sent, in Unix seconds.
  • v1: the HMAC-SHA256 signature in hex, made with the signing secret over the text formed by t, a dot and the raw request body.

Check it

  1. Read the raw request body, before any JSON parsing: JSON parsed and written again no longer gives the same signature.
  2. Compute the signature with your secret and compare it to v1 in constant time.
  3. Refuse a t older than 5 minutes. Each new try carries a recent t.

File: zean-signature.js

import crypto from 'node:crypto';// True when the call is signed with your secret and was sent less than five minutes ago.export function isValidSignature(rawBody, header, secret) {	const fields = Object.fromEntries(String(header).split(',').map(field => field.split('=')));	const fresh = Math.abs(Date.now() / 1000 - Number(fields.t)) < 300;	const expected = crypto.createHmac('sha256', secret).update(`${fields.t}.${rawBody}`).digest();	const received = Buffer.from(fields.v1 ?? '', 'hex');	return fresh && received.length === expected.length && crypto.timingSafeEqual(received, expected);}

In your route

import express from 'express';import { isValidSignature } from './zean-signature.js';const app = express();// The raw body: the signature covers the bytes as sent, before any JSON parsing.app.post('/zean/webhook', express.raw({ type: 'application/json' }), (req, res) => {	if (!isValidSignature(req.body, req.get('zean-signature'), process.env.ZEAN_WEBHOOK_SECRET)) {		return res.sendStatus(401);	}	const event = JSON.parse(req.body);	// Answer within 10 seconds, then do the long work.	res.sendStatus(200);	console.log(event.type, event.data.conversationId);});

In development

The calls the Zean CLI forwards to your computer are signed the same way, with the Listen secret in place of the signing secret. The code does not change: put the Listen secret in ZEAN_WEBHOOK_SECRET in development, and the signing secret in production. See Receive webhooks on your computer.

The signing secret is in App settings > Developers > Webhook. After regenerating it, update your server right away: the next calls, new tries included, are signed with the new secret.