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.