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

# API recipes: contact lifecycle — opt-outs, segments, frequency caps

> Runnable curl loops for contact-lifecycle surfaces — run the opt-outs bulk export → per-channel re-opt-in round trip, go from AI segment autopilot to a materialized lookalike audience, read and update the frequency-cap rules behind a send decision, and pull a day of usage for metering guardrails.

# API recipes: contact lifecycle

The contact-lifecycle cookbook. The canonical pages — [Opt-outs API](/api-reference/optouts), [Segments API](/api-reference/segments), [Frequency Caps API](/api-reference/frequency-caps), [Analytics API](/api-reference/endpoints/analytics) — document each endpoint; these recipes chain them into runnable loops: read, write, decide. All four loops stay on tenant-owned controls — opt-outs, segments, and caps are your rules, set and enforced by your key. Base URL is `https://api.orbit.devotel.io/api/v1` throughout; authenticate with `X-API-Key` and a test key (`dv_test_sk_…`) for the sandbox.

## 1. Opt-outs: bulk suppress → export → re-opt-in one channel

A suppression file arrives from support and you need the round trip: opt the contacts out in one batch, export the suppression list as compliance evidence, then restore the one contact who re-consents by phone or email. `contact` identifies the address-book row by phone (E.164) or email — pick the id off `GET /contacts` first if your file lists raw addresses.

**Bulk opt-out** — up to 500 rows per call, per-row outcomes isolated:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/optouts/bulk \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "contact": "+14155552671", "channel": "sms", "reason": "Support ticket #4481 — no more marketing SMS" },
      { "contact": "user@example.com", "channel": "email", "reason": "Replied stop-all to a nurture sequence" }
    ]
  }'
```

**207 Multi-Status** — the full-envelope batch result. `succeeded` / `skipped` / `failed` count independently, so one unknown phone number never rolls the rest back:

```json 207 theme={null}
{
  "data": {
    "total": 2,
    "succeeded": 2,
    "skipped": 0,
    "failed": 0,
    "results": [
      { "contact": "+14155552671", "channel": "sms", "ok": true, "status": "succeeded" },
      { "contact": "user@example.com", "channel": "email", "ok": true, "status": "succeeded" }
    ]
  },
  "meta": { "request_id": "req_abc123", "timestamp": "2026-05-24T12:00:00Z" }
}
```

A failed fifth row would show `ok: false`, `status: "failed"`, and an actionable `error` string — clean the named rows and resend only them; `skipped` rows (already opted out) never double-stamp the consent trail.

**Export the suppression list** — compliance evidence, up to 10 000 contacts:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/optouts/export \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "sms" }'
```

```json 200 theme={null}
{
  "data": {
    "contacts": [
      {
        "id": "con_9f8a7b6c",
        "phone": "+14155552671",
        "email": null,
        "display_name": "Ada Lovelace",
        "channel_preferences": { "sms": { "opted_out": true } },
        "updated_at": "2026-05-22T10:30:00Z"
      }
    ],
    "total": 1
  },
  "meta": { "request_id": "req_abc124", "timestamp": "2026-05-24T12:01:00Z" }
}
```

Omit `channel` to export every opt-out across channels. Past 10 000, page `GET /contacts/optouts` on its cursor instead — same rows, no cap.

**Re-opt-in the one contact who re-consents** — delete the channel's opt-out:

```bash cURL theme={null}
curl -X DELETE https://api.orbit.devotel.io/api/v1/contacts/optouts/con_9f8a7b6c/sms \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

`200` returns the updated contact row. The contact is eligible again on that channel **subject to consent records** — the delete clears the suppression, and any affirmative-consent requirement (a WhatsApp 24-hour window, RCS marketing consent) still has to be satisfied by your own capture. The round trip closes: every enlisted row, plus one restored.

**Labelled error envelope** — a channel outside the canonical nine (case-insensitive, whitespace-trimmed) fails the whole request before any row is processed:

```json 422 theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "channel must be one of: sms, whatsapp, email, rcs, viber, voice, messenger, instagram, telegram"
  },
  "meta": { "request_id": "req_abc125", "timestamp": "2026-05-24T12:02:00Z" }
}
```

Nothing was written on a validation failure, so retrying the corrected body is always safe; an already-opted-out single-row write resolves as `200` with `already_opted_out: true`, not an error.

Canonical page: [Opt-outs API](/api-reference/optouts). Envelope reference: [API error handling by example](/guides/error-handling-examples).

## 2. Segments: autopilot → evaluate → materialize-lookalike → members

The AI-assisted audience loop: describe the audience, persist the suggestion as a real segment, force a re-evaluation when data moves, then grow it — preview a lookalike expansion and materialize it as a static segment you can page members out of.

**Autopilot from a description** — suggest rules + live preview in one round-trip, nothing persisted:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/segments/autopilot \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "description": "customers who bought in the last 90 days but have not replied" }'
```

