> ## 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 CSV export: the bulk-pull contract and its RBAC split

> How the Interactions CSV export works as a synchronous bulk pull: the deliberately harder role gate than the search list, the owner/admin/developer + contacts:read matrix, the 20,000-row cap, the composite (last_activity, id) cursor the export shares with the list, and the data-handling rules for the file you download.

# Interactions CSV export: the bulk-pull contract and its RBAC split

The Interactions export endpoint (`GET /interactions/export.csv`) is the bulk-extraction counterpart to the unified search list (`GET /interactions`). It downloads the same unified row set — every messaging conversation and every voice call, filtered the same way — serialized as CSV in a single synchronous response.

This page names the export contract: what it pulls, why its gate is harder than the list's, the exact role + scope matrix, the 20,000-row cap, the cursor contract it shares with the list, the sync-vs-async decision, and the custodial rules for the downloaded file. It is the concept sibling to the [Interaction Search guide](/guides/interaction-search), which walks the operator workflow end to end.

## What the export does

The export is a **bulk pull of contact-linked interaction history**, filterable by the same parameters as the search list. An operator filtering to one contact, one channel, or one date window in the dashboard, then selecting **Export CSV**, receives the filtered set as a downloadable file rather than a paginated view.

The response is an `attachment` CSV with one header row and twelve columns, in order:

`id`, `type`, `channel`, `status`, `delivery_status`, `contact_id`, `contact_name`, `contact_phone`, `contact_email`, `direction`, `preview`, `last_activity_at`

The server shapes the file as `<stem>-YYYY-MM-DD.csv`; an optional `filename` query parameter sets the stem (sanitised to `[A-Za-z0-9._-]`, capped at 80 characters, default `interactions`). Response headers set `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`, so a bulk pull is never cached by an intermediary.

## Why the export is gated harder than the list

The search list returns the same rows a caller can already read page-by-page through the conversations list and call log, so it accepts every authenticated session. The export is different in kind, not in degree: it is a mass, filterable, one-response egress of contact-linked interaction history across the whole organization.

That asymmetry is deliberate. Bulk extraction of contact-linked PII is a data-loss risk that per-page viewing is not, so the export gate requires an elevated role (and, for API-key callers, a contacts scope) where the list requires neither. If your organization has opted into PII redaction under **Settings → Privacy**, the masked fields (`contact_name`, `contact_phone`, `contact_email`, `preview`) are masked identically on both doors — the one redaction gate funnels both paths, because masking only the export while the list stayed open would protect nothing.

## Role + scope matrix

Two caller paths reach the export, and both must satisfy the gate:

| Caller path       | Requirement on the export                                        | Why the list behaves differently                     |
| ----------------- | ---------------------------------------------------------------- | ---------------------------------------------------- |
| Dashboard session | Your session role must be **owner**, **admin**, or **developer** | The list accepts every role, including viewer/member |
| API key           | The key must carry the **contacts:read** scope                   | The list needs no specific scope                     |

For API-key callers the scope check substitutes for the role check: the export insists on `contacts:read` exactly because bulk egress of contact-linked data runs through the contacts permission family — a key holding only `conversations:read` still reads the search list but 403s the export.

**Dashboard example.** A viewer selects **Export CSV** in the page header; the API answers `403 INSUFFICIENT_PERMISSIONS` and the dashboard shows a friendly toast instead of a failed download. An owner (or admin/developer) running the same click receives the file. Grant the role at the organization level — no workaround exists.

