Skip to content

Verify Webhook Signatures

Why you should sign LeadPattern webhook deliveries, how to turn signing on, and how to verify the HMAC-SHA256 X-Webhook-Signature headers, with Node.js and Python examples that handle secret rotation.

7 min readUpdated Oct 7, 2026

When Sign deliveries is on, LeadPattern signs every delivery with your webhook's secret. Check the signature on your server before trusting the payload. It proves the request came from LeadPattern and that the body wasn't changed.

Why sign deliveries?

Your webhook URL is a public address. Anyone who finds it can send requests to it: in a log file, a browser history, a screenshot, or just by guessing. Without a signature, your server can't tell a real LeadPattern event from a fake one. Someone could:

  • Forge events. Post a fake message_received or chat_assigned and make your system create orders, send replies or update records that never happened.
  • Tamper with events. Change the body of a real request on its way to you, for example the phone number or order total.
  • Replay events. Capture a genuine request and send it again later to repeat its effect.

A signature stops all three. LeadPattern computes an HMAC-SHA256 of the exact request body using a secret that only you and LeadPattern know, and sends it in a header. Your server computes the same HMAC with its copy of the secret:

  • The signatures match: the request came from LeadPattern and the body wasn't changed. Without the secret, nobody can produce a valid signature.
  • They don't match: reject the request with 401.
  • The V2 signature also covers X-Webhook-Timestamp. If you reject anything older than a few minutes, a captured request can't be replayed later.

Turn on signing

Sign deliveries is on by default in the New Webhook form.

  • Generate a secret for me: LeadPattern creates a whsec_… secret and shows it once, after you click Create Webhook. Copy it into your server's environment (for example LEADPATTERN_WEBHOOK_SECRET).
  • Use my own secret: paste a random string of 16–128 characters that you've already set on your server.

Signature headers

HeaderContents
X-Webhook-IdEvent id (same as id in the payload). Identical across retries; use it to deduplicate.
X-Webhook-EventEvent name, e.g. message_received.
X-Webhook-Signaturesha256= + hex HMAC-SHA256 of the raw body, keyed with your secret.
X-Webhook-TimestampUnix time (seconds) when this attempt was signed.
X-Webhook-Signature-V2sha256= + hex HMAC-SHA256 of "<X-Webhook-Timestamp>.<raw body>". Recommended.
X-Webhook-Signature-PreviousOnly for 24 hours after a secret rotation: the V1 signature made with the old secret.
X-Webhook-Signature-V2-PreviousOnly for 24 hours after a secret rotation: the V2 signature made with the old secret.

Unsigned webhooks send only X-Webhook-Id and X-Webhook-Event, plus any custom headers and authorization you configured.

How to verify

1

Read the raw body

Capture the request body as raw bytes before any JSON parsing. Re-serialised JSON won't match.

2

Rebuild the signature

For V2, compute HMAC-SHA256 of the timestamp, a ., then the raw body, using your secret. Hex-encode it and prefix it with sha256=.

3

Compare safely

Use a constant-time comparison against X-Webhook-Signature-V2, and also against X-Webhook-Signature-V2-Previous if present.

4

Check freshness

Reject the request if X-Webhook-Timestamp is more than about 5 minutes from your server's clock. This blocks replayed requests.

5

Deduplicate and acknowledge

Skip events whose X-Webhook-Id you've already processed. Queue the work and return 2xx quickly, within 10 seconds.

Code examples

JAVASCRIPT
1const crypto = require('crypto');
2const express = require('express');
3const app = express();
4
5const SECRET = process.env.LEADPATTERN_WEBHOOK_SECRET; // whsec_... (or your own secret)
6const TOLERANCE_SEC = 300; // reject deliveries signed more than 5 minutes ago
7
8const sign = (data) => 'sha256=' + crypto.createHmac('sha256', SECRET).update(data).digest('hex');
9
10// Constant-time compare that never throws on length mismatch.
11function safeEqual(a, b) {
12 const ab = Buffer.from(a || '', 'utf8');
13 const bb = Buffer.from(b || '', 'utf8');
14 return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
15}
16
17// For 24h after a rotation, deliveries carry signatures from the new AND the old secret
18// (*-Previous). Accept a match on either.
19const matchesAny = (expected, ...headers) => headers.some((h) => h && safeEqual(h, expected));
20
21// IMPORTANT: verify against the RAW body bytes, before any JSON parsing.
22app.post('/leadpattern/webhook', express.raw({ type: 'application/json' }), (req, res) => {
23 const raw = req.body; // Buffer
24 const ts = req.get('X-Webhook-Timestamp');
25 let ok = false;
26
27 if (ts && req.get('X-Webhook-Signature-V2')) {
28 // V2 (recommended): signature over "<timestamp>.<raw body>" + freshness check
29 const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= TOLERANCE_SEC;
30 ok = fresh && matchesAny(
31 sign(`${ts}.${raw.toString('utf8')}`),
32 req.get('X-Webhook-Signature-V2'),
33 req.get('X-Webhook-Signature-V2-Previous'),
34 );
35 } else {
36 // V1: signature over the raw body only
37 ok = matchesAny(sign(raw), req.get('X-Webhook-Signature'), req.get('X-Webhook-Signature-Previous'));
38 }
39 if (!ok) return res.sendStatus(401);
40
41 const payload = JSON.parse(raw.toString('utf8'));
42 // Deduplicate on payload.id (== X-Webhook-Id), then hand off to a queue and ACK fast.
43 res.sendStatus(200);
44});

What's next

Was this article helpful?

Your feedback helps us continuously improve our documentation.

Need more help with this topic?Ask LeadPattern Support

Related Articles