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 withPOST /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 a422
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. Topicids 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(defaultfalse) — 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.
http(s)-scheme refinement are
rejected up front, and duplicate topic ids 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 withPOST /preference-center/link:
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.
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.
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: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 ofall,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).
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_recordsrow per channel (or per topic) is appended withsource: 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_updatedis recorded when you change the config, and contact-level opt-in/out events are captured in the consent ledger.
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
WhenshowGdprDelete 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.
6. Testing it
Two worked curl examples you can paste into a smoke script: Save the config:201 with the saved config echoed.
Mint a link and exercise the public endpoints:
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 — where the preference-center summary lives alongside quiet hours, emergency stop, and throttles.
- Opt-Out & Suppression Lists — how
scope
alland bulk CSV imports relate to this surface. - Consent management — the operator-side API that stores the same consent ledger.
- DSAR reference —
the tracked erasure pipeline to route
requestDataDeletionrequests into.