Skip to content
All articles

WhatsApp Webhooks: A Developer's End-to-End Guide

How WhatsApp webhooks actually work — verification handshake, payload anatomy, status lifecycle, HMAC signature checks, retries and local testing.

Summarise this article with:

On this page

Every WhatsApp integration we've seen fail in production failed at the webhook layer, not the send layer. Sending a message is one HTTP call with a bearer token; anyone gets that working in an afternoon. Receiving — inbound messages, delivery receipts, read confirmations, failure notices — is where teams lose days, because webhooks are asynchronous, unordered, retried, and unforgiving about response times.

This guide walks the whole pipeline end to end: the verification handshake Meta performs before it sends you anything, the anatomy of an inbound message payload, the status lifecycle and its out-of-order traps, HMAC signature verification in Node.js, and the retry/idempotency patterns that separate a demo from a system you can run at 50,000+ messages a day. We'll close with how InfiQ's webhook relay maps onto all of this, because the payload shape is deliberately identical.

The subscription and verification handshake

Before Meta delivers a single event, it verifies that you own the endpoint. When you register a callback URL in the App Dashboard (or via the API), Meta immediately fires a GET request at it with three query parameters:

  • hub.mode — always the string subscribe
  • hub.verify_token — the secret string you chose when configuring the webhook
  • hub.challenge — a random string Meta expects echoed back

Your job: check that hub.verify_token matches what you configured, then respond 200 with the raw hub.challenge value as the body. Not JSON-wrapped, not quoted — the bare string.

// GET /webhook — verification handshake
app.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 === process.env.VERIFY_TOKEN) {
    return res.status(200).send(challenge); // raw string, nothing else
  }
  return res.sendStatus(403);
});

Two gotchas that cost teams real time. First, the endpoint must be publicly reachable over HTTPS with a valid certificate at the moment you click "Verify and save" — self-signed certs and HTTP fail silently in the dashboard with an unhelpful error. Second, verifying the URL is not enough. You must also subscribe your app to the messages field under the WhatsApp Business Account (WABA), either in the dashboard or via POST /{waba-id}/subscribed_apps. Skipping this is the single most common "my webhook never fires" cause we see across accounts.

Anatomy of an inbound message payload

Every event — message or status — arrives as a POST with the same envelope: object, then an entry array, then a changes array, then a value object. The nesting looks excessive until you realise one HTTP request can batch multiple events. Here's a real-world-shaped inbound text message, annotated:

{
  "object": "whatsapp_business_account",
  "entry": [
    {
      "id": "102290129340398",          // your WABA ID
      "changes": [
        {
          "field": "messages",           // the subscribed field
          "value": {
            "messaging_product": "whatsapp",
            "metadata": {
              "display_phone_number": "919876543210",
              "phone_number_id": "106540352242922"  // which of your numbers received it
            },
            "contacts": [
              {
                "profile": { "name": "Priya Sharma" },  // sender's WhatsApp display name
                "wa_id": "919812345678"                  // sender's number, E.164 without '+'
              }
            ],
            "messages": [
              {
                "from": "919812345678",
                "id": "wamid.HBgLOTE5ODEyMzQ1Njc4FQIAEhggQjM0...",  // the wamid — your dedup key
                "timestamp": "1751871625",   // Unix seconds, as a STRING
                "type": "text",
                "text": { "body": "Is the blue kurta available in M?" }
              }
            ]
          }
        }
      ]
    }
  ]
}

Things worth internalising:

  • phone_number_id is your routing key. If you run multiple numbers on one WABA (a D2C brand with separate support and marketing lines, say), every event tells you which number it hit. Route on this, not on display_phone_number.
  • type drives everything downstream. Beyond text you'll receive image, document, audio, video, sticker, location, contacts, interactive (button and list replies), button (template quick replies), order (catalog carts), reaction, and the ones nobody handles until production: unsupported and system. Write a switch with an explicit default branch that logs and acknowledges — an unhandled type must never crash your handler.
  • Media messages don't contain media. An image message carries a media id; you exchange it for a short-lived download URL via the media endpoint, then fetch the binary with your access token. Those URLs expire in minutes, so download promptly and store in your own object storage.
  • Interactive replies carry the ID you set. A button reply comes back with the id you assigned when sending — design those IDs as machine-readable keys (confirm_order_8841), not display text.
  • Timestamps are strings. Unix seconds, quoted. Parse accordingly before your ORM does something creative.

If you're staring at a payload trying to work out which nested field you need, paste it into our webhook payload formatter — it annotates each field inline and flattens the envelope for you.

The status lifecycle: sent, delivered, read, failed

For every message you send, Meta pushes status events into the same webhook, under value.statuses instead of value.messages:

Status Meaning Typical arrival
sent Accepted by WhatsApp servers, en route Under a second, typically
delivered Reached the recipient's device Seconds to hours (device may be offline)
read Recipient opened the chat Only if read receipts are enabled
failed Permanently undeliverable Immediately or up to ~30 days later

Each status object carries the id (the same wamid returned when you sent the message), status, recipient_id, timestamp, and — on billable events — a conversation and pricing object telling you which conversation category the message was attributed to.

A few practical notes on each state:

  • read is best-effort. Users who disable read receipts generate delivered and stop. Never build business logic ("escalate if unread in 10 minutes") that assumes read events exist for everyone. In our experience, read-receipt coverage typically sits somewhere in the 70–90% range depending on the audience, so treat it as a signal, not ground truth.
  • failed carries an errors array with a code, title, and often an error_data.details string. The codes worth alerting on: 131047 (re-engagement needed — you messaged outside the 24-hour window without a template), 131026 (recipient not on WhatsApp or can't receive — common with landlines uploaded into CRMs), 131049 / 131050 (Meta limited delivery of marketing messages to that user), and 130472 (user is in an experiment group). A spike in 131049 on a broadcast is an early quality-rating warning; treat it like smoke.
  • A delivered that never comes isn't an error. The recipient's phone may be off for a week. Design timeouts around your own business rules, not around the assumption that WhatsApp will tell you promptly.

Verifying signatures: HMAC SHA-256

Every webhook POST includes an X-Hub-Signature-256 header: sha256= followed by an HMAC of the raw request body, keyed with your app secret (from the App Dashboard — not your access token, not your verify token; three different secrets, three different jobs).

If you skip verification, anyone who discovers your endpoint URL can inject forged "inbound messages" into your system. For a commerce bot that auto-confirms orders or a support system that closes tickets on customer replies, that's not theoretical.

The critical implementation detail: the HMAC is computed over the raw bytes of the body. If your framework parses JSON before you can read the raw body, JSON.stringify(req.body) will usually reproduce the original — until a payload contains a Unicode escape or key ordering that serialises differently, and verification starts failing intermittently. Capture the raw buffer:

const crypto = require("crypto");
const express = require("express");
const app = express();

// Preserve the raw body alongside the parsed JSON
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf;
    },
  })
);

