← Blog

·

WhatsApp API webhooks: a practical guide with examples

Updated on October 29, 2026

A WhatsApp API webhook is an HTTPS URL on your system that receives a POST for every event on your account: incoming messages, status changes (sent, delivered, read, failed) and account alerts. A well-built endpoint checks where the request came from, returns 200 quickly, puts the event on a queue, processes it in the background and drops duplicates by ID. When you integrate directly with Meta there is also a GET verification step; with Vectalk, you register the URL through the API and receive the same payload Meta sends.

This guide covers the event format, the differences between integrating directly and through Vectalk, and a complete server in Node.js and in Python.

How the event flow works

With a direct integration, Meta calls the endpoint configured in your app. With Vectalk, Meta calls Vectalk, which forwards the event to every active URL registered for that instance:

Direct:   Meta  ──POST──▶  your endpoint
Vectalk:  Meta  ──POST──▶  Vectalk  ──POST──▶  your URLs (one or several)

The body is the same in both cases. What changes is how the URL is registered and how the request is authenticated.

AspectDirect Cloud APIVectalk
URL setupApp Dashboard > WhatsApp > ConfigurationPOST /api/webhooks with instance_id, url and name
Endpoint verificationGET with hub.mode, hub.verify_token and hub.challengeNot described in the Vectalk docs
SignatureX-Hub-Signature-256 header (HMAC-SHA256 with the app secret)Not described in the Vectalk docs
URLs per accountOne endpoint per app (with overrides per messaging account or phone number)Several URLs per instance, all receiving the same event
Response timeMeta retries if it does not get a 200Return 200 in under 20 seconds; otherwise Vectalk resends
RetriesDecreasing frequency for up to 7 daysResending documented, no time frame given

Sources: WhatsApp webhooks overview, Create a webhook endpoint and webhooks in the Vectalk docs.

Registering the webhook with Vectalk

curl -X POST https://apis.vectalk.com.br/api/webhooks \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "instance_id": "YOUR_INSTANCE_ID",
    "url": "https://myapp.com/whatsapp/events",
    "name": "Production"
  }'

The response includes the webhook id and active: true. The update endpoint takes that id and lets you change the URL, the name or turn the webhook off (active); see Register webhook and Update webhook. The URL must be HTTPS.

Having several URLs per instance helps in two cases: keeping production and staging apart, and sending the same event to two systems (your CRM and your data warehouse, for example) without building your own fan-out.

Want to try it while you read? Create a Vectalk account and send your first message through the official API. Create free account →

Endpoint verification (direct integration)

When you set the webhook in Meta’s App Dashboard (under WhatsApp > Configuration, or Use cases > Customize > Configuration for apps created with the WhatsApp use case), Meta sends a GET to your URL with three parameters:

GET https://myapp.com/whatsapp/events?hub.mode=subscribe&hub.challenge=1158201444&hub.verify_token=my-secret-token

Your endpoint must check that hub.verify_token matches the value you entered in the dashboard and return hub.challenge in the body with status 200. Any other response fails verification. Meta also requires a valid TLS certificate on the endpoint; self-signed certificates are not supported.

The payloads you will receive

Every event arrives with object: "whatsapp_business_account" and an entry list. Each entry has a changes list, and each change has a field and a value. For messages and statuses, field is messages. Other subscribable fields include account_alerts, account_update, message_template_status_update, phone_number_quality_update and smb_message_echoes.

Incoming message

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "WABA_ID",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": {
          "display_phone_number": "558430000000",
          "phone_number_id": "PHONE_NUMBER_ID"
        },
        "contacts": [{
          "profile": { "name": "João Silva" },
          "wa_id": "5584988888888",
          "user_id": "BR.13491208655302741918"
        }],
        "messages": [{
          "from": "5584988888888",
          "from_user_id": "BR.13491208655302741918",
          "id": "wamid.HBgLNTUx...",
          "timestamp": "1760000000",
          "type": "text",
          "text": { "body": "Hi, I'd like more information" }
        }]
      }
    }]
  }]
}

