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

# Unsubscribe API

> Push and query opt-out / opt-in rules keyed by email or phone — stored independently of TruAgents contact records, and able to apply across multiple organizations.

## Machine-readable specification

The complete OpenAPI 3.0.3 description of the Unsubscribe API — request and response schemas, parameters, error envelopes, and security definitions — is published alongside this guide. Generate client SDKs from it, or import it into Postman / Insomnia for interactive exploration.

<Card title="OpenAPI specification" icon="file-code" href="/openapi/truagents.json">
  Download `truagents.json` — covers `POST /oauth/token` and all six `/api/v1/unsubscribe/{email,sms,phone}` operations.
</Card>

This guide is the canonical prose narrative; the OpenAPI document is the machine-readable contract. If you spot drift between the two, treat this page as authoritative and let us know.

## Summary

The Unsubscribe API lets a partner system maintain **opt-out and opt-in rules** keyed by raw identifier — an email address or a phone number. Each rule decides whether TruAgents may send on a given channel.

Three properties distinguish these rules from per-contact suppression flags:

* **Decoupled from Contact records.** Rules are stored separately from TruAgents contacts. You can push a rule for an email or phone that does not exist as a contact in any of your TruAgents organizations, and you can query state for any identifier whether or not a matching contact exists.
* **Can apply globally across organizations.** A single rule can govern sending behavior in any subset (or all) of the organizations you manage on TruAgents — including ones where the contact only exists in *one* of those organizations, or in *none* of them. This is configured per channel at onboarding time (see [Channel scoping model](#channel-scoping-model) below).
* **Opt-ins are first-class.** Rules carry an `unsubscribed` boolean; setting it to `false` re-enables sending on that channel. TruAgents preserves the row and updates `source` and `updated_at` to reflect the latest transition. A complete event log of every change is not part of this contract — `source` and `updated_at` describe only the most recent transition.

## Who this is for

External compliance, suppression-list, or CRM systems that need to keep TruAgents in sync with the partner's source-of-truth opt-out / opt-in state.

## Rules, not contacts

Records pushed through this API are independent of any TruAgents `Contact` row:

* A rule is a `(channel, identifier, unsubscribed)` triple stored against an admin-controlled channel scope, not tied to a TruAgents contact record.
* TruAgents joins rules to contacts at send time, by matching the contact's email or phone against rules in the contact's organization's channel scope. The rule existed before the contact was created, or after, or even if the contact is never created — the join is by raw identifier.
* A single rule can suppress (or re-enable) a channel for contacts living in multiple TruAgents organizations, when an administrator has configured those organizations to share a channel scope. The contact does not need to exist in every organization that the rule applies to — or in any of them.
* Deleting a contact in TruAgents does not delete the rule; pushing a rule does not create a contact.

This means you can populate the unsubscribe API in any order relative to contact uploads:

* before any contacts exist in TruAgents (e.g., at onboarding, when pushing a known suppression list)
* alongside contact uploads (order of operations does not matter)
* for identifiers that may never become contacts (e.g., addresses you have suppressed on your side but never sent us as leads)

## Channel scoping model

TruAgents tracks opt-out state for three channels: **email**, **sms**, and **phone** (voice calls). For each channel, a *scope* decides which TruAgents organizations a rule applies to. Each organization has an independent scope per channel, set by a TruAgents administrator at onboarding time and invisible to the partner over the API.

Each channel's scope works the same way:

* **Default:** a fresh per-organization scope at organization creation. Rules pushed against an organization's scope apply only to that organization.
* **Shared scope:** an administrator can point any group of organizations at the same scope value. Rules then apply across every organization in that group — including ones where no matching Contact exists.

The `phone` (voice) channel has one additional configuration option: an administrator can set its scope equal to the same organization's `sms` scope, producing a single suppression list that covers both SMS and voice on the same phone number. Equivalently, they can be kept distinct so SMS and voice can be opted out independently.

The partner does not pass a channel scope identifier. The partner *may* pass an `org_slug` to target a specific organization their API key is authorized to address (see [Managing multiple organizations](#managing-multiple-organizations)); when omitted, the request applies to the key's default organization. The configured channel scoping then decides whether each write fans out to other organizations.

### Examples

* An organization with the default per-organization SMS scope: `POST` an SMS opt-out for `+15551234567` — it suppresses SMS sends in that one organization only.
* An organization whose SMS scope is shared with two sibling organizations (A, B, C): `POST` an SMS opt-out for `+15551234567` once — it suppresses SMS sends in A, B, and C, *regardless of which of A, B, or C the phone exists as a contact in, including the case where the phone is not a contact in any of them*.
* An organization with the default per-organization email scope: `POST` an email opt-out for `john@example.com` — it suppresses email sends in that one organization only.
* An organization whose email scope is shared with siblings: `POST` an email opt-out for `john@example.com` — it suppresses email sends across the entire shared scope, by the same rule as SMS.
* An organization with `sms` and `phone` scopes set to the same value: a single `POST` to `/api/v1/unsubscribe/sms` and a separate `POST` to `/api/v1/unsubscribe/phone` for the same phone number write to the same underlying list; setting one channel's `unsubscribed` independently of the other is not possible until an administrator splits the scopes.

## Authentication

The Unsubscribe API uses TruAgents' standard OAuth 2.0 `client_credentials` flow. Your TruAgents administrator issues a `client_id` + `client_secret` pair; your service exchanges them for a short-lived bearer token at `POST /oauth/token` and sends the bearer token on every request below. See [Authentication](/developers/authentication) for the full flow, token lifetimes, refresh-token rotation, and error envelopes.

Each `client_id` is bound to a **set of organizations** with one designated as the **default** — requests that omit `org_slug` apply to the default; requests that include `org_slug` apply to that organization, provided it is in the authorized set. See [Managing multiple organizations](#managing-multiple-organizations) for the full mechanic and how to discover the slugs your key is authorized for.

## Managing multiple organizations

One `client_id` can address several TruAgents organizations on your behalf. Your TruAgents administrator picks the authorized set and the default when the credential is issued and reviews them on every change.

**Targeting a specific organization.** Supply an `org_slug` on `GET` (as a query parameter) or `POST` (as a top-level body field). One call addresses exactly one organization — responses are never mixed.

**Omitting `org_slug`.** The request applies to the key's **default organization**. The response echoes the resolved `org_slug` so you can confirm which default was used.

**Authorized vs. unknown slug.** The server returns `403 unauthorized_organization` whenever the supplied slug is not currently in your key's authorized set — including the case where the slug does not exist at all. The response body echoes the slug back so you can spot a typo without the server confirming whether the slug exists:

```json theme={null}
{
  "error": "unauthorized_organization",
  "error_description": "The provided org_slug is not authorized for this API key.",
  "org_slug": "acme-cop"
}
```

**Discovering your slugs.** Until a discovery endpoint ships, your TruAgents administrator tells you which `org_slug` values your key is authorized for and which one is the default. Keep these in your configuration alongside the `client_id`.

**Scope-sharing interaction.** The authorized set bounds the `org_slug` values you may target. The downstream effect of each write is still decided by admin-configured channel scopes — addressing org A may suppress sends in sibling organizations whose channel scope is shared with A, even if those siblings are not in your authorized set. The [channel scoping model](#channel-scoping-model) section describes that mechanic; the authorized set just decides which slug you may *name*, not which orgs are *affected* downstream.

**Rate limits.** Per `client_id`, not per `client_id` × `org_slug` — see [Rate limits](/developers/authentication#rate-limits) for ceilings and how to request a bump.

## Endpoints

Three channel-scoped resources, each with `GET` (list current state) and `POST` (push changes):

| Channel | Base path                                            | Identifier field |
| ------- | ---------------------------------------------------- | ---------------- |
| Email   | `https://api.truagents.com/api/v1/unsubscribe/email` | `email`          |
| SMS     | `https://api.truagents.com/api/v1/unsubscribe/sms`   | `phone` (E.164)  |
| Voice   | `https://api.truagents.com/api/v1/unsubscribe/phone` | `phone` (E.164)  |

### Query opt-out state

Each `GET` returns a page of state-change records for one channel, in one organization, **newest first**. Treat the endpoint as a **change stream**: each call returns a page within the pagination window; `cursor` advances toward older records; `since` and `until` bound the window. The same parameters apply to all three endpoints — the examples below illustrate different combinations of `org_slug`, `since`, and `limit`.

```bash GET /api/v1/unsubscribe/email (default organization, latest 50) theme={null}
curl -X GET "https://api.truagents.com/api/v1/unsubscribe/email?limit=50" \
  -H "Authorization: Bearer <access_token>"
```

```bash GET /api/v1/unsubscribe/sms (since a fixed timestamp) theme={null}
curl -X GET "https://api.truagents.com/api/v1/unsubscribe/sms?since=2026-04-01T00:00:00Z" \
  -H "Authorization: Bearer <access_token>"
```

```bash GET /api/v1/unsubscribe/phone (explicit org_slug) theme={null}
curl -X GET "https://api.truagents.com/api/v1/unsubscribe/phone?org_slug=acme-corp&limit=200" \
  -H "Authorization: Bearer <access_token>"
```

**Query parameters**

| Parameter  | Type     | Required | Description                                                                                                                                                                                                                                                                                          |
| ---------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `org_slug` | string   | No       | Organization to read. Must be in your key's authorized set. Omit to use the key's default organization. Optional when `cursor` is supplied — the cursor self-encodes the target organization. If provided alongside a cursor, the value must match the cursor's `org_slug`; mismatches return `400`. |
| `since`    | ISO 8601 | No       | Floor of the pagination window — records with `updated_at` on or after this timestamp. Omit to leave the window open-ended on the old side. Silently ignored when `cursor` is supplied (the cursor encodes the window).                                                                              |
| `until`    | ISO 8601 | No       | Ceiling of the pagination window — records with `updated_at` on or before this timestamp. Omit to leave the window open-ended on the newest side; the first page then starts at the most recent record overall. Silently ignored when `cursor` is supplied.                                          |
| `cursor`   | string   | No       | Opaque continuation token from the previous response's `next_cursor`. When supplied, the server reads the target `org_slug` and pagination state from the cursor. Send `org_slug` alongside only as a sanity check — disagreement triggers `400`.                                                    |
| `limit`    | integer  | No       | Records per page (default: 100, max: 1000).                                                                                                                                                                                                                                                          |

`since` and `until` are pagination bounds, not arbitrary filters: each call returns a page in `updated_at` descending order, and subsequent calls via `cursor` walk toward older records until `has_more` is `false`. To find the current state of a specific identifier, walk far enough — the most recent record for that identifier (the first one encountered) is its current state.

**Response — email endpoint**

```json theme={null}
{
  "org_slug": "acme-corp",
  "data": [
    {
      "email": "john@example.com",
      "unsubscribed": true,
      "source": "api",
      "updated_at": "2026-04-15T10:30:00Z"
    },
    {
      "email": "alice@example.com",
      "unsubscribed": false,
      "source": "admin",
      "updated_at": "2026-04-13T09:00:00Z"
    }
  ],
  "next_cursor": "eyJvcmciOiJhY21lLWNvcnAiLCJ0cyI6IjIwMjYtMDQtMTNUMDk6MDA6MDBaIn0",
  "has_more": true
}
```

**Response — SMS / Voice endpoints**

```json theme={null}
{
  "org_slug": "acme-corp",
  "data": [
    {
      "phone": "+15551234567",
      "unsubscribed": true,
      "source": "user_action",
      "updated_at": "2026-04-12T16:22:01Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

* `org_slug` echoes the organization this page belongs to — either the value you supplied, the one carried by `cursor`, or your key's default organization when neither was provided.
* Each record represents the **current** state of one identifier on this channel; the page only contains identifiers whose most recent transition falls inside the pagination window.
* `unsubscribed: true` ⇒ opted out (TruAgents will not send on this channel).
* `unsubscribed: false` ⇒ opted in (record kept for history; sends proceed normally if no other suppression applies).
* `source` records who or what last changed the state. The same five values apply to every endpoint (email, SMS, voice):
  * `api` — pushed by the partner via this API.
  * `admin` — a TruAgents operator manually changed the state via TruAgents' internal admin surface (e.g. compliance response, support-driven override). The `updated_at` reflects the operator's action; the operator identity is not exposed in the partner response.
  * `import` — bulk import: CSV upload, CRM sync (HubSpot / Salesforce / Mailchimp), or the one-time legacy backfill from pre-existing opt-out state.
  * `user_action` — recipient took a concrete provider-observed action: SendGrid unsubscribe link, SendGrid list-unsubscribe header, Twilio STOP / UNSUBSCRIBE keyword. Opt-ins via Twilio START / YES / UNSTOP emit the same source with `unsubscribed: false`. On the voice endpoint, `user_action` is visible when your administrator has unified your organization's SMS and voice scopes and a Twilio event suppressed the shared row.
  * `user_intent` — TruAgents inferred opt-out intent from an inbound message. The provider did not observe a subscription action; TruAgents acted on the recipient's behalf. Only visible for organizations enrolled in TruAgents' inbound-message classifier.
* `updated_at` is the timestamp of the most recent state change.
* Records are ordered by `updated_at` descending.
* `next_cursor` is `null` when there are no more results.

### Push opt-out / opt-in changes

Each endpoint accepts a batch of `{ identifier, unsubscribed }` items targeting one organization. Both opt-outs (`true`) and opt-ins (`false`) are accepted. Up to 10,000 items per request.

Items are processed in array order. If the same identifier appears more than once in the same batch, only the first occurrence is applied; every subsequent occurrence is rejected with `reason: "duplicate identifier"` and surfaces in `skipped_items`. Dedupe on your side before posting.

The examples below show explicit `org_slug` on each channel; omit it to use the key's default organization.

```bash POST /api/v1/unsubscribe/email theme={null}
curl -X POST "https://api.truagents.com/api/v1/unsubscribe/email" \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "org_slug": "acme-corp",
    "items": [
      { "email": "john@example.com",  "unsubscribed": true  },
      { "email": "alice@example.com", "unsubscribed": false }
    ]
  }'
```

```bash POST /api/v1/unsubscribe/sms theme={null}
curl -X POST "https://api.truagents.com/api/v1/unsubscribe/sms" \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "org_slug": "acme-corp",
    "items": [
      { "phone": "+15551234567", "unsubscribed": true }
    ]
  }'
```

```bash POST /api/v1/unsubscribe/phone theme={null}
curl -X POST "https://api.truagents.com/api/v1/unsubscribe/phone" \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "org_slug": "acme-corp",
    "items": [
      { "phone": "+15551234567", "unsubscribed": true }
    ]
  }'
```

To suppress both SMS and voice for the same phone, call both endpoints — or call them in parallel. Each endpoint is independent. To write to multiple organizations, send one request per organization with the appropriate `org_slug`.

**Request body** (same shape for all three endpoints, identifier field differs)

| Field      | Type   | Required | Description                                                                                                                                             |
| ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `org_slug` | string | No       | Organization to write to. Must be in your key's authorized set. Omit to use the key's default organization. One batch targets exactly one organization. |
| `items`    | array  | Yes      | Array of state-change operations (max 10,000).                                                                                                          |

Each item:

| Field          | Type    | Required                    | Description                                         |
| -------------- | ------- | --------------------------- | --------------------------------------------------- |
| `email`        | string  | Yes (email endpoint)        | Email address.                                      |
| `phone`        | string  | Yes (sms / phone endpoints) | Phone number in E.164 format (e.g. `+15551234567`). |
| `unsubscribed` | boolean | Yes                         | `true` to opt-out, `false` to opt-in.               |

**Response**

```json theme={null}
{
  "org_slug": "acme-corp",
  "processed": 2,
  "updated": [
    { "email": "john@example.com",  "unsubscribed": true,  "updated_at": "2026-04-15T10:30:00Z" },
    { "email": "alice@example.com", "unsubscribed": false, "updated_at": "2026-03-02T14:11:42Z" }
  ],
  "skipped_items": []
}
```

* `org_slug` echoes the organization the batch was applied to — either the value you supplied, or your key's default organization when you omitted it.
* The operation is **not atomic** — each item is processed independently. Partial failures do not roll back successful updates.
* `processed` is the count of items accepted into `updated` (i.e., `items.length − skipped_items.length`). The call is **idempotent**: items already at the requested state are still counted in `processed` and listed in `updated`, even though no state change occurred in the database.
* `updated` lists every accepted item — one input item produces exactly one entry. The returned `updated_at` is the row's current state-change timestamp; to detect whether this write moved state, compare it against the `updated_at` you previously observed for the same identifier (from an earlier GET or POST response).
* `skipped_items` lists items rejected due to validation conflicts (see below).

### Conflicts and validation

| Condition                                                                                                           | Outcome                                                                                                                                       |
| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Email endpoint item missing `email`, or value not a string                                                          | 422; `skipped_items` with `reason: "missing identifier"`.                                                                                     |
| SMS / Voice endpoint item missing `phone`, or value not a string                                                    | 422; `skipped_items` with `reason: "missing identifier"`.                                                                                     |
| Item missing `unsubscribed` field                                                                                   | 422; `skipped_items` with `reason: "missing unsubscribed"`.                                                                                   |
| Email is not a valid email address                                                                                  | 422; `skipped_items` with `reason: "invalid email format"`.                                                                                   |
| Phone is not valid E.164                                                                                            | 422; `skipped_items` with `reason: "invalid phone format"`.                                                                                   |
| Identifier already appeared earlier in the batch                                                                    | 422; `skipped_items` with `reason: "duplicate identifier"`. First occurrence is processed normally.                                           |
| Batch exceeds 10,000 items, malformed JSON, missing `items`, or any item carries an unknown field                   | 400 on the request.                                                                                                                           |
| `GET` with both `cursor` and `org_slug` where the cursor's embedded slug does not match the supplied `org_slug`     | 400 on the request. Drop the cursor or omit the slug.                                                                                         |
| `cursor` cannot be decoded (malformed base64 or JSON, or truncated during transport)                                | 400 on the request with `error: "invalid_cursor"`. Drop the cursor and start a fresh page.                                                    |
| `org_slug` not in your key's authorized set (either it does not exist, or your administrator has not authorized it) | 403 on the request; body carries `error: "unauthorized_organization"` and echoes the offending slug. See [Error responses](#error-responses). |

## Error responses

| Status | `error`                     | Description                                                                                                                                                                                                 |
| ------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `bad_request`               | Malformed request, missing `items`, batch size exceeds 10,000, an unknown query parameter, or a `GET` that supplies both `cursor` and `org_slug` with disagreeing values.                                   |
| 400    | `invalid_cursor`            | The `cursor` cannot be decoded (malformed base64 or JSON), or references an organization no longer authorized for this API key. Drop the cursor and start a fresh page.                                     |
| 401    | `invalid_token`             | Missing, invalid, expired, or inactive access token. See [Authentication](/developers/authentication#step-3-refresh-the-access-token).                                                                      |
| 403    | `unauthorized_organization` | The supplied `org_slug` is not in your key's authorized set. Returned identically whether the slug does not exist at all or simply is not authorized for this key. Response body echoes the offending slug. |
| 422    | n/a                         | Per-item validation failure; details in `skipped_items`.                                                                                                                                                    |
| 429    | n/a                         | Rate limit exceeded — see [Authentication / Rate limits](/developers/authentication#rate-limits). Includes a `Retry-After` header.                                                                          |

## What TruAgents tracks per record

The channel is implicit in the endpoint you call. Each record persisted by the API or by an internal source carries:

| Field                                                     | Meaning                                                                                                                                                                                                                                                 |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email` (email endpoint) / `phone` (sms, phone endpoints) | Identifier the record applies to.                                                                                                                                                                                                                       |
| `unsubscribed`                                            | Current opt-out state.                                                                                                                                                                                                                                  |
| `source`                                                  | What system most recently changed the state. One of `api`, `admin`, `import`, `user_action`, `user_intent` — the same five values on every endpoint. See the source bullets under [Query opt-out state](#query-opt-out-state) for the full definitions. |
| `updated_at`                                              | Timestamp of the most recent state change.                                                                                                                                                                                                              |

Opt-ins (transitions from `true → false`) are first-class — TruAgents preserves the record with `unsubscribed: false`, updates `source` and `updated_at` to reflect the new state, and respects the most recent change regardless of which source wrote it. Only the latest state is queryable; intermediate transitions are not retained.

## Internal sources that can change state

In addition to the partner API, the same records are written by:

* **Provider-managed subscription actions** — recipient-driven opt-outs (SendGrid unsubscribe link, SendGrid list-unsubscribe header, Twilio STOP keyword) and opt-ins (Twilio START / YES / UNSTOP, and equivalents). Source: `user_action` on both channels. If your TruAgents administrator has configured your SMS and voice scopes to share a single suppression list, Twilio-sourced records also surface on the voice (`/phone`) endpoint — they suppress both channels in that configuration.
* **Bulk imports** — CSV uploads and CRM sync (HubSpot / Salesforce / Mailchimp). Source: `import` on every endpoint. Imported opt-outs are pre-existing state whose original mechanism is not persisted on the row itself; the ingestion pipeline is identified separately on internal records for identifiable imports.
* **TruAgents admin UI** — manual changes by a TruAgents operator. Source: `admin`. Admin changes to the SMS or voice list under a unified scope behave the same as provider-sourced ones: the single underlying record is visible on both the `/sms` and `/phone` endpoints.
* **TruAgents classifier** — when TruAgents infers opt-out intent from an inbound message, TruAgents' classifier writes `source='user_intent'`. No partner or admin action is involved; TruAgents acts on the recipient's behalf. This is distinct from `user_action`, where a provider (SendGrid / Twilio) directly observes the recipient's subscription action.

When two sources change state for the same identifier, the most recent write wins. The partner API and internal sources do not have a conflict resolution beyond timestamps; each push is authoritative for the moment it is applied.

## Common mistakes

* Calling the wrong endpoint for the channel — SMS opt-outs go to `/api/v1/unsubscribe/sms`, voice opt-outs go to `/api/v1/unsubscribe/phone`. They are independent lists.
* Including the wrong identifier field in an item — the email endpoint accepts only `email`; the SMS and voice endpoints accept only `phone`.
* Mixing organizations in one batch — `org_slug` is top-level; one request targets one organization. Build one batch per organization and send them in sequence or in parallel.
* Sending a mismatched `org_slug` alongside `cursor` on a `GET` — `org_slug` is optional when `cursor` is supplied (the cursor already encodes the target). Sending a matching value as a sanity check is fine; sending a different value returns `400`. To switch organizations, drop the cursor and start a fresh page.
* Sharing cursors between deployments or credentials — cursors encode the target scope and pagination position, not an identity. Two keys authorized for the same organization can in principle interoperate on a cursor, but treat cursors as ephemeral and non-transferable across credentials by convention. Each credential should walk its own pagination state; if a credential's authorized set changes mid-walk, its own cursor may start returning `400 invalid_cursor` and you re-anchor by dropping it.
* Assuming a `403 unauthorized_organization` means the slug is wrong — it can also mean the slug exists but your administrator has not (yet) added it to your key's authorized set. The response does not distinguish the two; ask your administrator if you expected access.
* Assuming an email opt-out applies across all your TruAgents organizations — each organization's email channel scope is independent unless your TruAgents administrator has explicitly configured a shared channel scope at onboarding. Targeting one organization with `org_slug` only changes which organization you address; it does not change downstream channel-scope sharing.
* Using inconsistent phone formats — phones must be E.164 (`+15551234567`), not `(555) 123-4567` or `555-123-4567`.
* Treating every item in the POST `updated` list as a state change. Compare the returned `updated_at` against a value you previously observed for the same identifier to distinguish actual transitions from idempotent no-ops. For an identifier you have not previously read, the row may already exist at the requested state because an internal source (`user_action`, `import`, `admin`, or `user_intent`) wrote it first — issue a GET before the POST if definitive no-op detection matters.
* Branching on old channel-specific source values. Prior to this contract revision, email opt-outs surfaced as `user_sendgrid` and SMS / voice opt-outs as `user_twilio`. Both collapse to `user_action` under the current enum, and `external_api` collapses to `api`. Partner integrations that branch on the old values must accept the new tokens; during the transition window a partner may observe both old and new values in the same response until the underlying rows are updated.

## Related pages

* [Integrations overview](/integrations/overview)
* [Authentication](/developers/authentication)
* [Errors and status codes](/developers/errors-and-status-codes)
* [Contacts, segments, and tags](/concepts/contacts-segments-and-tags)
