Skip to main content

Team Chat API

Team Chat is internal, employee-to-employee messaging inside your Orbit workspace — channels, threaded replies, huddles (ad-hoc voice/video rooms), and direct messages between operators. It is entirely separate from customer-facing conversations: nothing here reaches a customer, and no outbound customer voice or SMS is touched by any endpoint on this page. Base path: /api/v1/team-chat Authentication: API key (X-API-Key) or session JWT. Posting or reading a channel requires channel membership; managing membership requires an owner or admin role on that channel. DM endpoints require the caller to be one of the two participants.

Using the SDKs

Prefer the typed client, but this page’s endpoint has no helper yet — the generic request() keeps auth/retries and the { data, meta } envelope identical:
The Python SDK wraps none of this page’s surface either — its client.request escape hatch lists your channels the same way (source-only; vendor it from packages/sdk-python):
Raw curl in the body of this page works identically. Full SDK index at SDK quickstart.

Channels

Channel messages

GET /api/v1/team-chat/search ranks message bodies by relevance and paginates with an opaque cursor. It only ever searches channels you’re a member of — membership is the authorization boundary, so a channel you were removed from drops out of results with no separate check.
The cursor is opaque — pass meta.pagination.cursor back verbatim as the cursor query param to fetch the next page, and do not parse or rebuild it. has_more: false (or a null cursor) means you have seen the full result set. Results are ordered by rank, then recency.

Huddles

A huddle is an ad-hoc, low-friction voice/video room scoped to a channel — anyone in the channel can start one or drop into the live one.

Presence

Every seat on the dashboard posts a presence heartbeat roughly every 25 seconds; the snapshot endpoint is what the member lists and DM rail render from. Presence is schedule-agnostic — it reports who is around right now, with no calendar or availability logic.

Direct messages

Members, threads, DMs, and huddles

Samples below pin the response envelope; every sample id, body, and timestamp on this page is an illustrative placeholder, not a value to send. data carries the payload shown; every response also carries meta.request_id (echo it back to support — it is the fastest cross-reference) and meta.timestamp.

Add a member

POST /api/v1/team-chat/channels/{channelId}/members with { "user_id": "<workspace user id>" } adds one member. Only the channel’s owner or an admin can add or remove members; a caller without that role gets 403 NOT_A_MEMBER. Response 201:
201
Re-adding an existing member is idempotent and returns the same shape with the refreshed role and joined timestamp.

Reply in a thread

POST /api/v1/team-chat/channels/{channelId}/messages/{messageId}/thread posts into the thread rooted at {messageId}. Threads are one level deep: if {messageId} is itself a reply, the new reply still points at that reply’s root, so parent_message_id always names the root message. Response 201:
201
GET on the same path returns the thread in reading order as { "root": <message>, "replies": [<message>...] }; a soft-deleted root renders root: null with the surviving replies.

List DM threads

GET /api/v1/team-chat/dms returns the threads you participate in, most-recent-message first. Response 200:
200
Each row is a thread id plus the counterparty’s hydrated identity (name and avatar fall back to null — render the id tail then), their last inbound message preview (last_body, capped at 140 characters), and your capped unread_count. DM endpoints reject callers who are not one of the two participants.

Start or join a huddle

POST /api/v1/team-chat/channels/{channelId}/huddle starts the huddle (or rejoins the one already running) and returns a join hint to pass to your WebRTC client — this is an internal operator voice/video plane; nothing on this page exercises outbound customer voice or SMS. Response 201 (first start) or 200 (joining a live huddle):
201
started: true means you opened the room; started: false means you joined it. Present token around media_url as the client join hint — the server pre-creates the room with an empty-timeout so an abandoned huddle reclaims itself, and POST /huddle/leave ends it when the room empties.

Example — create a channel and post a message