```json 200 theme={null}
{
  "data": {
    "operator": "and",
    "rules": [
      { "field": "last_purchase_at", "op": "in_last", "value": "90d" },
      { "field": "last_reply_at", "op": "is_unset" }
    ],
    "filters": {
      "op": "AND",
      "conditions": [
        { "field": "last_purchase_at", "op": "in_last", "value": "90d" },
        { "field": "last_reply_at", "op": "is_unset" }
      ]
    },
    "preview": { "match_count": 1842, "sample_contacts": [] },
    "llm_output_preview": "{\"operator\":\"and\",\"rules\":[...]"
  },
  "meta": { "request_id": "req_seg001", "timestamp": "2026-08-25T09:41:12Z" }
}
```

Persist the winner with `POST /contacts/segments` (the main cookbook's [task 15](/guides/api-recipes#15-build-a-cdp-segment-and-read-its-members)); membership materializes on create.

**Labelled error envelope** — the model emits a rule the segmentation engine cannot validate, and the error names the field:

```json 422 theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "rules[0].field: 'account_balance_usd' is not a segmentable field",
    "status": 422,
    "details": { "field": "account_balance_usd" }
  },
  "meta": { "request_id": "req_seg002", "timestamp": "2026-08-25T09:41:13Z" }
}
```

Retry with a tighter description; a `503` means no AI provider is configured on the deployment — fall back to the manual builder.

**Evaluate on demand** — after data (not rules) changed, rebuild membership now instead of waiting for the periodic refresh:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/segments/seg_9c2b1q/evaluate \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

`200` returns the evaluation result — the matched count, duration, and the membership diff — in the standard envelope.

**Materialize a lookalike audience** — promote a preview into a new static segment (no rules, `auto_refresh` off — the k-NN snapshot is a point-in-time cohort the refresh scheduler never wipes):

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/segments/seg_9c2b1q/materialize-lookalike \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "limit": 100, "sample_size": 50 }'
```

`201` returns `materialized: true` plus the new segment — its id is a first-class segment you activate like any other. A preview with no candidates returns `200` with `materialized: false` and a `reason`, persisting nothing.

**Read members** — page the materialized snapshot with the same cursor contract as any list:

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/contacts/segments/seg_9c2b1q/members?limit=100" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

Pass `meta.pagination.cursor` while `has_more` is true. The members list reads the last-materialized snapshot — call evaluate (above) first if you need the very latest audience.

Canonical pages: [Segments API](/api-reference/segments) (AI surfaces), [Contacts API](/api-reference/endpoints/contacts) (saved-segment CRUD + membership).

## 3. Frequency caps: read the rules behind a send decision

Caps are the tenant's rolling-window limits on how often one contact can be messaged. Two reads serve the "what applies to this contact?" question: the per-contact rules read, then the live per-recipient usage you branch a send decision on. Rule writes ride `PATCH /frequency-caps/:id`; reads ride `GET /contacts/:id/frequency-caps` (rules) and `GET /contacts/:id/cap-status` (live usage) — the decision loop below uses the rule IDs from the first read to target back-off per category.

**Read the rules that apply to the contact** — flat plus grouped by channel, globals separated:

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/contacts/con_9f8a7b6c/frequency-caps \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json 200 theme={null}
{
  "data": {
    "contact_id": "con_9f8a7b6c",
    "caps": [
      {
        "id": "cap_01JYMK",
        "organization_id": "org_01HX",
        "channel": "sms",
        "scope": "channel",
        "window_seconds": 86400,
        "max_count": 3,
        "applies_to_categories": ["marketing"],
        "enabled": true,
        "created_at": "2026-05-01T00:00:00Z",
        "updated_at": "2026-05-01T00:00:00Z"
      }
    ],
    "grouped_by_channel": {
      "sms": [
        {
          "id": "cap_01JYMK",
          "organization_id": "org_01HX",
          "channel": "sms",
          "scope": "channel",
          "window_seconds": 86400,
          "max_count": 3,
          "applies_to_categories": ["marketing"],
          "enabled": true,
          "created_at": "2026-05-01T00:00:00Z",
          "updated_at": "2026-05-01T00:00:00Z"
        }
      ]
    },
    "global_caps": []
  },
  "meta": { "request_id": "req_cap001", "timestamp": "2026-08-25T10:00:00Z" }
}
```

