Webhooks Guide
LeadPattern sends a POST to your endpoint (https:// recommended) whenever a subscribed event happens. Every delivery is a JSON envelope { id, event, timestamp, data }. Signing is optional but recommended: when the webhook has a signing secret, every delivery carries signature headers you can verify. A generated secret is shown once — when you create the webhook with generateSecret, or generate/rotate it from the dashboard — so store it safely. You can also set your own secret (16–128 characters) or remove it.
Payloads were updated to stop exposing internal ids and to use consistent shapes. If your receiver reads any of these fields, update it:
- Internal database ids are no longer sent:
contact.idandmessage.idare removed — identify contacts bywaId(orbsuid) and messages bymessageId(the WhatsApp wamid). - People —
assignedTo,agent, note authors — are{ id, name }, where id is the workspace member id. - Flow events (
flow_sent,flow_submitted) reference the flow asflow: { metaFlowId, name }. wabaNumberis your workspace's WhatsApp business display phone number.raw_whatsappis sent once per change in the WhatsApp webhook body; call WebRTC session details (calls[].session) are removed.- Custom attributes are
[{ slug, value }]; media is{ url, contentType, size, fileName }. - Signing is optional: unsigned webhooks send no
X-Webhook-Signature*headers. CheckhasSecretbefore requiring them.
Delivery headers
Verifying signatures
Signature headers are only sent when the webhook has a signing secret (hasSecret: true). If your receiver requires signatures, reject any request without them. Compute an HMAC-SHA256 with your signing secret over the raw request body (the exact bytes received — don't re-serialise parsed JSON) and compare it, in constant time, to the header value after the sha256= prefix.
- V1 —
X-Webhook-Signature = sha256=hex(HMAC(secret, body)) - V2 (recommended) —
X-Webhook-Signature-V2 = sha256=hex(HMAC(secret, X-Webhook-Timestamp + "." + body)). Also reject requests whose timestamp is more than a few minutes old to block replays. - Rotation grace — after a secret is rotated, the old secret stays valid for 24 hours. During that window each delivery also carries
X-Webhook-Signature-PreviousandX-Webhook-Signature-V2-Previous(made with the old secret), so accept a match on either the current or the previous header. Setting your own secret or removing the secret ends the grace period immediately.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.LEADPATTERN_WEBHOOK_SECRET; // whsec_... (or your own secret)
const TOLERANCE_SEC = 300; // reject deliveries signed more than 5 minutes ago
const sign = (data) => 'sha256=' + crypto.createHmac('sha256', SECRET).update(data).digest('hex');
// Constant-time compare that never throws on length mismatch.
function safeEqual(a, b) {
const ab = Buffer.from(a || '', 'utf8');
const bb = Buffer.from(b || '', 'utf8');
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
// For 24h after you rotate the secret in LeadPattern, deliveries carry a signature made with
// the new secret AND one made with the old secret (*-Previous). Accept a match on either, so
// it doesn't matter whether your receiver has switched to the new secret yet.
const matchesAny = (expected, ...headers) => headers.some((h) => h && safeEqual(h, expected));
// IMPORTANT: verify against the RAW body bytes, before any JSON parsing.
app.post('/leadpattern/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body; // Buffer
const ts = req.get('X-Webhook-Timestamp');
let ok = false;
if (ts && req.get('X-Webhook-Signature-V2')) {
// V2 (recommended): signature over "<timestamp>.<raw body>" + freshness check
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= TOLERANCE_SEC;
ok = fresh && matchesAny(
sign(`${ts}.${raw.toString('utf8')}`),
req.get('X-Webhook-Signature-V2'),
req.get('X-Webhook-Signature-V2-Previous'),
);
} else {
// V1: signature over the raw body only
ok = matchesAny(sign(raw), req.get('X-Webhook-Signature'), req.get('X-Webhook-Signature-Previous'));
}
if (!ok) return res.sendStatus(401);
const payload = JSON.parse(raw.toString('utf8'));
// Deduplicate on payload.id (== X-Webhook-Id), then hand off to a queue and ACK fast.
res.sendStatus(200);
});Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['LEADPATTERN_WEBHOOK_SECRET'].encode() # whsec_... (or your own secret)
TOLERANCE_SEC = 300 # reject deliveries signed more than 5 minutes ago
def sign(data: bytes) -> str:
return 'sha256=' + hmac.new(SECRET, data, hashlib.sha256).hexdigest()
def matches_any(expected: str, *headers) -> bool:
# For 24h after a rotation, deliveries carry signatures from both the new and the
# previous secret (*-Previous). Accept a match on either.
return any(h and hmac.compare_digest(h, expected) for h in headers)
@app.route('/leadpattern/webhook', methods=['POST'])
def leadpattern_webhook():
raw = request.get_data() # RAW bytes — verify before parsing JSON
h = request.headers
ts = h.get('X-Webhook-Timestamp')
if ts and h.get('X-Webhook-Signature-V2'):
# V2 (recommended): signature over "<timestamp>.<raw body>" + freshness check
fresh = abs(time.time() - int(ts)) <= TOLERANCE_SEC
ok = fresh and matches_any(sign(ts.encode() + b'.' + raw),
h.get('X-Webhook-Signature-V2'), h.get('X-Webhook-Signature-V2-Previous'))
else:
# V1: signature over the raw body only
ok = matches_any(sign(raw), h.get('X-Webhook-Signature'), h.get('X-Webhook-Signature-Previous'))
if not ok:
abort(401)
payload = json.loads(raw)
# Deduplicate on payload['id'] (== X-Webhook-Id), then queue the work and ACK fast.
return '', 200Delivery rules
- Success means any 2xx response within 10 seconds. The response body is ignored.
- Timeouts, network errors, 408, 425, 429 and 5xx responses are retried for about 24 hours — up to 9 attempts in total, waiting 10s, 1m, 5m, 30m, 1h, 3h, 6h and 12h between them. A Retry-After header on 429/503 is honoured (up to 15 minutes).
- Any failed delivery can be sent again from the dashboard's delivery logs or with Redeliver Webhook Event — same event id and payload, fresh signature.
- A webhook is only saved (and only re-enabled) after its endpoint answers a signed test event with a 2xx. Have your endpoint live before registering it; the same check runs when you change the URL, headers, authorization or signing secret.
- Other 4xx responses are treated as permanent failures and are not retried.
- Redirects (3xx) are not followed — they count as a failed delivery. Register the final URL.
- Use https:// endpoints. http:// URLs are accepted but deliveries — payloads, custom headers and authorization credentials — travel unencrypted; registering or editing one returns a warnings array, and the dashboard marks the webhook "Not secure". Private, loopback and internal addresses are rejected (400 "Invalid webhook URL: …") at registration and blocked again on every delivery.
- Custom header names that are reserved and rejected: Host, Content-Length, Content-Type, User-Agent, Expect, Proxy-Authorization, the hop-by-hop headers Connection, Keep-Alive, Transfer-Encoding and Upgrade, and any X-Webhook-* header.
- Changing a webhook's URL never carries its saved headers or credentials to the new URL. Re-send headers and authorization in the same request, or they are dropped; sending the masked "••••••" values with a new URL returns 400.
- Events are delivered at least once and may arrive out of order. Deduplicate on X-Webhook-Id and order by payload timestamp (or the data's own timestamps).
- After 50 consecutive events fail all their attempts, the webhook is automatically disabled (disabledReason: failing) and workspace owners are notified. Fix the endpoint, then re-activate it (Toggle Webhook or the dashboard) — this resets the counter.
- Delivery logs (one row per attempt, with status code and error) are kept for 30 days. The delivered payload is stored with attempt 1 of each event and returned when you pass includePayload=true.
- The plan limit counts active webhooks only. Creating or enabling one beyond it returns 402 "Your plan allows up to N active webhook(s). Disable or delete another webhook, or upgrade your plan."
- Deliveries pause while the workspace's subscription is blocked or data-locked, and resume for new events once billing is restored.
- If LeadPattern restricts (suspends) a workspace, its webhooks stop — queued and retrying deliveries are dropped and no new events are sent — and every public API request returns 403 "Project is suspended" until the restriction is lifted.
Event payloads
Event names are lowercase and match what you subscribe with. wabaNumber is your workspace's WhatsApp business phone number. Contact objects carry bsuid and username instead of waId/phone for opted-in users who haven't shared their number. Messages are identified by messageId (the WhatsApp wamid); people (assignedTo, agent, note authors) are { id, name } where id is a workspace member id; tags are { id, name, color }; custom attributes are [{ slug, value }]; media is { url, contentType, size, fileName }. "Send test" deliveries use event test with data: { message }. New fields may be added at any time — ignore fields you don't recognise.
{
"id": "3f1c2a9e-7b4d-4c1e-9a52-0d6c8e7f1b20",
"event": "message_received",
"timestamp": "2026-07-01T10:30:00.000Z",
"data": {
"waId": "919999999999",
"message": {
"waId": "919999999999",
"messageId": "wamid.HBgMOTE5OTk5OTk5OTk5FQIAEhgg",
"direction": "incoming",
"type": "text",
"content": {
"text": {
"body": "Hi, is the Pro plan available?"
}
},
"status": "received",
"statusTimestamp": "2026-07-01T10:29:58.000Z",
"timestamp": "2026-07-01T10:29:58.000Z",
"replyTo": null
},
"contact": {
"waId": "919999999999",
"bsuid": null,
"username": null,
"name": {
"first": "Asha",
"last": "Rao"
},
"profileName": "Asha",
"phone": "9999999999",
"countryCode": "91",
"status": "open",
"optOut": false,
"assignedTo": {
"id": "664abc123def456789000001",
"name": "Ravi (Sales)"
},
"tags": [
{
"id": "664abc123def456789000aaa",
"name": "Hot lead",
"color": "#6366f1"
}
],
"customAttributes": [
{
"slug": "city",
"value": "Pune"
},
{
"slug": "plan_interest",
"value": "Pro"
}
]
},
"wabaNumber": "919876543210"
}
}