Channel objects carry id, name, description, created_by_user_id, created_at, updated_at, plus my_role — your role on the channel, owner for its creator. Message objects carry id, channel_id, sender_user_id, body, mentions, attachments, created_at, and edited_at; a top-level message has parent_message_id: null (replies point at their thread root). The workspace user id you see as created_by_user_id / sender_user_id qualifies as an owner_id / author_id wherever a client types that field — only members yield ids on this surface (403 NOT_A_MEMBER otherwise).

Walked mini-recipe — create, post, edit, and search

The four-step miniature below recreates a create channel → post → edit own message → search it flow end to end; drop the drafts into the Task index in API recipes. Shape it as copyable steps and keep every request id so a page hop across the four calls stays traceable. Create the channel — capture data.id (tch_…) for the two follows:
Post the original message — capture data.id (the message id):
Edit own message — only the author can PATCH; the sender-scoped WHERE clause is the authorization boundary, so another member gets 404 NOT_FOUND:
Search it — your membership on that channel is the only gate:
Each hit sits directly in data (a flat array, data.results if your client normalizes it) in rank-descending order, and meta.pagination.cursor carries forward the next-page token. Reuse the cursor verbatim — parsing or rebuilding it voids the page; an unparsed/null cursor always starts at rank 1.

Worked samples by verb

The recipe above covers create → post → edit → search. The samples below close the remaining per-verb gaps: channel-message listing, DM send + read receipts, and the presence loop every seat runs.

Read a channel’s message feed

GET /api/v1/team-chat/channels/{channelId}/messages?limit={N} returns the channel’s thread roots newest-first (limit defaults to 50, max 200) — replies never flood the main feed; each root carries reply_count + last_reply_at, and a deleted root renders root: null. Membership is checked before any message row leaves the server; a non-member gets 403 NOT_A_MEMBER.
Every message row is hydrated with the sender’s sender_name + sender_avatar_url; when those resolve to null (a system message or a deleted user), fall back to rendering the sender_user_id tail.

Edit, then re-check one message

PATCH /api/v1/team-chat/channels/{channelId}/messages/{messageId} updates a message body in place — only the author can edit, and the presenter-scoped ownership check is enforced in the update itself, so another member gets 404 NOT_FOUND. Edited messages expose edited_at in every subsequent read. The mini-recipe above shows the call and its { "body": "<new text>" } payload verbatim; the updated row returns 200 with the same message shape shown in the feed sample. A soft-deleted message keeps its replies readable (root: null on the thread GET), so an edit loop that sees 404 is hitting a lifecycle state, not a membership failure.

Send a DM and advance read receipts

DMs are a two-party thread: open the thread once, then post messages and advance read markers scoped to that thread. Opening reverses the canonical user pair (get-or-create) and the history/membership gates reject non-participants.
The read-receipt call is fire-and-forget and designed to be re-issued: a transient failure degrades to a best-effort no-op and the dashboard re-posts it on the next open. Unread badges cap at 99+. Both channel and DM threads use the same read-cursor calling pattern (POST .../read on a channel is POST /team-chat/channels/{channelId}/read).

Run the presence loop

A member appears online once it heartbeats; each heartbeat refreshes a short TTL so a closed laptop drops off the snapshot without an explicit DELETE. Post status: "away" to keep the seat listed but marked away.
A snapshot row carries user_id, status (online / away / offline), online, and last_seen_at — explicit user_ids return offline callers’ last-seen (null when no heartbeat ever landed). Both snapshot and heartbeat are degrade-open by design: an empty online list answers a snapshot whose presence backend is unavailable, and heartbeats ride an availability-aware degrade that resurfaces on the next poll. status accepts online or away; anything else is a 422 at the schema gate.
Presence and typing indicators are ephemeral realtime hints broadcast over the tenant’s dashboard event stream — typing decays without a persisted read, and the membership/party gate still authorizes the target channel or thread. They power the presence rail, DM thread badges, and huddle-join hints; nothing here touches message history or the customer-facing surfaces.

See also

  • On-Call API — a common pairing: page a rotation, then coordinate the incident in a team-chat channel
  • Inbox API — the customer-facing conversation surface this is intentionally separate from