Skip to main content

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. No interactions table exists; the surface reads the tenant’s conversations and call_logs tables and reshapes each row into one common record:
  • typeconversation or call; the discriminator that tells the two arms apart.
  • channel — the messaging channel on conversation rows (sms, whatsapp, email, rcs, …); calls are synthesized with channel: "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.
Because a row is a projection, Interactions is read-only and writes nothing back into either source. Mutations still happen on the underlying conversation or call; the unified list reflects them on the next read.

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 a UNION 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 on messages); the unified list derives a conversation row’s activity timestamp from its latest message.
  • Voice — the call lifecycle writes to call_logs as 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).
Each arm resolves the linked contact its own way: conversations join through their contact pivot; calls resolve through the call’s own contact first and fall back to the pivot of a voice call bridged into an inbox conversation. The unified row is contact-normalized either way, so the free-text filter matches both arms against the same contact fields.

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 an X-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.