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

# Consent Management & Receipts

> Record, look up, and audit per-channel messaging consent on Orbit — including GDPR lawful-basis tracking and India DPDP Consent-Manager signed receipts.

# Consent Management & Receipts

Before you message a contact on a regulated channel you generally need
a lawful basis — most often **consent**. Orbit's consent API is the
system of record for who opted in or out, on which channel, when, and
under what legal basis. Every write fans out to the surfaces your
sends are gated against, so recording consent here is what actually
unblocks (or blocks) a message.

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

<Warning>
  Recording consent in Orbit creates an auditable trail, but it does
  not by itself make a send lawful. You remain responsible for
  obtaining valid consent and for the content you send. This page is
  not legal advice.
</Warning>

***

## Channels and states

Consent is tracked **per channel**. The supported channel set is:

`email`, `fax`, `instagram`, `line`, `messenger`, `push`, `rcs`,
`sms`, `viber`, `voice`, `whatsapp`.

A `(contact, channel)` pair resolves to one of three states:

| State       | Meaning                                                                     |
| ----------- | --------------------------------------------------------------------------- |
| `opted_in`  | Consent granted and not revoked.                                            |
| `opted_out` | Consent revoked, or an explicit opt-out recorded.                           |
| `unknown`   | No consent record exists for the pair — your send gate decides the default. |

***

## Recording consent

`POST /compliance/consent` records an opt-in or opt-out across one or
more channels in a single call. Identify the contact by `contact_id`
**or** by `identifier` (an email, E.164 phone, or WhatsApp ID — Orbit
resolves the type automatically).

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "jordan@example.com",
    "channels": ["email", "sms"],
    "opt_in": true,
    "source": "web_form",
    "consent_type": "marketing",
    "lawful_basis": "consent",
    "purpose": "Weekly product newsletter and order updates",
    "consent_text_version": "tos-2026-04",
    "consent_proof_url": "https://example.com/proofs/abc123.png"
  }'
