Sending messages, and receiving them back: the two halves of a real integration

Outbound is a plain HTTP call. Inbound needs a public endpoint, a verification handshake, and signature validation before anything else. Both halves, and the error-handling discipline that keeps either from breaking the product they're attached to.

Intermediate

5 min read

Sending: a plain HTTP call, no SDK required

The Cloud API is a normal REST API — no special client library is needed to use it:

POST https://graph.facebook.com/{api_version}/{phone_number_id}/messages
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "messaging_product": "whatsapp",
  "to": "15551234567",
  "type": "template",
  "template": { "name": "order_confirmation", "language": { "code": "en" }, ... }
}

A successful call returns a 2xx status with a message ID (wamid...) Meta assigns to that specific send. That message ID matters more than it looks — it's how an inbound reply can later be correlated back to the specific message it's replying to (covered in the interactive-messages lesson).

The trigger point: after the real operation commits, never inside it

A message send should be a side effect that happens after the actual business operation succeeds, never something the core operation depends on to complete:

await db.booking.create({ data });
// only after the write commits:
await whatsapp.sendTemplate(bookingConfirmation(...)); // fire-and-forget is fine here

Wiring the send inside the same transaction as the core write, or making the core operation wait on (or roll back because of) a WhatsApp API failure, couples a real business operation's success to a third-party service's uptime. A booking should still succeed even if WhatsApp is briefly unreachable.

Receiving: this is the part most first integrations skip

Everything above only covers messages a business initiates. To receive anything — delivery status updates, replies, button taps — a public HTTPS endpoint has to exist, and Meta has to be told where it is (the webhook URL, configured on the App's WhatsApp product page, per the developer-portal lesson).

Step 1: the verification handshake

When a webhook URL is first configured, Meta sends a GET request to confirm it's real:

GET https://your-domain.com/webhook?hub.mode=subscribe
    &hub.verify_token=YOUR_CHOSEN_SECRET
    &hub.challenge=1158201444

The endpoint must check that hub.verify_token matches a secret value chosen when configuring the webhook, then respond with the raw hub.challenge value as plain text. Get this wrong and Meta refuses to save the webhook configuration at all — this is usually the very first thing to debug if webhook setup seems stuck.

The full round trip, visually

Step 2: passing verification is necessary, but not sufficient

This is the gotcha that costs the most debugging time in practice, because everything looks correctly configured while it's happening: the webhook can pass Meta's verification handshake cleanly and still receive zero real events. Two separate, easy-to-miss platform-level requirements sit between "verified" and "actually receiving traffic":

  • The WABA has to be explicitly subscribed to the App. Registering a webhook URL on the App's Configuration tab is not the same as telling a specific WABA to send its events through that App — that's a separate call, POST /{waba-id}/subscribed_apps. Skip it, and the endpoint stays configured-looking but silent. If a fully-set-up webhook seems to be receiving nothing, GET /{waba-id}/subscribed_apps is the first thing to check, before re-reading webhook code that was never the problem.
  • The App itself has to be Published, not just configured. An App left in "unpublished" (development/test) status only delivers webhook payloads triggered manually from the App Dashboard's own Test button — real inbound traffic (an actual button tap, an actual delivery-status update) never reaches the endpoint until the App's status is flipped to Published, even if the webhook URL, verify token, and subscribed-fields are all otherwise correct.

Both failures produce the exact same symptom: a webhook that looks done, that passed its own verification step, and that simply never fires for real traffic. Neither is visible from reading application code, because neither is application code — they're account-state toggles on Meta's side.

Step 3: signature validation on every subsequent inbound request

Every real event Meta sends afterward includes an X-Hub-Signature-256 header — an HMAC-SHA256 signature of the raw request body, signed with the App Secret. A receiving endpoint should compute the same HMAC over the exact raw bytes it received and compare it to the header, before trusting the payload at all. Skipping this check means the endpoint would process a POST from literally anyone who finds the URL, not just genuine Meta traffic.

Step 4: branch on what actually arrived

A single webhook receives several different event shapes, and a receiver needs to check the payload's type field to know which:

// A delivery status update
{ "statuses": [{ "id": "wamid...", "status": "delivered" }] }
 
// An inbound free-text message
{ "messages": [{ "type": "text", "text": { "body": "can I reschedule?" } }] }
 
// A button tap on a template's Quick Reply button
{ "messages": [{ "type": "button", "button": { "payload": "CANCEL" }, "context": { "id": "wamid..." } }] }

A minimal first version can simply log delivery statuses and acknowledge inbound messages; acting on replies and button taps is the subject of a later lesson.

The error-handling philosophy that applies to both directions

The same rule shows up on both the sending and receiving side: a WhatsApp integration failing should never take down or corrupt something else.

  • A send function catches everything internally and returns a clear success/failure signal — it never throws (covered in the previous lesson).
  • A webhook receiver should acknowledge receipt quickly (Meta expects a fast 200 response) and do any slow work — calling an internal service, writing to a database — asynchronously rather than blocking the response. A slow or failing internal dependency shouldn't cause Meta to see a webhook delivery failure and start retrying the same event.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Where should the call to send a WhatsApp confirmation message be placed relative to a booking's database write?

2. A webhook URL is entered into the App's WhatsApp Configuration page for the first time, and nothing seems to save. What's the most likely first thing to check?

3. A webhook receiver processes every inbound POST request without checking the X-Hub-Signature-256 header. What's the risk?

4. A webhook receiver does a slow database lookup and calls an internal service synchronously before responding to Meta's request. What's the concern?

5. A webhook URL, verify token, and subscribed fields are all configured correctly, and the endpoint passed Meta's verification handshake. It still receives zero real events. What's a likely cause the code itself can't fix by re-reading it?

6. An App's webhook fires correctly when triggered manually from the App Dashboard's Test button, but never receives real production traffic. What's the likely cause?