Connect by JBRH Open Connect

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 What this means
Audience
both
Channels
whatsapp
In the app
#/whatsapp, #/inbox, #/needs-you
Last verified
Product version
6.3.2

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.

Stage by stage, with the failures#

StageWhat you seeWhat changesWhat can fail
Webhook arrivesNothing yetNothing until the signature passesA forged or unsigned call — answered 403, nothing recorded
Workspace resolvedNothing yetThe request enters one workspacephone_number_id matches no connected account; the change is skipped
Person resolvedA conversation appears against a nameA Person is created if the sender is newTwo plausible records — the merge tools exist for this
Message storedThe message on #/whatsapp and #/inboxA conversation and a message row; the 24-hour window opensA Meta retry, absorbed by the wamid
Draft writtenA reply, sent or waitingA draft against the threadSTOP, a pause, a takeover from the handset, automation off, or a closed window — no model is asked
Autonomy decisionSent, in Needs You, or neitherThe decision, recorded with its ruledraft_only queues nobody, which reads as silence
SendThe reply in the customer's chatState moves uncertain → sent when Meta returns a message idSuppression, spent allowance, the closed window, an unapproved template, or the hourly cap
Timeline and auditThe exchange on the Person's historyTimeline entry, audit row, usage on a customer planNothing 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 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.