```

Returns `201 Created`:

```json theme={null}
{
  "contact_id": "cnt_9f…",
  "consent_record_ids": ["cr_a1…", "cr_b2…"],
  "channels": ["email", "sms"],
  "state": "opted_in",
  "valid_until": null
}
```

| Field                       | Type                  | Notes                                                                                                                  |
| --------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `contact_id` / `identifier` | string                | Provide one of the two.                                                                                                |
| `channels`                  | string\[]             | One or more of the channel set; deduplicated and sorted.                                                               |
| `opt_in`                    | boolean               | **Required.** `true` = opt-in, `false` = opt-out.                                                                      |
| `source`                    | string                | How consent was captured (e.g. `web_form`, `import`, `double_opt_in`). Default `consent_api`.                          |
| `consent_type`              | string                | Purpose category, e.g. `marketing`, `transactional`. Default `messaging`.                                              |
| `lawful_basis`              | enum                  | GDPR Art 6 basis: `consent`, `contract`, `legal_obligation`, `vital_interests`, `public_task`, `legitimate_interests`. |
| `purpose`                   | string                | Free text describing the use (≤ 500).                                                                                  |
| `consent_text_version`      | string                | Version of the notice the subject agreed to.                                                                           |
| `consent_proof_url`         | string (url)          | Link to a screenshot or signed document.                                                                               |
| `valid_until`               | string (ISO-8601 UTC) | Absolute instant the consent expires. Opt-in only — ignored on opt-out. Mutually exclusive with `expires_in_days`.     |
| `expires_in_days`           | integer               | Relative validity window (1–3650 days from now). Opt-in only. Mutually exclusive with `valid_until`.                   |
| `metadata`                  | object                | Arbitrary custom key/values.                                                                                           |

Supplying **both** `valid_until` and `expires_in_days` is ambiguous and
rejected with `422 VALIDATION_ERROR`. When you set a window, the `201`
response echoes the resolved `valid_until` (the absolute expiry instant);
it is `null` for a non-expiring grant or an opt-out. Re-recording an
opt-in with a fresh window **extends** validity — the original
`granted_at` is preserved, but the expiry is updated.

**What a write does.** Each recorded channel updates four
synchronized surfaces: the `consent_records` audit table, the
contact's `channel_preferences` mirror (the read-side fast path your
sends check), the `suppression_list` (on opt-out), and a short-lived
Redis STOP-fence so in-flight campaign batches honour the change
within \~10 minutes.

<Note>
  Writes are **partial-safe**: if one channel fails, the others still
  apply. Compare `consent_record_ids.length` against the number of
  channels you requested to detect a partial write. Re-recording an
  opt-in for a channel that is already opted-in refreshes the
  metadata/proof but **keeps the original `granted_at`**.
</Note>

***

## Looking up consent

`GET /compliance/consent/lookup` returns the current state for one
`(contact, channel)` pair — use it as a pre-send gate.

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/compliance/consent/lookup?identifier=jordan@example.com&channel=sms" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

```json theme={null}
{
  "contact_id": "cnt_9f…",
  "channel": "sms",
  "state": "opted_in",
  "source": "web_form",
  "granted_at": "2026-04-02T10:11:00.000Z",
  "revoked_at": null,
  "lawful_basis": "consent",
  "purpose": "Weekly product newsletter and order updates",
  "consent_text_version": "tos-2026-04",
  "consent_proof_url": "https://example.com/proofs/abc123.png",
  "valid_until": null,
  "expired": false,
  "requires_reconfirmation": false
}
```

A `state` of `unknown` means no record exists for the pair — your
application decides whether that implies consent (some transactional
flows) or blocks the send (most marketing flows).

The last three fields report time-bounded consent and are **always
present**:

| Field                     | Type           | Notes                                                                                                                                        |
| ------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `valid_until`             | string \| null | The expiry instant for the grant, or `null` when the consent never expires (or the pair is opted-out).                                       |
| `expired`                 | boolean        | `true` when the grant is opted-in but its `valid_until` is now in the past. Treat an expired grant as no-longer-consented at your send gate. |
| `requires_reconfirmation` | boolean        | Mirrors `expired` — a hint to trigger a re-permission flow. Clear it by recording a fresh opt-in (optionally with a new window).             |

***

## Finding expiring consent

`GET /compliance/consent/expiring` sweeps the tenant for opt-ins whose
validity window has lapsed or is about to — the input to a re-permission
(re-confirmation) campaign. Only grants that carry a `valid_until` are
returned; non-expiring consent never appears.

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/compliance/consent/expiring?within_days=30&status=all" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

Query parameters:

| Parameter     | Type    | Notes                                                                                                                                                       |
| ------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `within_days` | integer | Look-ahead horizon (0–3650, default 30). Returns grants whose `valid_until` is at or before now + `within_days`; already-lapsed grants are always included. |
| `channel`     | enum    | Optional — restrict to a single channel.                                                                                                                    |
| `status`      | enum    | `all` (default), `expired` (already past `valid_until`), or `expiring` (still valid but within the horizon).                                                |
| `limit`       | integer | Page size (1–100, default 50).                                                                                                                              |
| `cursor`      | string  | Opaque pagination cursor — round-trip verbatim.                                                                                                             |

```json theme={null}
{
  "within_days": 30,
  "channel": null,
  "status": "all",
  "as_of": "2026-05-01T09:00:00.000Z",
  "items": [
    {
      "id": "cr_b2…",
      "contact_id": "cnt_9f…",
      "channel": "sms",
      "consent_type": "marketing",
      "source": "web_form",
      "lawful_basis": "consent",
      "granted_at": "2025-05-02T10:11:00.000Z",
      "valid_until": "2026-04-20T00:00:00.000Z",
      "expired": true,
      "status": "expired",
      "requires_reconfirmation": true
    }
  ],
  "next_cursor": null
}
```

Items are ordered oldest-expiring first. Each carries `status`
(`expired` or `expiring`) so you can split "must re-confirm now" from
"warn before the window closes". Re-confirmation is an ordinary
`POST /compliance/consent` opt-in — optionally with a fresh `valid_until`
or `expires_in_days`.

<Tip>
  Treat `next_cursor` as opaque and round-trip it verbatim; a `null`
  value means the final page. An invalid or stale cursor is treated as a
  fresh first page rather than an error.
</Tip>

***

## Consent history

`GET /compliance/consent/history` returns the full, paginated audit
trail for a contact — every grant and revocation, most recent first.

Query parameters: `contact_id` or `identifier` (one required), an
optional `channel` filter, `limit` (≤ 100, default 50), and an opaque
`cursor`.

```json theme={null}
{
  "contact_id": "cnt_9f…",
  "channel": null,
  "items": [
    {
      "id": "cr_b2…",
      "channel": "sms",
      "consent_state": "opted_in",
      "granted": true,
      "source": "web_form",
      "granted_at": "2026-04-02T10:11:00.000Z",
      "revoked_at": null,
      "lawful_basis": "consent",
      "created_at": "2026-04-02T10:11:00.000Z"
    }
  ],
  "next_cursor": "eyJ0…"
}
```

<Tip>
  Treat `next_cursor` as opaque — round-trip it verbatim to fetch the
  next page. An invalid or stale cursor is treated as a fresh first
  page rather than an error.
</Tip>

***

## Consent receipts (India DPDP) & Consent Managers

India's **Digital Personal Data Protection Act (DPDP)** introduces
the concept of a **Consent Manager** — an accountable, registered
intermediary that mints cryptographically **signed consent receipts**
on behalf of a data principal. Orbit can register the managers your
users go through and verify the receipts they issue.

### Register a Consent Manager

`POST /compliance/consent/managers` (admin/owner) registers a manager
and stores its public key (an ECDSA P-256 SPKI PEM) used to verify
every receipt it signs.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent/managers \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Consent Manager",
    "manager_id": "acme-cm-001",
    "manager_url": "https://cm.acme.example",
    "public_key": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----",
    "country_code": "IN"
  }'
```

