Skip to main content

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