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

# Bulk-import a suppression list from CSV

> Walk through the suppression-list CSV contract, channel scope defaults, idempotent re-runs, and audit evidence for a tenant-owned DNC ledger.

# Bulk-import a suppression list from CSV

Use the bulk import when you are bringing a suppression ledger from another
provider, applying a batch from a CRM, or remediating a known exposure. The
endpoint writes each accepted address to your tenant's suppression ledger and
returns enough detail to reconcile the file without guessing which rows were
new.

This is an operator-side control. Devotel Orbit does not decide which
recipients your tenant must suppress or replace your legal process. Review the
[three suppression entry points](/guides/suppression-three-entry-points) when
you need to choose between a CSV backfill, a live Consent API event, or a
recipient-managed Preference Center.

<Warning>
  A phone or WhatsApp row with no `channel` value becomes `channel: all`.
  That scope blocks the address on every supported send path. Do not add a
  channel override unless your source ledger explicitly represents a
  channel-only restriction.
</Warning>

## Before you import

Prepare a UTF-8 RFC-4180 CSV with a header and confirm that your source system
has already identified the correct country for any national-format phone
numbers. The endpoint accepts one multipart file per request and requires an
owner or admin credential. The operation is `POST /api/v1/compliance/suppression-list/import`.

```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=@suppression-backfill.csv" \
  -F "default_country=US" \
  -F "default_reason=migrated_from_legacy_ledger"
```

`default_country` is optional. Use it when the CSV contains national-format
phone numbers such as `4155550101`; it is a two-letter ISO 3166-1 alpha-2
country hint. You, as the operator, are responsible for supplying the right
country. Orbit cannot safely infer whether an unqualified number belongs to
one country or another. A number that cannot be normalized is returned as
`invalid_phone` instead of being written.

Use `dry_run=true` with the same file and defaults to parse and classify rows
without writing them. A dry run returns a preview, but it does not create an
audit-chain import record because no suppression write occurred.

## CSV contract

The first row is the header. Parsing follows RFC 4180: quote a field that
contains a comma or newline, escape a literal quote by doubling it, and use
CRLF or LF row endings. A UTF-8 BOM is accepted and removed from the first
header. Header matching is case-insensitive, ignores spaces, hyphens, and
underscores, and accepts the aliases below. Header order does not matter.

| Logical column | Required | Accepted headers | Row behavior |
| - | -: | - | - |
| `phone` | One of `phone`, `email`, or `wa_id` per row | `phone`, `phonenumber`, `mobile`, `msisdn` | Normalized to E.164. Defaults to `channel: all`. |
| `email` | One of `phone`, `email`, or `wa_id` per row | `email`, `emailaddress`, `mail` | Lowercased and shape-checked. Defaults to `channel: email`. |
| `wa_id` | One of `phone`, `email`, or `wa_id` per row | `wa_id`, `whatsapp`, `whatsappid`, `wabsuid` | Accepts a WhatsApp MSISDN with or without `+`; stored in `+E.164` form. Defaults to `channel: all`. |
| `channel` | Optional | `channel` | Overrides the default for every populated address on that row. |
| `reason` | Optional | `reason`, `note`, `notes` | Free text, truncated to 512 characters. If empty, `default_reason` or `manual_bulk_import` is used. |

A row may contain more than one address. Each populated address becomes its
own suppression entry, with the row's `channel` override applied to each one.
For example, this file creates one `all` phone entry and one `email` entry on
the first data row:

```csv theme={null}
PHONE NUMBER,Email Address,Channel,Reason
+14155550101,, ,Replied STOP on the old short code
,jordan@example.com,,Unsubscribed from email
+442071838750,sam@example.co.uk,sms,Legacy SMS-only restriction
```

An explicit `channel` must be one of `all`, `sms`, `voice`, `whatsapp`,
`email`, `push`, `telegram`, `messenger`, or `rcs`. An invalid override is
classified as a row-level invalid result; it does not make the entire file
fail.

### Address normalization

* **Phone:** Orbit calls its phone normalizer. Valid input is stored in the
  canonical E.164 representation. Pass `default_country` for national-format
  input, and verify the resulting country before importing. Do not prepend a
  country code based on a guess in a batch script.
* **WhatsApp ID:** Supply the recipient's numeric MSISDN, with an optional
  leading `+`. Orbit validates the 7-to-15-digit E.164 range and stores the
  canonical `+` form. A bare national number is not made unambiguous by the
  `wa_id` column; convert it before upload.
* **Email:** Orbit lowercases the address and requires a practical RFC 5321
  mailbox shape. Addresses longer than 254 characters are invalid.

Orbit does not modify your source file. Keep the original file and the
returned `file_sha256` together so you can prove exactly which bytes were
submitted.