* `GET /compliance/consent/managers` lists registered managers
  (active first).
* `PUT /compliance/consent/managers/{id}` updates or deactivates one
  (partial update; all fields optional).

### Store a signed receipt

`POST /compliance/consent/receipts` verifies a manager-signed receipt
and persists it as consent. The signature (ECDSA P-256 / SHA-256,
IEEE-P1363, base64url) is checked against the registered manager's
public key over a JCS-inspired, sorted-key JSON canonicalization of the
payload **before** anything is stored. This canonicalization sorts object
keys ascending by UTF-16 code unit and drops insignificant whitespace, but
it is not a full RFC 8785 implementation — in particular it does not apply
JCS's mandated number serialization rules. Sign receipts with the same
sorted-key form Orbit uses rather than assuming a spec-complete RFC 8785
verifier will produce a matching hash.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent/receipts \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "cnt_9f…",
    "consent_manager_id": "acme-cm-001",
    "channel": "sms",
    "consent_type": "marketing",
    "receipt": {
      "receipt_id": "rcpt_77…",
      "issued_at": "2026-05-01T09:00:00.000Z",
      "purpose": "Promotional SMS",
      "fiduciary_id": "fid_acme",
      "signature": "MEUCIQ…",
      "payload": { "…": "…" }
    }
  }'
```

Returns `201` with `{ "id": …, "receipt_id": …, "verified": true }`.
A bad signature, or an unregistered/inactive manager, returns `422
CONSENT_RECEIPT_INVALID` — the detail notes the payload may have been
tampered with or the manager may have rotated keys.

### Re-verify a stored receipt

`POST /compliance/consent/receipts/{id}/verify` re-checks a
previously stored receipt against the manager's **current** key — use
it during an audit to confirm a receipt still validates and whether
its manager remains active. `{id}` accepts either the consent-record
id or the `receipt_id`.

<Note>
  Consent receipts require the tenant `consent_managers` migration. On
  tenants that predate it, the read paths degrade gracefully: the
  manager list returns an empty list and the re-verify endpoint returns
  `404`. Minting a receipt is fail-closed, so `POST
      /compliance/consent/receipts` returns `422 CONSENT_RECEIPT_INVALID`
  on pre-migration tenants rather than degrading — run the migration
  before issuing receipts.
</Note>

***

## Related references

* [Opt-Out & Suppression Lists](/compliance/opt-out-suppression) —
  bulk-import opt-outs and how the suppression list gates sends.
* [DSAR](/compliance/dsar) — honouring access/delete requests over
  the consent record.
* [DLT-India Onboarding](/compliance/dlt-india) — the registration
  layer that pairs with DPDP consent on Indian SMS.
* [API Reference → Compliance](/api-reference/compliance) — full
  request/response schemas (regenerated from the live API).
