Skip to main content

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. You can also export the whole trail as an audit-ready CSV or JSON file. All endpoints below are rooted at https://api.orbit.devotel.io/api/v1/compliance.
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.

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:
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).
Returns 201 Created:
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.
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.

GET /compliance/consent/lookup returns the current state for one (contact, channel) pair — use it as a pre-send gate.
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:
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.
Query parameters:
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.
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.

A plain POST /compliance/consent asserts the grant — it is the system of record once your own surface has obtained consent. When the evidence tier needs a recorded recipient reply (TCPA express written consent, EU confirmed opt-in, 10DLC campaign review), drive the managed double opt-in handshake instead:
  1. POST /compliance/consent/double-opt-inbegin: records a pending row (not yet a consent grant) and returns the confirmation-prompt copy for the pair.
  2. The recipient replies; relay the text to POST /compliance/consent/double-opt-in/confirmconfirm: an affirmative keyword against the pending prompt converts the pair into a confirmed opted_in grant.
  3. GET /compliance/consent/double-opt-in/statusread: the current state (opted_in | opted_out | pending | none) plus confirmed / awaiting_reply flags, side-effect free.
Confirmed handshakes land in the same consent ledger this page documents — /lookup, /history, and the export read them identically. Until confirmed, a pending handshake is not a consent grant. Tenant-owned: nothing starts a handshake on the platform’s behalf. Full mechanics on Confirmed Consent (Double Opt-In) Handshakes.

Guardian Advisory

Every consent tenant in the grant lifecycle carries the same weight on revocation: a contacted person may withdraw any prior agree at any time, in whatever way they communicate the withdrawal — and that withdrawal is binding immediately, across every channel the original grant covered. The 2024–2025 FCC TCPA rulemaking codified this as revocation parity for US A2P SMS and voice: revocation bound in one reasonable means (a STOP keyword, an email unbutton, a verbal objection to a live agent) cannot be limited to the channel it arrived on when the consent it revokes spans more. What is at stake is not only a statutory penalty per accidental send — a revocation your platform honors on SMS but loses on voice is the exact pattern TCPA plaintiff counsel subpoenas. Orbit’s ledger collapses all revocation entry points (below) into one grant ledger, so parity is what you get by construction rather than a policy you must maintain by hand: one revocation write revokes every channel the pair carries, and the revocation is a first-class, timestamped row your audit reads back.
The revocation flows below are your knobs: nothing on the platform revokes or restores consent on a tenant’s behalf, and the records they write belong to the tenant. Orbit is the conduit and system of record; the lawfulness of your grant, your revocation, and your send stays with you. This page is not legal advice.

Revocation entry points

A recipient revokes through four mechanisms. Each converges on the same ledger — a (contact, channel) pair becomes opted_out, and a suppression write with scope all fences the address on every channel the number or address can reach.

1. STOP keyword (SMS / WhatsApp)

The recipient replies STOP (or another configured end-keyword) to one of your SMS or WhatsApp numbers. Orbit records it as an opted_out consent record with source inbound_keyword and writes the matching suppression entry with scope all — because the number itself revoked, every channel reachable on that number (voice, SMS, WhatsApp, RCS, …) is fenced, and the voice/dialer gates read that same entry. The full keyword list, reply handling, and the preference-center entry are covered on Opt-Out & Suppression Lists; point your recipients at the hosted Preference Center when you want revocation to be self-service per channel rather than all-or-nothing on the keyword. Your own surface (a preference portal, a CRM sync, an unsubscribe microservice) records the revocation with POST /compliance/consent and opt_in: false:
This is the direct inverse of the grant call above — and it differs from the double-opt-in handshake in one important way: a plain opt_in: true grant is the system of record the moment you call it (your surface has already obtained consent), whereas a double-opt-in pair stays unconfirmed — not a consent grant — until the recipient’s affirmative reply “shakes” the handshake. Revocation, by contrast, is always single-action: one opt_in: false write is final immediately.

3. One-click unsubscribe (email)

Every Orbit email sends carries the RFC-8058 List-Unsubscribe one-click header; the recipient’s mail client POSTs the unsubscribe and Orbit records an opted_out row scoped to email. You do not build this flow — it is woven into the email sender — but the revocation lands in the same ledger with source unsubscribe_link and shows up in /history like any other.

4. Verbal objection (voice)

