Skip to main content

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

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.

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 ids 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 ids fail with “Topic ids must be unique” rather than silently overwriting.
Once configured, generate a link for one contact at a time with POST /preference-center/link:
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:
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

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

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.
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 and the DSAR + breach register guide.

6. Testing it

Two worked curl examples you can paste into a smoke script: Save the config:
Expected: 201 with the saved config echoed. Mint a link and exercise the public endpoints:
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.