> ## 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 inspector: per-contact consent records and destination verdicts

> Inspect one contact's consent ledger, run a live destination-delivery verdict before you send, and grant or revoke consent from the dashboard.

# Consent inspector

Consent touches every channel — SMS, voice, email, push, WhatsApp, MMS, fax — and it's one thing Orbit owns end to end. The **Consent inspector** (**Audience → Consent**) shows, for a single contact, the complete consent ledger plus the exact allow/block verdict the platform would compute if a destination tried to deliver to them today. It's the surface you open before you queue a send, and the surface you open when a send was suppressed and you need to know why.

Before this page existed, the only way to read a contact's consent ledger or preview the destination verdict was a raw API call. The inspector closes that gap — the same two endpoints the compliance team hits with curl, wrapped in a form.

***

## What the inspector shows

Type a `contact_id` (`cnt_…` — the identifier shown on the contact's record page, or the id returned by the Contacts API) into **Look up a contact** and the page splits into two cards.

**Consent records** — one row per consent ledger entry, newest first:

| Column       | What it tells you                                                                                      |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| Channel      | The channel the grant or revoke applies to (`sms`, `mms`, `voice`, `email`, `push`, `whatsapp`, `fax`) |
| State        | `opted_in` / `opted_out` / `unknown` — the derived state badge                                         |
| Policy       | The regulatory template the grant was captured under (`gdpr`, `ccpa`, `lgpd`, `cpra`, `other`)         |
| Lawful basis | The GDPR Art 6 basis recorded with the grant (consent, contract, legitimate interests, …)              |
| Granted      | When the grant was captured (your timezone)                                                            |
| Revoked      | When the grant was withdrawn, if ever                                                                  |
| Row id       | The tail of the ledger row id, for cross-referencing in exports                                        |

**Verdict simulator** — pick a channel (and optionally a policy template) and press **Run consent check**. You get back the same structured verdict the destination dispatch path computes at delivery time — **Allowed** or **Blocked**, with the machine-readable reason and human label.

The verdict reasons and what to do with each:

| Reason                        | Verdict | What it means                                                               |
| ----------------------------- | ------- | --------------------------------------------------------------------------- |
| `allowed`                     | Allowed | An active `opted_in` grant exists on this channel                           |
| `no_consent_on_record`        | Blocked | No ledger row — the default deny-on-missing posture refuses to send         |
| `opted_out`                   | Blocked | The contact positively opted out of this channel                            |
| `unknown_consent_state`       | Blocked | A row exists but its state is neither in nor out (refuse-on-unknown)        |
| `revoked`                     | Blocked | Every grant on this channel has been withdrawn                              |
| `expired`                     | Blocked | The opt-in's validity window (`valid_until`) has lapsed — re-consent needed |
| `policy_mismatch`             | Blocked | The grant exists, but not under the policy template you checked against     |
| `permitted_on_missing_record` | Allowed | No ledger row, but your tenant opted into the permit-on-missing posture     |
| `permitted_on_unknown_state`  | Allowed | Unknown state, permitted by the same posture                                |

***

## When to open it

* **Preflight a single contact** before you queue them into a campaign, add them to a destination, or hand them to an agent. The verdict simulator runs the identical decision the destination dispatch path runs at delivery time, so a `Blocked` answer now is a send that would be suppressed later — catch it in the inspector, not in the delivery log.
* **Debug a suppressed send.** When a message was suppressed on a consent gate, paste the contact id and rerun the check against the channel that was suppressed. The reason code tells you which gate fired (`opted_out`, `revoked`, `expired`, `policy_mismatch`, or the refuse-on-unknown defaults).
* **Compliance audit.** For GDPR Art 30 burden-of-proof purposes, the records card exposes the lawful basis, policy template, and captured-at timestamp of every grant and revoke in the ledger — the same evidence the export endpoints can hand to a DPA.

***

## How the verdict is computed

The verdict folds three stores, per the [consent and suppression model](/concepts/consent-and-suppression-model):

1. **`consent_records`** — the append-only ledger. Every opt-in or opt-out flip inserts a new row; the decision reads the newest active row (`revoked_at IS NULL`). A partial unique index guarantees at most one active row per (contact, channel), so the verdict is deterministic.
2. **`channel_preferences`** — the contact profile's per-channel preference flags, consulted only when the ledger is empty: an explicit profile opt-in or opt-out is surfaced so the inspector doesn't lie "no records on file" about a positive opt-in captured via import, merge, flows, or CRM sync. A top-level `global_opt_out` flag suppresses every channel and returns `opted_out`.
3. **Suppression list** — the opt-out suppression mirror that the send gate consults for hard blocks.

The per-tenant posture (`deny_on_missing` or `permit_on_missing` — see [Consent default policy](/compliance/consent-default-policy)) can invert only the two "no record / unknown state" branches. Four gates are always enforced regardless of tenant posture, because they're universal-legal: a positive opt-out, a revoked grant, a policy-template mismatch, and an expired validity window.

***

## Fixing a verdict from the surface

The inspector is a read surface — the grant/revoke writes stay on the surfaces that own them. From a `Blocked` verdict:

* **To consent the contact** — open the contact's profile in the audience workspace and set the channel opt-in; the write lands in the ledger. Rerun the verdict and the reason moves to `allowed`.
* **To revoke** — use the same contact-level opt-out control. The revocation inserts a new ledger row (the ledger is append-only), stamps `revoked_at`, and the verdict flips to Blocked with `revoked`.
* **To restore** (re-consent after a revocation) — set the opt-in again from the contact profile. The new row supersedes the revoked one and the verdict returns to Allowed.

The member-level permission the page warns about is the same permission the Contact Consents tab enforces — only authorized roles see these controls.

***

## API walkthrough

Two endpoints back the inspector. Both are restricted to **owner / admin / developer** roles, tenant-scoped, and rate-limited (60 reads/min for the ledger endpoint, 600 verdicts/min for the check endpoint — the check is the dispatch-path hot path).

### Read the full ledger for a contact

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/cdp/consent/cnt_4f3a9b1c2d \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

Response — the full ledger plus any profile-reconciled rows for channels the ledger has never covered:

```json theme={null}
{
  "data": {
    "contact_id": "cnt_4f3a9b1c2d",
    "records": [
      {
        "id": "csr_9e4b…",
        "channel": "sms",
        "consent_state": "opted_in",
        "granted": true,
        "source": "web_form",
        "lawful_basis": "consent",
        "purpose": "marketing orchestration",
        "policy_template": "gdpr",
        "granted_at": "2026-08-12T14:03:22Z",
        "revoked_at": null
      }
    ]
  },
  "meta": { "request_id": "req_01KX…", "timestamp": "2026-08-31T09:00:00Z" }
}
```

### Run a single destination verdict

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/cdp/consent/check \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "contact_id": "cnt_4f3a9b1c2d", "channel": "sms", "policy_template": "gdpr" }'
```

Response — the verdict the dispatch path would compute (`policy_template` is optional; omit it to skip the template gate):

```json theme={null}
{
  "data": {
    "allowed": true,
    "reason": "allowed",
    "consent_record_id": "csr_9e4b…",
    "granted_at": "2026-08-12T14:03:22Z",
    "valid_until": null
  },
  "meta": { "request_id": "req_01KX…", "timestamp": "2026-08-31T09:00:00Z" }
}
```

Every check call emits a structured log line (`cdp_consent_check`) with the inputs and verdict — that's the forensic trail you can tail instead of an audit row per call.

### Grant / revoke from the API

The targeted grant or revoke goes to the compliance consent endpoint. An opt-out also mirrors the channel into the suppression list so the send gate sees it within the same call:

```bash theme={null}
# Grant
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "cnt_4f3a9b1c2d",
    "channels": ["sms"],
    "opt_in": true,
    "source": "web_form",
    "lawful_basis": "consent",
    "purpose": "marketing orchestration"
  }'

