> ## Documentation Index
> Fetch the complete documentation index at: https://developers.telnyx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Suppressions & Unsubscribes

> Manage the Telnyx Email suppression list — reason taxonomy, account vs group scope, CSV import from SendGrid/Mailgun/SES, send-time override behavior, unsubscribe groups, and RFC 8058 one-click unsubscribe.

Manage the Telnyx Email suppression list (blocklist) to protect your sender reputation. Suppressions stop Telnyx from sending to addresses that have bounced, complained, unsubscribed, or that you've manually blocked. This guide covers the suppression model, scope, CRUD operations, CSV import/export, send-time override behavior, unsubscribe groups, and the RFC 8058 one-click unsubscribe flow.

All requests use the production base URL `https://api.telnyx.com/v2` and an `Authorization: Bearer ***` header.

## Suppression model

A suppression (block) is a record that prevents email from being sent to a recipient address. Telnyx creates suppressions automatically in response to deliverability signals, and you can create them manually via the API.

Every suppression has a **reason** — the canonical enum string that describes why the block exists — and a **source** that records how it was created.

### Reasons

| Reason           | Created by                                                                                          | Overridable with `ignore_suppression`? |
| ---------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `hard_bounce`    | Auto — permanent bounce or soft-bounce escalation                                                   | **No**                                 |
| `spam_complaint` | Auto — ARF feedback (complaint)                                                                     | **No**                                 |
| `invalid`        | Import (SendGrid `invalid_emails`, generic) or internal                                             | **No**                                 |
| `unsubscribe`    | Auto — recipient unsubscribed (RFC 8058 or link), or manual group opt-out                           | **Yes**                                |
| `manual_block`   | Manual — public API `POST /email_blocks`, or CSV import (SendGrid `blocks`, generic `manual_block`) | **Yes**                                |

