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

# Confirmed Consent (Double Opt-In) Handshakes

> Start, confirm, and read a managed double opt-in handshake — the recorded proof-of-reply flow that promotes a consent record from pending to confirmed over a (contact, channel) pair.

# 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`.

<Warning>
  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.
</Warning>

***

## States

Each `(contact, channel)` pair reports one handshake state — read it
back from the status endpoint (or drive begin/confirm transitions)
without side effects:

| State       | Meaning                                                                                                              |
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
| `opted_in`  | The handshake is complete — an affirmative reply has promoted the pair to a confirmed grant.                         |
| `opted_out` | The pair has a recorded opt-out. A new start is refused (a no-op short-circuit), matching the inbound path.          |
| `pending`   | A confirmation prompt has been recorded and the affirmative reply is still awaited. Handshake writes are idempotent. |
| `none`      | No handshake exists for the pair (begin has not run, or a begin failed to record a row).                             |

Two convenience flags are returned alongside the state on every
begin/confirm/status response:

* `confirmed` — `true` when `status === "opted_in"`.
* `awaiting_reply` — `true` 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.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent/double-opt-in \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "+14155550101",
    "channel": "sms",
    "source": "signup_form",
    "brand_name": "Acme",
    "help_contact": "support@example.com"
  }'
```

Returns `201 Created`:

```json theme={null}
{
  "contact_id": "cnt_9f…",
  "channel": "sms",
  "status": "pending",
  "confirmation_message": "Acme: msg & data rates may apply. Reply YES to confirm, STOP to opt out.",
  "pending_record_id": "cr_a1…",
  "already_resolved": false,
  "dispatched": false,
  "dispatch_channel": null,
  "dispatch_error": false,
  "dispatch_error_message": null
}
```

| Field                       | Type    | Notes                                                                                                                                                                                                                               |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contact_id` / `identifier` | string  | Provide one of the two.                                                                                                                                                                                                             |
| `channel`                   | enum    | One of the channel set shared with the consent surface (`email`, `fax`, `instagram`, `line`, `messenger`, `push`, `rcs`, `sms`, `viber`, `voice`, `whatsapp`). Default `sms`.                                                       |
| `source`                    | string  | How the handshake was started (default `double_opt_in_api`).                                                                                                                                                                        |
| `brand_name`                | string  | Embedded into the prompt copy (≤ 32).                                                                                                                                                                                               |
| `help_contact`              | string  | Support contact embedded into the prompt (≤ 64).                                                                                                                                                                                    |
| `metadata`                  | object  | Arbitrary key/values stored on the pending row.                                                                                                                                                                                     |
| `dispatch`                  | boolean | When `true`, Orbit hands the prompt to the existing messaging send path (the same envelope other outbound flows use) instead of just recording state. Defaults `false` — external integrators deliver the returned copy themselves. |

**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).

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/consent/double-opt-in/confirm \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "+14155550101",
    "channel": "sms",
    "reply": "YES"
  }'
```

| Field                       | Type   | Notes                                                                                           |
| --------------------------- | ------ | ----------------------------------------------------------------------------------------------- |
| `contact_id` / `identifier` | string | One of the two.                                                                                 |
| `channel`                   | enum   | Default `sms`.                                                                                  |
| `reply`                     | string | The recipient's raw reply text (≤ 2000 characters). **Required.**                               |
| `lang`                      | string | Optional 2–8 character language tag that selects the accepted affirmative/negative keyword set. |
| `source`                    | string | Provenance tag (default `double_opt_in_confirm`).                                               |

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

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/compliance/consent/double-opt-in/status?identifier=%2B14155550101&channel=sms" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

```json theme={null}
{
  "contact_id": "cnt_9f…",
  "channel": "sms",
  "status": "pending",
  "confirmed": false,
  "awaiting_reply": true
}
```

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.

***

## 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 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](/compliance/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](/compliance/consent-management) surface is
still the system of record for low-friction opt-ins.

***

## Related references

* [Consent Management & Receipts](/compliance/consent-management) —
  the plain consent ledger and its export.
* [Opt-Out & Suppression Lists](/compliance/opt-out-suppression) —
  the read-side layer the confirmed grant clears.
* [Consent Posture: The Unknown-Consent Policies](/compliance/consent-default-policy) —
  the org-level knob that decides what a pending handshake reads as.
* [API Reference → Compliance](/api-reference/endpoints/compliance) —
  full request/response schemas for `begin`, `confirm`, and `status`.
