Security
Verify webhook signatures to ensure requests are from Zavu
Zavu signs every webhook with HMAC-SHA256. Verify the signature before you trust a payload.
Signature Header
Every request carries X-Zavu-Signature:
CodeX-Zavu-Signature: t=1786113454,v2=b4b2b61cdcdb241e4270298a08c3c13c964642df...
| Part | Meaning |
|---|---|
t | Unix timestamp in seconds, when the signature was generated |
v1 | HMAC_SHA256(secret, body) |
v2 | HMAC_SHA256(secret, "{t}.{body}") |
A header carries v1, v2, or both. Which one you get is per receiver, and
you control it.
The two schemes
v2 is the current scheme, and it is what new webhooks use. It signs the
timestamp together with the body, so more of the request is covered by the
signature.
v1 signs the body only. Webhooks created before the scheme was
configurable are on v1 and stay there until you move them. There is no
deadline.
Moving takes three steps and no downtime. See Migrating to v2 signatures.
Which scheme is my webhook on?
terminalcurl https://api.zavu.dev/v1/senders/$SENDER_ID \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"
JSON{ "id": "sndr_...", "webhook": { "url": "https://api.example.com/webhooks/zavu", "events": ["message.inbound"], "active": true, "signatureVersion": "v1" } }
Verifying
Four steps, in this order.
1. Read the raw body
The signature covers the exact bytes that were sent. A parsed and re-serialized object is not those bytes.
JavaScript// Express: mount a raw parser on this route, not express.json() app.use('/webhooks/zavu', express.raw({ type: 'application/json' }));
2. Parse the header
JavaScriptfunction parseSignature(header) { const parts = {}; for (const piece of header.split(',')) { const i = piece.indexOf('='); if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1); } return parts; // { t, v1?, v2? } }
Ignore parts you do not recognize, so a future scheme does not break your parser.
3. Check the timestamp
JavaScriptconst age = Math.floor(Date.now() / 1000) - Number(parts.t); if (age > 300) return false; // older than five minutes if (age < -60) return false; // clock skew, or forged
4. Recompute and compare
Use the t from the header, not your own clock.
JavaScriptconst expected = parts.v2 ? crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex') : crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const received = parts.v2 ?? parts.v1;
Compare in constant time. === leaks how many characters matched.
JavaScriptif (expected.length !== received.length) return false; crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
Complete examples
Each one prefers v2 and falls back to v1, so the same code works before,
during and after a migration.
import crypto from 'crypto';
import express from 'express';
const app = express();
const SECRET = process.env.ZAVU_WEBHOOK_SECRET!;
const MAX_AGE_SECONDS = 300;
// Raw body. Not express.json().
app.use('/webhooks/zavu', express.raw({ type: 'application/json' }));
function verifyZavuSignature(rawBody: string, header: string, secret: string): boolean {
if (!header) return false;
const parts: Record<string, string> = {};
for (const piece of header.split(',')) {
const i = piece.indexOf('=');
if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1);
}
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
const age = Math.floor(Date.now() / 1000) - timestamp;
if (age > MAX_AGE_SECONDS || age < -60) return false;
// v2 covers the timestamp; v1 covers the body alone.
const received = parts.v2 ?? parts.v1;
if (!received) return false;
const signedPayload = parts.v2 ? `${timestamp}.${rawBody}` : rawBody;
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
// Length first: timingSafeEqual throws on a mismatch.
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
app.post('/webhooks/zavu', (req, res) => {
const rawBody = req.body.toString('utf8');
const header = req.headers['x-zavu-signature'] as string;
if (!verifyZavuSignature(rawBody, header, SECRET)) {
return res.status(401).send('Invalid signature');
}
// Answer fast, then work. A slow handler turns one event into five retries.
res.status(200).send('OK');
processEvent(JSON.parse(rawBody)).catch(console.error);
});
Moving a webhook to v2
Three steps, and the middle one is what makes this safe.
1. Turn on both signatures.
terminalcurl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"webhookSignatureVersion": "v1+v2"}'
Both signatures arrive in the same header, sharing one t. Your current
receiver still verifies v1 and notices nothing.
2. Deploy a receiver that verifies v2, and watch real deliveries.
The examples above already do this: they prefer v2 when it is present. Confirm
in your own logs that deliveries are landing before moving on.
3. Turn v1 off.
terminalcurl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \ -d '{"webhookSignatureVersion": "v2"}'
Check the timestamp, and be idempotent
Two separate things, and you want both.
Reject deliveries whose t is far from now, as the examples above do. And
handle repeats: legitimate retries are real deliveries with a fresh
timestamp and a valid signature. Zavu retries non-2xx responses with backoff,
and delivery is at-least-once, so you will see the same event twice.
Store event.id and ignore what you have already processed.
Troubleshooting
Every delivery returns 401
In order of likelihood:
- You are hashing the wrong payload. Check
signatureVersionon the sender. Onv1hash the body alone; onv2hash{t}.{body}. - A body parser ran first. The signature covers the raw bytes.
JSON.stringify(JSON.parse(x))is not alwaysx. - Wrong secret. It is per sender. Regenerating it invalidates the old one immediately, with no overlap.
- Milliseconds.
tis in seconds.
It worked, then stopped
Check whether the sender's signatureVersion changed, and whether the secret
was regenerated. Both take effect on the next delivery.
Testing locally
Send yourself a signed request rather than disabling verification:
terminalBODY='{"id":"evt_test","type":"message.inbound","data":{}}' SIG=$(node -e " const c=require('crypto'); const t=Math.floor(Date.now()/1000); const b=process.argv[1]; console.log('t='+t+',v2='+c.createHmac('sha256','$ZAVU_WEBHOOK_SECRET').update(t+'.'+b).digest('hex')); " "$BODY") curl -i -X POST localhost:3000/webhooks/zavu \ -H "X-Zavu-Signature: $SIG" \ -H "Content-Type: application/json" \ -d "$BODY"
Expect 200. Then change one character of the body and expect 401. A
receiver that returns 200 to both is not verifying anything.
Next Steps
- Event Types - Understand webhook payloads
- Webhooks - Configure your endpoints