WhatsApp webhook reference documentation — subscription fields table, HMAC header spec, and retry timeline on dark developer terminal background
Sections: Webhook fundamentals · Complete properties reference · All subscription fields · Events per field · Retry schedule · HTTP response behavior · HMAC-SHA256 spec · WABA vs Phone Number level · At-least-once delivery · Payload schemas · Security checklist · Error catalog · SocialHook normalized format · FAQ

Webhook fundamentals

The WhatsApp Cloud API delivers events to your application through a push-based webhook system. Rather than polling an endpoint for new events, Meta's servers POST a JSON payload to a URL you register — your webhook endpoint. Your server processes the payload and returns HTTP 200 to acknowledge receipt.

Two HTTP methods operate on the same URL:

  • GET — one-time verification challenge during registration. Your endpoint must return the hub.challenge query parameter as plain text.
  • POST — live event notifications. Every inbound message, status update, and account event arrives here.

The most important constraints to internalize before writing any code:

  • Respond within 20 seconds — anything slower triggers a retry. Acknowledge immediately, process async.
  • At-least-once delivery — the same event may arrive more than once. Your handler must be idempotent.
  • No ordering guarantee — events may arrive out of sequence. Never assume chronological order.
  • 3MB payload size limit — individual webhook payloads will not exceed 3MB. Media files are not included inline.
  • HTTPS required — Meta rejects plain HTTP endpoints. Valid SSL certificate required.

Complete webhook properties reference

Property Value / Spec Notes
ProtocolHTTPS onlyValid SSL cert required. HTTP rejected outright.
DirectionProvider → Consumer (push)Meta pushes to you. No polling required.
Verification methodGET + hub.verify_tokenOne-time on registration. Return hub.challenge as plain text.
Auth on eventsX-Hub-Signature-256 HMAC-SHA256Signed with App Secret. Verify every POST.
HTTP method for eventsPOSTAlways POST. Never GET for live events.
Data formatJSONContent-Type: application/json
Response timeout20 secondsExceeding triggers retry. Return 200 immediately.
Success criteriaHTTP 200Any non-200 triggers retry mechanism.
Payload size limit3 MBMedia not inline — referenced by ID.
Retry durationUp to 7 daysExponential backoff. See full schedule below.
Delivery guaranteeAt-least-onceDuplicates possible. Make handler idempotent.
Ordering guaranteeNoneEvents may arrive out of chronological order.
Webhook levelsPhone Number + WABAPhone Number takes priority over WABA fallback.
Manual replayNot supportedNo native replay. Events lost after 7 days.
Signature algorithmHMAC-SHA256Key = App Secret. Input = raw body bytes.
Signature headerX-Hub-Signature-256Format: sha256=<hex>
Concurrent deliveriesMultiple per secondHigh-volume numbers receive many events/second.

All webhook subscription fields

You subscribe to individual fields in the Meta Developer Dashboard (WhatsApp → Configuration → Webhooks → Manage). Subscribing to messages is mandatory for most integrations. Each field represents a category of events — subscribe only to what you need to reduce payload volume.

Field Coverage Priority
messages All inbound customer messages + all outbound status updates (sent / delivered / read / failed). The primary field for any messaging integration. Subscribe always
account_update Policy violations (ACCOUNT_VIOLATION) and active restrictions (ACCOUNT_RESTRICTION) on your phone numbers. Essential for production monitoring. Recommended
message_template_status_update Template approval, rejection, pausing, and disabling events. Fires whenever Meta changes the status of a template you've submitted. Recommended
phone_number_quality_update Quality rating changes (GREEN / YELLOW / RED) for your phone numbers. Quality affects your messaging tier. Subscribe to detect degradation early. Recommended
phone_number_name_update Display name approval or rejection events. Fires when Meta reviews a name you've submitted for your WhatsApp Business number. Situational
business_capability_update Messaging limit tier changes — when Meta upgrades or downgrades your daily conversation tier (1K / 10K / 100K / Unlimited). Situational
flows WhatsApp Flows interactions — data submission events when a user completes or interacts with a Flow attached to your number. If using Flows
security Two-step verification PIN events. Fires when the PIN for a phone number is changed or disabled. Optional
message_template_components_update Fires when Meta modifies the components of an approved template (rare — Meta may adjust templates to comply with policy). Optional
account_alerts Billing alerts, capacity warnings, and other account-level operational notices from Meta. Optional

Events delivered per subscription field

messages field — inbound message types

The messages field delivers two categories: inbound messages from customers (identified by a messages array in the value object) and outbound delivery status updates (identified by a statuses array). Check which array is present before processing.