**API-key example.** Re-issue the key including `contacts:read`, then call:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/interactions/export.csv?channels=whatsapp&since=2026-08-01T00:00:00Z" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -o interactions.csv
```

A `403 INSUFFICIENT_PERMISSIONS` body names the missing role or scope outright; the runbook at [Interactions list: CSV export](/troubleshooting/interactions-list.csv-export) spells out the exact error envelope.

The export family enforces this gate uniformly — the same role-plus-scope pair fronts the other bulk contact exports, so `contacts:read` grants (or withholds) them together.

## The 20,000-row cap

Each export is capped at **20,000 rows per response**, default and maximum. The server fetches one row past the cap to detect truncation without a second count query; when your filtered set exceeds the cap, the CSV is cut at 20,000 rows and the response carries an `X-Export-Truncated: true` header so a client detects the cut rather than silently parsing a partial file.

Stay below the cap by narrowing the existing filters — all of them apply to the export exactly as they do to the list:

* **Date window** — `since` / `until` bound the last-activity timestamp; export per week or per month instead of all time.
* **Channel or type** — `channels=whatsapp` or `types=call` splits one oversized pull into per-channel or per-type files.
* **Contact** — `contact_id` restricts to one contact for archival of a single relationship.
* **Status** — `statuses` trims terminal states you do not need to archive.
* **`limit` below the cap** — request fewer rows explicitly when a smaller slice is the goal.

Every export is **audit-logged** with its row count, the truncation flag, and the applied cap, and the endpoint is rate-limited to **5 exports per minute** so bulk egress stays bounded per tenant. Pagination-related quirks on the list (duplicated or skipped rows under live traffic) live in the runbook; the export is unaffected by them because it reads a single bounded snapshot rather than walking pages.

## The composite cursor contract, and what the export does with it

The list paginates on a **composite keyset cursor over `(last_activity_at, id)`, ordered descending** — the tie-breaking `id` makes the ordering total, so a page boundary never skips or repeats a row sharing a timestamp. The full reasoning lives in [the unified model](/concepts/interactions-unified-model#the-cursor-contract-composite-keyset-over-updatedat-id).

The export consumes that same contract differently: it **requests the first page with no cursor** and a `limit` up to the 20,000-row cap, reading one bounded snapshot ordered by the same composite key. It never chains cursors — when the set fits under the cap, one snapshot serializes the whole result. When you are exporting via the search list, walk the cursor chain page by page (`has_more` tells you when to stop) and recognize that the conversation-to-CSV export is the bounded alternative the gate exists for.

## Async vs sync

The export is **synchronous**: the request holds until the CSV body returns. Unlike scheduled exports elsewhere on the platform (see [the async processing model](/concepts/async-processing-model)), no `202 job_id` path exists here — there is no queued webhook event to watch for. Bound your filter set to something that resolves within a normal request window; the 20,000-row cap plus the 5-per-minute rate limit are the throughput budget, and narrowing filters is the only correct response to a slow export.

## Data-handling rules for exported CSVs

The downloaded file leaves the platform's custody. The CSV contains **contact-linked PII** — names, phone numbers, email addresses, and message previews — and Orbit treats the export as tenant-owned data in the tenant's hands from the moment the response completes.

Practical obligations that follow:

* **Custody and retention are yours** — store the file where your data-handling policy permits, and delete it on your schedule; the platform neither retains the export nor tracks the copy.
* **Redaction exports the masked view** — if your organization opted into PII redaction, the masked form is what the file carries; treat even masked exports as personal data.
* **The audit ledger records the pull** — the audit entry (row count, truncation, cap) is the org-level record of who extracted what; the file itself is your artifact to govern.
* **Downstream sharing** — hand exports to auditors or archives under your own data-processing terms; the endpoint does not watermark or revoke a distributed file.

## Related reading

* [Interaction Search guide](/guides/interaction-search) — the operator workflow on top of this contract.
* [Unified interaction model](/concepts/interactions-unified-model) — the projection both endpoints read.
* [Export families model](/concepts/export-families-model) — where this export sits among the platform's bulk-export surfaces.
* [Troubleshooting: Interactions search export 403s and pagination](/troubleshooting/interactions-list.csv-export) — the runbook for the flat-403 and pagination-oddity failures.
* [PII redaction vault](/concepts/redaction-vault) — the redaction gate both doors funnel through.
