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 byt, a dot and the raw request body.
Check it
- Read the raw request body, before any JSON parsing: JSON parsed and written again no longer gives the same signature.
- Compute the signature with your secret and compare it to
v1in constant time. - Refuse a
tolder than 5 minutes. Each new try carries a recentt.
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.