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

# Troubleshooting: Interactions search export 403s and pagination oddities

> Resolve the flat 403 a viewer gets on the CSV export button, duplicate or skipped rows across Load-More pages, and the filter quirks on the unified Interactions list — including the small-batch dupes a row moved high in the ranking can cause while you page.

# Troubleshooting: Interactions search, export, and pagination

The unified **Interactions** surface (`GET /interactions` and `GET /interactions/export.csv`) merges every messaging conversation and voice call into one recency-ordered, filterable list. This page covers the six failures operators hit on it: the export's flat 403 on viewer roles, duplicate/skipped rows while paging, `type`/`channel` discriminator quirks, a contact column that renders text but is not clickable, a list that returns nothing, and last-activity ordering that jumps when a stale thread re-fires.

## 1. The flat 403 on the Export CSV button

Export is deliberately asymmetric to the search list. Search (`GET /interactions`) reads the same rows you already see in the conversations list and call logs, so it only needs your authenticated session. Export (`GET /interactions/export.csv`) is a bulk, filterable pull of contact-linked interaction history — up to 20,000 rows in one response — so the API gates it to **owner, admin, or developer role** AND (for API-key callers) the `contacts:read` scope.

A member/viewer clicking the button gets an opaque error and the browser shows a failed download (`toast: "Export failed. Please try again."`). The API response itself is:

```json theme={null}
{
  "error": {
    "code": "INSUFFICIENT_PERMISSIONS",
    "message": "This action requires one of: owner, admin, developer",
    "status": 403
  },
  "meta": {
    "request_id": "req_01J9X4KZT8Q2M7W3N5VSP8DH6B",
    "timestamp": "2026-09-08T10:44:02.118Z"
  }
}
```

Resolution: have an org owner promote the operator to developer (or run the export yourself), or for API-key usage re-issue the key including `contacts:read`. The same gate applies across the whole export family — here is how it maps to each role, exactly:

| Role                      | Search list | CSV export                        |
| ------------------------- | ----------- | --------------------------------- |
| Owner / admin / developer | 200         | 200                               |
| Member / viewer           | 200         | **403 INSUFFICIENT\_PERMISSIONS** |

For API keys, the effective role is resolved from the key's scopes; a key that holds only `conversations:read` reads the search list but still 403s the export, because the export insists on `contacts:read` (the bulk egress of contact-linked PII runs through the contacts permission family).

## 2. Duplicate rows (or skipped rows) across pages

Pagination on Interactions is a keyset cursor over the composite `(last_activity DESC, id DESC)` — not a numbered page. Because the list streams while you read it and the underlying data is live, a row that was on page 1 can move onto page 2 which you then visit: you will see that row twice. The composite cursor remembers the timestamp+id you already saw, so it is specifically the ranking fallback of `last_message_at`/`ended_at` etc. that shifts a row, not the primary key.

If the list looks weird while paging:

* **Duplicate row** — a conversation had `last_message_at` become more recent (someone received a message, e.g. the skipped row explanation below) and it re-entered the top of the ranking while you were on page 2. Clear filters if you want a strict, stable snapshot.
* **Skipped row (a row vanished between when you saw it and when you exported)** — the same mechanism in reverse: a `last_message_at` that regressed (for instance on a row where the fallback uses `created_at`, below) moved it down the ranking. The export and Load-More pages both resolve against the same ordering.

Reset to page 1 by clearing filters/sort (the page does this automatically when a filter changes), or export a snapshot instead of paging through Load-More.

## 3. Type and channel discriminators mis-filter

The surface merges two arms:

* `type = conversation` — messaging threads (SMS/WhatsApp/email/RCS/…), with `channel` set to the thread's real channel id.
* `type = call` — voice call logs, synthesized so `channel == 'voice'`.

Two common operator surprises:

* **Filter `channel=voice` only** — that turns *on* the calls arm; there is no `voice` value stored in the conversations table, so the value is reserved to mean "include calls". It is not a mis-indexed messaging channel; the synthesized discriminator is deliberate (see the schema header: `INBOX_CHANNEL_VALUES` carries `voice` as a standalone token).
* **Filter `type=conversation` plus `channel=voice`** — returns nothing on purpose: the type gate applies first, so restricting to conversations while asking for the voice channel yields zero rows. Either drop the type filter or use `type=call`.

If a row appears under an unexpected channel, check it with `GET /conversations/:id` — the conversation arm reads `c.channel` straight off the row, so a channel written as null/blank at thread creation surfaces literally and will not match a channel chip.

## 4. Contact column renders text but the row is not clickable

Every row resolves a contact through a chain of fallbacks (`c.contact_id` FK, then the thread's address matched against `contacts.email`/`contacts.phone`, then for calls the conference participant ladder). The resulting **contact name/phone/email text** can therefore render even when the row's `contact_id` is NULL. The contact column only becomes a link when `contact_id` is present:

* `contact_id` non-empty → name links to Audience → Contacts detail.
* `contact_id` empty (e.g. a PSTN call whose caller survived as a bare phone, or a thread without a linked contact) → the text renders as plain text, and no deep link is attempted.

When operators report "the list shows a name but I cannot click through," this is the intended shape; merge the duplicate contact in **Audience → Identity resolution** or link the thread from the inbox, then the deep link appears.

## 5. Search returns nothing

Details start at filters, then at role:

1. **Filters match nothing** — the chips (`type`/`channel`/`status`/`since`/`until`) compose AND; dropping everything to All/7d rarely yields zero rows. Reset and see the list return.
2. **The free-text box is prefix-anchored** — the `q` parameter matches a contact name/phone/email with a prefix index, so a middle-of-name fragment silently matches zero. Enter the leading part of the name or phone.
3. **You can see the list but the export 403s** — member/viewer is expected to read the list but not export bulk. Have an owner/admin/developer perform the export; do not judge the data as absent when the gate is.
4. **Unrecognised enum tokens are dropped, not rejected** — a hand-typed `type=calll` simply matches zero rows instead of 422ing; check the exact token against the list above (also `voice` with `conversation` matches zero rows — see section 3).

If none of those apply, the endpoint itself is at fault — collect the `meta.request_id` from the response and open a ticket before any deeper debugging.

## 6. Last-activity ordering jumps

The sort key is `last_activity_at` (`COALESCE(last_message_at, created_at)` for conversations, `COALESCE(ended_at, answered_at, started_at, created_at)` for calls). That fallback matters under maintenance:

* A conversation row that was created without ever receiving a message falls back to its `created_at`, so an import or a reconcile that re-stamps `last_message_at` moves it up the ranking an entire import window later.
* A call that runs until only `created_at` is set (recording-only modes, or failed dials) falls back to `created_at`, so rows enter the ranking at their start time, not their end time; long active-ish calls appear arbitrarily deep in page space.
* Because the export caps at 20,000 rows, a narrow `since`/`until` window (or `statuses` / `contact_id` filter) resolves faster and stays below the cap — widening the range silently changes the fallback ordering or the sampled set.

For duplicate/skipped rows while you page, use section 2.

## Collect diagnostics before opening a ticket

When the page stays unexplained, include in the ticket:

* The filter set (`type`, `channel`, `status`, `q`, `since`/`until`) reproduced verbatim,
* The `meta.request_id` from the last search response,
* For exports, the role the caller holds (owner/admin/developer/member/viewer) and the API-key scopes when applicable,
* A page-2 Load-More URL including the cursor, so support can replay the keyset.