The three non-overridable reasons (`hard_bounce`, `spam_complaint`, `invalid`) can never be bypassed by `ignore_suppression` — Telnyx will not deliver to those recipients regardless of the flag. The two overridable reasons (`unsubscribe`, `manual_block`) can be bypassed when you send with `ignore_suppression: true` and an API key that has the `email:override` scope. See [Send-time behavior](#send-time-behavior).

### Sources

| Source     | Meaning                                                            |
| ---------- | ------------------------------------------------------------------ |
| `system`   | Auto-created by Telnyx (bounce, complaint, or unsubscribe workers) |
| `manual`   | Created via the public API or group suppressions sub-resource      |
| `import`   | Created via CSV import                                             |
| `feedback` | Created from feedback-loop data                                    |

### How auto-suppressions are created

Telnyx's delivery event pipeline classifies incoming KumoMTA log records and enqueues a suppression worker for suppressible events:

* **Spam complaint** — an ARF `Feedback` record creates a `spam_complaint` suppression.
* **Hard bounce** — a `Bounce` or `OOB` record with a `5.1.x` or `5.2.x` enhanced status code, or a `5xx` SMTP code with no enhanced code, creates a `hard_bounce` suppression.
* **Soft-bounce escalation** — repeated `TransientFailure` (deferred) records for the same recipient increment a counter; when the threshold is breached, Telnyx escalates to a `hard_bounce` suppression.
* **Expiration** — a KumoMTA `Expiration` record (system gave up retrying) creates a `hard_bounce` suppression.
* **Unsubscribe** — a recipient unsubscribes via the RFC 8058 one-click flow or the unsubscribe link, creating an `unsubscribe` suppression (see [RFC 8058 one-click unsubscribe](#rfc-8058-one-click-unsubscribe)).

`AdminBounce` records, `5.7.x` enhanced codes (sender-side policy), and other unclassified records are **not** suppressed.

<Callout type="info">
  The public API can only create `manual_block` suppressions. Any `reason` value you send to `POST /email_blocks` is ignored — the server forces `reason: manual_block` and `source: manual`. Auto-suppressions (`hard_bounce`, `spam_complaint`, `unsubscribe`) are created by Telnyx internally. CSV import is the one other path that can produce `manual_block` rows, with `source: import`.
</Callout>

## Scope

A suppression's **scope** determines which sends it blocks. Scope is derived server-side from the suppression's `domain_id` and `from` fields — it's never trusted from the client. There are exactly three scopes:

| Scope     | Condition                           | Blocks                                                                                                                                                  |
| --------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `account` | `domain_id` is null                 | All sends to the recipient address, across all domains and senders. If `from` is also set, send-time matching still restricts the block to that sender. |
| `domain`  | `domain_id` is set, `from` is null  | Sends to the recipient from the specified domain                                                                                                        |
| `address` | Both `domain_id` and `from` are set | Sends to the recipient from the specified sender address on the specified domain                                                                        |

At send time, Telnyx checks the recipient against all matching active suppressions. Account-scoped blocks match across all domains, but a non-null `from` still restricts matching to that sender. Domain-scoped blocks match when the send's domain matches. Address-scoped blocks match when both domain and sender match.

### `group_id` is a filter, not a scope

`group_id` is **not** a fourth scope — it's an independent association that narrows *which sends* a suppression applies to, and it combines with whichever of the three scopes the record already has. A suppression with `group_id: null` applies to every send regardless of group; a suppression with `group_id` set applies only to sends that carry the same `group_id`. See [Unsubscribe groups](#unsubscribe-groups).

## Manage suppressions

### List suppressions

`GET /email_blocks` returns the account's suppressions with offset or cursor pagination, sort, and filters.

```bash curl theme={null}
curl -X GET "https://api.telnyx.com/v2/email_blocks?page[number]=1&page[size]=25&filter[reason]=manual_block&sort=-created_at" \
  -H "Authorization: Bearer ***"
```

| Parameter                | Type                    | Notes                                                                                     |
| ------------------------ | ----------------------- | ----------------------------------------------------------------------------------------- |
| `page[number]`           | int (≥1, default 1)     | Offset pagination page number.                                                            |
| `page[size]`             | int (1–100, default 25) | Page size for offset mode.                                                                |
| `page[after]`            | string (cursor)         | Cursor for forward pagination. Mutually exclusive with `page[number]` and `page[before]`. |
| `page[before]`           | string (cursor)         | Cursor for backward pagination.                                                           |
| `sort`                   | string                  | `created_at` (asc) or `-created_at` (desc, default). Only `created_at` is sortable.       |
| `filter[reason]`         | enum                    | `hard_bounce`, `spam_complaint`, `unsubscribe`, `invalid`, or `manual_block`.             |
| `filter[domain_id]`      | uuid                    | Filter by domain.                                                                         |
| `filter[created_after]`  | date-time               | Suppressions created after this timestamp.                                                |
| `filter[created_before]` | date-time               | Suppressions created before this timestamp.                                               |

Offset-mode response (default):

```json theme={null}
{
  "data": [
    {
      "id": "5f3e2a1c-7b8d-4e2f-9a1c-3d5e7f9a1b2c",
      "record_type": "email_block",
      "domain_id": null,
      "group_id": null,
      "from": null,
      "to": "user@example.com",
      "reason": "manual_block",
      "source": "manual",
      "scope": "account",
      "status": "active",
      "created_at": "2026-07-06T12:00:00.000000Z",
      "updated_at": "2026-07-06T12:00:00.000000Z",
      "expires_at": null
    }
  ],
  "meta": {
    "page_number": 1,
    "page_size": 25,
    "total_pages": 1,
    "total_results": 1
  }
}
```

Cursor-mode response (when you use `page[after]` or `page[before]`):

```json theme={null}
{
  "data": [
    {
      "id": "5f3e2a1c-7b8d-4e2f-9a1c-3d5e7f9a1b2c",
      "record_type": "email_block",
      "domain_id": null,
      "group_id": null,
      "from": null,
      "to": "user@example.com",
      "reason": "manual_block",
      "source": "manual",
      "scope": "account",
      "status": "active",
      "created_at": "2026-07-06T12:00:00.000000Z",
      "updated_at": "2026-07-06T12:00:00.000000Z",
      "expires_at": null
    }
  ],
  "meta": {
    "page_size": 25,
    "next_cursor": "eyJjcm...Ijoi...",
    "previous_cursor": "eyJjcm...Ijoy...",
    "has_next": true,
    "has_previous": true
  }
}
```

Cursor `meta` always carries `page_size`, `next_cursor`, `previous_cursor`, `has_next`, and `has_previous`. All five keys are always present in the response; when there is no next or previous page, the corresponding cursor is `null` (not omitted).

### Retrieve a suppression

`GET /email_blocks/{id}` returns a single suppression by ID.

```bash curl theme={null}
curl -X GET https://api.telnyx.com/v2/email_blocks/5f3e2a1c-7b8d-4e2f-9a1c-3d5e7f9a1b2c \
  -H "Authorization: Bearer ***"
```

Returns `200` with the suppression in `data`, or `404` if the ID doesn't exist or belongs to another account.

### Create a manual suppression

`POST /email_blocks` creates a `manual_block` suppression. The server forces `reason: manual_block` and `source: manual` regardless of any values you send.

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_blocks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ***" \
  -d '{
    "to": "spammer@bad.tld",
    "expires_at": "2026-12-31T23:59:59Z"
  }'
```

| Field        | Type              | Notes                                                                                   |
| ------------ | ----------------- | --------------------------------------------------------------------------------------- |
| `to`         | string (required) | The recipient address. Normalized to lowercase, trimmed.                                |
| `from`       | string            | Sender address. When null, the suppression is account- or domain-scoped.                |
| `domain_id`  | uuid              | When null, the suppression is account-scoped.                                           |
| `expires_at` | date-time         | Stored as metadata on the suppression. **Not currently enforced** — see the note below. |

```json theme={null}
{
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "record_type": "email_block",
    "domain_id": null,
    "group_id": null,
    "from": null,
    "to": "spammer@bad.tld",
    "reason": "manual_block",
    "source": "manual",
    "scope": "account",
    "status": "active",
    "created_at": "2026-07-06T12:30:00.000000Z",
    "updated_at": "2026-07-06T12:30:00.000000Z",
    "expires_at": "2026-12-31T23:59:59Z"
  }
}
```

<Callout type="warning">
  **`expires_at` is not currently enforced.** The field is accepted, stored, and returned, but no expiration process runs against it: the send-time suppression check matches on `status = "active"` alone and never compares `expires_at` to the current time. A suppression with a past `expires_at` keeps blocking sends. Treat `expires_at` as informational metadata and remove suppressions explicitly with `DELETE /email_blocks/{id}` when you want them to stop applying.
</Callout>

Creating a suppression that already exists and is still **active** (same account, scope, recipient, reason, domain, from, and group) is idempotent — the API returns `200` with the existing record and creates no new audit event.

If the matching record was previously removed, the create **reactivates** it instead: the existing row flips back to `status: "active"`, the request's `expires_at` is re-applied, a fresh `created` audit event is appended, and the API returns `201`. The row is reused rather than duplicated, so the audit trail reads `created → removed → created` on the same suppression ID.

| Existing record    | Response                    | Audit event |
| ------------------ | --------------------------- | ----------- |
| None               | `201 Created`               | `created`   |
| Active duplicate   | `200 OK`                    | none        |
| Previously removed | `201 Created` (reactivated) | `created`   |

### Delete a suppression

`DELETE /email_blocks/{id}` soft-deletes a suppression. The record is retained with `status: "removed"` and a `removed` audit event is appended.

```bash curl theme={null}
curl -X DELETE https://api.telnyx.com/v2/email_blocks/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer ***"
```

Returns `200` with the suppression (now `status: "removed"`). Deleting an already-removed suppression is idempotent — returns `200` with no new event.

### Suppression audit events

`GET /email_blocks/{id}/events` lists the audit trail for a suppression. Each event records a lifecycle transition.

```bash curl theme={null}
curl -X GET "https://api.telnyx.com/v2/email_blocks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/events?page[size]=50" \
  -H "Authorization: Bearer ***"
```

```json theme={null}
{
  "data": [
    {
      "id": "e1...",
      "record_type": "email_block_event",
      "event_type": "created",
      "reason": "manual_block",
      "source": "manual",
      "actor": "user-123",
      "meta": {},
      "occurred_at": "2026-07-06T12:30:00.000000Z"
    }
  ],
  "meta": {
    "page_number": 1,
    "page_size": 50,
    "total_pages": 1,
    "total_results": 1
  }
}
```

| `event_type`    | When                                                                                                                                         |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `created`       | The suppression was created, or a previously removed suppression was reactivated by a new create.                                            |
| `removed`       | The suppression was deleted.                                                                                                                 |
| `expired`       | Reserved. No process currently emits this event — `expires_at` is not enforced, so suppressions do not transition to `expired` on their own. |
| `override_used` | A send bypassed this suppression via `ignore_suppression` (overridable reasons only).                                                        |

Uses offset pagination (`page[number]` default 1, `page[size]` default 50, max 100). Events are ordered by `occurred_at` descending.

## CSV import & export

Migrate an existing suppression list from SendGrid, Mailgun, or Amazon SES, or import a generic CSV. Export your Telnyx suppressions as CSV for backup or transfer.

### Import a CSV

`POST /email_blocks/import` accepts a multipart upload with a CSV file and returns `202` with an async import job. The provider format is auto-detected from the CSV header row.

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_blocks/import \
  -H "Authorization: Bearer ***" \
  -F "file=@blocks.csv" \
  -F "block_ttl_days=30"
```

| Form field       | Type              | Notes                                                                                                                                         |
| ---------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `file`           | binary (required) | The CSV file.                                                                                                                                 |
| `block_ttl_days` | int (default 30)  | TTL applied to imported `manual_block` rows only. All other reasons get indefinite (`expires_at: null`). Invalid or missing falls back to 30. |

**Limits:** decoded CSV up to 25 MiB and 250,000 data rows. Exceeding either returns `413`.

All imported suppressions are account-scoped (`scope: account`, `domain_id: null`, `from: null`).

### Provider auto-detection

The importer detects the provider from the CSV header row (normalized to lowercase, trimmed):

| Provider       | Detected by                                                              | Reason mapping                                                                                                                              |
| -------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **SendGrid**   | Header has `email` + `type`                                              | `blocks`→`manual_block`, `bounces`→`hard_bounce`, `spam_reports`→`spam_complaint`, `unsubscribes`→`unsubscribe`, `invalid_emails`→`invalid` |
| **Mailgun**    | Header has `recipient`                                                   | `bounces`→`hard_bounce`, `complains`→`spam_complaint`, `unsubscribes`→`unsubscribe`                                                         |
| **Amazon SES** | Header has `email` + (`bounce_type` or `complaint_type` or `diagnostic`) | Non-blank `bounce_type`→`hard_bounce`, non-blank `complaint_type`→`spam_complaint`                                                          |
| **Generic**    | Header has `email`, `to`, or `to_address`                                | `reason` column must be one of: `hard_bounce`, `spam_complaint`, `unsubscribe`, `invalid`, `manual_block`                                   |

Detection order is SendGrid → Mailgun → SES → generic. An undetectable or header-only CSV returns `400`.

Rows with a missing or blank email address, or an unmappable reason, are skipped and recorded in the import's `errors` map (keyed by row number, 1-indexed with the header as row 1).

### Poll an import job

`GET /email_blocks/import/{id}` returns the current state of an import job. Poll this until `status` is `completed` or `failed`.

```bash curl theme={null}
curl -X GET https://api.telnyx.com/v2/email_blocks/import/2c3d4e5f-... \
  -H "Authorization: Bearer ***"
```

```json theme={null}
{
  "data": {
    "id": "2c3d4e5f-...",
    "record_type": "email_block_import",
    "status": "completed",
    "total": 2,
    "provider": "sendgrid",
    "created_at": "2026-07-06T13:00:00.000000Z",
    "updated_at": "2026-07-06T13:00:05.000000Z",
    "completed_at": "2026-07-06T13:00:05.00Z",
    "processed_rows": 2,
    "created_count": 2,
    "existing_count": 0,
    "skipped_count": 0,
    "error_count": 0
  }
}
```

| `status`     | Meaning                                                 |
| ------------ | ------------------------------------------------------- |
| `pending`    | Job enqueued, not yet started.                          |
| `processing` | Worker is importing rows.                               |
| `completed`  | Import finished. Counts and `completed_at` are present. |
| `failed`     | Import failed. `failure_reason` is present.             |

When `status` is `completed`, the response includes `processed_rows`, `created_count`, `existing_count`, `skipped_count`, and `error_count`. Rejected rows are counted in `skipped_count` and detailed in an `errors` object that maps row numbers to skip reasons. `error_count` tracks a separate failure class that the importer does not currently populate, so it stays `0` even when rows were rejected:

```json theme={null}
{
  "data": {
    "id": "...",
    "status": "completed",
    "total": 3,
    "provider": "generic",
    "processed_rows": 3,
    "created_count": 2,
    "existing_count": 0,
    "skipped_count": 1,
    "error_count": 0,
    "errors": {
      "3": "unmapped reason: deferred"
    }
  }
}
```

<Callout type="info">
  Use `skipped_count` and `errors` — not `error_count` — to detect rows the importer rejected. A row with a missing/blank email address or an unmappable reason increments `skipped_count` and gets an entry in `errors`; `error_count` remains `0`.
</Callout>

Also note `block_ttl_days` only writes an `expires_at` value onto imported `manual_block` rows; because `expires_at` is not enforced, those rows keep blocking after the TTL date passes until you delete them.

### Export suppressions as CSV

`GET /email_blocks/export` streams the account's suppressions as a chunked CSV response with `Content-Type: text/csv`. Filters affect which rows are exported; `sort` and `page` parameters are accepted but ignored.

```bash curl theme={null}
curl -X GET "https://api.telnyx.com/v2/email_blocks/export?filter[reason]=manual_block" \
  -H "Authorization: Bearer ***" \
  -H "Accept: text/csv" \
  -o email_blocks_export.csv
```

The CSV columns, in order: `id,to,from,reason,source,scope,status,domain_id,created_at,updated_at,expires_at,group_id`.

```csv theme={null}
id,to,from,reason,source,scope,status,domain_id,created_at,updated_at,expires_at,group_id
a1b2c3d4-e5f6-7890-abcd-ef1234567890,spammer@bad.tld,,manual_block,manual,account,active,,2026-07-06T12:30:00.000000Z,2026-07-06T12:30:00.000000Z,2026-12-31T23:59:59Z,
```

<Callout type="warning">
  **Export → import is lossy — it is not a round-trip.** The export writes all twelve columns, but the generic importer only consumes `to`/`to_address`/`email` and `reason`. Everything else in the file is discarded: `from`, `domain_id`, `group_id`, `source`, `status`, `id`, and the exported `expires_at` are all dropped on re-import.

  Consequences of re-importing your own export:

  * **Domain- and address-scoped suppressions are broadened to account scope.** Every imported row is written with `from_address: null`, `domain_id: null`, `scope: account`, so a block that previously applied to one sending domain now blocks that recipient across your whole account.
  * **Group-scoped opt-outs become global.** `group_id` is not read on import, so a newsletter-only unsubscribe re-imports as a suppression that blocks every send to that recipient, including transactional mail.
  * **Expiry metadata is not preserved.** Imported `manual_block` rows get a fresh `expires_at` derived from `block_ttl_days`; every other reason gets `expires_at: null`.
  * **Removed rows come back active.** `status` is ignored, so an exported tombstone re-imports as an active suppression.

  Use the export for backup, reporting, or migration to another system — not as a way to restore Telnyx state. To recreate scoped or group-scoped suppressions faithfully, replay them through `POST /email_blocks` (with `domain_id` / `from`) or `POST /email_unsubscribe_groups/{id}/suppressions`.
</Callout>

## Send-time behavior

When you send an email, Telnyx checks every recipient against the suppression list before creating the message. Suppressed recipients are reported in the response — they're never silently dropped.

### Partial suppression (202)

When **some** recipients are suppressed but others are not, the send proceeds for the non-suppressed recipients. The `202` response includes a top-level `suppressed` array describing each suppressed recipient:

```json theme={null}
{
  "data": {
    "record_type": "email_message",
    "id": "...",
    "status": "queued"
  },
  "suppressed": [
    {
      "to": "suppressed@example.com",
      "reason": "hard_bounce",
      "scope": "account",
      "override_allowed": false
    }
  ]
}
```

Each entry in the `suppressed` array has:

| Field              | Type    | Notes                                                                                                                                              |
| ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `to`               | string  | The suppressed recipient address.                                                                                                                  |
| `reason`           | string  | The suppression reason (e.g. `hard_bounce`).                                                                                                       |
| `scope`            | string  | The scope that matched (e.g. `account`, `domain`, `address`).                                                                                      |
| `override_allowed` | boolean | `true` if the suppression is overridable (`unsubscribe`, `manual_block`); `false` if non-overridable (`hard_bounce`, `spam_complaint`, `invalid`). |

### All recipients suppressed (422)

When **every** recipient is suppressed, the request returns `422` with a `recipient_suppressed` error code and the `suppressed` array:

```json theme={null}
{
  "errors": [
    {
      "code": "recipient_suppressed",
      "title": "Recipient Suppressed",
      "detail": "All recipients are suppressed. The email was not sent."
    }
  ],
  "suppressed": [
    {
      "to": "suppressed@example.com",
      "reason": "hard_bounce",
      "scope": "account",
      "override_allowed": false
    }
  ]
}
```

### Batch sends

In a batch (`POST /email_messages/batch`), suppression is checked per message. A message whose recipients are all suppressed produces a per-item error with `code: "recipient_suppressed"` in the batch response's `errors` array (indexed by position in the request).

### Overriding suppressions

Set `ignore_suppression: true` on a send to bypass **overridable** suppressions (`unsubscribe` and `manual_block`). This requires an API key with the `email:override` scope — without it, the request returns `403`:

```json theme={null}
{
  "errors": [
    {
      "code": "10007",
      "title": "Forbidden",
      "detail": "This action requires the email:override scope"
    }
  ]
}
```

When `ignore_suppression: true` is honored:

* Recipients with only **overridable** suppressions (`override_allowed: true`) proceed — the send goes through and an `override_used` audit event is recorded on each bypassed suppression.
* Recipients with any **non-overridable** suppression (`override_allowed: false`) remain blocked — `ignore_suppression` cannot bypass `hard_bounce`, `spam_complaint`, or `invalid`.

The scope hierarchy grants `email:override` to keys with `full_access` or `email:admin`. Keys with only `email:send` or `email:read` cannot override.

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_messages \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ***" \
  -d '{
    "from": "sender@example.com",
    "to": ["unsubscribed@example.com", "new@example.com"],
    "subject": "Transactional update",
    "text_body": "Your account was updated.",
    "ignore_suppression": true
  }'
