How a message arrives
A provider webhook becomes a stored entry on a thread through one ordered pipeline. This page is the canonical description of that pipeline, because almost every "the message never showed up" question is answered by knowing which step returned early.
The pipeline, in order
1. Verification, before any persistence. POST /webhooks/:integrationKey resolves the adapter for
that integration key and calls adapter.verifyWebhook(rawBody, headers). A failed verification throws
invalid_api_key and nothing is written. The one escape hatch is INBOX_SKIP_WEBHOOK_VERIFY=true,
which logs a loud warning and accepts the body anyway — a development affordance, not a production one.
2. The WebhookInbox row. The verified raw body plus its parsed JSON is stored as a delivery row.
The parse is done here when the adapter did not already supply verification.payload: an adapter that
verifies from the raw bytes alone correctly returns { ok: true } and nothing else, and taking
verification.payload as the whole answer used to store null, which normalised to zero events. The
delivery was then marked processed with no error and no message — a silent drop that looked like
success in every column.
3. The queue. The row id is enqueued and the POST returns { status: 'queued' }. Enqueuing is
awaited (processing is not): if the row cannot be queued the POST fails so the provider retries,
rather than the delivery vanishing.
4. normalizeWebhook. The worker hands payloadJson to the adapter, which returns zero or more
NormalizedEvents. One delivery can carry several messages; zero events is a legitimate outcome for a
delivery type Inbox does not act on.
5. upsertThread. Keyed on (connectionId, threadType, externalThreadId) — the unique constraint
on inbox_thread. This is the only place threads are created; see
Threads and entries.
6. Handler stamping. If the thread has no handler yet, InboxAccountConfig.defaultHandler is
copied onto it. Only at creation. That is what makes the default safe against the one thing it must
never fight: once a human takes a thread over, handler is 'human', and a later inbound finds a
handler already set and leaves it alone. The connection's default decides where a conversation
starts; takeover owns it thereafter.
7. Participants. Every participant in the event is upserted onto the thread concurrently, with merge semantics — a field the payload omits is not cleared.
8. resolveThreadContact. Attaches the thread to a person. It runs on every inbound rather than
only at creation, because identity arrives late: a WhatsApp contact may be nameless on the first
message and named on the third. Three properties matter — it never fails ingest (every error is
swallowed and logged at debug, without the identifier value, because that is PII), it is skipped when
the thread already has a contact, and it resolves only the non-owner participant. See
Identity and deduplication.
9. Profile enrichment, fire-and-forget. For an inbound whose payload carried no name,
enrichParticipantProfile asks the adapter for the counterparty's public name/username/avatar. It is
not awaited, never throws, and is gated by its own per-connection budget (300 lookups per hour,
separate from the messaging limit) so a webhook replay storm cannot turn into a profile-lookup storm.
Only Meta DMs implement enrichParticipant today.
10. Dedup, twice.
| Check | Test | Why |
|---|---|---|
| Exact | An entry with this externalEntryId already exists on the thread |
Gateways retry deliveries by design |
| Near | Same direction + same body + same author within 60 s |
Same message re-delivered under a different id |
Both return early and log at debug rather than erroring — a retry is not a failure, but it is indistinguishable from a total failure unless it says so.
11. upsertEntry. The entry is written with seq assigned by a Postgres sequence.
12. touchActivity. Uses the provider's event time (event.message.createdAt), not the
ingest instant, falling back to now only when absent. A webhook delivered late must not extend the
24-hour messaging window, and a historical import must not make an old thread read as brand new. For
an inbound it sets lastInboundAt, activityAt, and firstUnansweredInboundAt set-if-null — the
first unanswered inbound opens the response turn and later nudges do not move it, because the
customer's clock starts when they first spoke. For an outbound it sets lastOutboundAt and closes
the turn.
13. incrementUnread (inbound only).
14. Publish message.received on the tenant's realtime bus.
15. Re-read the thread. The row upserted in step 5 is deliberately discarded here. A human may
have taken the conversation over between then and now, and the stale row would still say
handler: 'ai'. One indexed lookup, next to the model call it may prevent.
16. Auto-open a ticket (inbound only). Fire-and-forget and error-isolated everywhere except the
ai network, where it is awaited because the same call performs the AI-resume-on-reopen that step 20
must observe. See Tickets.
17. STOP handler. Awaited, and a consumed STOP returns — nothing below runs. A STOP is a control message, not a conversational turn; if a flow waiting on an answer or the AI bridge got to it first, the opt-out would be swallowed as an ordinary reply. See Consent and suppression.
18. Flow inbound. Awaited, and a consumed message returns. A thread mid-flow is a conversation the automation is already having — it asked a question and is parked waiting for this exact answer. If the agent also answered, the customer would get two replies to one message from what they believe is one correspondent.
19. Auto-response. Fire-and-forget. Independent of the AI reply below: a workspace can run both.
20. AI bridge, then maybeAutoReply. The bridge (modules/ai-inbound) answers a non-ai
connection whose InboxAccountConfig.agentSlug binds an intelligence agent. It is awaited because its
return value decides whether maybeAutoReply — the dedicated ai-network path — may also fire. A
thread must never get two AI answers. See AI and human routing.
Every hook from step 16 on is error-isolated. The customer's message is already stored; a missing ticket is a reporting gap, a failed ingest is a lost message.
silent: true
ingest(connectionId, event, { silent: true }) marks historical backfill, never a live webhook.
It gates every side effect that would reach a real customer or fabricate state:
Suppressed under silent |
Consequence if it were not |
|---|---|
incrementUnread |
Migrated, already-handled threads grow unread badges |
message.received publish |
Every websocket client and outbound webhook floods |
| Auto-open ticket | ~35k migrated threads mint spurious open tickets on top of their real historical ones |
| STOP handler | Historical STOPs re-processed |
| Flow inbound | Every historical flow run advances, completes, times out — sending real messages about conversations that closed months ago |
| Auto-response | Real away/welcome replies to old conversations |
AI bridge and maybeAutoReply |
~252k model calls, each delivered to a real customer |
What silent does not turn off is the write itself: the thread, the participants, the contact
resolution and the entry are all persisted normally. Backfill exists to produce history, not to skip it.
InboxBackfillService passes silent: true on every call.
Why seq exists
InboxEntry.seq is a Postgres sequence, monotonic by insertion. createdAt is millisecond-precision,
so two entries written in the same millisecond tie, and the tiebreak then fell to the cuid id — which
is arbitrary, not chronological. A message and a fast reply could render in either order, and
differently on each read. The AI channel made this routine rather than theoretical: a generated answer
routinely lands in the same millisecond as the message it answers. listEntries orders by
createdAt ASC, seq ASC, so seq settles the tie honestly.