Interactions: the unified cross-channel read model
An interaction is Orbit’s unified read unit: one row that represents either a messaging conversation or a voice call, normalized onto a shared shape so both can live in a single list. The Interactions surface (GET /api/v1/interactions) exists because an operator asking “what has this customer touched?” was previously paging two separate surfaces — Conversations for messaging, Call logs for voice — and merging the timelines by hand. This page names the model that endpoint operates on: what an interaction row is, why one list replaces two, the cursor contract the list paginates on, and why the export gate is deliberately stricter than the search gate.
What an interaction is
An interaction row is a derived projection, not a stored entity. Nointeractions table exists; the surface reads the tenant’s conversations and call_logs tables and reshapes each row into one common record:
type—conversationorcall; the discriminator that tells the two arms apart.channel— the messaging channel on conversation rows (sms,whatsapp,email,rcs, …); calls are synthesized withchannel: "voice"so the channel filter can address both arms.contact_*fields — resolved through the linked contact when one exists.last_activity_at— the ordering key: a conversation’s latest message time (falling back to its creation time), a call’s start-and-end progression.href— the dashboard deep-link back to the native surface: the inbox thread for a conversation, the call detail for a call.
The unified model: one list over two surfaces
Before Interactions, the two surfaces answered different slices of the same question. Conversations held every messaging thread; call logs held every voice call. Neither could see the other, and an operator reconstructing a cross-channel history had to fetch both, then interleave two timelines client-side. The merge happens at read time as aUNION ALL over the two arms, projected onto the shared shape above and ordered as one list. Producers stay unchanged — rows land exactly as they always have:
- Messaging — inbound and outbound channel traffic writes to
conversations(with per-message delivery status living onmessages); the unified list derives a conversation row’s activity timestamp from its latest message. - Voice — the call lifecycle writes to
call_logsas a call starts, answers, and ends; a call row’s activity timestamp tracks that progression, and its direction (inbound/outbound) is call-scoped (a conversation has no single direction — it is a thread).
The cursor contract: composite keyset over (updatedAt, id)
Interactions follows the platform-wide cursor, not offset rule, with one refinement worth naming: the cursor is a composite keyset over(last_activity_at, id), ordered DESC.
Offset pagination is wrong for a live recency feed. The head of this list moves constantly — every new message and every voice call inserts above page one. An offset-based LIMIT n OFFSET k walks into that drift: rows inserted while you page shift every subsequent offset, so the same interaction appears on two pages (duplicates) or slides past the window between fetches (gaps). A keyset cursor instead remembers the exact (last_activity_at, id) of the last row you saw and asks for rows strictly after that pair — inserts above the cursor never disturb the walk.
The composite key matters because timestamps are not unique. Two messages landing in the same second produce two conversation rows with identical activity times; pagination on the timestamp alone would skip the second one. The id tiebreaker makes the ordering total, so every page boundary is exact and no row can be skipped or repeated.
Operational notes:
- Cursors are opaque — pass them back verbatim; their internals are not a contract.
- An expired, malformed, or tampered cursor degrades to the first page rather than erroring; search stays available.
- The dashboard Load more button and the API walk the same cursor chain, so behaviour is identical across both doors.
Permissions: an asymmetric pair of gates
Search and export read the same rows but carry deliberately different gates:
The asymmetry is the point: letting viewers search keeps the everyday “find this touchpoint” workflow open, while export — a mass egress of contact-linked data in one response — demands the elevated role plus the contacts scope, the same scope other mass contact-exports require. A viewer who selects Export CSV receives a friendly toast explaining the 403 rather than a raw error page; the search itself remains unaffected.
Export: the same filters, through the same gate
The CSV export accepts the same filter parameters as the search list and serializes the same unified row shape, so narrowing on screen then exporting is the intended flow — narrow first, export second. Server-side bounds cap each export to 20,000 rows; a truncated export answers with anX-Export-Truncated: true header so a client can detect the cut rather than silently parse a partial file. Every export is audit-logged with its row count and truncation flag, and the endpoint is rate-limited to 5 exports per minute so bulk egress stays bounded per tenant.
One redaction gate covers both doors: if your organization has opted into PII redaction under Settings → Privacy, the masked contact fields (contact_name, contact_phone, contact_email, preview) are masked identically on the search list and in the CSV. Redacting only the export would protect nothing — the same columns are readable on the lower-gated search surface. See the PII redaction model for how the opt-in posture works.
Scope of this page
This page names the model only. It deliberately stops where its siblings start:- Field-level schemas — the request/response contract, query parameters, and response fields live in the messaging API reference; they are not duplicated here.
- The operator workflow — filters, query syntax, troubleshooting, and the export walk-through live in the Interaction Search guide; read that for the how.
- Shared conventions — envelope shape, identifier prefixes, and the platform-wide cursor rule live in the data model concept.
Related reading
- Interaction Search guide — the operator workflow on top of this model.
- Conversations: the omnichannel thread model — the messaging arm of the union.
- Voice call lifecycle — how the voice arm’s rows are produced.
- Data model — the shared envelope and pagination conventions this surface borrows.
- PII redaction vault — the tenant-owned redaction posture both doors share.