## Understand channel scope before committing

The suppression key is `(channel, address)`. The default is intentionally based
on the address type, not on the transport that delivered the original opt-out:

| CSV row | Default scope | What the send gates see |
| - | - | - |
| `phone=+14155550101` | `all` | SMS, voice, WhatsApp, and push paths match the same suppression. |
| `wa_id=14155550101` | `all` | The normalized `+14155550101` is fenced across channels. |
| `email=jordan@example.com` | `email` | Email is fenced; the address is not treated as a phone identity. |
| Any populated address with `channel=sms` | `sms` | Only SMS is fenced, even if the value is in the `phone` column. |

The override is a deliberate narrowing or selection, not a declaration of
where the CSV came from. A phone row with `channel=email` can be stored, but it
will only match email sends. If a recipient still receives a message after an
accepted import, first inspect the row's scope and follow
[Troubleshooting: suppression scope mismatch](/troubleshooting/suppression-scope-mismatch).

A scope mismatch is different from a failed import. Export the active ledger,
check the `channel` value, and compare it with the send channel. Re-uploading
the same `(channel, address)` is idempotent; it does **not** replace an
existing row with a different scope. If you intentionally need both scopes,
import the address with the additional key, and if you need to correct an
existing scope, follow the revocation or correction procedure in the
troubleshooting guide rather than assuming a duplicate changed it.

## Walk through an import response

The successful response is an envelope whose `data` contains the run summary.
The endpoint classifies bad rows and continues with the valid ones:

```json theme={null}
{
  "data": {
    "run_id": "supimp_4d…",
    "total_rows": 4,
    "accepted": 3,
    "duplicates": 0,
    "intra_file_duplicates": 0,
    "invalid": 1,
    "errors": [
      { "status": "invalid", "reason": "missing_address", "raw_line": 5 }
    ],
    "by_channel": { "all": 2, "email": 1 },
    "file_sha256": "9b2e…"
  }
}
```

`accepted` counts newly inserted suppression keys. `duplicates` counts keys
that were already active before this request. `intra_file_duplicates` counts
repeated keys that were collapsed before the database write. `invalid` is the
complete invalid count; only the first 100 invalid rows are expanded in
`errors`. `by_channel` counts newly accepted rows by their stored scope.

### Invalid and empty rows

A row with no value in any recognized address column returns a row-level
classification, not an HTTP failure:

```json theme={null}
{
  "status": "invalid",
  "reason": "missing_address",
  "raw_line": 12
}
```

The same `missing_address` reason is used when a populated row has an invalid
`channel` override. Fix the source row and re-upload it. If the **header** has
no recognized address column at all, the request instead returns HTTP `422`
with error code `MISSING_ADDRESS_COLUMN`; add `phone`, `email`, or `wa_id` (or
one of the aliases in the schema table). Other row-level reasons include
`invalid_phone`, `invalid_email`, `invalid_wa_id`, `row_too_long`, and
`too_many_columns`.

## Re-run a strict subset safely

The database's active partial unique key makes a re-run safe. A second upload
of a key that is already active is a no-op, and the response exposes it as a
`duplicates` count. This lets you retry a timed-out or overlapping batch
without creating a second active suppression row.

**File 1: the original ledger**

```csv theme={null}
phone,email,reason
+14155550101,,legacy DNC
+442071838750,,legacy DNC
,jordan@example.com,legacy unsubscribe
```

Suppose the response is:

```json theme={null}
{ "accepted": 3, "duplicates": 0, "intra_file_duplicates": 0, "invalid": 0 }
```

**File 2: a strict subset used for a safe retry**

```csv theme={null}
phone,email,reason
+14155550101,,retry after worker timeout
,jordan@example.com,retry after worker timeout
```

The second response reports the two existing keys as duplicates:

```json theme={null}
{
  "data": {
    "accepted": 0,
    "duplicates": 2,
    "intra_file_duplicates": 0,
    "invalid": 0,
    "errors": [],
    "by_channel": {}
  }
}
```

The changed `reason` does not rewrite an active row. `duplicates` means “it
was already active before this run,” while `intra_file_duplicates` means “the
same key appeared more than once in this file.” Keep the second run's
`run_id` and hash as evidence of the retry even though it added no rows.

## Capture audit-chain evidence

Every committing import writes one tamper-evident audit-chain row with the
operation `compliance.suppression_list_import`. The row is associated with
the tenant and contains:

| Evidence | Purpose |
| - | - |
| `run_id` | The audit resource ID and the response's `run_id`; use it to correlate the request, response, and ledger export. |
| Operator `userId` | Identifies the owner or admin who initiated the import. |
| `file_sha256` | Binds the evidence to the exact uploaded bytes. |
| `accepted`, `duplicates`, `invalid` | Reconciles the outcome; the record also carries `total_rows` and `intra_file_duplicates`. |
| `by_channel` | Shows which scopes were newly accepted. |
| `file_name`, defaults, and elapsed time | Preserves import context without storing the raw CSV. |
| Masked address samples | Helps an operator recognize a run without placing full recipient PII in the chain. |

For a regulator or discovery request, preserve the original CSV, the response,
and the audit export containing this run. Match the audit row's hash to the
file, match its counts to the response, and export the active suppression
ledger for the affected time window. The ledger proves which entries were
active; the audit row proves who ran the import and what the operator
submitted. Do not send a raw CSV as the only evidence: it does not establish
that the file was actually imported.

## Post-incident remediation example

If a GDPR or CCPA review finds that a segment was exposed to an unintended
campaign, use a repeatable tenant-owned sequence:

1. **Discover the exposure.** Freeze the affected segment in your own
   incident process, identify the recipient identifiers and the communication
   window, and preserve the source report.
2. **Build the CSV.** Deduplicate by intended `(channel, address)` scope. Use
   E.164 phone values, canonical WhatsApp IDs, and lowercase email values.
   Leave phone and `wa_id` rows untagged when the incident requires a
   cross-channel fence; add `channel` only for a documented channel-specific
   restriction.
3. **Preview and import.** Run the exact file with `dry_run=true`, fix every
   `invalid` row, then commit the clean file with a reason such as
   `post_incident_remediation_2026_10_11`.
4. **Capture evidence.** Save the `run_id`, `file_sha256`, response counts, and
   the audit-chain export. Export the active ledger and verify representative
   rows have the intended scope.
5. **Close the loop.** Attach the evidence bundle to the tenant's incident
   record and update the outbound audience process that admitted the exposed
   recipients. A suppression import is the containment step, not a substitute
   for the tenant's privacy, consent, or incident-response work.

The [Consent vs. suppression model](/compliance/consent-vs-suppression-model)
explains why an active suppression fence wins over an affirmative consent
record. For revocation signals and reasonable channel interpretation, see
[TCPA reasonable revocation](/compliance/tcpa-reasonable-revocation-2025).

## Limits and HTTP failures

| Limit | Value |
| - | -: |
| File size | 25 MB |
| Parsed rows | 100,000 per request |
| Cell length | 4,096 characters |
| Columns per row | 32 |
| Reason length | 512 characters |
| Processing budget | 60 seconds |

Split larger ledgers into batches. A batch that hits the 60-second budget may
have already written rows; its `408 IMPORT_TIMEOUT` response identifies the
partial counts, and a retry is safe because active keys are idempotent.

These request-level failures write no normal completion summary:

* `400 NO_FILE` or `MULTIPLE_FILES`: attach exactly one multipart `file`.
* `400 CSV_PARSE_ERROR`: fix malformed quoting or the missing header.
* `413 PAYLOAD_TOO_LARGE` or `TOO_MANY_ROWS`: split the source file.
* `415 UNSUPPORTED_MEDIA_TYPE`: export CSV rather than XLSX.
* `422 MISSING_ADDRESS_COLUMN`: add a recognized address header.
* `422 VALIDATION_ERROR`: correct `default_country` or the form-level
  `default_reason`.

A malformed address inside an otherwise valid file is not one of these
request-level failures. It is returned in `errors[]`, counted in `invalid`,
and leaves the other rows eligible for import.

## How this differs from other opt-out paths

All of these inputs converge on the tenant's suppression ledger, but they
serve different operational jobs. Keep one row per channel of truth in your
source systems and avoid treating one input as a replacement for the others.

| Input | Best use | Scope and evidence |
| - | - | - |
| **Bulk CSV import** | Backfill, migration, or incident remediation | Address-type defaults, optional per-row override, file hash, counts, and operator audit row. |
| **Inbound STOP keywords** | A recipient revokes through an inbound message | The recognized inbound STOP keywords flow writes an `all` suppression for the phone identity immediately; retain the inbound message evidence. |
| **Consent API** | Record a live grant or revocation with purpose and lawful-basis context | The consent record carries the affirmative/revocation metadata; a suppression fence still takes precedence when active. |
| **Preference Center** | Let a recipient manage their own choices | The hosted interaction is the event source; it writes the corresponding preference/suppression state without a batch file. |

Use the [suppression three-entry-points guide](/guides/suppression-three-entry-points)
for the selection decision. Use the [Consent vs. suppression model](/compliance/consent-vs-suppression-model)
and [TCPA reasonable revocation](/compliance/tcpa-reasonable-revocation-2025)
to align your tenant's consent and revocation records with the suppression
fence.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.