```

<Callout type="warning">
  `ignore_suppression` can only bypass `unsubscribe` and `manual_block`. It will never override a `hard_bounce`, `spam_complaint`, or `invalid` suppression — those recipients stay blocked and appear in the `suppressed` array even when the flag is set.
</Callout>

## Unsubscribe groups

Unsubscribe groups let you suppress a recipient for one category of email without blocking all your sends to them. This is the standard pattern for separating newsletter opt-outs from transactional email.

For example, a recipient who unsubscribes from your "Weekly Newsletter" group should still receive password-reset emails. Group-scoped suppressions only block sends that specify the same `group_id`.

### Create a group

`POST /email_unsubscribe_groups` creates a group.

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_unsubscribe_groups \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ***" \
  -d '{
    "name": "Newsletter",
    "description": "Weekly digest"
  }'
```

| Field         | Type                     | Notes                 |
| ------------- | ------------------------ | --------------------- |
| `name`        | string (required, 1–255) | Group name.           |
| `description` | string                   | Optional description. |

```json theme={null}
{
  "data": {
    "id": "g2...",
    "record_type": "email_unsubscribe_group",
    "name": "Newsletter",
    "description": "Weekly digest",
    "created_at": "2026-07-06T14:00:00.000000Z",
    "updated_at": "2026-07-06T14:00:00.000000Z"
  }
}
```

