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

# Choose your suppression entry point: CSV import, Consent API, or Preference Center

> Decide which of the three suppression inputs to wire — bulk CSV for a legacy backfill, the Consent API for live per-event grants and revocations, or the Preference Center for recipient self-service — choose the right channel scope on each, and read the ledger back for audit.

# Choose your suppression entry point

Three inputs write into the one suppression ledger: the **bulk CSV
import**, the **Consent API**, and the **Preference Center**. Every
send gate reads that single ledger regardless of which input wrote the
row, so a suppressed address is fenced identically no matter how the
opt-out arrived.

This guide teaches the decision between the three inputs — when to
pick each one, how to wire it — and the two disciplines that keep the
ledger clean: choosing the right **channel scope** (the one place the
three mirrors can disagree at the gate) and reading the ledger back
for audit.

<Note>
  All three entry points are **tenant-owned controls**: you decide what
  qualifies as an opt-out, and the consequences attach to your own
  records. Orbit operates the platform and enforces the gate; the
  lawfulness of your sends stays with you. This guide is not legal
  advice.
</Note>

***

## The decision table

| Input                 | Use it for                                                                                                                              | Mechanism                                                                                                                        | Example payload                                                                        |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| **CSV import**        | A legacy opt-out list — the one-time backfill when you migrate from another platform, or a periodic batch a downstream system hands you | `POST /compliance/suppression-list/import` (multipart); parses ≤ 100,000 rows per file with intra-file dedup and per-row results | `curl -F "file=@suppressions.csv"` with a per-row address + optional `channel` column  |
| **Consent API**       | Live, per-event grants and revocations — your app, CRM, or unsubscribe service records a consent change the moment it happens           | `POST /compliance/consent` (JSON); one call covers one or more channels, carrying the lawful basis and purpose                   | `{"identifier": "…", "channels": ["sms"], "opt_in": false, "lawful_basis": "consent"}` |
| **Preference Center** | Recipient self-service — the contact manages their own opt-ins on a hosted page, without logging in                                     | `POST /preference-center/link` mints a signed URL (30-day TTL); the contact's page actions write the same revocation             | `{"contactId": "cnt_…"}` → a token-signed link you drop in an email footer or SMS      |

How to choose:

* **Backfilling a list?** CSV import. It deduplicates within the file
  and against prior imports, and returns per-row acceptance counts.
* **Recording consent as it changes?** Consent API. It is the only
  input that carries the GDPR burden-of-proof fields (`lawful_basis`,
  `purpose`, `consent_text_version`, a proof URL) on the consent
  record.
* **Letting the recipient decide?** Preference Center. The signed link
  expires in 30 days and scopes the page to exactly one contact.

You can use all three at once — they are mirrors over the same
ledger, so recipients arriving through different inputs still fence
identically. Run a CSV backfill first, then let the Consent API and
Preference Center keep the ledger current from that point.

***

## Worked example: the full CSV row set

`POST /compliance/suppression-list/import` takes a
`multipart/form-data` file. The parser reads a header with
case-insensitive, position-independent column names, and accepts any
of `phone`, `email`, `wa_id` (plus an optional `channel` scope
override and `reason`) per row:

```csv theme={null}
phone,email,channel,reason
+14155550101,,,replied STOP on the legacy short code
,jordan@example.com,email,unsubscribed via the old mailer
+442071838750,sam@example.co.uk,all,carrier complaint
+919876543210,,sms,MISSED-CALL opt-out captured at the kiosk
```

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/suppression-list/import \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -F "file=@suppressions.csv" \
  -F "default_country=US" \
  -F "default_reason=migrated_from_legacy_platform"