function verifySignature(req) {
  const header = req.get("X-Hub-Signature-256") || "";
  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", process.env.APP_SECRET)
      .update(req.rawBody)
      .digest("hex");

  // timingSafeEqual throws on length mismatch — guard first
  if (header.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

app.post("/webhook", (req, res) => {
  if (!verifySignature(req)) {
    return res.sendStatus(401);
  }
  // Acknowledge immediately; process out of band
  res.sendStatus(200);
  enqueue(req.body); // push to your queue, then return
});

Use crypto.timingSafeEqual, not ===, to avoid timing side-channels — pedantic, perhaps, but it's one line. And rotate your app secret if it ever lands in a log or a client-side bundle; every historical payload becomes forgeable otherwise.

Retries, ordering and idempotency: key on the wamid

Meta considers a delivery failed if your endpoint doesn't return an HTTP 200 quickly — the documented budget is small, and in practice you should treat anything beyond low single-digit seconds as a timeout risk. Failed deliveries are retried with backoff, for up to several days on subscribed fields. Two consequences follow:

Acknowledge first, process later. Your webhook handler should do exactly three things synchronously: verify the signature, enqueue the payload (SQS, Pub/Sub, Redis stream, Kafka — anything durable), and return 200. Database writes, media downloads, LLM calls, CRM syncs — all of it happens off the request path. Teams that process inline see cascading retries the first time their database has a slow minute: the retry storm arrives on top of live traffic, latency climbs further, more retries queue up, and eventually Meta may pause deliveries to a persistently failing endpoint.

Every consumer must be idempotent. Because retries exist, you will receive duplicates — the same message payload two, three, occasionally more times. The wamid (messages[].id for inbound, statuses[].id for statuses) is globally unique per message and is your idempotency key. The pattern we recommend:

async function handleInboundMessage(msg) {
  // Composite key: wamid + event kind, since a message and its
  // statuses share the same wamid on the sending side
  const dedupKey = `wh:${msg.id}:inbound`;

  // SET NX with a TTL — atomic check-and-claim
  const claimed = await redis.set(dedupKey, "1", "EX", 7 * 86400, "NX");
  if (!claimed) {
    return; // duplicate delivery — already processed or in flight
  }

  await processMessage(msg); // your actual business logic
}

A seven-day TTL comfortably outlives Meta's retry horizon without growing your keyspace forever. For status events, extend the key with the status name (wh:${id}:status:read), since one wamid legitimately produces up to three non-terminal status events. And remember the ordering rule from earlier: dedup stops duplicate events, but only a forward-only state machine stops out-of-order ones. You need both.

One more subtlety: a single webhook POST can contain multiple entries, each with multiple messages. Iterate every level of the envelope. Code that reads entry[0].changes[0].value.messages[0] works in testing and silently drops events in production — we see this across accounts more often than we'd like.

Testing webhooks locally

Meta can't reach localhost, so local development needs one of three approaches:

  1. A tunnel. ngrok http 3000 (or Cloudflare Tunnel, which gives you a stable hostname on the free tier) exposes your dev server publicly. Point the App Dashboard's callback URL at the tunnel, run the verification handshake against your laptop, and iterate. The catch with ngrok's free tier is that the URL changes on every restart, which means redoing the handshake — a stable tunnel hostname saves genuine time.

  2. Replay captured payloads. Once you've logged a handful of real payloads (one per message type, plus each status), you don't need Meta at all for most iteration. A curl with the saved JSON body — plus a locally computed X-Hub-Signature-256 using your dev app secret — exercises the full path including signature verification. Keep a fixtures/ directory of these in the repo; they double as regression test inputs.

  3. Meta's test number. Every app gets a test phone number that can message up to five verified recipients without template approval or payment setup. It's the fastest way to generate genuine payloads (send yourself a location, a document, a button reply) before you have a production number.

Whichever route you take, write integration tests that feed your handler malformed payloads too: empty changes arrays, unknown type values, statuses for wamids you've never seen (this happens after a redeploy that loses in-flight state). The handler's contract is simple — never 5xx on anything Meta could plausibly send.

How InfiQ webhooks work

If you build on InfiQ rather than raw Cloud API, the webhook model is intentionally familiar — we forward the same payload shape Meta sends, so everything above about envelope structure, message types, status lifecycle, and wamid-keyed idempotency applies unchanged. Code written against Meta's payloads ports over without a translation layer, and vice versa.

The differences are operational:

  • Authentication uses an X-INFIQ-API-KEY header on every delivery instead of Meta's HMAC scheme. Compare it against the key from your dashboard with a constant-time check, exactly as you would a signature. One static header check replaces raw-body capture and HMAC plumbing, which removes the most common class of verification bugs — though if you prefer, the raw Meta signature material is preserved so you can verify both.
  • Dashboard replay. Every webhook delivery — payload, response code, latency, attempt count — is logged and inspectable in the InfiQ dashboard, and any event can be replayed to your endpoint with one click. When your consumer had a bad deploy at 2 a.m. and dropped forty minutes of events, replay beats reconstructing state from Meta's retry behaviour. It's also the fastest local-testing story: point a tunnel at your laptop and replay real production payloads against dev code.
  • Managed retries with visibility. We retry failed deliveries with exponential backoff and surface the failure reason (timeout, TLS error, non-2xx) per attempt, so "why didn't my endpoint get this?" is a dashboard lookup rather than an archaeology project.

Endpoint setup, key rotation, and the full event reference live in the developer docs. Webhook access comes with the Growth plan (Rs.2,999/month) and above; once you're on Growth, you can point real payloads at your endpoint with a test number before going live. (The 7-day free trial runs on the Lite plan, which doesn't include webhooks.)

A production checklist

Before you point real traffic at your endpoint, walk this list:

Check Why it matters
Handshake echoes raw hub.challenge Dashboard verification fails otherwise
App subscribed to messages field on the WABA URL verification alone delivers nothing
Signature verified against raw body bytes Parsed-then-restringified JSON fails intermittently
200 returned before any processing Slow handlers trigger retry storms
Dedup on wamid (+ status name for statuses) Retries guarantee duplicates
Forward-only status state machine read can arrive before delivered
Every entry/change/message iterated Single-index access drops batched events
Unknown type logged, acknowledged, never 5xx'd Meta ships new types; your handler shouldn't care
failed errors alerting on codes 131047/131049 Early warning for window violations and quality limits
Media downloaded promptly to your own storage Media URLs expire in minutes

None of this is exotic engineering. It's a queue, a dedup key, a state machine, and the discipline to acknowledge fast and process slow. Get those four right and the webhook layer becomes the boring part of your WhatsApp stack — which is exactly what it should be.

Put this guide to work

Skip the blank-message stage

The template library has ready-to-adapt WhatsApp messages for campaigns, reminders and support — organised by use case and written to pass Meta's review.

Browse templates

Talk to InfiQ

See what WhatsApp can do for your business

Tell us your volume — we map templates, estimate cost, and get you a sandbox in about 2 hours.

Step 1 of 2
WhatsApp

Protected by invisible spam checks · replies within 1 working day

Meta Business Partner

Put this guide to work.

Get live on the official WhatsApp Business API in 2 hours with InfiQ.

7-day free trial Enterprise-grade reliability Live in 2 hours Built for Indian businesses