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

# Public consent form

> Host a public unsubscribe / preferences page on Orbit so recipients can manage channel consent without contacting support.

# Public consent form

Orbit can host a public landing page where recipients enter their email or phone, choose channels, and opt in or out of receiving messages. Submissions write to the same consent surfaces as carrier-side STOP / START keywords — `consent_records`, `channel_preferences`, and `suppression_list` — so the platform's outbound send gate immediately honours the new state.

The feature is **off by default**. Each tenant explicitly turns it on under **Settings → Compliance → Public consent form**. When disabled, the public URL returns `404`.

## When to use it

* The footer of email / SMS campaigns where you'd otherwise embed a vendor unsubscribe link.
* Sender bios (Instagram, LinkedIn) where recipients ask you to stop messaging.
* Operator-driven opt-in capture, e.g. when transcribing paper-form consent.
* Bulk re-permissioning campaigns after a privacy-policy change.

The page does **not** replace carrier-side STOP / START keywords or per-channel inline unsubscribe headers — it complements them.

## Enable the form

1. Sign in as an **owner** or **admin**.
2. Go to **Settings → Compliance → Public consent form**.
3. Toggle **Enable public consent form** on.
4. Copy the public URL (under the toggle) — it has the form
   `https://orbit.devotel.io/<locale>/consent/<tenantId>`.

The URL becomes live the moment you save changes.

### Optional configuration

All fields below are optional. Leave blank to use Orbit defaults.

**Branding**

* **Business name** — shown above the headline.
* **Primary color** — 3- or 6-digit hex (e.g. `#0ea5e9`). Drives the submit button and success badge.
* **Logo URL** — must be `https://` (or `http://`). Rendered above the business name.

**Page copy**

* **Headline** — replaces the default "Manage your preferences" (max 240 chars).
* **Body** — short explanatory paragraph (max 2000 chars).
* **Footer** — fine print, support contact, link to your privacy policy (max 1000 chars).

**Allowed channels**

Restrict which channels appear on the form. When unchecked, the channel is hidden on the page **and** submissions for it are rejected with `422 INVALID_CHANNEL`. Leave all 11 channels selected to accept the platform default (`email`, `fax`, `instagram`, `line`, `messenger`, `push`, `rcs`, `sms`, `viber`, `voice`, `whatsapp`).

## Pre-fill the recipient identifier

Append `?contact=<email-or-phone>` to the URL to pre-fill the form. The recipient can still edit the value before submitting.

```
https://orbit.devotel.io/en/consent/<tenantId>?contact=user@example.com
https://orbit.devotel.io/en/consent/<tenantId>?contact=%2B14155551212
```

Use this in campaign footers so the recipient lands one-tap away from saving their preferences.

## Programmatic submissions

The form is a thin client over a public REST endpoint. Direct integrations (e.g. an embedded preference centre on your own marketing site) can POST against the same handler.

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/public/consent \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "<tenantId>",
    "contactIdentifier": "user@example.com",
    "channels": ["sms", "email"],
    "optIn": false,
    "source": "embedded_preference_centre"
  }'
```

Response:

```json theme={null}
{
  "data": {
    "status": "ok",
    "consentRecordIds": ["consent_...", "consent_..."]
  },
  "meta": {
    "request_id": "req_...",
    "timestamp": "2026-05-15T08:14:22Z"
  }
}
```

### Body

| Field               | Type      | Notes                                                                                                                 |
| ------------------- | --------- | --------------------------------------------------------------------------------------------------------------------- |
| `tenantId`          | string    | The platform UUID, NOT the org id. Visible in the public URL.                                                         |
| `contactIdentifier` | string    | RFC-5322 email, E.164 phone (`+14155551212`), or raw WhatsApp ID / Messenger PSID.                                    |
| `channels`          | string\[] | One or more of: `email`, `fax`, `instagram`, `line`, `messenger`, `push`, `rcs`, `sms`, `viber`, `voice`, `whatsapp`. |
| `optIn`             | boolean   | `true` = subscribe, `false` = unsubscribe.                                                                            |
| `source`            | string    | Optional free-form tag (≤ 64 chars). Defaults to `"public_form"`.                                                     |

### Errors

| Code                     | When                                                                                                        |
| ------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `VALIDATION_ERROR` (422) | Body failed Zod validation.                                                                                 |
| `INVALID_TENANT` (422)   | Tenant id is unknown OR the public form is disabled. Same shape either way — no existence-enumeration leak. |
| `INVALID_CHANNEL` (422)  | None of the submitted channels are in `allowed_channels`.                                                   |

### Read the tenant's public config

For embedding teams that want to render their own UI against the same config the Orbit-hosted form uses:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/public/consent/<tenantId>/config
```

```json theme={null}
{
  "data": {
    "enabled": true,
    "branding": {
      "logo_url": "https://cdn.example.com/logo.png",
      "primary_color": "#0ea5e9",
      "business_name": "Acme Inc."
    },
    "copy": {
      "headline": "Manage your preferences",
      "body": "...",
      "footer": "..."
    },
    "allowed_channels": ["sms", "email", "whatsapp"]
  }
}
```

When the tenant has NOT enabled the form, the response carries `enabled: false` and empty branding / copy / channels. Render a 404 — that matches how the Orbit-hosted page reacts.

## What the form writes

Every submission writes to the same three surfaces the carrier-side `handleOptIn` / `handleOptOut` routes touch:

1. **`consent_records`** — one row per submitted channel with `consent_state = 'opted_in'` / `'opted_out'`. The send-side gate at outbound time checks this column.
2. **`contacts.channel_preferences`** JSONB — per-channel `opted_out` flag flipped, `global_opt_out` flipped on opt-out, and an entry appended to `consent_history` for audit continuity.
3. **`suppression_list`** — opt-out inserts a `channel='all'` row that survives soft-delete. Opt-in sets `revoked_at = NOW()` on every active row (audit-friendly, NOT a DELETE).

A redis-backed STOP-fence cache key is also flipped so in-flight campaign batches converge within sub-RTT, and an entry lands on the tamper-evident audit log (`action: consent.public_opt_in` / `consent.public_opt_out`).

## Limits and abuse defence

* The POST + GET endpoints are rate-limited per source IP (`RATE_LIMIT_PUBLIC_UNAUTH` — 10 requests per minute).
* There is no Clerk session or API key on this route.
* Logs capture source IP, user-agent (truncated to 500 chars), and a contact-identifier kind classifier (`email` / `phone` / `whatsapp_id`).
* Suppression and consent state are authoritative in Postgres; the STOP-fence cache is a sub-RTT convenience that fails open.

## Disabling the form

Toggle **Enable public consent form** off. The public URL returns `404` from that moment, and the POST handler returns `422 INVALID_TENANT` for that tenant — no in-flight submissions can land.

Stored consent records remain — disabling only stops new submissions. Re-enabling restores the previous configuration block (branding, copy, allowed channels) so an accidental toggle doesn't lose the customisation.