```

```json theme={null}
{
  "data": {
    "run_id": "supimp_4d…",
    "total_rows": 4,
    "accepted": 4,
    "duplicates": 0,
    "intra_file_duplicates": 0,
    "invalid": 0,
    "by_channel": { "all": 2, "email": 1, "sms": 1 },
    "errors": []
  }
}
```

Read the rows and the scopes they land on, left to right:

1. A bare phone with no `channel` column → scope `all` (a STOP on a
   phone number covers every channel reachable on it).
2. A bare email with an explicit `email` scope (redundant — email rows
   default to `email`; kept here to make the file self-describing).
3. Phone + email on one row with `channel: all` → the phone row fences
   every channel; the email row's scope stays `all` too, but an email
   address only ever gates email sends anyway.
4. A phone narrowed to `sms` by the `channel` column — the recipient
   stayed reachable on voice and email by design.

The `file_sha256` in the response and the run's per-row verdicts are
what reconcile the import later. A full contract — column aliases,
validation reasons, and every HTTP rejection shape — is on
[Opt-Out & Suppression Lists](/compliance/opt-out-suppression).

***

## The grant/revoke pair through the Consent API

The Consent API is the live path: one write per event, structured
consent metadata on the record, effective immediately. This pair
records a grant and, two months later, its revocation — and then reads
them back the way an auditor does.

**Grant** (`201 Created`):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "jordan@example.com",
    "channels": ["email"],
    "opt_in": true,
    "source": "web_form",
    "consent_type": "marketing",
    "lawful_basis": "consent",
    "purpose": "Weekly product newsletter",
    "consent_text_version": "tos-2026-04",
    "consent_proof_url": "https://example.com/proofs/abc123.png"
  }'
```

**Revoke** (also `201 Created` — the revoke call is the same endpoint
with `opt_in: false`):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "jordan@example.com",
    "channels": ["email"],
    "opt_in": false,
    "source": "preference_center"
  }'
```

A revocation is single-action and final immediately — a plain grant
asserts on the spot, a revocation revokes on the spot; the
[double-opt-in handshake](/compliance/double-opt-in) is the only flow
that holds a pending state. Re-granting after a revoke never rewrites
history: it appends a new grant row, and the prior revoke is still in
the trail.

**Read back** with `GET /compliance/consent/history`:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/compliance/consent/history?identifier=jordan%40example.com&channel=email" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

The newest item is the revoke (`granted: false`, a `revoked_at`), and
the original grant row carries the same `revoked_at` — grant and
revocation tied by timestamp is the audit answer.
[Consent Management](/compliance/consent-management) covers the full
field list, the lookup endpoint a send gate can call pre-send, and the
expiring-consent sweep.

***

## Preference Center: the signed link

The Preference Center is the recipient-facing input. Configure it
once, then mint a per-contact signed link:

```bash theme={null}
# Configure once (owner/admin key)
curl -X POST "https://api.orbit.devotel.io/api/v1/compliance/preference-center" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "companyName": "Acme Communications",
    "channels": ["sms", "email"],
    "headerText": "Your communication preferences",
    "showGdprDelete": true
  }'

# Mint one contact's link
curl -X POST "https://api.orbit.devotel.io/api/v1/compliance/preference-center/link" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contactId": "cnt_01H…"}'
```

The returned `link` carries an HMAC-signed token with a 30-day TTL.
Place it in your email footer's unsubscribe area, or inline in SMS and
WhatsApp bodies. Every opt-out the contact makes on the page records a
consent revocation and adds the same ledger row as the other two
inputs. Full configuration fields, link placement, and the public
page's update surface are in the
[Preference Center guide](/guides/preference-center-opt-out-page);
the endpoints are summarized on
[Send Gates](/compliance/send-gates#preference-center).

***

## Scope: decide `all` vs a single channel first

Scope is the one decision that survives entry-point choice — a row's
scope is the exact set of channels it fences, and the send gate
honours it precisely. Decide it from the signal the opt-out carries,
not from the transport that delivered it:

| Signal                                                                     | Correct scope                                   | Why                                                                      |
| -------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ |
| STOP keyword on a phone number                                             | `all`                                           | The number itself revoked; every channel reachable on it                 |
| Consent API `opt_in: false` or Preference Center opt-out                   | `all` (written for you)                         | Either input revokes across the board — parity with the grant it revokes |
| An unsubscribe that arrived on email                                       | `email`                                         | An email address has no cross-channel parity to assert                   |
| A legacy suppression list from a single-channel migration                  | The channel the source list covers (e.g. `sms`) | The CSV `channel` column says so explicitly                              |
| A genuinely channel-specific restriction (kiosk opt-out, phone-policy ban) | That channel (CSV `channel` column)             | The recipient stays reachable elsewhere by design                        |

The two failure shapes, both diagnosed on
[Troubleshooting: suppression scope mismatch](/troubleshooting/suppression-scope-mismatch):

* **Under-blocked** — a recipient on the list still receives messages.
  The row carries a narrowed scope (`email` on a phone row, or a
  `channel` column that trimmed it), so the channel you're sending on
  isn't the one it fences.
* **Over-blocked** — a recipient you meant off one channel can't be
  reached anywhere. The row landed scope `all`, which is the phone-row
  default.

Rule of thumb: behave like a STOP — write `all` on phone identifiers
unless you have an explicit reason to narrow. And match scope with
the recipients' intent, not the sender's file format.

***

## Reading the ledger back for an audit

The suppression ledger is the single picture every gate reads. Export
it to answer a regulator, an auditor, or a discovery request:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/compliance/suppression-list/export?status=active&format=csv" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -o suppression-list.csv
```