msg.typeContent locationNotes
textmsg.text.bodyPlain text message body. May include URLs.
imagemsg.image.id, .mime_type, .captionID → resolve URL → download. Caption optional.
audiomsg.audio.id, .voicevoice: true if recorded in-app. Always download immediately.
videomsg.video.id, .captionCaption optional.
documentmsg.document.id, .filename, .mime_typeFilename included — store it.
stickermsg.sticker.id, .animatedAnimated flag distinguishes WebP vs animated sticker.
locationmsg.location.latitude, .longitude, .name, .addressName and address are optional.
contactsmsg.contacts[n].name, .phonesArray — customer may share multiple contacts.
reactionmsg.reaction.emoji, .message_idReferences the message ID the customer reacted to.
interactivemsg.interactive.type, then .button_reply or .list_replyCheck type before reading sub-object.
ordermsg.order.catalog_id, .product_itemsWhatsApp Commerce — product order from catalog.
systemmsg.system.body, .typeSystem events: customer changed number, etc.
buttonmsg.button.text, .payloadQuick reply button tap from a template message.
referralmsg.referral.source_url, .source_type, .source_idClick-to-WhatsApp ad referral data alongside the message.

messages field — outbound status updates

status valueMeaningAdditional fields
sentMessage accepted by Meta and forwarded to WhatsApp. Not yet delivered to device.timestamp, recipient_id, conversation, pricing
deliveredMessage reached the recipient's device (double grey tick).timestamp, recipient_id, conversation, pricing
readRecipient opened the chat (double blue tick). Only fires if read receipts are enabled.timestamp, recipient_id
failedDelivery permanently failed. Check status.errors[0].code for the specific error.errors array with code and title

Exact retry schedule with backoff timings

Meta retries failed webhook deliveries for up to 7 days using exponential backoff. "Failed" means your endpoint returned non-200, timed out (took more than 20 seconds), or was unreachable. After 7 days, the event is permanently discarded — no recovery path exists natively.

1Immediate (first delivery)
2~5 seconds~5 seconds after first attempt
3~30 seconds~35 seconds
4~2 minutes~2.5 minutes
5~10 minutes~12 minutes
6~30 minutes~42 minutes
7~2 hours~2 hours 42 minutes
8~6 hours~8.5 hours
9~12 hours~20 hours
10~24 hours~44 hours
11+~24 hoursEvery 24h until 7 days
FinalAfter 7 daysEvent permanently discarded — no recovery
Practical implication: If your server is down for maintenance, events queue at Meta for up to 7 days. When your server comes back online and starts returning 200, Meta delivers the backlog — potentially a burst of thousands of events arriving simultaneously. Design your webhook handler and queue to absorb high-concurrency bursts without dropping events or cascading failures.

HTTP response code behavior

What you return to Meta determines whether the event is considered delivered, retried, or triggers an alert. The decision tree is simpler than most developers expect — everything is binary: 200 means success, anything else means retry.

200
Acknowledged — delivery complete
Meta considers the event delivered. No retry scheduled. Return 200 immediately after HMAC verification, regardless of whether you've finished processing. Move all processing to an async queue.
403
Forbidden — triggers retry
Return 403 when HMAC verification fails. Meta treats this as a failed delivery and schedules a retry. Useful for explicitly rejecting requests that don't match your signature — though in practice, return 200 for security requests that pass verification and 403 only for genuine forgery attempts.
4xx (other)
Client error — triggers retry
Any 4xx response triggers Meta's retry mechanism. Never return 4xx for business logic errors inside valid payloads — always return 200 to acknowledge receipt and handle the error in your application. A 400 or 404 from your handler is indistinguishable from a server configuration error from Meta's perspective.
5xx
Server error — triggers retry
Genuine server errors (crash, OOM, unhandled exception) return 5xx. Meta retries. The critical design point: never let business logic errors bubble up as 5xx. Wrap all processing in try-catch, return 200 to acknowledge receipt, and log the error internally. 5xx should only fire on genuine infrastructure failures.
Timeout
No response within 20s — triggers retry
If your handler takes longer than 20 seconds to respond, Meta treats it as a failure. This is the most common production issue. Every synchronous operation (database write, LLM call, HTTP request) inside your webhook handler is a timeout risk. Return 200 in <100ms. Push everything else to a queue worker.

Complete HMAC-SHA256 signature specification

Every POST request from Meta includes a cryptographic signature that lets you verify the request genuinely came from Meta and was not tampered with in transit. Skipping this check means any attacker who discovers your webhook URL can feed arbitrary data to your application.

