Skip to content
WhatsApp API 12 min read · July 2026 Production Guide

WhatsApp Business API Webhook with Node.js — Signature Verification & Rate Limiting

A production-level walkthrough of integrating a WhatsApp Business API webhook in Node.js — covering HMAC-SHA256 signature verification, dynamic per-number rate limiting, and common pitfalls like token error 190 and duplicate delivery race conditions.

Santosh Gautam - Full Stack Developer India

Full Stack Developer · India

WhatsApp APINode.jsWebhooksSecurityRate Limiting

1. How WhatsApp Webhooks Work

When a customer replies to a WhatsApp message, Meta sends an HTTP POST request to your registered webhook endpoint. Your server has under 20 seconds to respond with HTTP 200 — otherwise Meta retries, which causes duplicate processing. The payload is JSON and arrives with a X-Hub-Signature-256 header you must verify before doing anything else.

// What Meta sends to your endpoint
POST /webhook HTTP/1.1
Host: api.yourapp.com
X-Hub-Signature-256: sha256=abc123...
Content-Type: application/json

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
    "changes": [{
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "phone_number_id": "1234567890" },
        "messages": [{
          "from": "919876543210",
          "id": "wamid.xxx",
          "type": "text",
          "text": { "body": "Hello" }
        }]
      },
      "field": "messages"
    }]
  }]
}
  • Meta delivers to your webhook URL via HTTPS POST only.
  • X-Hub-Signature-256 is an HMAC-SHA256 of the raw request body, signed with your App Secret.
  • You must echo a hub.challenge token during the initial verification GET request to register the endpoint.
  • Duplicate delivery is real — the same wamid message ID can arrive more than once under load.

2. HMAC Signature Verification — The Right Way

Skipping signature verification means anyone can POST fake payloads to your endpoint and trigger business logic. The verification must use crypto.timingSafeEqual — never a plain string comparison, which is vulnerable to timing attacks.

Critical: You must pass the raw request body buffer to the HMAC — not req.body after JSON parsing. JSON.parse then JSON.stringify can reorder keys, breaking the hash. Use express.raw() on the webhook route.

const express = require("express");
const crypto  = require("crypto");
const router  = express.Router();

const APP_SECRET = process.env.WHATSAPP_APP_SECRET;

// ✅ Use express.raw() so req.body is a Buffer, not parsed JSON
router.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  verifySignature,
  handleWebhook
);

function verifySignature(req, res, next) {
  const sigHeader = req.headers["x-hub-signature-256"];

  if (!sigHeader) {
    return res.status(401).json({ error: "Missing signature" });
  }

  // Header format: "sha256=[hex_digest]"
  const receivedSig = Buffer.from(sigHeader.replace("sha256=", ""), "hex");

  const expectedSig = crypto
    .createHmac("sha256", APP_SECRET)
    .update(req.body) // req.body is raw Buffer here
    .digest();

  // ✅ Timing-safe comparison — prevents timing attacks
  if (
    receivedSig.length !== expectedSig.length ||
    !crypto.timingSafeEqual(receivedSig, expectedSig)
  ) {
    return res.status(403).json({ error: "Invalid signature" });
  }

  // Now safe to parse
  req.body = JSON.parse(req.body.toString());
  next();
}
  • express.raw() preserves the original byte stream for correct HMAC computation.
  • timingSafeEqual takes constant time regardless of where byte comparison fails — preventing leaking info about the secret.
  • Length check before timingSafeEqual is required — the function throws if buffers differ in length.
  • Parse JSON only after the signature passes — never before.

3. Webhook Registration — Handling the Verification GET

Before Meta starts sending events, it makes a one-time GET request to verify you own the endpoint. You must respond with the hub.challenge value only if the hub.verify_token matches your configured secret.

const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN;

// GET /webhook — Meta's one-time endpoint verification
router.get("/webhook", (req, res) => {
  const mode      = req.query["hub.mode"];
  const token     = req.query["hub.verify_token"];
  const challenge = req.query["hub.challenge"];

  if (mode === "subscribe" && token === VERIFY_TOKEN) {
    console.log("Webhook verified successfully");
    return res.status(200).send(challenge); // Must be plain text
  }

  res.status(403).json({ error: "Verification failed" });
});

The verify token is not the App Secret — it is a separate string you choose and paste into the Meta Developer Console. Store it in an environment variable.

4. Dynamic Per-Number Rate Limiting

WhatsApp APIs have per-phone-number rate limits enforced by Meta (typically 80 messages/second per number on Cloud API). Sending beyond this returns error code 130429. On the inbound side, a single user can flood your webhook endpoint with rapid messages. A dynamic rate limiter scoped to the sender's phone number fixes both problems.

// In-process rate limiter using a Map (swap for Redis in multi-instance)
const senderLimits = new Map(); // { phone: { count, windowStart } }

const WINDOW_MS  = 60_000; // 1-minute window
const MAX_IN_WIN = 10;      // max messages per sender per window

function rateLimitSender(phone) {
  const now = Date.now();
  const entry = senderLimits.get(phone);

  if (!entry || now - entry.windowStart > WINDOW_MS) {
    // New window
    senderLimits.set(phone, { count: 1, windowStart: now });
    return false; // not limited
  }

  entry.count += 1;

  if (entry.count > MAX_IN_WIN) {
    return true; // limited — drop or queue
  }

  return false;
}

