Skip to content

Security

Verify webhook signatures to ensure requests are from Zavu

View .md

Zavu signs every webhook with HMAC-SHA256. Verify the signature before you trust a payload.

Signature Header

Every request carries X-Zavu-Signature:

Code
X-Zavu-Signature: t=1786113454,v2=b4b2b61cdcdb241e4270298a08c3c13c964642df...
PartMeaning
tUnix timestamp in seconds, when the signature was generated
v1HMAC_SHA256(secret, body)
v2HMAC_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?

terminal
curl 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

JavaScript
function 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

JavaScript
const 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.

JavaScript
const 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.

JavaScript
if (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.

terminal
curl -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.

terminal
curl -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:

  1. You are hashing the wrong payload. Check signatureVersion on the sender. On v1 hash the body alone; on v2 hash {t}.{body}.
  2. A body parser ran first. The signature covers the raw bytes. JSON.stringify(JSON.parse(x)) is not always x.
  3. Wrong secret. It is per sender. Regenerating it invalidates the old one immediately, with no overlap.
  4. Milliseconds. t is 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:

terminal
BODY='{"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