Header format

Header spec
X-Hub-Signature-256
X-Hub-Signature-256: sha256=a1b2c3d4e5f6... Format: sha256= + hex_digest (lowercase hex, 64 characters) Key: Your App Secret (NOT your access token) Message: Raw request body bytes — BEFORE any JSON parsing Find: Meta Developer Dashboard → App Settings → Basic → App Secret

Verification in Node.js

Node.js
verify.js
const crypto = require('crypto'); function verifyWebhook(rawBody, signatureHeader, appSecret) { if (!signatureHeader?.startsWith('sha256=')) return false; const received = signatureHeader.slice(7); // strip 'sha256=' const expected = crypto .createHmac('sha256', appSecret) .update(rawBody) // ← raw Buffer, NOT parsed JSON .digest('hex'); try { return crypto.timingSafeEqual( // prevents timing attacks Buffer.from(received, 'hex'), Buffer.from(expected, 'hex') ); } catch { return false; // length mismatch → invalid } } // Usage in Express — requires express.raw() middleware app.post('/webhook', (req, res) => { const valid = verifyWebhook( req.body, // raw Buffer req.headers['x-hub-signature-256'], // header process.env.WHATSAPP_APP_SECRET ); if (!valid) return res.sendStatus(403); res.sendStatus(200); enqueue(JSON.parse(req.body)); });

Verification in Python

Python
verify.py
import hmac, hashlib def verify_webhook(raw_body: bytes, sig_header: str, app_secret: str) -> bool: if not sig_header.startswith("sha256="): return False received = sig_header[7:] # strip 'sha256=' prefix expected = hmac.new( app_secret.encode(), raw_body, # ← raw bytes, NOT decoded/parsed hashlib.sha256 ).hexdigest() return hmac.compare_digest(received, expected) # timing-safe
The #1 HMAC bug: computing the signature after JSON parsing. JSON.stringify(JSON.parse(body)) produces different bytes than the original — whitespace, key order, and number precision can all change. Always compute HMAC on the exact bytes that arrived in the HTTP request, before any transformation.

WABA-level vs Phone Number-level webhooks

The WhatsApp Cloud API supports two webhook configuration levels that interact in a specific priority order. Understanding this prevents silent event loss when managing multiple phone numbers.

AspectPhone Number WebhookWABA Webhook
Config locationMeta Developer Dashboard → WhatsApp → ConfigurationMeta Business Settings → WhatsApp Accounts → [WABA] → Webhook
ScopeOne specific phone numberAll phone numbers in the WABA
PriorityHigher — takes precedenceLower — fallback only
Fallback behaviorIf set, WABA webhook does not receive events for this numberReceives events for numbers with no Phone Number webhook
Best forSingle-number integrations, per-number routing logicMulti-number operations, agency managing many client numbers
Event routingEvents go to this URL onlyEvents for all unconfigured numbers arrive here
Subscription fieldsConfigured separatelyConfigured separately
Agency pattern: Configure one WABA-level webhook pointing to a central endpoint that dispatches events by metadata.phone_number_id. Add a Phone Number-level webhook only for numbers that need different routing. This is cleaner than maintaining separate webhooks per client number, and SocialHook supports multiple numbers per account under a single normalized event stream.

At-least-once delivery — implications and deduplication

Meta's webhook system guarantees that a given event will be delivered at least once. It does not guarantee exactly once. The duplicate scenario: your server processes an event, returns 200, but the acknowledgment is lost in transit before Meta's system records it. Meta retries. Your handler processes the same event again.

For a simple echo bot this is inconsequential. For an AI agent that triggers a CRM action, a payment, or an outbound message — duplicates cause real problems. Your handler must be idempotent.

Deduplication pattern

Node.js + Redis
dedup.js
const redis = getRedisClient(); const DEDUP_TTL = 86400; // 24 hours — longer than 7-day retry window for safety async function processIfNew(messageId, processFn) { const key = `whatsapp:dedup:${messageId}`; // SET ... NX — only sets if key doesn't exist const isNew = await redis.set(key, '1', 'EX', DEDUP_TTL, 'NX'); if (!isNew) { console.log(`Duplicate event skipped: ${messageId}`); return; // already processed — idempotent skip } await processFn(); } // In your worker: await processIfNew(msg.id, () => handleMessage(msg));

The msg.id value (the wamid.HBgL... string) is unique per message. Status updates use the outbound message ID. Reactions and other event types have their own unique IDs. Always use the event-specific ID — never a derived value — as your deduplication key.

Payload schemas for key event types

Top-level envelope (all events)

JSON
top-level-envelope.json
{ "object": "whatsapp_business_account", // always this string "entry": [{ // array — usually 1 entry "id": "WABA_ID", "changes": [{ "field": "messages", // subscription field name "value": { /* event-specific data */ } }] }] }

Inbound text message (value object)

JSON
inbound-text-value.json
{ "messaging_product": "whatsapp", "metadata": { "display_phone_number": "+1 555 000 1234", "phone_number_id": "PHONE_NUMBER_ID" // use for routing in WABA setups }, "contacts": [{ "profile": { "name": "Alice" }, "wa_id": "15550002345" }], "messages": [{ "id": "wamid.HBgL...", // unique message ID — use for dedup "from": "15550002345", // no + prefix "timestamp": "1747231892", // string — parseInt() before use "type": "text", "text": { "body": "Hello!" } }] }

Outbound message status update (value object)

JSON
status-update-value.json
{ "messaging_product": "whatsapp", "metadata": { "display_phone_number": "...", "phone_number_id": "..." }, "statuses": [{ // note: 'statuses' not 'messages' "id": "wamid.HBgL...", // your outbound message ID "status": "delivered", // sent | delivered | read | failed "timestamp": "1747231900", "recipient_id": "15550002345", "conversation": { "id": "CONVERSATION_ID", "origin": { "type": "service" } // pricing category }, "pricing": { "billable": true, "pricing_model": "CBP", "category": "service" } }] }

Security checklist

Verify HMAC-SHA256 signature on every POST
Reject with 403 any request where the X-Hub-Signature-256 header is missing, malformed, or doesn't match. Without this, your endpoint accepts forged events from anyone.
Use timing-safe comparison for signature matching
crypto.timingSafeEqual() in Node.js, hmac.compare_digest() in Python. Simple string equality (=== / ==) leaks timing information that attackers can exploit to forge signatures character by character.
Parse raw body before HMAC, not after
Compute HMAC on the raw request body bytes. JSON parsing alters byte representation. In Express: express.raw() middleware on the webhook route. In FastAPI: await request.body() before any JSON deserialization.
Store App Secret in environment variables, never in source code
The App Secret is the HMAC signing key. Anyone with it can forge valid webhook signatures. Store in process.env.WHATSAPP_APP_SECRET (Node.js) or os.environ["WHATSAPP_APP_SECRET"] (Python). Rotate immediately if it leaks.
Implement idempotent event processing
At-least-once delivery means duplicate events will arrive. Store processed msg.id values in Redis with a 24h+ TTL. Check before processing. Skip duplicates silently — never error on them.
Implement rate limiting on your webhook endpoint
During normal operation, Meta sends bursts during high-traffic periods. Implement per-IP rate limiting that allows Meta's legitimate delivery rate but throttles unexpected high-volume requests from unknown IPs. 1000 req/min is a reasonable ceiling for most integrations.
Use a non-guessable verify token
The verify token is checked during registration only, but using a predictable string ("mytoken", "test", "whatsapp") makes your setup easier to probe. Generate a random 32-byte hex string: crypto.randomBytes(32).toString('hex').
Monitor your 7-day retry window with alerting
If your webhook endpoint is unreachable, events silently queue at Meta for 7 days. After that — gone. Set up uptime monitoring (Uptime Robot, Better Uptime, or similar) with alerting on HTTP failures at your webhook URL. Page on the first failure; don't wait for reports of lost messages.

Common errors and fixes

Error / SymptomRoot causeFix
Verification fails at registration Returning JSON instead of plain text, wrong verify token, or returning hub.challenge wrapped in a JSON object Return hub.challenge as plain text with Content-Type: text/plain. Exact string — no JSON wrapping.
HMAC always fails Computing HMAC after JSON parsing, using access token instead of App Secret, double-encoding the body Use express.raw() in Express. Call await request.body() in FastAPI before parsing. Key = App Secret from App Settings → Basic.
Events not arriving at all messages field not subscribed, or webhook not saved/verified Dashboard → WhatsApp → Configuration → Webhooks → Manage → enable messages field. Confirm webhook shows as verified.
Receiving same event multiple times At-least-once delivery — expected behavior, not a bug Implement deduplication using msg.id as Redis key with 24h TTL. Check before processing.
Events arriving out of order No ordering guarantee — by design Use timestamp field to sort when building conversation threads. Never assume sequential delivery order.
Meta retries keep coming Handler returning non-200, timing out (>20s), or crashing Return 200 immediately. Move all processing to async queue. Wrap handler in try-catch. Monitor server stability.
Media download returns 401 Using wrong token or token expired Media downloads require the same access token used for the API. Ensure it's a permanent System User token, not a temporary user token. Check token hasn't been revoked.
messages array missing from payload It's a status update — the array is statuses, not messages Check for value.messages AND value.statuses separately. Both arrive under the messages field subscription.
Sender number has no + prefix Cloud API delivers from without the + prefix (e.g. 15550001234 not +15550001234) Normalize on receipt: '+' + msg.from to get E.164 format. SocialHook normalizes this automatically.
Timestamp is a string, not an integer Cloud API delivers timestamp as a Unix timestamp string Always parseInt(msg.timestamp, 10) (Node.js) or int(msg["timestamp"]) (Python) before date operations.

SocialHook: the managed webhook layer

Everything in this reference — HMAC verification, payload extraction from the nested envelope, timestamp parsing, sender normalization, retry handling, deduplication infrastructure, WABA vs phone-level routing — is infrastructure work your product doesn't differentiate on.

SocialHook handles the entire Cloud API webhook layer and delivers a normalized event to your endpoint, so your application code only ever sees clean, consistent JSON — never the raw nested Meta payload with its quirks.

ConcernRaw Cloud APISocialHook normalized
Message extractionentry[0].changes[0].value.messages[0]Flat message object at top level
Sender format"15550001234" — no + prefix"+15550001234" — E.164 normalized
Timestamp"1747231892" — string1747231892 — integer
HMAC verificationYou implement itDone — signature_verified: true
Retry on your downtimeMeta retries (7 days)SocialHook retries (3x exponential)
Multi-channel formatDifferent schema per platformSame schema: WhatsApp + FB + Instagram
Delivery logsNot availableFull log per event
Monthly costYour server infra costs$50 flat

SocialHook covers WhatsApp, Facebook Messenger, and Instagram DMs — all three Meta messaging channels — under one account, one webhook URL, one normalized payload format. See the full payload reference or start with the 5-minute quickstart.

Common questions

What webhook subscription fields does the WhatsApp Cloud API support?
10+ fields: messages (inbound messages + outbound status), account_update (violations, restrictions), message_template_status_update (approval/rejection), phone_number_quality_update (quality rating), phone_number_name_update (display name), business_capability_update (tier changes), flows (WhatsApp Flows interactions), security (2FA changes), message_template_components_update (template modifications), and account_alerts. Subscribe per-field in the Meta Developer Dashboard.
How long does WhatsApp retry failed webhook deliveries?
Up to 7 days with exponential backoff: ~5s → ~30s → ~2m → ~10m → ~30m → ~2h → ~6h → ~12h → ~24h, then every 24h until 7 days. After 7 days, the event is permanently discarded with no recovery path natively available.
What is at-least-once delivery and how do I handle it?
Meta guarantees events are delivered at least once but may deliver the same event more than once. Make your handler idempotent: store the msg.id in Redis with a 24h TTL using SET ... NX. Before processing any event, check if the ID exists. If it does, skip. If not, add it and process. This prevents duplicate CRM records, double AI responses, and duplicate payments.
What is the X-Hub-Signature-256 header format?
Format: sha256=<64-character lowercase hex>. The hex value is HMAC-SHA256 of the raw request body bytes, using your App Secret as the key (find it in App Settings → Basic — this is different from your access token). Strip the sha256= prefix before comparing. Always use timing-safe comparison.
Why are inbound messages and status updates on the same webhook field?
Both inbound messages and outbound status updates (sent/delivered/read/failed) are delivered under the messages subscription field, but they differ in the value object: inbound events contain a messages array, status updates contain a statuses array. Always check which array is present before processing — attempting to read value.messages[0] on a status update payload returns undefined.
What is the difference between WABA-level and Phone Number-level webhooks?
Phone Number webhooks are configured per number and take priority. WABA webhooks are configured at the WhatsApp Business Account level and act as a fallback for numbers without a Phone Number webhook. For agencies managing multiple client numbers, configure one WABA-level webhook as a central receiver that dispatches by metadata.phone_number_id, and only add Phone Number-level webhooks where different routing is needed.

Reference read.
Now receive the first webhook.

You know the spec. SocialHook handles the HMAC verification, payload normalization, retry logic, and delivery logging — so your application code only sees clean JSON. Connect your number in under 5 minutes.

No credit card required · $50/month after trial · Cancel anytime