Conversations: the omnichannel thread model
A conversation is Orbit’s omnichannel thread — the single object the inbox, the AI agent, and your integrations all agree on. This page names the model the API reference endpoints operate on: what a conversation is versus a message and a contact, why the thread id survives channel switches, what attaches to the conversation versus the channel, and how lifecycle statuses interact with new inbound.Conversation, message, contact
Three distinct objects:- A contact (
cnt_…) is the person the thread is with. Identity — phone numbers, email addresses, widget sessions — lives on the contact. - A message (
msg_…) is one turn in the thread, sent or received on one channel. - A conversation (
conv_…) is the thread that owns the messages.
channels array. The one current channel — which the next reply defaults to — is the conversation’s channel field. Routing and identity travel between thread and contact; ordering and history stay thread-scoped.
The thread id survives channel switches
Moving a thread between channels moves the reply pointer, never the thread.POST /conversations/{id}/resume-channel rebinds the conversation onto a new channel and returns where it came from plus every channel it has touched:
previous_channel— the channel the thread was on before the move (nullwhen there was none).channels— the union of every channel this thread has touched, including the new one.already_on_channel—truewhen the request repeated the current channel, making the endpoint idempotent.
Ownership attaches to the conversation
Assignees, queues, and teams bind to the thread, not to a channel. An assignment isPOST /conversations/{id}/assign on the conversation id; the handoff either places the thread into a team’s queue or targets an individual agent. Channel moves keep the assignee and queue untouched — a conversation assigned to a teammate stays assigned after resume-channel, and routing rules re-evaluate the same thread regardless of which channel arrived last.
The AI-agent session carries across channels
When an AI agent is attached, it attaches to the conversation, not the channel. The session-check endpointGET /conversations/{id}/agent/active reports { agent_active, agent_id }; the resume-channel response echoes both so a caller can confirm the session moved with the thread. Because the thread is one object, the agent’s memory and context travel with it — cross-channel continuation does not restart the agent.
Channel-scoped versus conversation-scoped
The move boundary is explicit, and it is the same boundary the API exposes:
Outbound delivery still picks the channel per message — the
channel field is only the default. Nothing else about the thread re-scopes when the default moves.
Programmatic threading: conversation_id
By default, every outbound send threads itself: the channel send endpoints (POST /messages/sms, /messages/whatsapp, /messages/email, and the other /messages/* routes) run an implicit recipient-based auto-upsert that matches the send onto the contact’s existing open conversation for that channel, or creates one when none exists. One active thread per (channel, recipient) pair — that is why the thread model above “just works” without any id on the send.
When the auto-upsert ambiguity bites — a contact with multiple open conversations, or a multi-channel contact whose reply must land on an exact thread — pass conversation_id with the id of an existing conv_… conversation on the send body:
conversation_id that does not exist in your tenant is rejected with a 404, and one that exists but addresses a different recipient or channel than the send is rejected with a 422 — the message is never silently re-threaded onto a foreign conversation. Omitting the field preserves the default auto-upsert behavior exactly.
Passing conversation_id matters in three situations:
- A contact has more than one open conversation and the reply must land on the intended thread, not whichever open thread the recipient match finds first.
- A contact is reachable on several channels at once, and the send must continue a specific thread rather than the current default one.
- Your integration stores its own thread mapping and wants deterministic landing instead of re-deriving it from auto-upsert rules.
GET /conversations, GET /conversations/{id}, or the conversation_id filter on GET /messages once a thread exists.
Lifecycle statuses and new inbound
A conversation moves through a fixed status set:closed conversations can be reopened but cannot be continued onto another channel — reopen first, then resume-channel. Closed, archived, and snoozed threads reject continuation with a validation error rather than silently succeeding. resolved is the terminal sibling of closed and the two are interchangeable on read; treat both as “not live.” New inbound on a live-channel thread re-keys against the contact’s open conversation; terminal threads stay closed until POST /conversations/{id}/reopen (or the snooze deadline) brings them back.
Scope of this page
This page names the model only. It does not walk through the channel-switch steps (that is the continue-conversation guide) and it does not document the request/response schemas per endpoint (that is the conversations API reference). Read those for the how and the contract; read this for the mental model both assume.Related reading
- Conversations API reference — every endpoint on the surface.
- Continue a conversation across channels — the operational walkthrough.
- Data model — envelope, id, and pagination conventions shared across endpoints.
- Tenant isolation — why thread ids never cross tenant boundaries.