A called party tells your agent “stop calling me.” Wrap the call with a do_not_contact (or your equivalent) disposition code, and let the disposition webhook record the same POST /compliance/consent with opt_in: false — the recipient revoked verbally, and parity requires a verbal revocation to be as binding as opt_in: false via the API. The write then flows to the suppression ledger exactly like a keyword STOP: scope all on the number, voice and dialer gates fenced.
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. A TCPA audit does not ask “what is the current state” — it asks “show me every grant and revocation.” Reading /history with no channel filter returns the cross-channel sequence a revocation travels through: a grant on sms, a revocation that also lands on voice (scope parity), and any later re-grant. The example below walks one contact through grant → revoke → grant → revoke on SMS, showing the parity write onto voice, then reads the sms-filtered slice an auditor would request.
How to read it, oldest first: 2026-05-18 the contact opts in on SMS (cr_a0…); 2026-05-20 they text STOP — that first grant row now carries a revoked_at and a fresh opted_out row (cr_a1…) is appended, so the audit sees both the original grant and its revocation; 2026-06-01 they re-subscribe (cr_b2…, a new grant — re-permission never rewrites history); 2026-06-04 they text STOP again (cr_c3…), and because a keyword STOP on a phone number asserts scope all, the same second stamps a paired opted_out row onto voice (cr_d4…). This pairing is what you hand an auditor when a flagged suppression is challenged: the revoked grant row proves the revocation, its revoked_at is the no-send-after instant, and the suppression export (below) proves the send gate fenced the address.
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.

Revocation semantics — scopes and their parity

The single concept US TCPA audits probe: revocation is as broad as the consent it revokes. Orbit encodes that with three scope shapes on the suppression entry a revocation writes. Do not confuse the address form with the scope: a revocation that arrives on a phone number (keyword STOP, verbal objection, API opt_in: false on an E.164 identifier) writes scope all — the number, not the arriving channel, is the revoked subject. The channel-scoped shapes exist for list migrations and email, where the source list or the address form legitimately bounds the fence. Reason in revocation scope, not in the transport that carried the signal.

Worked example — revoke, then read back the timestamp for audit

Two revocation paths for the same contact, ending at the same evidence: the STOP keyword, and the Consent API. Both converging on /history is the point of the ledger — there is one trail regardless of entry point.
  1. Keyword path. The recipient replies STOP to your SMS number. No API call is needed from you: Orbit writes opted_out with source inbound_keyword and a scope-all suppression row for the number.
  2. API path. Your surface calls (equivalent outcome):
  3. Read the revocation back for the audit. Immediately after either path, pull the history and confirm the row exists with its revoked_at — this is the timestamp you present as the no-send-after instant:
    The newest item shows consent_state: "opted_out", granted: false, and a revoked_at like 2026-06-04T09:12:35.000Z. The prior grant row in the same response now carries that same instant in its own revoked_at — grant and revocation tied by timestamp is what makes the ledger a single story, not two uncorrelated tables.
  4. Corroborate against the send gate. Export the suppression ledger (Opt-Out & Suppression Lists) with status=active and confirm the number is fenced, and read the scoped /history again after any re-permission to confirm the fence is gone. Audit evidence is the consent row and the suppression row agreeing.

GET /compliance/consent/export downloads your tenant-wide consent trail as a single file — the answer to a TCPA audit, GDPR Art 7(1) burden-of-proof, or discovery request (“show who opted in or out, when, on which channel, from what source”). It is the bulk counterpart to /lookup and /history.
Query parameters: Each row carries one consent event joined to the contact’s identifiers — record_id, contact_id, email, phone, whatsapp_id, channel, consent_state, granted, consent_type, source, plus the GDPR burden-of-proof columns lawful_basis, purpose, policy_template, consent_text_version, consent_proof_url, ip_address, valid_until, and the grant/revoke/update timestamps. CSV downloads arrive with a dated filename (consent-proof-of-record-YYYY-MM-DD.csv) and never cross a read cache (Cache-Control: no-store). Ask for format=json and the response instead returns a columns / items / count envelope — the same data for programmatic consumers. Access is restricted to owner and admin keys — the payload exposes raw recipient identifiers tenant-wide, the same trust tier as the suppression import. Every export run is itself written to the audit log with its filters and row count.
When your ledger exceeds 50,000 rows the export is truncated at the cap: CSV responses carry an X-Export-Truncated: true header and the JSON envelope sets truncated: true. Narrow by channel or state, or page by exporting consecutive date windows with from/to.

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