### List, retrieve, update, and delete groups

* `GET /email_unsubscribe_groups` — list groups (offset pagination, `page[size]` default 25, max 100).
* `GET /email_unsubscribe_groups/{id}` — retrieve a group.
* `PATCH /email_unsubscribe_groups/{id}` — update `name` and/or `description`. `PUT` is not supported.
* `DELETE /email_unsubscribe_groups/{id}` — delete a group. If the group has active suppressions, the request returns `409` unless you pass `?force=true`, which soft-deletes the suppressions first.

### Manage suppressions in a group

* `GET /email_unsubscribe_groups/{id}/suppressions` — list suppressions in a group (offset pagination).
* `POST /email_unsubscribe_groups/{id}/suppressions` — add a recipient to the group's suppression list. The server forces `reason: unsubscribe`, `source: manual`, and `group_id: <this group>`. Only `to` is read from the request body.
* `DELETE /email_unsubscribe_groups/{id}/suppressions/{email}` — remove a recipient from the group's suppression list. Returns `204`.

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_unsubscribe_groups/g2.../suppressions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ***" \
  -d '{ "to": "opted.out@example.com" }'
```

### Send with a group

Pass `group_id` on a send to scope the suppression check to that group. The send will check account-, domain-, and address-scoped suppressions as usual. Records with `group_id: null` apply to every send regardless of group; records with `group_id` set additionally require the same `group_id` to match.

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_messages \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ***" \
  -d '{
    "from": "news@example.com",
    "to": ["subscriber@example.com"],
    "subject": "This week's updates",
    "text_body": "Here's what happened this week.",
    "group_id": "g2..."
  }'
```

