NewAds Insights — see what each Facebook ad costs and the WhatsApp leads it brings. Free on every plan. See Ads Insights →
Lead Pattern

Developers · Webhooks

Receive WhatsApp webhooks in Node.js

Get incoming WhatsApp messages, delivery updates and 39 more events pushed to your own server in real time — with a working Express receiver that checks the signature, ignores duplicates and replies.

Last updated: 6 November 2026

✨ Ask AI about this page:ChatGPT logoChatGPTClaude logoClaudePerplexity logoPerplexity

Quick answer

Build an HTTPS endpoint that answers any 2xx within 10 seconds, register it in the dashboard or with POST /v2/webhook/register (generateSecret: true), then verify each delivery's X-Webhook-Signature-V2 — an HMAC-SHA256 of "<X-Webhook-Timestamp>.<raw body>" with your signing secret — and deduplicate on X-Webhook-Id. Every delivery is a JSON envelope { id, event, timestamp, data }.

Webhooks Guide

Step by step

Receive and answer WhatsApp events

1

Get an API key

In your Lead Pattern project dashboard, open API Keys and create a key. You need it to register the webhook through the API and to reply to messages.

2

Build the endpoint first

Lead Pattern only saves a webhook after your URL answers a signed test event with a 2xx within 10 seconds, so deploy the receiver below before registering. Use https:// — private, loopback and internal addresses are rejected.

3

Register the webhook

Register in the dashboard, or POST to /v2/webhook/register with name, url, webhookEvents and generateSecret: true. The signing secret is returned once as data.secret — store it.

4

Verify, deduplicate, ACK fast

Check the HMAC signature over the raw body, skip event ids you have already processed (deliveries are at least once and may arrive out of order), and return 2xx within 10 seconds — do the heavy work afterwards.

5

Reply inside the 24-hour window

A message_received event means the customer just wrote, so you can answer with a free-form text message via /whatsapp/message/text.

Express receiver with signature checkjavascript
// npm install express
// Adapted from the Webhooks Guide (docs.leadpattern.com/api/webhooks-guide).
const crypto = require("crypto");
const express = require("express");

const app = express();
const SECRET = process.env.LEADPATTERN_WEBHOOK_SECRET; // whsec_... (shown once when you create the webhook)
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 24 h after a secret rotation, deliveries also carry *-Previous signatures — accept either.
const matchesAny = (expected, ...headers) => headers.some((h) => h && safeEqual(h, expected));

const seen = new Set(); // use Redis or your database in production

// 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): HMAC 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: HMAC 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 event = JSON.parse(raw.toString("utf8")); // { id, event, timestamp, data }

  // Events are delivered at least once: deduplicate on id (== X-Webhook-Id)
  if (seen.has(event.id)) return res.sendStatus(200);
  seen.add(event.id);

  // ACK fast (any 2xx within 10 seconds), then do the work
  res.sendStatus(200);
  setImmediate(() => handleEvent(event));
});

function handleEvent({ event, data }) {
  if (event === "test") return; // "Send test" deliveries: data = { message }
  if (event === "message_received") {
    const text = data.message?.content?.text?.body;
    console.log("New message from", data.waId, ":", text);
  }
  // New fields may be added at any time — ignore ones you don't recognise.
}

app.listen(3000);
Register the webhook (returns the signing secret once)bash
curl -X POST https://public.leadpattern.com/v2/webhook/register \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Webhook",
    "url": "https://example.com/leadpattern/webhook",
    "webhookEvents": ["message_received", "message_status_update"],
    "generateSecret": true
  }'

# Response (201) includes data.secret ONCE — store it as LEADPATTERN_WEBHOOK_SECRET.
# Before saving, Lead Pattern POSTs a signed "test" event to your URL;
# your endpoint must answer 2xx within 10 seconds, or the request returns 422.
Example delivery — message_receivedjson
{
  "id": "3f1c2a9e-7b4d-4c1e-9a52-0d6c8e7f1b20",
  "event": "message_received",
  "timestamp": "2026-07-01T10:30:00.000Z",
  "data": {
    "waId": "91XXXXXXXXXX",
    "message": {
      "waId": "91XXXXXXXXXX",
      "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": "91XXXXXXXXXX",
      "bsuid": null,
      "username": null,
      "name": { "first": "First", "last": "Last" },
      "profileName": "First",
      "phone": "XXXXXXXXXX",
      "countryCode": "91",
      "status": "open",
      "optOut": false,
      "assignedTo": { "id": "664abc123def456789000001", "name": "Agent name" },
      "tags": [ { "id": "664abc123def456789000aaa", "name": "Hot lead", "color": "#6366f1" } ],
      "customAttributes": [ { "slug": "city", "value": "Pune" } ]
    },
    "wabaNumber": "91XXXXXXXXXX"
  }
}
Reply to the customer from your handlerjavascript
// Reply to an incoming message — the customer just wrote, so the 24-hour window is open
// and a free-form text message is allowed.
async function handleEvent({ event, data }) {
  if (event !== "message_received" || !data.waId) return; // BSUID contacts have bsuid instead of waId
  const res = await fetch("https://public.leadpattern.com/whatsapp/message/text", {
    method: "POST",
    headers: {
      Authorization: process.env.LEADPATTERN_API_KEY, // raw key, no "Bearer " prefix
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to: "+" + data.waId,
      content: { text: "Thanks for your message! Our team will reply shortly." },
    }),
  });
  if (res.status !== 201) console.error("Reply failed", res.status, await res.text());
}