Since April 2026, Meta also sends the BSUID (business-scoped user ID) in contacts[].user_id and messages[].from_user_id. The phone number is no longer guaranteed: if the customer has turned on a WhatsApp username and none of Meta’s conditions apply (a message or call exchanged with that business number in the last 30 days, or the customer being in your contact book), from and wa_id are omitted. Don’t use the phone number as your only contact key; the details are in WhatsApp usernames and BSUIDs.

The type field tells you where the content is. The most common ones:

typeWhere to read
texttext.body
image, video, audio, document, sticker<type>.id (download through the media endpoint), caption, filename
interactiveinteractive.button_reply or interactive.list_reply
locationlocation.latitude, location.longitude
reactionreaction.message_id, reaction.emoji
orderorder.catalog_id, order.product_items[]

Status of a sent message

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "WABA_ID",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "display_phone_number": "558430000000", "phone_number_id": "PHONE_NUMBER_ID" },
        "statuses": [{
          "id": "wamid.HBgLNTUx...",
          "status": "delivered",
          "timestamp": "1760000010",
          "recipient_id": "5584988888888",
          "recipient_user_id": "BR.13491208655302741918",
          "pricing": { "billable": true, "pricing_model": "PMP", "type": "regular", "category": "utility" }
        }]
      }
    }]
  }]
}

The status id is the wamid you got back when you sent the message. The recipient_user_id field carries the customer’s BSUID whether you sent the message to the phone number or to the BSUID; recipient_id (the phone number) is omitted when you sent to the BSUID and Meta cannot include the number. Besides sent, delivered, read and failed, there is played for voice messages. When status is failed, the object includes errors[] with code, title, message and error_data.details.

The pricing object carries category (marketing, utility, authentication, service and others) and type: regular for billable messages, or a free type such as free_customer_service or free_entry_point. Since October 1, 2026, utility templates sent inside the customer service window are charged, so they arrive as regular instead of free_customer_service. Meta has announced that the billable field will be deprecated in favor of type and category. pricing comes with the sent status and with one of delivered or read, never with failed. Full reference: status webhook documentation. For what each category costs, see WhatsApp API pricing in Brazil and, for other markets, WhatsApp API pricing by country. Status handling is covered in depth in WhatsApp API message status webhooks.

A single POST can carry several messages or several statuses: Meta’s webhooks documentation says notifications are batched with up to 1,000 updates per request, though batching is not guaranteed. Iterate over the lists instead of reading only the first item.

Best practices

Return 200 fast and process later

The endpoint should not call your database, your AI or another API before responding. Write the raw body to a queue (Redis, SQS, RabbitMQ, an events table) and return 200. A separate worker does the heavy lifting. Vectalk documents a 20-second limit for the response; stay well below it.

Be idempotent

Meta warns that retries can produce duplicate notifications, and a proxy can resend too. Use the message id (or the id + status pair, for statuses) as a unique key. If the key has already been processed, drop the event.

Validate the signature

With a direct integration, Meta signs every POST with an HMAC-SHA256 of the body, keyed with your app secret, and sends the result in the X-Hub-Signature-256 header as sha256=<hash>. Compute the hash over the raw body, before any JSON parsing, and compare in constant time; the step-by-step guide is in how to validate the WhatsApp webhook signature. Webhooks also support mutual TLS (mTLS). Avoid IP allowlists as your only defense: Meta changes its IP addresses periodically and recommends mTLS instead.

With Vectalk, use the authentication mechanism Vectalk specifies for forwarded requests (check the docs or the contact page). Until that is set up in your environment, use a hard-to-guess URL path, keep the endpoint HTTPS-only, and do not trust the content for sensitive actions without checking the expected phone_number_id.

Don’t depend on order

Retries mean an older status can arrive after a newer one, and when a message is read right away Meta sends read without a delivered webhook. Store the most advanced status and the timestamp of each step, without ever moving backward.

Plan for large payloads

Meta says webhook payloads can be up to 3 MB. Raise your server’s body size limit accordingly (Express defaults to 100 kb).

Monitor

Track how many events arrive per minute and how many fail in the worker. A sudden drop in message events is often the first sign of a disabled webhook, an expired certificate or a disconnected number.

Complete example in Node.js (Express)

