> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Interactions: the unified cross-channel read model

> How Orbit's Interactions surface models one recency-ordered list over every messaging conversation and every voice call: the UNION of two previously separate surfaces, the composite (updatedAt, id) keyset cursor, the asymmetric search-vs-export permission gates, and where this page's model hands off to the guide and the API reference.

# 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:

* `type` — `conversation` 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.

```mermaid theme={null}
graph LR
  subgraph before["Before: two surfaces"]
    C1[Conversations list<br/>messaging only]
    V1[Call logs<br/>voice only]
    OP1[Operator merges<br/>timelines by hand]
    C1 --> OP1
    V1 --> OP1
  end
  subgraph after["Unified: one read model"]
    C2[conversations]
    V2[call_logs]
    U[Interactions<br/>UNION ALL, shared row shape]
    OP2[One recency-ordered,<br/>filterable, exportable list]
    C2 --> U
    V2 --> U
    U --> OP2
  end
```

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](/concepts/data-model#pagination-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:

| Door                                        | Who can use it                                             | Why that gate                                                                                                                                                            |
| ------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Search (`GET /interactions`)                | Every role — owner, admin, developer, **viewer**           | It returns the same rows the conversations list and call log already expose to those roles individually, so it inherits the standard authenticated-session read posture. |
| CSV export (`GET /interactions/export.csv`) | owner, admin, developer **plus the `contacts:read` scope** | A bulk, filterable, org-wide pull of contact-linked history has a far larger blast radius than a single-page view, so the aggregate-read gate is tighter.                |

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](/concepts/redaction-vault) 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](/guides/interaction-search); read that for the how.
* **Shared conventions** — envelope shape, identifier prefixes, and the platform-wide cursor rule live in the [data model](/concepts/data-model) concept.

## Related reading

* [Interaction Search guide](/guides/interaction-search) — the operator workflow on top of this model.
* [Conversations: the omnichannel thread model](/concepts/conversations) — the messaging arm of the union.
* [Voice call lifecycle](/concepts/voice-call-lifecycle) — how the voice arm's rows are produced.
* [Data model](/concepts/data-model) — the shared envelope and pagination conventions this surface borrows.
* [PII redaction vault](/concepts/redaction-vault) — the tenant-owned redaction posture both doors share.