<Callout type="info">
  The `group_id` you set on a send is persisted on the message. When the recipient later unsubscribes via the RFC 8058 one-click flow, the resulting suppression is scoped to that same group — so the opt-out only affects future sends to that group, not your transactional email.
</Callout>

## RFC 8058 one-click unsubscribe

RFC 8058 defines a one-click unsubscribe mechanism: supporting email clients (Gmail, Yahoo, Apple Mail, and others) send an automatic `POST` to a unsubscribe URL when the recipient clicks the unsubscribe button, without the recipient having to visit a web page.

### How it works

1. **Headers on outgoing mail.** When the sending domain has `unsubscribe_tracking` enabled (the default), Telnyx adds two headers to every outgoing message:
   * `List-Unsubscribe: <https://...tracking/unsubscribe/{token}>` — a signed, unguessable URL.
   * `List-Unsubscribe-Post: List-Unsubscribe=One-Click` — signals RFC 8058 support to the mailbox provider.

2. **Recipient unsubscribes.** A recipient can unsubscribe two ways:
   * **One-click (`POST`)** — the email client sends an automatic `POST` to the unsubscribe URL (RFC 8058). Telnyx records the unsubscribe with `method: "one_click"`.
   * **Link (`GET`)** — the recipient clicks the unsubscribe link and lands on a confirmation page. Telnyx records the unsubscribe with `method: "link"`.

