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

# Preference center: tenant controls and contract

> The posture reference for the hosted preference center: every configuration toggle and its default, the consent, suppression, and audit artifacts each contact decision writes, the public token contract and its failure codes, and the RBAC boundary.

# Preference center: tenant controls and contract

The **preference center** is a hosted public page where a contact manages
their own channel opt-ins, subscription topics, message frequency, and
(optionally) files a data-deletion request. This page is the posture and
contract reference: the knobs your organization owns, the artifact each
contact decision writes, and how the public surface fails. For
step-by-step configuration, branding, and link placement, use the
walkthrough at
[Preference center: the public opt-in/opt-out page](/guides/preference-center-opt-out-page).

<Note>
  The preference center is a **tenant-owned control**: you choose the
  channels, topics, and branding, and your organization holds the consent
  evidence. Orbit hosts the page and records the artifacts; it does not
  set your posture. This page is not legal advice.
</Note>

***

## 1. Where it sits in the control map

The preference center is the contact-written half of your consent
posture. Four surfaces work together:

| Surface | Written by | Holds |
| - | - | - |
| [Consent management](/compliance/consent-management) | Your operators and API | The append-only consent ledger: proof of consent per channel and per purpose. |
| [Opt-out & suppression](/compliance/opt-out-suppression) | STOP keywords, CSV imports, the preference center | The durable suppression entries every send gate reads. |
| [Send gates](/compliance/send-gates) | Orbit, at send time | Pre-send enforcement: suppression, STOP-fence, quiet hours, frequency caps. |
| Preference center (this page) | Your contacts, self-serve | Contact-written channel, topic, and frequency consent that feeds the three surfaces above. |

A decision made on the hosted page is not a UI flag: it lands in the same
consent ledger and suppression entries that a STOP keyword or an operator
action writes.

The contract is defined by two route surfaces:

* **Admin/API configuration** (`apps/api/src/routes/compliance/preference-center.routes.ts`):
  `POST /preference-center`, `GET /preference-center`, and
  `POST /preference-center/link`.
* **Public, unauthenticated** (`apps/api/src/routes/compliance/preference-center-public.ts`):
  `GET /preferences/:token` and `PUT /preferences/:token`, gated by a
  signed token instead of a login.

All endpoints are rooted at
`https://api.orbit.devotel.io/api/v1/compliance`.

***

## 2. The toggle surface

Configure with `POST /preference-center` and read back with
`GET /preference-center`. Every field is validated server-side; a
rejected POST returns `422` with per-field issues. Fields you omit take
the server-side defaults below, so a minimal config is safe, and optional
fields left unset simply do not render.

| Field | Default | Posture when unset or off |
| - | - | - |
| `enabled` | `true` | `false` (or no config at all): the public page answers `404 NOT_FOUND`, and `GET /preference-center` returns `enabled: false` with a hint. Nothing else in your compliance posture changes. |
| `companyName` | required | A POST without it is rejected with `422`. |
| `logoUrl` | none | No logo on the page. `http://`/`https://` only; any other scheme is a `422`. |
| `primaryColor` | `#2563eb` | The default accent. Must be `#rrggbb`. |
| `headerText` | `"Communication Preferences"` | The default heading. |
| `footerText` | `"We respect your communication preferences."` | The default footer copy. |
| `optOutMessage` | `"Manage your preferences"` | The label appended next to the link when Orbit appends the footer to a message for you. One string in one language: set it in the language your recipients read. |
| `channels` | `["sms","email"]` | At least one of `sms`, `whatsapp`, `email`, `rcs`, `viber`, `voice`; an empty array is a `422`. Only the listed channels render on the page. |
| `showFrequencyOptions` | `true` | `false` hides the frequency selector (`all`, `important_only`, `weekly_digest`, `monthly_digest`) on the page. |
| `showGdprDelete` | `true` | `false` hides the deletion toggle on the page. The `requestDataDeletion` field stays part of the PUT contract. |
| `customCss` | none | The page keeps its default styling. Up to 10,000 characters, injected into the hosted page. |
| `redirectUrl` | none | The contact stays on the confirmation view after saving instead of being redirected. `http://`/`https://` only. |
| `topics` | `[]` | No topic toggles render and topic-level consent does not apply until you define groups. Up to 50. |

**Topics.** Each topic `id` is a stable slug
(`[a-z0-9][a-z0-9_-]{0,63}`), unique across the array (duplicates are a
`422`); `name` is the page label; `description` is optional context shown
beside the toggle; `defaultOptIn` (default `false`) decides how a contact
with no recorded choice is treated; `archived` removes the topic from the
page while its history stays in the consent ledger.

**Links.** A minted link carries an HMAC-SHA256 token
(`v1.<payload>.<signature>`) scoped to exactly one contact in exactly one
organization, valid for **30 days**.

***

## 3. What each action writes

Every contact decision on the page writes the same artifacts the
STOP-keyword path writes on the inbound webhooks, so a form opt-out
blocks sends exactly as a keyword STOP does:

