# Receiving a WhatsApp message

Meta posts to one Connect URL, `/api/whatsapp/webhook`, for every workspace. The `phone_number_id` in the body finds the account, that account's app secret has to validate an HMAC-SHA256 signature over the raw bytes, and only then is the payload read as meaningful. Connect resolves the sender to a Person and writes the conversation and message the engine and every screen read.

- **Status:** Available
- **Audience:** both, developer
- **Channels:** whatsapp
- **In the app:** #/whatsapp, #/inbox
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/whatsapp/inbound-webhook/

## The four checks before a record exists

1. **Which account does this claim to be for?** `phone_number_id` inside the payload is the routing key, and it arrives untrusted. It is used only to *find* the account whose app secret then has to prove the call.
2. **Is this call genuine?** `X-Hub-Signature-256` is an HMAC-SHA256 of the raw body keyed with that account's app secret, compared in constant time against the bytes exactly as received. A forged call is refused 403 and nothing is written. Re-serialising parsed JSON would change whitespace and key order, so the check runs before the body is parsed as meaningful.
3. **Have I already seen this?** Meta retries. `_known` looks the `wamid` up, and a repeat is counted as a duplicate rather than becoming a second message or re-opening a window that has closed.
4. **Who sent it?** The sending number is resolved through `identity.py` to a Person, creating one where there is no match.

The order matters. The signature is checked before the account is used for anything, so learning the URL buys an attacker nothing, and workspace resolution comes before person resolution so that a Person is only ever created inside the workspace that is allowed to have it. Every write after that point passes through the workspace kernel and row-level security, so a mistake at this stage cannot leak a record sideways.

There is also a GET on the same path: Meta's `hub.mode=subscribe` handshake. `verify_handshake` returns the challenge only for a verify token that matches — the platform token, or the one generated for an account when it was connected — and stamps that account as verified.

## Everything a payload can carry

`_body_of` turns any message Meta delivers into a type, a text, and a media reference. Text is the body; an image, document, audio, video or sticker becomes a media id, a MIME type, a filename and any caption; a tapped button or a list reply becomes the label the person chose, because that is the text Connect has to answer; a reaction becomes the emoji; a location becomes its name and address, or its coordinates. Anything Meta adds later is kept as JSON rather than dropped.

A reply keeps its context: the `wamid` the person was quoting is stored on the message, so a two-word answer to something said yesterday is still readable. Status callbacks are a separate shape and carry Meta's own billed category for the conversation — the category Connect meters and prices against, rather than the one the sender typed.

Three payload kinds exist only on a QR-linked number. `smb_message_echoes` are replies typed in the WhatsApp Business app on the handset and take the conversation over; `history` is up to 180 days of the phone's own chats, stored as already handled and deliberately never opening a 24-hour window; `smb_app_state_sync` is the phone's address book, which is what names the threads.

## What the canonical record is

WhatsApp keeps its own transport tables — an account, a conversation per contact and a message per `wamid` — and bridges them into the relationship timeline that the agent loop and the Conversations screen read. The engine never sees Meta's field names, which is why the same reasoning answers email, phone and WhatsApp without knowing which arrived.

For WhatsApp that means the thread you open on `#/whatsapp` and the thread the engine reasons over are the same object, and a correction you make to it — a priority change, a person re-attachment — is read back by the engine the next time it picks work. Triage is not a separate view over the data; it is the data.

## Retries, duplicates and ordering

A webhook is an at-least-once delivery. Meta retries when it does not receive a prompt acknowledgement, which means the same message can arrive two or three times, and messages can arrive out of the order they were sent. Connect treats Meta's `wamid` as the thing that makes a message unique, so a retry is counted and discarded rather than duplicating the customer or re-opening a window that has already closed.

That is worth understanding before you conclude a customer sent something twice. Two identical texts a second apart with one identifier are one message delivered twice; two identical texts with two identifiers are a customer who pressed send twice, and the conversation should read that way. The same reasoning applied to telephony is written up in [Idempotency for retried telephony webhooks](/research/idempotent-telephony-webhooks/).

> **Careful** A slow acknowledgement is itself a cause of duplicates. If Meta reports repeated deliveries of the same message, the useful question is not "why is the customer resending" but "what is taking so long to answer the webhook".

## What can fail, and how it looks

| Failure | What you see | What Connect did |
|---|---|---|
| Signature does not match | Nothing at all — no conversation appears | Answered 403 and recorded nothing against the workspace |
| `phone_number_id` matches no connected account | Nothing, on every workspace | Skipped the change: a subscription Connect does not serve, not an attack |
| Meta never calls | Nothing, and no error anywhere in Connect | Nothing — there is no missed-delivery signal to raise |
| Person resolution finds two candidates | The message on a thread, attached to one of them | Attached to the best match; the merge tools exist for the rest |
| Media cannot be fetched | The message text, with the attachment noted | Recorded what it received; it does not invent the file |

This path is the part of WhatsApp with live production evidence behind it: a signed webhook to the public URL was stored, drafted by the real model and held for approval, a forged webhook was refused 403, and the probe rows were then removed. The third row is still the one that catches people — when Meta stops calling, Connect has nothing to notice, because silence on an inbound channel is indistinguishable from a quiet afternoon. That is why the setup test matters and why [WhatsApp messages are not arriving](/docs/troubleshooting/whatsapp-not-receiving/) starts at Meta rather than at Connect.

## Questions

### Does an inbound message trigger a reply immediately?

It creates the record immediately. What happens next is the ordinary agent loop — triage, grounding, drafting, and then the autonomy decision — so a reply follows within the loop's normal cycle rather than inside the webhook call itself.

### Can I see the raw payload Meta sent?

Not from the documentation surfaces, and not from a customer session. What is exposed is the stored message and its type. The transport rows exist for the channel itself, and reading them is an operator task against the database, not a screen.

### What happens to a message from a number on the suppression list?

It is still received and recorded — suppression governs what Connect sends, not what it hears. A person who has opted out and then writes again is a meaningful event, and hiding it would be the wrong behaviour. See [Opting out of WhatsApp](/docs/whatsapp/opt-out/).

## Related

- [WhatsApp in Connect](https://connectbyjbrh.com/docs/whatsapp/)
- [Recognising who sent a WhatsApp message](https://connectbyjbrh.com/docs/whatsapp/person-resolution/)
- [An inbound WhatsApp message, end to end](https://connectbyjbrh.com/docs/workflows/whatsapp-inbound/)
- [WhatsApp messages are not arriving](https://connectbyjbrh.com/docs/troubleshooting/whatsapp-not-receiving/)
- [Idempotency for retried telephony webhooks](https://connectbyjbrh.com/research/idempotent-telephony-webhooks/)
- [Outbound webhooks](https://connectbyjbrh.com/developers/webhooks-outbound/)

## What this page is based on

- `backend/app/whatsapp.py` — `process_webhook`, `signature_ok`, `verify_handshake`, `_body_of`
- `backend/app/whatsapp_api.py` — `WEBHOOK_PATH`, `receive_webhook`
- Connect capability registry (docs-source/facts.py) — `WHATSAPP`, what is proven live
- `docs-source/sources/GENERAL.md` §2 — how a request flows, §3 — the data model
