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

Related