Confirmed Consent (Double Opt-In) Handshakes
A plain consent record says what was requested — it is the grant another system asserts on Orbit. A double opt-in handshake adds a recorded second step: the recipient replies YES to a confirmation prompt, and only that affirmative reply converts a pending row into a confirmedopted_in grant. TCPA (CTIA §5.1.3 express
written consent), GDPR confirmed-opt-in, and 10DLC campaign-review
QA all grade the quality of the opt-in evidence; a recorded double
confirmation is proof-of-reply that a bare consent POST cannot
supply on its own.
All endpoints below are rooted at
https://api.orbit.devotel.io/api/v1/compliance/consent/double-opt-in.
States
Each(contact, channel) pair reports one handshake state — read it
back from the status endpoint (or drive begin/confirm transitions)
without side effects:
Two convenience flags are returned alongside the state on every
begin/confirm/status response:
confirmed—truewhenstatus === "opted_in".awaiting_reply—truewhenstatus === "pending"(present on the status endpoint).
already_resolved /
already_confirmed markers so the caller can distinguish an
idempotent short-circuit from a real transition.
Begin — start the handshake
POST /compliance/consent/double-opt-in starts the handshake for a
(contact_id or identifier, channel) pair. It records a
pending consent row (not a confirmed grant) and returns the
rendered confirmation-prompt copy.
Identify the contact by contact_id or by freeform identifier (an
email, E.164 phone, or WhatsApp wa_id — classified the same way as
the consent surface). The contact must already exist — a begin 404s
rather than auto-creating one.
201 Created:
Idempotent. A second start for a pair already
pending returns
the same pending state without re-prompting; a pair already
opted_in / opted_out short-circuits with its current status and
already_resolved: true.
Dispatch is opt-in and best-effort. Only sms and email resolve a
first-party address (sms from the contact’s phone, email from the
contact’s email); every other channel stays record-only (the
endpoint still returns the prompt copy for your own tooling to
deliver). A dispatch failure is surfaced as dispatched: false +
dispatch_error: true + a customer-safe dispatch_error_message,
and the pending row is not recorded — nothing claims a handshake
the recipient never received the prompt for. Email prompts use
email-native copy (subject + unsubscribe link) instead of the CTIA
SMS carrier boilerplate. Compliance reckoning note: the public
source/metadata are stored verbatim — this endpoint wires no
provider itself; the only thing it can do is hand your prompt to the
standard send envelope.
Confirm — complete from a reply
POST /compliance/consent/double-opt-in/confirm completes the
handshake from the recipient’s relayed reply text. Only an
affirmative keyword against a pending prompt records consent;
anything else leaves the state untouched (the inbound STOP webhook
owns opt-out fan-out — this endpoint never opts out from a relayed
free-text reply).
The classification runs the same keyword matcher as the inbound
carrier path — YES / START / SUBSCRIBE and multilingual variants —
with opt-out keywords winning the tie (STOP is the higher-stakes
signal). Possible outcomes:
- Affirmative + pending → records the same
opted_inconsent row the inbound YES handler writes, clears the read-side opt-out gate, marks the pending row confirmed, and returns200 { "status": "opted_in", "confirmed": true }. - Affirmative + already opted_in → idempotent
200 { "confirmed": true, "already_confirmed": true }— no duplicate grant row. - Affirmative + nothing pending →
409 NO_PENDING_DOUBLE_OPT_IN(start the handshake first). - Non-affirmative →
200 { "status": <current>, "confirmed": false, "reply_class": "<negative|unrecognised>" }and the state is deliberately unmodified.
Read the current state
GET /compliance/consent/double-opt-in/status answers the current
state for a pair without side effects. Use it as a pre-send check
when your flow is unsure whether a handshake is open, or before
relying on confirmed downstream.
contact_id or identifier parameter
pair as begin/confirm, defaults channel to sms, and 404s on an
unresolvable contact.
How it interacts with consent records and suppression
Confirmed handshakes are ordinary rows in the same consent ledger every other surface reads. A successful confirm writes the sameopted_in grant the inbound YES handler writes, clears the
per-channel opt-out flag the row is read through, and stamps the
pending row as confirmed on the audit trail. GET /compliance/consent/consent/lookup is not needed for confirmation —
your send gates read the confirmed result the same way they read any
other consent grant.
A pending handshake is deliberately not a consent grant: the
pending row is not consented-until-confirmed — the begin endpoint
records intent (and a prompt copy) without unblocking sends, and
only the confirm endpoint converts the pair into an opted_in
grant. This asymmetry is the whole point of the handshake: it
records a prompt and a reply, two separate timestamps, so
the opt-in evidence carries two events rather than one write.
Limits and retry behavior
- TTL. The pending row does not expire on its own — a begin
that has not yet been confirmed keeps
status: "pending"(idempotent re-starts return the same pending state, so retrying begin is safe). If a prompt is never confirmed, the downstream re-permission / expiring-consent sweep (see Consent Management) decides when to re-ask. - Retry semantics. Begin is idempotent for an already-pending
pair. Confirm is idempotent for an already-confirmed pair. A
begin against
opted_in/opted_outis a safe no-op short-circuit. A confirm againstopted_outrecords nothing (a relayed non-affirmative reply never changes opt-out state). - Dispatch failures. When
dispatch: truecallers attempted auto-delivery and it failed (hard error or soft suppression), the response isdispatch_error: truewithdispatch_error_messagecarrying the reason,status: "none", and no pending row — that pair is safe to retry with begin once the cause is resolved. Retry after a softduplicate_contentsuppression only when the recipient genuinely has no prompt; the suppression is a cooldown on identical copy. - Confirmation keywords. The affirmative set matches the inbound
carrier path exactly (YES / START / SUBSCRIBE + multilingual
variants). Non-keyword replies — long form “yes please sign me
up” — classify as
unrecognisedand confirm nothing.
Tenant-owned framing
The handshake is a tenant-controlled control, not a platform mandate: nothing on Orbit ever starts a handshake on its own — the tenant (or the contact’s reply through your stack) is what drives begin and confirm. It is the stronger evidence tier above the plain consent record, meant for tiers where a bare consent POST does not survive review (TCPA litigation discovery, a 10DLC campaign audit, a GDPR confirmed-opt-in commitment). Ship it only where the extra proof is worth the extra leg; the plain Consent Management surface is still the system of record for low-friction opt-ins.Related references
- Consent Management & Receipts — the plain consent ledger and its export.
- Opt-Out & Suppression Lists — the read-side layer the confirmed grant clears.
- Consent Posture: The Unknown-Consent Policies — the org-level knob that decides what a pending handshake reads as.
- API Reference → Compliance —
full request/response schemas for
begin,confirm, andstatus.