**Read live usage for the "can I send?" decision** — snapshot across every channel, with the consent state folded in:

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/contacts/con_9f8a7b6c/cap-status \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

Per cap that applies, the response carries `max_count` / `current_count` / `sends_remaining` and (once slots are exhausted) the `next_slot_at` ISO timestamp, plus the channel's consent state — one read answers both "how many left" and "may we send at all".

**Update a rule** — widen the marketing SMS cap; rule writes are owner/admin/developer with `frequency-caps:write`:

```bash cURL theme={null}
curl -X PATCH https://api.orbit.devotel.io/api/v1/frequency-caps/cap_01JYMK \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "max_count": 5, "window_seconds": 86400 }'
```

**Decide in your send gate:**

```typescript Node.js — branch your composer on the snapshot theme={null}
const { data: status } = await orbit.request(
  'GET',
  '/api/v1/contacts/con_9f8a7b6c/cap-status',
);
// Walk status channels: zero sends_remaining (or opted_out) → hold or
// skip the recipient; positive → the cap admits the send. The 429 loop
// below is the fallback when the snapshot went stale before dispatch.
```

**Labelled error envelope** — a send that would exceed the cap:

```json 429 theme={null}
{
  "error": {
    "code": "FREQUENCY_CAP_EXCEEDED",
    "message": "Frequency cap exceeded for sms (3 per day per contact). Try again after the window resets."
  },
  "meta": { "request_id": "req_cap002", "timestamp": "2026-08-25T10:00:01Z" }
}
```

Defer that recipient and continue the batch (the main cookbook's [error table](/guides/api-recipes#10-errors-as-recipes) has the retriable-vs-terminal split). Caps are exclusion enforcement, not a block on your whole send plan — suppression lists and opt-outs gate in parallel ([Opt-outs round trip](#1-opt-outs-bulk-suppress--export--re-opt-in-one-channel) above).

Canonical pages: [Frequency Caps API](/api-reference/frequency-caps), [Contacts API](/api-reference/endpoints/contacts).

## 4. Usage: fetch a day of metering for guardrails

The metering guardrail read: the home-overview usage chart's same daily timeline, pulled for a one-day window so your own guardrails (budget dashboards, spend alerts, anomaly heuristics) sit on the numbers the platform charts. Read-only; an explicit `start_date` / `end_date` pair takes precedence over the `days` trailing window.

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/stats/usage?start_date=2026-08-20&end_date=2026-08-20" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json 200 theme={null}
{
  "data": {
    "totals": {
      "total_sent": 2450,
      "total_delivered": 2416,
      "total_failed": 34,
      "total_read": 1720,
      "delivery_rate": 98.61,
      "read_rate": 71.19,
      "avg_delivery_time_ms": 1795
    },
    "time_series": [
      {
        "period": "2026-08-20T00:00:00.000Z",
        "period_date": "2026-08-20",
        "total_sent": 2450,
        "total_delivered": 2416,
        "total_failed": 34,
        "total_read": 1720,
        "delivery_rate": 98.61,
        "read_rate": 71.19,
        "avg_delivery_time_ms": 1795
      }
    ]
  },
  "meta": { "request_id": "req_usg001", "timestamp": "2026-08-21T09:00:00Z" }
}
```

Feed `totals` into a budget check before you queue a batch, or row-by-row into a daily anomaly heuristic. For spend-shaped guardrails instead of volume, the billing surface's spend series and burn rate (the main cookbook's [task 22](/guides/api-recipes#22-run-a-billing-what-if-preview-and-read-spend--burn)) price the same window.

**Labelled error envelope** — an explicit pair with a missing end:

```json 400 theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid query parameters",
    "status": 400,
    "details": { "issues": ["start_date and end_date must be provided together"] }
  },
  "meta": { "request_id": "req_usg002", "timestamp": "2026-08-21T09:00:01Z" }
}
```

A lone `start_date` (or `end_date`) is a 400 — the pair refines together, and the `details` issues list names the fix instead of silently falling back to the trailing `days` window.

Canonical page: [Analytics API → per-day usage timeline](/api-reference/endpoints/analytics). Finer-grained metering (rate a batch, credit notes): [Usage metering](/api-reference/usage-metering).

## See also

* [REST API recipes](/guides/api-recipes) — the main task-by-task cookbook these four loops extend
* [API recipes: operations endpoints](/guides/api-recipes/operations) — the operational surfaces (approvals, cascade preview, search DSL, SMPP window, brand trust, risk gate)
* [Using worked samples](/guides/using-orbit-samples) — translate any curl block here into your HTTP client
* [API error handling by example](/guides/error-handling-examples) — retriable-vs-terminal for every failure shape above
