> ## 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: the public opt-in/opt-out page

> Configure the hosted, token-signed preference center — branding, channels, frequency options, and the GDPR-delete toggle — mint a per-contact link, and know exactly which compliance surfaces an opt-out writes (consent, suppression, STOP-fence, audit).

# Preference center: the public opt-in/opt-out page

The **preference center** is a public page where a contact manages their
own channel opt-ins, subscription topics, message frequency, and (if you
enable it) files a data-deletion request — no account, no login. Each
contact reaches it through a **signed link**: the URL carries an
HMAC-SHA256 token (`v1.<payload>.<signature>`) that expires after
**30 days**, so the page is self-serve but still scoped to exactly one
contact in exactly one organization.

The summarized endpoint surface also lives inside
[Send Gates](/compliance/send-gates#preference-center); this guide is the
full walkthrough: every configuration field, where to place the link,
what the public page's API returns, and which compliance surfaces an
opt-out or opt-in writes.

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

<Note>
  The preference center is a **tenant-owned control**: you choose the
  channels, topics, and branding, and your organization holds the consent
  evidence. Orbit operates the platform; the consent decision belongs to
  the contact. This guide is not legal advice — confirm your obligations
  with counsel.
</Note>

***

## 1. Configure once: POST/GET /preference-center

Set the configuration with `POST /preference-center` (owner/admin API
key). The endpoint upserts the config into your organization's settings
and returns the saved object — run it again to update. `GET
/preference-center` reads the current config back; before configuration
it returns `enabled: false` with a hint message.

### Configuration fields

Every field is validated server-side — a rejected POST returns a `422`
with per-field issues (`field`, `message`) so you can tell which one
failed.

| Field                  | Type            | Default                                        | What it controls                                                                                   |
| ---------------------- | --------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `enabled`              | boolean         | `true`                                         | Master switch. When `false`, the public page returns "not available" to contacts.                  |
| `companyName`          | string (1–200)  | **required**                                   | Company name rendered on the hosted page.                                                          |
| `logoUrl`              | string (URL)    | —                                              | Logo picked up by the page. URLs are restricted to `http://` or `https://`.                        |
| `primaryColor`         | hex `#rrggbb`   | `#2563eb`                                      | Accent color for the page UI.                                                                      |
| `headerText`           | string (≤500)   | `"Communication Preferences"`                  | Page heading.                                                                                      |
| `footerText`           | string (≤1000)  | `"We respect your communication preferences."` | Page footer copy.                                                                                  |
| `optOutMessage`        | string (≤500)   | `"Manage your preferences"`                    | Footer label used when the link is appended to outbound messages automatically.                    |
| `channels`             | enum array (≥1) | `["sms","email"]`                              | Channels offered on the page. Allowed values: `sms`, `whatsapp`, `email`, `rcs`, `viber`, `voice`. |
| `showFrequencyOptions` | boolean         | `true`                                         | Expose the frequency selector (`all`, `important_only`, `weekly_digest`, `monthly_digest`).        |
| `showGdprDelete`       | boolean         | `true`                                         | Show the data-deletion toggle (see section 6).                                                     |
| `customCss`            | string (≤10000) | —                                              | Extra CSS injected into the hosted page.                                                           |
| `redirectUrl`          | string (URL)    | —                                              | Where the contact is sent after completing an opt-out. `http(s)` schemes only.                     |
| `topics`               | array (≤50)     | `[]`                                           | Subscription groups a contact toggles independently of the channel switch (see below).             |

### Subscription topics

A topic is a named group — Newsletter, Product Updates, Billing Alerts —
a contact can opt in or out of **without** touching the whole channel.
Topic `id`s must be unique and match the slug pattern
(`[a-z0-9][a-z0-9_-]{0,63}`); each entry has:

* `name` (1–120 chars) — the display name on the page.
* `description` (optional, ≤500) — one line of context shown beside the
  toggle.
* `defaultOptIn` (default `false`) — how a contact with no recorded
  preference is treated.
* `archived` (optional) — archived topics stay in the audit trail but no
  longer render on the page.

Lint-level gotchas: URLs that fail the `http(s)`-scheme refinement are
rejected up front, and duplicate topic `id`s fail with "Topic ids must
be unique" rather than silently overwriting.

***

## 2. Mint a per-contact link

Once configured, generate a link for one contact at a time with
`POST /preference-center/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` — a URL of the form
`${DEVOTEL_WEB_URL}/preferences?token=v1…`. Points to note:

* **The link targets the hosted page, not the JSON endpoint.** Copy it
  verbatim into your footer/sender templates; the page itself fetches
  the data endpoint under the hood.
* **30-day TTL.** After that the token verifies as expired and the
  contact must request a fresh link (minting a new one takes one API
  call).
* **The page is locale-agnostic at mint time.** The web app resolves a
  redirect while preserving the `?token=` query, so you don't need to
  guess the contact's locale.

### Where to place it

* **Email footer (primary).** Append the generated link (or the short
  tracked variant your mailer uses) in the unsubscribe area of marketing
  templates.
* **SMS / WhatsApp fallback.** When the message has no footer block,
  append the link inline: `{optOutMessage}: {link}`. The helper that
  builds outbound bodies accepts a pre-minted short link, so your
  unsubscribe click still gets normal click-attribution.
* **Suppression-driven re-opt-in.** When a contact re-opt-ins through
  some other flow, you can hand them a fresh link so they get the same
  self-serve page.

A raw link still works if short-link minting fails — the fallback is
additive, never load-bearing for compliance.

***

## 3. The public token page

The hosted page reads and writes through two unauthenticated endpoints
gated by the signed token:

* `GET /preferences/:token` — returns the page payload.
* `PUT /preferences/:token` — applies updates.

Invalid token shapes return `400 INVALID_TOKEN`; expired or tampered
tokens return `401 TOKEN_EXPIRED` with "Please request a new link."

### GET response

The payload bundles the contact's current state and the org's config:

```json theme={null}
{
  "contactId": "cnt_01H…",
  "displayName": "…",
  "email": "m*****@example.com",      // masked
  "phone": "+15551****…",             // masked
  "channelPreferences": { "sms": "opted_in", "email": "opted_out" },
  "frequencyPreference": "all",
  "channels": ["sms", "email"],
  "topics": [ { "id": "newsletter", "name": "Newsletter", "defaultOptIn": false } ],
  "topicPreferences": { "newsletter": "opted_in" },
  "consentHistory": [
    { "channel": "all", "state": "opted_out", "topicId": "newsletter", "occurredAt": "2026-09-01T…" }
  ],
  "config": { /* the org's preference-center config */ }
}
```

The email and phone are **masked** in the public response — the page
never shows the raw identifier it is being called with. `consentHistory`
is the contact's newest-first opt-in/opt-out audit trail, capped at 20
rows, drawn from the same consent ledger your operators see in the
dashboard.

### PUT request body

```json theme={null}
{
  "channelPreferences": {
    "sms": "opted_in",
    "email": "opted_out"
  },
  "frequencyPreference": "important_only",
  "topicPreferences": { "newsletter": "opted_in" },
  "requestDataDeletion": false
}
```

* `channelPreferences` — partial map allowed (Zod partial-record);
  **at least one channel is required**.
* `frequencyPreference` — optional, one of `all`, `important_only`,
  `weekly_digest`, `monthly_digest`.
* `topicPreferences` — optional `{ topicId: opted_in | opted_out }` map
  validated against your configured topics; unknown ids are ignored.
* `requestDataDeletion` — sets a GDPR-deletion request alongside the
  opt-out (see section 6).

A 422 response carries per-field issues so the hosted form can point at
the invalid choice.

***

## 4. How updates flow

An opt-in/opt-out written here is **not a UI flag only** — the same four
compliance surfaces a STOP keyword writes are updated:

* **Consent ledger.** One `consent_records` row per channel (or per
  topic) is appended with `source: preference_center` — your GDPR
  Art 7 burden-of-proof audit trail.
* **Suppression list.** On any opted-out channel, the contact's
  canonicalized phone/email is inserted with scope `all` —
  a cross-channel block every send gate reads.
* **STOP fence.** A Redis fast-path fence is set on opt-out (and cleared
  on a full re-opt-in), so in-flight campaign batches see the change
  before the slower database suppression propagates.
* **Audit log.** `compliance.preference_center_updated` is recorded
  when you change the config, and contact-level opt-in/out events are
  captured in the consent ledger.

Re-opt-in symmetric: a full opt-in (every channel `opted_in`) revokes
active suppression rows for the contact's phone and clears the STOP
fence, while the consent ledger gains the reversed entry.

<Note>
  **Topic vs channel.** A channel-level opt-out always wins — a topic
  toggle narrows consent *within* the channels the contact still accepts.
  Unknown topic ids in a PUT are ignored rather than persisted, so a
  stale form can't write arbitrary attribute keys.
</Note>

***

## 5. GDPR-delete toggle semantics

When `showGdprDelete` is enabled and the contact checks
`requestDataDeletion: true` in the PUT, the API records a **legacy GDPR
deletion request** — a `pending` row flagged for your data-deletion
process — alongside the opt-out. That flagging is deliberate: the
preference-center delete toggle flags the contact, it does **not** start
the tracked DSAR pipeline.

<Warning>
  The preference-center delete toggle has **no SLA clock, no decrypted
  data export, and no Article-17 erasure certificate.** For a
  right-to-erasure request your DPO can track, file it through the DSAR
  endpoint (`POST /compliance/dsar`, owner/admin) — see
  [Data Subject Access Requests](/compliance/dsar) and the
  [DSAR + breach register guide](/guides/compliance-dsar-breach-register).
</Warning>

***

## 6. Testing it

Two worked curl examples you can paste into a smoke script:

**Save the config:**

```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",
    "primaryColor": "#1d4ed8",
    "channels": ["sms", "email"],
    "headerText": "Manage how Acme contacts you",
    "topics": [
      { "id": "shipping-updates", "name": "Shipping updates", "defaultOptIn": true },
      { "id": "promotions", "name": "Promotions", "defaultOptIn": false }
    ]
  }'
```

Expected: `201` with the saved config echoed.

**Mint a link and exercise the public endpoints:**

```bash theme={null}
LINK=$(curl -s -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…"}' | jq -r '.link')

TOKEN="${LINK#*token=}"

curl -s "https://api.orbit.devotel.io/api/v1/compliance/preferences/$TOKEN" | jq

curl -s -X PUT "https://api.orbit.devotel.io/api/v1/compliance/preferences/$TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"channelPreferences":{"sms":"opted_out"}}' | jq
```

Expected: the GET returns the contact's current preferences; the PUT
returns `updated: true` plus the applied preferences and, when
requested, a `gdprRequest` entry.

Common failures to check against: `400 INVALID_TOKEN` (malformed token),
`401 TOKEN_EXPIRED` (TTL passed or signature mismatch — mint a new link),
`422 VALIDATION_ERROR` (field-level issues in the config or update
body), and `404 NOT_FOUND` when the preference center is disabled or the
contact id does not exist.

***

## Related

* [Send gates and pre-send guards](/compliance/send-gates) — where the
  preference-center summary lives alongside quiet hours, emergency stop,
  and throttles.
* [Opt-Out & Suppression Lists](/compliance/opt-out-suppression) — how
  scope `all` and bulk CSV imports relate to this surface.
* [Consent management](/compliance/consent-management) — the
  operator-side API that stores the same consent ledger.
* [DSAR reference](/compliance/dsar) —
  the tracked erasure pipeline to route `requestDataDeletion` requests
  into.
