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).
Worked sequence: opt a contact out, then read the list back
The run-through every integration does first: create an opt-out withPOST /contacts/optouts, then confirm it shows up in the cursor-paginated GET /contacts/optouts list. The create call returns the full {data, meta} envelope — data.already_opted_out tells you whether this was a genuine create (false) or an idempotent re-apply of an opt-out the contact already had (true).
1. Create the opt-out
POST /api/v1/contacts/optouts200 OK with already_opted_out: true in data — no duplicate audit entry, no second webhook fire:
2. Read the opt-outs list back
GET /api/v1/contacts/optoutspagination.cursor is present, pass it back as ?cursor=<value> on the next call to page forward. The opted-out contact you created in step 1 appears here on its opted-out channels.
Privacy note: opt-out records are tenant-owned consent data. Write the most specific reason you have (the inbound STOP keyword, the unsubscribe link, the support ticket) — that string is what your compliance export and audit trail carry.
Using the SDKs
Prefer the genericorbit.request() escape hatch here (the typed orbit.contacts.* helpers cover contact CRUD and segments; the opt-out surface uses the same envelope anyway). Create an opt-out with a real body and read the idempotent re-apply flag back — then list the tenant’s opt-outs through the same envelope:
The create-and-list round trip above is already sampled inside the CodeGroup blocks of steps 1–2 in all six languages — through the SDK escape hatch in Node.js, plain requests in Python, and standard-library HTTP in Go, Ruby, and PHP, so the samples run without installing an SDK for the opt-out surface.
The Python SDK is core-scope — it wraps the 8 core resources (messaging, voice, contacts, campaigns, verify, numbers) and reaches the opt-out endpoints through the generic client.request() escape hatch. 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’schannel_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/optoutscontacts:read.
Cursor-paginated list of all opt-outs in the tenant.
Query parameters
cURL
Create opt-out
POST /api/v1/contacts/optoutscontacts: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
channel_preferences, plus an already_opted_out flag (false on a genuine create, true on an idempotent re-apply).
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/bulkcontacts: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.rows array (each { contact, channel, reason } — one row of your CSV), so the samples above send a JSON body rather than a multipart upload; the dashboard handles the file-to-rows parsing before it calls this endpoint. Each result 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}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
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/exportcontacts: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.
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
A validation failure returns the standard error envelope — a bad
channel value is the most common trigger:
- Privacy. Validation errors on opt-out writes never echo the
contactvalue back in the response — the identifier (a phone number or email) is consent data, and it is not smuggled into an error string your logs might ship elsewhere. Fix the field the message names. - Idempotency. A
422means nothing was written — no partial opt-out, no audit entry. Retrying the corrected body is always safe, and retrying an already-opted-out contact is also safe (that path returns200 OKwithalready_opted_out: true, never an error).
Audit + observability
Every state change writes an audit log:contact.opted_outonPOST /optoutsand per row onPOST /optouts/bulk.contact.opted_inonDELETE /optouts/:id/:channel.
{ 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.