Skip to main content

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 confirmed opted_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.
A confirmed handshake records the affirmative reply, but it does not by itself make the send lawful. You remain responsible for the content of the first message and for the eligibility of the ask. This page is not legal advice.

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:
  • confirmedtrue when status === "opted_in".
  • awaiting_replytrue when status === "pending" (present on the status endpoint).
Begin and confirm responses also carry 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.
Returns 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_in consent row the inbound YES handler writes, clears the read-side opt-out gate, marks the pending row confirmed, and returns 200 { "status": "opted_in", "confirmed": true }.
  • Affirmative + already opted_in → idempotent 200 { "confirmed": true, "already_confirmed": true } — no duplicate grant row.
  • Affirmative + nothing pending409 NO_PENDING_DOUBLE_OPT_IN (start the handshake first).
  • Non-affirmative200 { "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.
The request keys on the same contact_id or identifier parameter pair as begin/confirm, defaults channel to sms, and 404s on an unresolvable contact.
Confirmed handshakes are ordinary rows in the same consent ledger every other surface reads. A successful confirm writes the same opted_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_out is a safe no-op short-circuit. A confirm against opted_out records nothing (a relayed non-affirmative reply never changes opt-out state).
  • Dispatch failures. When dispatch: true callers attempted auto-delivery and it failed (hard error or soft suppression), the response is dispatch_error: true with dispatch_error_message carrying the reason, status: "none", and no pending row — that pair is safe to retry with begin once the cause is resolved. Retry after a soft duplicate_content suppression 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 unrecognised and 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.