import express from 'express'
import crypto from 'node:crypto'
import { createClient } from 'redis'

const app = express()
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN // direct integration
const APP_SECRET = process.env.META_APP_SECRET        // direct integration

// Keep the raw body so the signature can be validated
app.use('/whatsapp/events', express.raw({ type: 'application/json', limit: '3mb' }))

// Endpoint verification (direct integration with Meta only)
app.get('/whatsapp/events', (req, res) => {
  const { 'hub.mode': mode, 'hub.verify_token': token, 'hub.challenge': challenge } = req.query
  if (mode === 'subscribe' && token === VERIFY_TOKEN) return res.status(200).send(challenge)
  return res.sendStatus(403)
})

function validSignature(req) {
  const header = req.get('X-Hub-Signature-256') || ''
  const expected = 'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(req.body).digest('hex')
  const a = Buffer.from(header)
  const b = Buffer.from(expected)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

app.post('/whatsapp/events', async (req, res) => {
  if (APP_SECRET && !validSignature(req)) return res.sendStatus(401)
  await redis.lPush('whatsapp:events', req.body.toString('utf8'))
  res.sendStatus(200)
})

app.listen(3000)

And the worker, which consumes the queue and drops duplicates:

// uses a Redis client connected the same way as above
async function processEvent(event) {
  for (const entry of event.entry ?? []) {
    for (const change of entry.changes ?? []) {
      const v = change.value ?? {}
      for (const msg of v.messages ?? []) {
        const isNew = await redis.set(`wa:msg:${msg.id}`, '1', { NX: true, EX: 60 * 60 * 24 * 8 })
        if (!isNew) continue // duplicate
        await handleMessage(v.metadata.phone_number_id, msg, v.contacts) // your business logic
      }
      for (const st of v.statuses ?? []) {
        const isNew = await redis.set(`wa:st:${st.id}:${st.status}`, '1', { NX: true, EX: 60 * 60 * 24 * 8 })
        if (!isNew) continue
        await updateStatus(st) // store status, errors and pricing
      }
    }
  }
}

while (true) {
  const item = await redis.brPop('whatsapp:events', 0)
  try {
    await processEvent(JSON.parse(item.element))
  } catch (err) {
    console.error('failed to process event', err)
    await redis.lPush('whatsapp:events:error', item.element)
  }
}

The eight-day expiry on the keys covers Meta’s retry window, which lasts up to seven days.

Complete example in Python (FastAPI)

import hashlib, hmac, json, os
import redis.asyncio as redis
from fastapi import FastAPI, Request, Response

app = FastAPI()
r = redis.from_url(os.environ["REDIS_URL"])
VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN")  # direct integration
APP_SECRET = os.environ.get("META_APP_SECRET", "")      # direct integration


@app.get("/whatsapp/events")
async def verify(request: Request):
    q = request.query_params
    if q.get("hub.mode") == "subscribe" and q.get("hub.verify_token") == VERIFY_TOKEN:
        return Response(content=q.get("hub.challenge", ""), media_type="text/plain")
    return Response(status_code=403)


def valid_signature(body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(APP_SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")


@app.post("/whatsapp/events")
async def receive(request: Request):
    body = await request.body()
    if APP_SECRET and not valid_signature(body, request.headers.get("X-Hub-Signature-256")):
        return Response(status_code=401)
    await r.lpush("whatsapp:events", body)
    return Response(status_code=200)

Python worker:

import asyncio, json, os
import redis.asyncio as redis

r = redis.from_url(os.environ["REDIS_URL"])
EIGHT_DAYS = 60 * 60 * 24 * 8


async def process_event(event: dict):
    for entry in event.get("entry", []):
        for change in entry.get("changes", []):
            v = change.get("value", {})
            for msg in v.get("messages", []):
                if not await r.set(f"wa:msg:{msg['id']}", 1, nx=True, ex=EIGHT_DAYS):
                    continue
                await handle_message(v["metadata"]["phone_number_id"], msg, v.get("contacts", []))
            for st in v.get("statuses", []):
                if not await r.set(f"wa:st:{st['id']}:{st['status']}", 1, nx=True, ex=EIGHT_DAYS):
                    continue
                await update_status(st)


async def main():
    while True:
        _, item = await r.brpop("whatsapp:events")
        try:
            await process_event(json.loads(item))
        except Exception as exc:
            print("failed to process event", exc)
            await r.lpush("whatsapp:events:error", item)

asyncio.run(main())

The handle_message and update_status functions (and their Node equivalents) are your business logic: reply, write to the CRM, trigger an agent. Inside them, identify the contact by msg.from_user_id (or contacts[].user_id) and use msg.from only when it is present.

What to do with each event type in the worker

Splitting handling by event type makes the worker easier to maintain. A split that works well:

  • Incoming messages: store the message, update the contact’s last-message time (that is what tells you whether the 24-hour window is open) and only then trigger the chatbot, the agent or the human support queue.
  • sent, delivered and read statuses: update the record of the sent message by wamid, storing the time of each step. This data feeds delivery and read reports.
  • failed status: store the error code and decide what to do. Some errors call for a retry later; others mean the send will not work without a change, such as a template sent outside the window or an invalid number. Don’t resend in a loop.
  • Other fields (account_update, account_alerts, template status changes): route them to a technical alert channel, because they usually need human action.

Also keep an error queue, as in the examples above. An event that failed processing should not disappear: it needs to be available for reprocessing once the bug is fixed.

Testing locally

To receive real events on your machine, expose the local port through an HTTPS tunnel (ngrok, Cloudflare Tunnel or similar) and register the tunnel URL as an additional webhook on the instance, with a name like “dev”. Since Vectalk delivers each event to every active URL, your development environment gets the same traffic as production without interfering with it. Turn the dev webhook off when you are done. The full walkthrough is in how to test the WhatsApp webhook locally with ngrok.

You can also test the parser without real traffic by saving sample payloads and sending them with curl (leave META_APP_SECRET unset in this test, or sign the body as shown in the signature guide, or the server will answer 401):

curl -X POST http://localhost:3000/whatsapp/events \
  -H "Content-Type: application/json" \
  --data @samples/text-message.json

Common errors

SymptomLikely cause
Messages processed twiceNo deduplication by id
Events arriving late in burstsSlow endpoint; the sender piled up retries
Signature never matchesHash computed over parsed JSON instead of the raw body
413 Payload Too LargeServer body limit below 3 MB
Only the first message in a batch is handledCode reads messages[0] instead of iterating
Status going back from read to deliveredOut-of-order events overwriting state
Contacts without a phone number in the CRMCode keyed only on from/wa_id, ignoring the BSUID

Frequently asked questions

Do I need to return anything in the webhook response body?

No. HTTP status 200 is enough. The response body is ignored, except in the direct integration’s GET verification, where the body must be hub.challenge.

Can I use the same URL for several numbers?

Yes. Use metadata.phone_number_id to tell which number an event belongs to. With Vectalk, each webhook is registered per instance, and you can point several instances to the same URL.

How long does Meta keep retrying an event?

Up to seven days, with decreasing frequency, according to Meta’s webhooks documentation. With Vectalk, the docs state that the event is resent when the endpoint does not return 200 within 20 seconds.

How do I receive the messages the owner sends from the app, under coexistence?

Through the smb_message_echoes webhook field. The WhatsApp API coexistence guide explains the extra fields.

I’m migrating from Baileys. Is the format very different?

Yes. Events follow Meta’s entry[].changes[].value structure, contacts are identified by wa_id and, since April 2026, also by the BSUID (user_id), and media arrives as an id. The guide how to migrate to the official WhatsApp API covers that transition. To get started with Vectalk, create your account at apps.vectalk.com.br/register; account questions go to the Help Center (in Portuguese).


Sources checked on October 29, 2026: WhatsApp webhooks overview, Create a webhook endpoint, Graph API webhooks: getting started, Status webhook reference, Text message webhook reference, Business-scoped user IDs, Pricing, Webhooks in the Vectalk docs, Register webhook (Vectalk), Update webhook (Vectalk).

Try the official WhatsApp API today

Create your account, generate a token and send your first message with the quick-start guide. Same payloads and webhooks as Meta's Cloud API.