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:
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_atbecome 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_atthat regressed (for instance on a row where the fallback usescreated_at, below) moved it down the ranking. The export and Load-More pages both resolve against the same ordering.
3. Type and channel discriminators mis-filter
The surface merges two arms:type = conversation— messaging threads (SMS/WhatsApp/email/RCS/…), withchannelset to the thread’s real channel id.type = call— voice call logs, synthesized sochannel == 'voice'.
- Filter
channel=voiceonly — that turns on the calls arm; there is novoicevalue 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_VALUEScarriesvoiceas a standalone token). - Filter
type=conversationpluschannel=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 usetype=call.
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_idnon-empty → name links to Audience → Contacts detail.contact_idempty (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.
5. Search returns nothing
Details start at filters, then at role:- 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. - The free-text box is prefix-anchored — the
qparameter 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. - 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.
- Unrecognised enum tokens are dropped, not rejected — a hand-typed
type=calllsimply matches zero rows instead of 422ing; check the exact token against the list above (alsovoicewithconversationmatches zero rows — see section 3).
meta.request_id from the response and open a ticket before any deeper debugging.
6. Last-activity ordering jumps
The sort key islast_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-stampslast_message_atmoves it up the ranking an entire import window later. - A call that runs until only
created_atis set (recording-only modes, or failed dials) falls back tocreated_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/untilwindow (orstatuses/contact_idfilter) resolves faster and stays below the cap — widening the range silently changes the fallback ordering or the sampled set.
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_idfrom 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.