Skip to main content

Opt-Outs API

Per-contact, per-channel opt-out records. When a contact opts out of a channel, future outbound sends on that channel for that contact are blocked at the compliance gate — across every campaign, flow, and direct-send path in the tenant. This API powers the dashboard’s bulk-CSV import wizard (one batched request replaces what used to be 50 000 sequential round-trips) and the export flow for compliance evidence. Base path: /api/v1/contacts/optouts Authentication: Clerk session (Authorization: Bearer <token>) or API key (X-API-Key). Scopes: contacts:read for GET, contacts:write for everything else (with role gate owner / admin / developer).

Using the SDKs

Prefer the typed client, but this page’s endpoint has no helper yet — the generic request() keeps auth/retries and the { data, meta } envelope identical:
Python (same call via the SDK’s escape hatch):
The Python SDK is core-scope — it wraps the 8 core resources (messaging, voice, contacts, campaigns, verify, numbers) and reaches everything else through the generic client.request() escape hatch above. See the Python SDK. Raw curl in the body of this page works identically. Full SDK index at SDK quickstart.

Canonical channels

Opt-outs are scoped to one of the 9 canonical contact channels. These are the channels surfaced in the contact-facing UI (preferences, opt-outs, consent records). The full platform supports more channels (push, web_chat, agent, video, fax) which are NOT consumer-facing in the same way. Consent state is stored per-channel on the contact’s channel_preferences object as a boolean — channel_preferences[<channel>].opted_out. There is no separate timestamp or channel-array column on the contact row; a channel is opted out when its opted_out flag is true, and opted in (or never set) when the flag is absent or false.
The per-opt-out reason and the time each opt-out happened are recorded on the contact.opted_out / contact.opted_in audit-log entries (see Audit + observability), not on the contact row. Pull the audit log when you need a timestamped, reasoned trail for compliance evidence.
The channels above are validated via Zod enum — any value not in the list is rejected at the request boundary with 422 VALIDATION_ERROR. Bulk-CSV imports normalize channel values case-insensitively and trim surrounding whitespace before enum validation.

List opt-outs

GET /api/v1/contacts/optouts
Scope: contacts:read. Cursor-paginated list of all opt-outs in the tenant. Query parameters
cURL
200 OK

Create opt-out

POST /api/v1/contacts/optouts
Scope: contacts:write. Role: owner / admin / developer. Opts a single contact out of a single channel by setting channel_preferences[<channel>].opted_out to true. Idempotent — re-applying an opt-out the contact already has returns 200 OK (instead of 201 Created) with already_opted_out: true, and does not write a duplicate audit entry or re-fire the webhook.
string
required
Contact identifier — either an E.164 phone (+14155552671) or a normalized email address. The platform resolves it to an existing contacts row or creates a stub row scoped to the tenant.
enum
required
One of the 9 canonical channels listed above.
string
required
Why the contact opted out — surfaced in audit logs and compliance exports. 1–500 chars.
cURL
201 Created Returns the updated contact row, including the merged channel_preferences, plus an already_opted_out flag (false on a genuine create, true on an idempotent re-apply).
Also writes a contact.opted_out audit log with the channel + reason — that entry is where the reason and timestamp for this specific opt-out are recorded.

Bulk-CSV opt-out

POST /api/v1/contacts/optouts/bulk
Scope: contacts:write. Role: owner / admin / developer. Batched opt-out for the dashboard’s CSV-import wizard. The pre-fix path issued one HTTP request per row — a 1 000-row file = 1 000 round-trips. This endpoint accepts up to 500 rows per call; the dashboard chunks larger files client-side so a 50 000-row CSV uploads in ~100 batches instead of 50 000. Per-row isolation: A malformed row does NOT roll back the batch — each row is attempted independently, and the response carries {succeeded, skipped, failed, results[]} so the dashboard can surface actionable per-row errors. Partial success is preferable to silent total-loss. Counts: total always equals succeeded + skipped + failed. A row is counted as skipped (not succeeded) when the contact was already opted out of that channel — the import is idempotent, so re-uploading the same CSV reports those rows as skipped and does not write a duplicate audit entry or re-fire the webhook. Reconcile against total rather than assuming succeeded + failed === total.
array
required
Array of { contact, channel, reason } objects. 1–500 entries per request. Each channel is normalized (lowercased + trimmed) before enum validation.
cURL
207 Multi-Status
Each row carries a status — one of succeeded (newly opted out), skipped (already opted out; deduplicated), or failed. The ok boolean is retained for backwards compatibility: it is true for both succeeded and skipped, and false for failed. A failed row also carries an error message string.
Channel normalization. "SMS", "sms ", and "Sms" all map to the canonical sms. A value that isn’t one of the 9 canonical channels (even after normalization) fails request validation — the whole request returns 422 VALIDATION_ERROR rather than failing that row alone. Per-row isolation applies to processing failures (such as a contact that doesn’t yet exist), not to channel validation, so clean the channel column before upload. The dashboard previously substituted sms as a fallback for unknown channel strings — that silent fallback is now reserved for explicit operator confirmation in the wizard’s “review” step rather than the API.

Re-opt-in (delete opt-out)

DELETE /api/v1/contacts/optouts/{id}/{channel}
Scope: contacts:write. Role: owner / admin / developer. Removes the opt-out record for one channel on one contact. The contact becomes eligible for outbound sends on that channel again, subject to consent records — re-opt-in does not by itself create the affirmative consent some channels require (e.g. WhatsApp 24h window, RCS marketing). Path parameters:
  • id — the contact id (con_*)
  • channel — one of the 9 canonical channels
cURL
200 OK — returns the updated contact row. Also writes a contact.opted_in audit log with the channel.
Platform-scope only. This endpoint updates consent within Orbit — the contact’s channel_preferences, the suppression list, and (if you’ve connected one) the CRM/marketing tools consent is propagated to. It does not reach into the upstream SMS carrier network. For a US/CA toll-free number, a carrier can apply its own opt-out block independently of Orbit’s records; re-opting in here does not clear that carrier-level block. If a contact re-consents outside a messaging channel (a web form, email, or preference center) and still can’t be reached on SMS after this call, the block may be held at the carrier and needs to be resolved through your toll-free verification / carrier support channel, not through this API.

Export opt-outs

POST /api/v1/contacts/optouts/export
Scope: contacts:write (export is treated as a write because it produces an artifact and is rate-limited as such). Returns up to 10 000 opted-out contacts as JSON. Use for compliance evidence (e.g. carrier registration review, FCC complaint response).
enum
Optional filter — one of the 9 canonical channels. Omit to export all opt-outs.
cURL
200 OK
For tenants with >10 000 opt-outs, use the paginated GET /optouts endpoint and iterate cursor.
The export returns current opt-out state per contact. For a timestamped, reasoned history of when each opt-out or opt-in occurred — the trail most compliance reviews ask for — read the audit log (contact.opted_out / contact.opted_in entries).

Errors


Audit + observability

Every state change writes an audit log:
  • contact.opted_out on POST /optouts and per row on POST /optouts/bulk.
  • contact.opted_in on DELETE /optouts/:id/:channel.
Each entry carries { channel, reason } (and bulk: true on bulk-imports). Per-row audit writes during a bulk import are best-effort — a single audit-write failure does NOT roll the batch back, but the warning is logged structurally.

Cross-channel STOP/START

A STOP reply received on one channel automatically fans out to a configured set of related channels via the inbound webhook handler (handleOptOut). See Compliance overview for the cross-channel fan-out matrix.

See also