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 athttps://api.orbit.devotel.io/api/v1/compliance.
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:
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).
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.Looking up consent
GET /compliance/consent/lookup returns the current state for one
(contact, channel) pair — use it as a pre-send gate.
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:
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.
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.
Confirmed consent (double opt-in) handshakes
A plainPOST /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:
POST /compliance/consent/double-opt-in— begin: records a pending row (not yet a consent grant) and returns the confirmation-prompt copy for the pair.- The recipient replies; relay the text to
POST /compliance/consent/double-opt-in/confirm— confirm: an affirmative keyword against the pending prompt converts the pair into a confirmedopted_ingrant. GET /compliance/consent/double-opt-in/status— read: the current state (opted_in|opted_out|pending|none) plusconfirmed/awaiting_replyflags, side-effect free.
/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 repliesSTOP (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.
2. Consent API — opt_in: false
Your own surface (a preference portal, a CRM sync, an unsubscribe
microservice) records the revocation with
POST /compliance/consent and opt_in: false:
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-8058List-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 ado_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.
Consent history — reading the grant/revoke trail
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.
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.
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.
- Keyword path. The recipient replies
STOPto your SMS number. No API call is needed from you: Orbit writesopted_outwith sourceinbound_keywordand a scope-allsuppression row for the number. - API path. Your surface calls (equivalent outcome):
- 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 showsconsent_state: "opted_out",granted: false, and arevoked_atlike2026-06-04T09:12:35.000Z. The prior grant row in the same response now carries that same instant in its ownrevoked_at— grant and revocation tied by timestamp is what makes the ledger a single story, not two uncorrelated tables. - Corroborate against the send gate. Export the suppression ledger
(Opt-Out & Suppression Lists) with
status=activeand confirm the number is fenced, and read the scoped/historyagain after any re-permission to confirm the fence is gone. Audit evidence is the consent row and the suppression row agreeing.
Exporting the consent proof-of-record
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.
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.
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.
GET /compliance/consent/managerslists 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.
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.Related references
- Assembling a GDPR Posture End to End — the sequence this consent layer feeds.
- Confirmed Consent (Double Opt-In) Handshakes — the begin/confirm/status flow above a plain consent record.
- Consent Posture: The Unknown-Consent Policies — the org-level knobs that decide what contacts with no ledger row may receive (marketing sends vs CDP fanout).
- Opt-Out & Suppression Lists — bulk-import opt-outs and how the suppression list gates sends.
- Opt-Out & Suppression Lists — the
channel scopes a revocation writes (
allvs single channel) and how the suppression ledger fences sends. - Confirmed Consent (Double Opt-In) Handshakes — the begin/confirm/status flow — a pending handshake is unconfirmed, a single-action revocation is final.
- DNC Scrubbing — the phone-only counterpart to suppression: where the national DNC registry fits beside your own revoked list.
- DSAR — honouring access/delete requests over the consent record.
- DLT-India Onboarding — the registration layer that pairs with DPDP consent on Indian SMS.
- API Reference → Compliance — full request/response schemas (regenerated from the live API).