Each exported row carries the provenance fields that pin the entry
point and event back: `suppression_id`, `channel` (the scope),
`address`, `status` (`active` | `revoked`), `reason`, `source`,
`contact_id` (empty for a bulk-imported address with no contact),
plus `suppressed_at` / `revoked_at` / `created_at`. Access is
restricted to owner/admin keys, and every export run itself lands in
the audit log.

**Honor-time expectations per entry point:**

* **CSV import** — effective at import-completion: the gate reads
  exactly the rows the run accepted; a `dry_run: true` pass writes
  nothing.
* **Preference Center** — effective the moment the contact saves: the
  page POSTs straight into consent and suppression in the same
  request.
* **Consent API** — the record writes immediately, and a short-lived
  STOP-fence propagates to in-flight campaign batches within \~10
  minutes. Pre-send `GET /compliance/consent/lookup` reads the current
  state instantly.

For larger audits pair the suppression export with
`GET /compliance/consent/export` — the consent trail carries the
lawful-basis and proof columns the suppression list does not. Together
they are the evidence binder: the consent row proves the event, the
suppression row proves the fence.

***

## Edge cases

**Re-import with a corrected scope.** The import is idempotent on
`(channel, address)` — re-uploading the same address replaces its
scope cleanly. Export `status=active`, filter to the mis-scoped rows,
re-import them with a corrected `channel` column (or none, to accept
the address-type default). The full fix loop is the troubleshooting
page linked above.

**Mirrors never double-remove.** Because all three inputs write the
same ledger, a recipient who opts out twice — a STOP keyword and then
the Preference Center, or a CSV row that the Consent API already
recorded — converges to the same fence. Idempotency lives on
`(channel, address)`, so the second write is a no-op rather than a
conflict.

**Mirrors can scope-mismatch.** The one disagreement the three inputs
can have: a Consent API revoke on a phone identifier asserts `all`,
but a phone row your CSV narrowed to `sms` stays narrowed. Pick the
scope that matches the recipient's intent at the write, and favour
`all` on phone targets unless narrowing is deliberate.

**A revoked row stays in the ledger.** Revocation flips `status` to
`revoked` and stamps `revoked_at` — the row is kept for audit, never
deleted. Export with `status=revoked` to see the history; export
`status=all` to see both.

***

## Related references

* [Opt-Out & Suppression Lists](/compliance/opt-out-suppression) —
  the full CSV import contract and the export schema this guide's
  audit section reads.
* [Consent Management](/compliance/consent-management) — the grant,
  revoke, lookup, and history endpoints behind the Consent API pair.
* [Preference Center guide](/guides/preference-center-opt-out-page) —
  every config field, link placement, and the public page's update
  surface.
* [Send Gates](/compliance/send-gates) — the quiet-hours, DNC, and
  other gates that run alongside suppression on the send path.
* [Troubleshooting: suppression scope mismatch](/troubleshooting/suppression-scope-mismatch) —
  diagnosing the under- and over-blocked shapes.
* [SMS opt-out & opt-in rules](/guides/opt-out-rules) — the inbound
  keyword side of the ledger.
