Skip to main content

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.
The model is thread-first: when you talk to a contact on more than one channel, all of those messages belong to one conversation. A channel is a label on each message, not a separate thread — the conversation owns every channel the thread has ever touched in its 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.
SMS and email messages sit in the same thread; the contact’s phone and email identities both live on the contact.

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 (null when there was none).
  • channels — the union of every channel this thread has touched, including the new one.
  • already_on_channeltrue when the request repeated the current channel, making the endpoint idempotent.
The how-to walkthrough is the continue-conversation guide; this page names the model that guide operates.

Ownership attaches to the conversation

Assignees, queues, and teams bind to the thread, not to a channel. An assignment is POST /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 endpoint GET /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:
The id points at an existing conversation. Orbit validates it before stamping the thread onto the message: a 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.
Find the id on any conversation read — 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.