# Revoke
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "contact_id": "cnt_4f3a9b1c2d", "channels": ["sms"], "opt_in": false }'
```

Returns `201` with the new ledger `consent_record_ids`. You can pass a phone/email `identifier` instead of `contact_id` — the endpoint resolves it the same way the lookup does.

***

## Worked example: verify a verdict and restore consent

1. Open **Audience → Consent inspector**.
2. Paste the contact you were about to send to — say `Cnt_4f3a9b1c2d` (casing is normalized), press **Inspect**. The page confirms the profile identity inline so you're not working against the wrong hash.
3. In the **Verdict simulator**, pick `sms` and press **Run consent check**. Suppose it comes back **Blocked — contact opted out of this channel**.
4. The records card explains why: the newest sms row has state `opted_out` and a `Revoked` timestamp.
5. Check with the contact, then open their profile page and set the sms opt-in back on.
6. Re-run the check. It now returns **Allowed — active grant on record**, with the new `consent_record_id` and `granted_at`. You're clear to send.

For a parity check, clear the contact id and paste it back in — the verdict resets with the contact, so a stale verdict never lingers across contacts.

***

## See also

* [Consent and suppression model](/concepts/consent-and-suppression-model) — the full three-store model concept page
* [Consent default policy](/compliance/consent-default-policy) — the `deny_on_missing` vs `permit_on_missing` tenant posture
* [Consent management](/compliance/consent-management) — the ledger-management operations page
* [Consent suppression export](/compliance/consent-suppression-export) — exporting the ledger for a DPA
* [Contacts API](/api-reference/endpoints/contacts) — where a `contact_id` comes from
