Replying
InboxService.reply is the one sending path. Campaigns, flows, auto-responses, the AI bridge and the
HTTP route all go through it. That is deliberate: a separate campaign sender would have had to
re-derive the capability, re-resolve the adapter, re-check the window and re-do the optimistic-entry
dance, and the copy would drift the first time one of those rules changed on only one path.
curl -X POST "$INBOX_URL/v1/inbox/threads/$THREAD_ID/reply" \
-H "Authorization: Bearer $SESSION" \
-H "X-Axis-Tenant: ws_$GROUP_ID" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"message":"On its way — tracking follows shortly."}'
Returns 201 with the created InboxEntryDto.
The path, in order
1. ownThread. Loads the thread, loads its connection, asserts the connection belongs to this
tenant. A thread belonging to another tenant throws not_found, not forbidden — the endpoint must
not confirm that the id exists.
2. Capability. dm threads resolve the messages capability; comment threads resolve
comments. registry.resolveForConnection(tenantId, connectionId, capability) returns the adapter
plus its per-connection context. An adapter that turns out not to implement the surface throws
capability_unavailable.
3. assertMessagingWindow — DM threads only. The standard window is 24 hours since
lastInboundAt; past that it throws messaging_window_expired (422). A thread with no inbound at
all passes: first contact is the adapter's call, not this check's.
4. Rate limit. 200 sends per hour, per (connection, capability).
5. Response time. Computed as now − firstUnansweredInboundAt, capped by
INBOX_RESPONSE_TIME_CAP_SECONDS (default 86400). Over the cap, nothing is stored — a reply a day
late is not a "response" — though the turn still closes. Skipped entirely when campaignId is
set: a bulk send is not a reply to anything the recipient said.
6. The optimistic write. An entry is stored before the provider call, with
externalEntryId = "local:<uuid>" and deliveryStatus: 'pending'. This is what makes a send visible
in the UI immediately and what gives a failure something to mark.
7. The provider call, then reconciliation: updateEntry swaps in the real externalEntryId and
deliveryStatus: 'sent'.
8. touchActivity sets lastOutboundAt and closes the response turn — unless this was a
campaign send.
9. Publish thread.reply on the realtime bus, unless silent.
On a throw anywhere in steps 7–9, the optimistic entry is updated to deliveryStatus: 'failed' and
the error is rethrown. The failed entry stays: a send that did not land is a fact worth keeping.
The two options
| Option | Effect |
|---|---|
silent |
Suppresses the thread.reply publish, and nothing else. Ownership, window, rate limit, optimistic entry, delivery status and adapter dispatch all still run |
campaignId |
Stamps the entry with its campaign (passed to upsertEntry, never a second update, so the entry is never briefly unattributed) and skips response-time on both sides |
silent matters at scale: a 12,198-recipient campaign publishing one event per recipient means 12,198
websocket pushes and 12,198 outbound webhook deliveries for a single operator action. The campaign
publishes one completion event instead.
Sibling entry points
POST /v1/connections/:connectionId/send-template — a cold send to an address with no existing
thread (the digest WhatsApp path). It upserts a dm thread keyed on the address as
externalThreadId, then calls reply with providerTemplate. A brand-new thread has
lastInboundAt = null, so the window check passes, and a provider template is deliverable outside any
session window anyway. Sent silent: true so a fan-out does not spray events. Returns
{ sent, externalEntryId } with a 200.
Campaign sends call reply with both campaignId and silent. They do not close the response
turn — the customer's question is still unanswered and the next real reply must measure from it.
Flow sends and auto-responses go through reply like any other. A failure in either is logged
and swallowed rather than surfaced; an expired messaging window on an auto-response is expected, not a
fault.
LiveChat does not have an upstream to POST to — its adapter "delivers" by publishing to the realtime bus, which is what the widget is listening on.
The AI channel adapter generates rather than delivers. Its result carries a raw.body, and
reply replaces the optimistic body with what was actually sent — the caller never wrote that text.
Every other adapter leaves it undefined and the optimistic body stands.
Request body
ReplyRequest accepts message plus, where the channel supports them: subject, html,
plainText, attachmentUrl, attachmentType, providerTemplate, metadata, parentEntryId, and
quickReplies — up to ten { title, postback? } buttons rendered natively where supported (WhatsApp
today) and degraded to plain text elsewhere.
For a comment reply, parentEntryId names the comment being replied to; it defaults to the thread's
own externalThreadId (the root comment), which is what gives you FB/IG-style nesting.
Gotchas
messaging_window_expiredis a 422, not a retryable error. Retrying the same send will fail identically until the customer messages again. Send a provider template instead.- The optimistic entry survives a failure. If you list entries after a failed send you will see a
failedentry with alocal:external id. That is intended, not orphaned state. - A machine key stamps no
sentByUserId. The route records the sender only when the caller is a user session — even a supervisor replying on someone else's ticket. If your reporting needs "who answered", the BFF must use sessions. - Reply requires an
Idempotency-Key. It is a@DataWrite()route.