| Contact action on the page | Recorded artifacts |
| - | - |
| Opt out of any channel | `global_opt_out` set on the contact's channel preferences; one `consent_records` row per cross-channel with `source: preference_center`; a `suppression_list` entry on channel `all` keyed by the canonical E.164 address; the Redis STOP-fence, so in-flight batches stop before the slower ledger propagates. |
| Opt back in to every channel | `global_opt_out` cleared; `opted_in` consent rows; suppression entries revoked (`revoked_at` set, rows kept for the audit trail); STOP-fence cleared. |
| Toggle a topic | The contact's per-topic state merged into their attributes; one `consent_records` row per changed topic, keyed `purpose: <topicId>`, `consent_type: subscription_group`. Unknown topic ids are ignored, never persisted. |
| Choose a frequency | The contact's frequency preference stored on the contact record. |
| Request data deletion | A `pending` deletion-request row for your data-deletion process. This is a flag, not the tracked DSAR pipeline: route right-to-erasure requests through [DSAR](/compliance/dsar). |
| You change the config | An audit entry `compliance.preference_center_updated` recording the enabled state and channels. |

The operator-facing read surface for the same consent ledger is
[Consent management](/compliance/consent-management)
(`apps/api/src/routes/compliance/consent-management.controller.ts`), and
the suppression writes mirror the inbound STOP-keyword handler, so both
entry points block sends the same way. The hosted page also shows the contact their own
history, newest first, drawn from that same ledger.

<Note>
  **Channel wins over topic.** A channel-level opt-out silences every
  message on that channel; a topic toggle only narrows consent within the
  channels the contact still accepts. Re-opt-in is symmetric only when
  every channel in the request is set to `opted_in`.
</Note>

***

## 4. Regional anchoring

The preference center is the mechanism; the obligations are yours, per
jurisdiction.

* **US CAN-SPAM.** A clear unsubscribe mechanism that keeps working for at
  least 30 days after the message was sent, with requests honored within
  10 business days. The 30-day token TTL plus the durable suppression
  entry cover the mechanism; the honor window is your process. See
  [CAN-SPAM](/compliance/can-spam).
* **Canada CASL.** Unsubscribe requests handled without delay and no later
  than 10 business days after receipt. See
  [CASL](/compliance/casl-canada-anti-spam).
* **GDPR.** Withdrawal of consent must be as easy as granting it
  (Art 7(3)), information must be transparent (Arts 12–14), and consent
  must be demonstrable (Art 5(2)). The append-only consent ledger and the
  contact's self-serve history are the record-keeping half. See
  [GDPR posture](/compliance/gdpr-posture-guide).

**Your duty vs Orbit's.** You publish the link in footers and templates,
choose the channels, topics, and honor windows, and meet the response
deadlines. Orbit hosts the page, records the consent, suppression,
STOP-fence, and audit artifacts, and keeps them readable from your
dashboard and this API.

***

## 5. Failure modes

| Code | Meaning | What to do |
| - | - | - |
| `400 INVALID_TOKEN` | The token is malformed or truncated. | Check that the link was copied verbatim; the page fetches the data endpoint with the token from `?token=`. |
| `401 TOKEN_EXPIRED` | The 30-day TTL passed, or the signature does not match. | Mint a fresh link with `POST /preference-center/link`. |
| `404 NOT_FOUND` | No organization for the token's tenant, the preference center is disabled, or the contact does not exist (deleted contacts are excluded). | Confirm `enabled` is `true` and the contact id is live. |
| `422 VALIDATION_ERROR` | The config or update body failed field validation; the response carries per-field issues. | Fix the named field and resend. |

Error responses carry the code, a human-readable message, and a request
id. The invalid- and expired-token responses do not disclose which
contact or organization a token referred to.

***

## 6. RBAC posture

* **Changing the config** (`POST /preference-center`) and **minting links**
  (`POST /preference-center/link`): organization **owner or admin** roles
  only.
* **Reading the config** (`GET /preference-center`): any authenticated
  member of the organization.
* **The public page** (`GET`/`PUT /preferences/:token`): unauthenticated by
  design. The signed token is the authorization: one contact, one
  organization, 30 days. Email and phone come back masked, so the page
  never echoes the raw identifiers it was minted for.

Rate limits: 60/min on config reads, 10/min on admin writes, 30/min on
public reads, and 10/min on public writes.

***

## Worked example

Configure the center with topics and a custom opt-out label, then mint a
per-contact link:

```bash theme={null}
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 Logistics",
    "optOutMessage": "Manage how Acme contacts you",
    "channels": ["sms", "email"],
    "topics": [
      { "id": "shipping-updates", "name": "Shipping updates", "defaultOptIn": true },
      { "id": "promotions", "name": "Promotions", "defaultOptIn": false }
    ]
  }'
```

Expected: `201` with the saved config echoed. Then mint the link:

```bash theme={null}
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 response returns `link`: the hosted page URL with `?token=v1…`
appended. The page itself reads and writes through
`GET`/`PUT /preferences/:token`. Placement guidance (email footer, SMS
fallback) lives in the
[walkthrough](/guides/preference-center-opt-out-page); how the resulting
suppression entries interact with imports and keyword STOPs lives in
[Opt-Out & Suppression Lists](/compliance/opt-out-suppression).

***

## Related

* [Preference center: the public opt-in/opt-out page](/guides/preference-center-opt-out-page) —
  the setup walkthrough.
* [Opt-Out & Suppression Lists](/compliance/opt-out-suppression) — the
  suppression entries this surface writes.
* [Consent management](/compliance/consent-management) — the
  operator-side API over the same consent ledger.
* [Send gates and pre-send guards](/compliance/send-gates) — where the
  STOP-fence and suppression entries are enforced.
* [DSAR reference](/compliance/dsar) — the tracked erasure pipeline for
  deletion requests.


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