# An inbound WhatsApp message, end to end

A customer writes; Meta posts to Connect's one webhook URL; the signature is checked against the account's app secret, the sender is resolved to a Person, and the message is stored. `whatsapp_engine` grounds an answer and hands it to `whatsapp.dispatch`, which asks the autonomy policy whether to send it, queue it for approval, or hold it as a draft.

- **Status:** Available
- **Audience:** both
- **Channels:** whatsapp
- **In the app:** #/whatsapp, #/inbox, #/needs-you
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/workflows/whatsapp-inbound/

## The stages

1. Trigger — a person opens WhatsApp and writes to the workspace's business number.
2. External event — Meta posts the change to `/api/whatsapp/webhook`, the one URL every workspace shares.
3. Authentication and workspace resolution — `phone_number_id` in the body finds the account, that account's app secret must validate the HMAC-SHA256 signature over the raw bytes, and everything after this runs inside that workspace.
4. Ingest — `process_webhook` reads the change: a message, a status, or on a QR-linked number an echo, imported history or an address-book sync.
5. Canonical record — a conversation per contact and a message per `wamid`, so a retry is discarded rather than duplicating the customer; an inbound message opens a fresh 24-hour window.
6. Classification and reasoning — a STOP or START is applied as consent first; otherwise `whatsapp_engine` reads the thread and decides what kind of thing it is.
7. Knowledge, memory and rules — bounded extracts from Knowledge, the memory tiers for this contact and endpoint, what the same person said on email, phone and SMS, the conversation's own instructions and the regional style policy.
8. Autonomy and approval — `whatsapp.dispatch` prices the message on Meta's category and asks `autonomy.decide`: send, queue for approval, hold as a draft, or refuse.
9. Action through Meta — `send_message` checks the window, consent and the template's approval, then posts to the Graph API, subject to suppression, allowance and the hourly cap for the connection type.
10. Result — the send is recorded as sent only when Meta returns a message id; a refusal is recorded with its reason.
11. Relationship, timeline and memory — the exchange joins the Person's history across every channel, and anything durable is written as memory rather than left in the transcript.
12. Audit, usage and Needs You — the decision and its rule are in the audit trail, the send is metered on a customer plan, and anything a person must handle is queued.

> **Note** Stage three is ordered the way it is on purpose. `phone_number_id` arrives untrusted and is used only to find the account whose secret then proves the call, so a forged id simply fails to verify; and workspace resolution runs before person resolution, so a Person can only ever be created inside the workspace entitled to have one.

## Stage by stage, with the failures

| Stage | What you see | What changes | What can fail |
|---|---|---|---|
| Webhook arrives | Nothing yet | Nothing until the signature passes | A forged or unsigned call — answered 403, nothing recorded |
| Workspace resolved | Nothing yet | The request enters one workspace | `phone_number_id` matches no connected account; the change is skipped |
| Person resolved | A conversation appears against a name | A Person is created if the sender is new | Two plausible records — the merge tools exist for this |
| Message stored | The message on `#/whatsapp` and `#/inbox` | A conversation and a message row; the 24-hour window opens | A Meta retry, absorbed by the `wamid` |
| Draft written | A reply, sent or waiting | A draft against the thread | STOP, a pause, a takeover from the handset, automation off, or a closed window — no model is asked |
| Autonomy decision | Sent, in Needs You, or neither | The decision, recorded with its rule | `draft_only` queues nobody, which reads as silence |
| Send | The reply in the customer's chat | State moves uncertain → sent when Meta returns a message id | Suppression, spent allowance, the closed window, an unapproved template, or the hourly cap |
| Timeline and audit | The exchange on the Person's history | Timeline entry, audit row, usage on a customer plan | Nothing here fails quietly; a refusal is recorded as a decision |

## What counts as this flow having worked

1. A conversation exists on `#/whatsapp` attached to a Person, not to a bare number.
2. The same conversation is visible on `#/inbox`, because it is one canonical thread rather than a channel-specific copy.
3. Either a reply reached the customer's phone, or there is a queued item in Needs You, or the channel is deliberately on `draft_only`.
4. The decision log shows what was decided and under which rule — including a refusal, if that is what happened.

If the first is missing, the failure is on the inbound half and [WhatsApp messages are not arriving](/docs/troubleshooting/whatsapp-not-receiving/) is the page. If the first three are present and the reply did not go, the failure is on the outbound half and [The WhatsApp reply would not send](/docs/troubleshooting/whatsapp-send-failed/) is the page.

## Timing, retries and idempotency

The webhook is at-least-once: Meta retries when it is not acknowledged promptly, so the same message can arrive more than once and messages can arrive out of order. Meta's `wamid` is what makes a message unique, so a retry is counted as a duplicate and discarded — and, importantly, does not re-open a window that has already closed. Two identical texts with two `wamid`s are a customer who pressed send twice, and the conversation should read that way.

The reply is attempted twice by design. The webhook answers at once and starts the engine in the background; the engine's own tick then picks up anything the webhook missed, after a short settle. A conversation is never answered twice, because the engine works from what is unhandled rather than from the webhook call.

Reply timing is the agent loop's, not the webhook's. The record is created as the message arrives; the answer follows within the loop's ordinary cycle. A reply that appears seconds later has not been delayed — it has been thought about.

## Questions

### Where does the reply text come from?

The agent loop composes it from the thread, bounded Knowledge, the memory tiers that apply, and the standing instructions for the channel. There is no canned response table.

### Can I watch this flow happen?

The conversation shows the message and the reply; `#/autonomy-audit` shows the decision and its rule; `#/activity` shows the work. Between them they cover every stage that produces a visible change.

### What if the customer writes three messages in a row?

They land on one thread and the loop reads them together rather than answering each in isolation, which is why a reply to a burst reads as one answer instead of three.

## Related

- [WhatsApp in Connect](https://connectbyjbrh.com/docs/whatsapp/)
- [Receiving a WhatsApp message](https://connectbyjbrh.com/docs/whatsapp/inbound-webhook/)
- [Replying on WhatsApp](https://connectbyjbrh.com/docs/whatsapp/replying/)
- [Recognising who sent a WhatsApp message](https://connectbyjbrh.com/docs/whatsapp/person-resolution/)
- [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/)
- [End-to-end workflows](https://connectbyjbrh.com/docs/workflows/)

## What this page is based on

- `backend/app/whatsapp.py` — `process_webhook`, `dispatch`, `send_message`
- `backend/app/whatsapp_engine.py` — `respond`, `respond_after_webhook`, `tick`
- `backend/app/whatsapp_api.py` — the public webhook and its background handoff
- `docs-source/sources/GENERAL.md` §2 — how a request flows, §5 — autonomy and the audit trail
