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.
The stages#
- Trigger — a person opens WhatsApp and writes to the workspace's business number.
- External event — Meta posts the change to
/api/whatsapp/webhook, the one URL every workspace shares. - Authentication and workspace resolution —
phone_number_idin 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. - Ingest —
process_webhookreads the change: a message, a status, or on a QR-linked number an echo, imported history or an address-book sync. - 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. - Classification and reasoning — a STOP or START is applied as consent first; otherwise
whatsapp_enginereads the thread and decides what kind of thing it is. - 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.
- Autonomy and approval —
whatsapp.dispatchprices the message on Meta's category and asksautonomy.decide: send, queue for approval, hold as a draft, or refuse. - Action through Meta —
send_messagechecks 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. - Result — the send is recorded as sent only when Meta returns a message id; a refusal is recorded with its reason.
- 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.
- 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.
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#
- A conversation exists on
#/whatsappattached to a Person, not to a bare number. - The same conversation is visible on
#/inbox, because it is one canonical thread rather than a channel-specific copy. - 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. - 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 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 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 wamids 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.