Threads and entries

A thread is one conversation on one connection. An entry is one message, comment or reply within it. Everything else in the conversation domain — tickets, labels, notes, analysis, routing — hangs off a thread id.

Threads are never created directly

There is no create-thread endpoint. upsertThread is a store call, reached from ingest() and from the internal cold-send paths that reuse it — sendTemplate, campaign fan-out, the flow cross-channel sender, the LiveChat handshake and the historical import. A thread exists because a message arrived or because something sent one; it is never a resource you POST into being.

The key is the unique constraint @@unique([connectionId, threadType, externalThreadId]). The same external conversation id on two different connections is two threads, which is what you want — the connection is what carries the credentials to reply.

Shape

InboxThread fields you will actually read:

Field Notes
threadType dm | comment | review
status open | closed. Kept in step with the linked ticket — closing a ticket flips the thread too
unreadCount Team-wide counter
unreadForViewer Per-viewer unread, derived from that user's latest read event. Present only when a user id is in scope
lastInboundAt / lastOutboundAt Provider event times, not ingest times
firstUnansweredInboundAt Opens the response turn; set-if-null on inbound, cleared by any non-campaign outbound
contactId / contactName The resolved person. contactName is what a list actually renders — participants[].name is empty on webhook-created threads
handler / takenOver / takenOverAt Routing authority — see AI and human routing
priority low | normal | high | urgent
summary / summaryGeneratedAt Written by thread analysis

InboxEntry:

Field Notes
entryType message | comment | reply | postback | system
direction inbound | outbound
deliveryStatus pending | sent | delivered | read | failed
externalEntryId Unique per thread. Optimistic outbound entries carry local:<uuid> until reconciled
seq Postgres sequence; the tiebreak that makes ordering deterministic
sentByUserId / sentByAi Who answered. Absent for machine keys and flow sends
responseTimeSeconds Only on the first outbound that answers an open turn
campaignId Set when the entry was a bulk send, so a blast is visible in the conversation it belongs to
attachments [{ url, type? }]
commentStatus / isHidden / isLiked / canReply FB/IG comment entries only

Ordering

Entries come back oldest-first, ordered by createdAt ASC, seq ASC. Do not sort on createdAt alone in your client — it is millisecond-precision and ties are common on AI threads. Do not sort on id; cuids are not chronological.

Listing and reading

curl "$INBOX_URL/v1/inbox/threads?type=dm&status=open&limit=25" \
  -H "Authorization: Bearer $SESSION" \
  -H "X-Axis-Tenant: ws_$GROUP_ID"

Filters: type, connectionId, status, unreadOnly, labelId, priority, assignedUserId, unassigned, contactId, q, plus limit and cursor. labelId, assignedUserId and unassigned are joins over other modules' tables, resolved to thread-id sets and intersected — an empty intersection is a real "no results", never a skipped filter. assignedUserId resolves through the thread's open ticket, and wins over unassigned if both are set.

The list hides message-less threads, with one exception: LiveChat connections are always visible, because their threads exist from the widget handshake, before the visitor has typed, and a waiting visitor must not be invisible.

curl "$INBOX_URL/v1/inbox/threads/$THREAD_ID" \
  -H "Authorization: Bearer $SESSION" \
  -H "X-Axis-Tenant: ws_$GROUP_ID"

Reading a thread returns its entries and participants, zeroes unreadCount, and — when the caller is a user session — records that user's read watermark so the thread reads as read for them while staying unread for teammates. A machine key skips the per-viewer path entirely.

Comment threads and the virtual post id

Comments are stored one thread per root comment, but a person moderating them thinks in posts. So comment threads collapse into a virtual per-post thread whose id is:

comment-post:{connectionId}:{externalPostId}

Feed that id straight to GET /v1/inbox/threads/:threadId — it is parsed by stripping the prefix and splitting on the first colon only, because either component may itself contain colons. The whole comment write surface (reply, hide, like, delete, private_reply) is keyed on the same id, which is why there is deliberately no GET /v1/inbox/comments/:postId.

curl "$INBOX_URL/v1/inbox/comments?connectionId=$CONN" \
  -H "Authorization: Bearer $SESSION" -H "X-Axis-Tenant: ws_$GROUP_ID"

GET /v1/inbox/comments returns CommentedPostDto rows, each carrying that virtual threadId. It reads stored threads by default. ?refresh=true merges the adapter's authoritative view (captions, permalinks, upstream counts) at the cost of one upstream call per connected account — a workspace with eight pages spends eight Graph calls on a screen that polls, which is why it is opt-in.

Note that the two comment counts differ on purpose: the adapter's count is the post's total including comments you cannot act on; the stored count is what is actually in your inbox.

Per-user interactions need a user

These four routes call requireUserId(auth) and reject a machine key rather than silently no-op'ing, because a read receipt or a reaction with no actor is meaningless:

Route Effect
POST /v1/inbox/threads/:threadId/read Records this user's read watermark; also zeroes the legacy global counter
POST /v1/inbox/threads/:threadId/entries/:entryId/reaction Adds an emoji reaction. Idempotent on (entry, user, value)
DELETE /v1/inbox/threads/:threadId/entries/:entryId/reaction Removes one
POST /v1/inbox/threads/:threadId/entries/:entryId/like Sets or clears this user's like

All four are stored as InboxThreadEvent rows, which is also where per-viewer unread is derived from. If your integration uses a sk_ service key, these will fail — use an Accounts user session.

POST /v1/inbox/threads/:threadId/status takes { status } and is team-wide, so it works with either credential.

Related