Delivery headers

What arrives with every delivery

HeaderWhat it holds
Content-Typeapplication/json — deliveries are always a JSON POST.
User-AgentLeadPattern-Webhooks/1.0
X-Webhook-IdEvent id (same as payload.id), identical across retries — use it to deduplicate.
X-Webhook-EventEvent name, e.g. message_received.
X-Webhook-SignatureSigned webhooks only: sha256=<hex HMAC-SHA256 of the raw body>.
X-Webhook-TimestampSigned webhooks only: Unix time in seconds when this attempt was signed.
X-Webhook-Signature-V2Signed webhooks only (recommended): sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>">.
X-Webhook-Signature(-V2)-PreviousOnly for 24 hours after a secret rotation: the same signatures made with the old secret.
AuthorizationBearer or Basic credentials, if you configured them on the webhook.

Signing is optional: a webhook without a signing secret (hasSecret: false) sends no X-Webhook-Signature headers. If your receiver requires signatures, reject requests without them.

Delivery and retry rules

  • Success = any 2xx response within 10 seconds. The response body is ignored.
  • Retried: timeouts, network errors, 408, 425, 429 and 5xx — up to 9 attempts over about 24 hours (waiting 10s, 1m, 5m, 30m, 1h, 3h, 6h and 12h). A Retry-After header on 429/503 is honoured, up to 15 minutes.
  • Not retried: other 4xx responses. Redirects (3xx) are not followed and count as a failure — register the final URL.
  • Auto-disable: after 50 consecutive events fail all their attempts, the webhook is disabled and workspace owners are notified. Fix the endpoint, then re-activate it.
  • Delivery logs (one row per attempt, with status code and error) are kept for 30 days, and any failed delivery can be sent again from the dashboard or with the Redeliver Webhook Event endpoint.

Which events can I subscribe to?

Event names are lowercase — the same value arrives as `event` in the payload. They cover messages (message_received, message_status_update, first_message, template_updated, raw_whatsapp), conversations (chat_assigned, conversation_opened, conversation_closed and more), contacts (tags, custom fields, notes), WhatsApp Flows, WhatsApp calls and WooCommerce orders — 41 in all. The full list with an example of every payload is in the Webhooks Guide.

Good to know

  • Opted-in contacts who haven't shared their phone number arrive with bsuid and username instead of waId and phone.
  • Messages are identified by messageId (the WhatsApp wamid). New fields may be added at any time — ignore fields you don't recognise.
  • The plan limit counts active webhooks only; Growth includes unlimited webhooks. Webhooks & API →

Questions developers ask

How do I verify a Lead Pattern webhook signature?+

Compute an HMAC-SHA256 with your signing secret over "<X-Webhook-Timestamp>.<raw body>" and compare it, in constant time, with X-Webhook-Signature-V2 after the sha256= prefix. Use the exact raw bytes received — don't re-serialise parsed JSON — and reject timestamps more than a few minutes old.

Why won't my webhook save?+

Before saving, Lead Pattern sends a signed test event to your URL. If your endpoint doesn't answer with a 2xx within 10 seconds, registration returns 422 and nothing is saved. Private or internal addresses return 400, and a URL that's already registered returns 409.

Can the same event arrive twice?+

Yes. Events are delivered at least once and may arrive out of order. Deduplicate on X-Webhook-Id (the same as payload.id) and order by the payload timestamp.

How long do I have to respond?+

Ten seconds. Return a 2xx quickly and process the event afterwards, for example from a queue. Timeouts are retried for about 24 hours.

How many webhooks can I create?+

It depends on your plan — the limit counts active webhooks only. Growth includes unlimited webhooks. Going over the limit returns 402.

Lead Pattern

Get your API key — free 14-day trial.

Set up the official WhatsApp Business API, create a key and send your first message today.

14-day free trial · No credit card · Free WhatsApp API setup

Ask AI about this page

Get a quick summary of this page from your favourite AI assistant — based only on leadpattern.com. Be one of the first to try it.