3. **Suppression created.** Telnyx enqueues a worker that creates an `unsubscribe` suppression for the recipient. When the original send specified a `group_id`, the suppression is scoped to that group.

4. **Event recorded.** Telnyx records an `email.unsubscribed` event on the message (with the `method` — `link` or `one_click`). These events also flow to configured webhooks.

Both `POST` and `GET` always return `200`, even for invalid tokens, to avoid leaking information about which message IDs exist (per RFC 8058 §3).

### Enabled by default

`unsubscribe_tracking` defaults to `true` for every email domain. This is intentional: Gmail and Yahoo require RFC 8058 one-click unsubscribe support for bulk senders. You can disable it per domain if you handle unsubscribes yourself, but doing so risks deliverability problems with those providers.

```bash curl theme={null}
curl -X PATCH https://api.telnyx.com/v2/email_domains/{domain_id} \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ***" \
  -d '{
    "tracking": {
      "unsubscribe_tracking": true,
      "open_tracking": false,
      "click_tracking": false
    }
  }'
```

<Callout type="info">
  Open and click tracking default to `false`; unsubscribe tracking defaults to `true`. The one-click unsubscribe flow only fires when the sending domain has `unsubscribe_tracking` enabled.
</Callout>

<Callout type="warning">
  The `List-Unsubscribe` URL carries a signed HMAC token, not a bare message ID. If the signing key is missing or misconfigured, Telnyx omits the unsubscribe headers entirely rather than emitting an invalid token — recipients can't unsubscribe from a message with no headers, but they also won't see a broken unsubscribe button.
</Callout>