async function handleWebhook(req, res) {
  // Respond 200 immediately so Meta does not retry
  res.status(200).send("EVENT_RECEIVED");

  const messages = req.body?.entry?.[0]?.changes?.[0]?.value?.messages;
  if (!messages?.length) return;

  for (const msg of messages) {
    const phone = msg.from;

    if (rateLimitSender(phone)) {
      console.warn(`Rate limit hit for ${phone} — dropping message`);
      continue;
    }

    await processMessage(msg);
  }
}
  • Respond 200 OK before processing — this tells Meta the delivery succeeded and prevents retries.
  • For multi-instance deployments, replace the Map with Redis INCR + EXPIRE so counters are shared across pods.
  • Scope limits to phone (sender), not globally — one chatty user should not throttle others.

In production (MQwick), we used Redis INCR with a 60-second TTL per key format wh:rate:{phone}:{window_minute}. This survives pod restarts and auto-expires without cleanup jobs.

5. Deduplication — Handling Duplicate Webhook Delivery

Meta guarantees at-least-once delivery, not exactly-once. Under network instability or when your server is slow to respond, the same message can arrive twice within seconds. Without deduplication, you might send a customer two confirmation messages or charge them twice.

// Redis-based deduplication using message ID (wamid)
const redis = require("./redisClient"); // ioredis instance

async function isDuplicate(messageId) {
  const key = `wh:dedup:${messageId}`;

  // SET key 1 NX EX 300 — set only if not exists, expire in 5 min
  const result = await redis.set(key, "1", "NX", "EX", 300);

  // result is "OK" if key was NEW (first delivery)
  // result is null if key ALREADY EXISTED (duplicate)
  return result === null;
}

async function processMessage(msg) {
  if (await isDuplicate(msg.id)) {
    console.log(`Duplicate wamid ${msg.id} — skipping`);
    return;
  }

  // Safe to process — guaranteed first time
  const phone = msg.from;
  const text  = msg.text?.body ?? "";

  await handleBusinessLogic(phone, text);
}

// ⚠️ Race condition: two pods can receive the same wamid simultaneously.
// Redis SET NX is atomic — only ONE will get "OK". The other gets null.
// This is the correct pattern. Do NOT use GET + SET (not atomic).
  • SET key 1 NX EX 300 is atomic — no race condition between check and set.
  • Use the wamid (message ID from Meta's payload) as the dedup key — it is unique per message.
  • 5-minute TTL is enough; Meta's retry window is under 3 minutes in practice.
  • Never use GET + SET as two separate commands — that introduces a race window.

6. Error Code 190 — Token Expiry and How to Handle It

When your access token expires, every outbound message attempt returns HTTP 400 with error.code === 190. This is the single most common production incident with the WhatsApp Cloud API. Permanent tokens (System User tokens from the Meta Business Manager) do not expire — temporary tokens from the Graph API explorer expire in 60 days.

// Centralized WhatsApp send function with error 190 detection
async function sendWhatsAppMessage(phoneNumberId, to, template) {
  const url = `https://graph.facebook.com/v20.0/${phoneNumberId}/messages`;

  try {
    const response = await axios.post(
      url,
      {
        messaging_product: "whatsapp",
        to,
        type: "template",
        template,
      },
      {
        headers: {
          Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
          "Content-Type": "application/json",
        },
      }
    );

    return response.data;

  } catch (err) {
    const apiError = err.response?.data?.error;

    if (apiError?.code === 190) {
      // Token expired — alert ops immediately, do NOT silently retry
      await notifyOps("WHATSAPP_TOKEN_EXPIRED", {
        subcode: apiError.error_subcode,
        message: apiError.message,
      });
      throw new Error("WhatsApp token expired — ops notified");
    }

    if (apiError?.code === 130429) {
      // Rate limit from Meta — backoff and retry
      await delay(5000);
      return sendWhatsAppMessage(phoneNumberId, to, template); // one retry
    }

    throw err;
  }
}

Do not silently swallow error 190. Messages fail silently — customers never receive notifications and you have no visibility. Always alert to Slack or PagerDuty immediately on code 190.

  • Error 190 = access token expired or invalid.
  • Error 130429 = rate limit exceeded on Meta's side — retry with backoff.
  • Error 131030 = recipient phone number not on WhatsApp.
  • Use a System User token from the Business Manager for production — it does not expire unless revoked.

7. Complete Express Setup — Putting It Together

// server.js — production webhook setup
const express = require("express");
const crypto  = require("crypto");
const app     = express();

// Global JSON parser — NOT for the webhook route
app.use(express.json());

// ── Webhook routes ──────────────────────────────
app.get("/webhook",
  handleVerification      // Meta's one-time GET
);

app.post("/webhook",
  express.raw({ type: "application/json" }), // raw body for HMAC
  verifySignature,         // reject non-Meta payloads immediately
  handleWebhook            // 200 first, then process async
);

// ── Startup ─────────────────────────────────────
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook listening on port ${PORT}`);
});

// Environment variables required:
// WHATSAPP_APP_SECRET   — from Meta App Dashboard > App Settings
// WHATSAPP_VERIFY_TOKEN — chosen by you, set in Meta console
// WHATSAPP_TOKEN        — System User access token
// REDIS_URL             — for dedup + rate limiting in production

FAQ

The HMAC-SHA256 signature is computed over the exact raw bytes Meta sent. If you parse JSON first then re-stringify, key ordering may change and the hash will not match. express.raw() gives you the original Buffer — pass that directly to createHmac().update().

Related Services & Case Studies

Case Study

WhatsApp + WooCommerce Integration

Production plugin integrating WooCommerce order events with Meta's WhatsApp Business API for automated customer notifications.

Service Offering

REST API & Webhook Development

Secure webhook endpoints, JWT middleware, Redis rate limiting, and third-party API integrations.

Related Reading

JWT Authentication Guide

HMAC signing, token validation middleware, and secure API authentication — same cryptographic primitives used in webhook verification.