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

# Compliance integration recipes

> Wire your CRM, SIEM, and data warehouse into your Orbit compliance controls: a DSAR intake pipeline, ledger exports on a cron, 10DLC lifecycle automation, and a pre-send quiet-hours gate, each with sequence, errors, and a verify-it-works curl.

# Compliance integration recipes

Your compliance evidence lives in three systems: Orbit, your CRM (the
source of consent), and your data warehouse (retention). Orbit exposes the
controls you need to keep them in agreement as ordinary REST endpoints
under `https://api.orbit.devotel.io/api/v1`, authenticated with
`Authorization: Bearer <API_KEY>` (create keys under Settings → API keys;
see [Authentication](/authentication)). The public DSAR intake endpoints
are the one unauthenticated surface, by design, because the requester is
your customer, not your backend.

Orbit does not mandate how you run your compliance program. Every control
below is tenant-owned: you decide which ones to wire up and which stay
manual. These recipes show the four integrations teams build most often.

```mermaid theme={null}
flowchart LR
  CRM["Your CRM<br/>(consent source)"] -->|"consent + suppression sync"| API["Orbit compliance API"]
  FORM["Your DSAR webform"] -->|"public intake"| API
  API -->|"cron exports"| WH["Your warehouse / SIEM<br/>(retention, evidence)"]
  API -->|"pre-send posture check"| SENDER["Your send path"]
```

## Use case A: DSAR intake pipeline

Route your privacy webform through the public DSAR flow so access and
erasure requests land in the same queue your team already works, with the
identity proofing handled for you. The flow is six calls: begin,
verify-email, submit on the requester side; then list, watch SLA, and issue
the certificate on your side.

```mermaid theme={null}
flowchart LR
  F["Your webform"] -->|POST public/dsar/begin| B["claim created<br/>OTP emailed"]
  B -->|POST public/dsar/verify-email| V["identity proven"]
  V -->|POST public/dsar/submit| S["request queued"]
  S -->|GET compliance/dsar| Q["your team works it"]
  Q -->|GET erasure-requests/id/certificate| C["signed proof-of-deletion"]
```

Requester side (unauthenticated):

```bash theme={null}
# 1. Begin: Orbit emails a 6-digit code to the requester. Always 202 with a
#    claim_id, whether or not the tuple matches a contact.
curl -sS -X POST "$API/compliance/public/dsar/begin" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jamie@example.com",
    "phone": "+14155550100",
    "request_type": "delete"
  }'
# -> { "data": { "claim_id": "clm_..." } }

# 2. Verify the emailed code (the requester types it into your form;
#    your backend forwards it).
curl -sS -X POST "$API/compliance/public/dsar/verify-email" \
  -H "Content-Type: application/json" \
  -d '{ "claim_id": "clm_...", "code": "123456" }'

# 3. Submit: the request enters your org's DSAR queue.
curl -sS -X POST "$API/compliance/public/dsar/submit" \
  -H "Content-Type: application/json" \
  -d '{ "claim_id": "clm_..." }'
```

Your side (authenticated, owner or admin):

```bash theme={null}
# 4. Pull the queue into your case tool.
curl -sS "$API/compliance/dsar" -H "Authorization: Bearer $KEY"

# 5. Poll the SLA dashboard so statutory clocks never slip.
curl -sS "$API/compliance/dsar/sla" -H "Authorization: Bearer $KEY"

# 6. When an erasure completes, fetch the signed proof-of-deletion and
#    store it in your retention system.
curl -sS "$API/compliance/dsar/erasure-requests/<request_id>/certificate" \
  -H "Authorization: Bearer $KEY"
```

Handling inbound erasure end to end, including verification and regulator
presentation of the certificate, is covered in the
[Proof-of-Deletion Certificate Runbook](/guides/compliance-dsar-erasure-certificate-runbook).
The [DSAR reference](/compliance/dsar) covers the console flow.

**Verify it works:** the `begin` call returns HTTP 202 with a `claim_id`,
and `GET /api/v1/compliance/dsar` lists the submitted request.

**Common errors** (see also the
[compliance gate error codes FAQ](/compliance/compliance-gate-error-codes-faq)):

| Symptom | Cause | What to do |
| - | - | - |
| `429` on `begin` | Per-IP or per-address cooldown, or the bot-check rate cap | Back off exponentially. Real intake arrives at human speed; if your form is being scraped, throttle at your edge first. |
| `422` on `verify-email` | Wrong or expired 6-digit code | Codes are single-use and short-lived. Use `POST /api/v1/compliance/public/dsar/resend-email` to issue a fresh one. |
| `404` on `certificate` | The erasure is not `fulfilled` yet | Poll the request status; the certificate exists only after completion. |

## Use case B: suppression and consent ledgers to your SIEM or warehouse

Your warehouse should hold the same suppression and consent truth Orbit
enforces at send time. Three exports cover it: the suppression ledger
(proof of who was STOP-suppressed and when), the consent proof-of-record,
and the tamper-evident audit trail. Run all three on a cron and you can
re-answer an audit from your own storage.

```mermaid theme={null}
flowchart LR
  CRON["Your cron<br/>(daily)"] -->|GET suppression-list/export| WH
  CRON -->|GET consent/export| WH
  CRON -->|POST audit-export| JOB["export job"]
  JOB -->|poll GET audit-export/jobId| DONE["signed download_url"]
  DONE --> WH
  CRON -->|PUT siem-sinks/https + activate| SIEM["your SIEM<br/>(continuous)"]
```

For continuous delivery instead of cron pulls, register a SIEM sink
(Splunk HEC, Datadog, S3, or an HTTPS log drain). A new sink, or one whose
endpoint changed, starts `pending` and must be activated:

```bash theme={null}
# Register an HTTPS sink; the credential is a secret-manager reference,
# never the secret itself.
curl -sS -X PUT "$API/compliance/siem-sinks/https" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://logs.example.com/orbit/hec",
    "credential_ref": "secretmanager:orbit-siem-hec",
    "label": "corp-siem"
  }'

curl -sS -X POST "$API/compliance/siem-sinks/https/activate" \
  -H "Authorization: Bearer $KEY"
```

The cron exports:

```bash theme={null}
# Suppression ledger (RFC-4180 CSV by default; format=json for JSON).
curl -sS "$API/compliance/suppression-list/export?from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $KEY" -o suppression.csv

# Consent proof-of-record, filtered to one channel if you like.
curl -sS "$API/compliance/consent/export?channel=sms" \
  -H "Authorization: Bearer $KEY" -o consent.csv

# Tamper-evident audit trail: queue a bounded window, then download.
curl -sS -X POST "$API/compliance/audit-export?from=2026-09-01&to=2026-09-30&format=json" \
  -H "Authorization: Bearer $KEY"
# -> { "data": { "id": "job_..." } }
curl -sS "$API/compliance/audit-export/job_..." -H "Authorization: Bearer $KEY"
# when status is "complete", download_url is a signed link; verify replays
# the hash chain:
curl -sS "$API/compliance/audit-export/job_.../verify" -H "Authorization: Bearer $KEY"
```

Reference pages: [SIEM sinks](/compliance/siem-sinks),
[audit export](/compliance/audit-export),
[suppression ledger export](/compliance/consent-suppression-export), and
[bulk opt-out import](/compliance/opt-out-suppression) for the write path
your CRM sync feeds.

**Verify it works:** `GET /api/v1/compliance/siem-sinks` shows your sink
with `state: "active"`, and a day-old cron run leaves one CSV per export in
your bucket.

**Common errors:**

| Symptom | Cause | What to do |
| - | - | - |
| `409 SIEM_SINK_INVALID_ENDPOINT` | Endpoint fails the sink type's format check | Match the URL to the sink type (HEC endpoints end in `/services/collector`, for example). |
| `X-Export-Truncated: true` response header | The row cap truncated the export | Narrow the date range or filters; the truncation is also written to the audit trail. |
| Audit job stuck in `running` | Large window still streaming | Keep polling; switch to smaller day-aligned windows for backfills. |

## Use case C: 10DLC lifecycle automation

Registration, rejection, and re-vet are all API-callable, so the whole
lifecycle fits in a loop: create the brand, create the campaign under it,
poll status until a terminal verdict (`APPROVED` or `FAILED`), and on
failure decode the rejection into a fix card before resubmitting or
re-vetting.

```mermaid theme={null}
flowchart LR
  B["POST compliance/brands"] --> C["POST compliance/campaigns"]
  C --> P["poll GET compliance/brands<br/>until APPROVED or FAILED"]
  P -->|FAILED| D["POST 10dlc/decode-rejection"]
  D -->|resubmit_allowed| C
  D -->|score-limited| R["POST 10dlc/brands/id/revet"]
  R --> P
```

```bash theme={null}
# Register the brand (owner or admin).
curl -sS -X POST "$API/compliance/brands" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "entity_type": "PRIVATE_PROFIT",
    "display_name": "Acme Alerts",
    "company_name": "Acme Corp",
    "ein": "12-3456789",
    "phone": "+14155550100",
    "street": "100 Market St",
    "city": "San Francisco",
    "state": "CA",
    "postal_code": "94105",
    "email": "compliance@example.com",
    "vertical": "TECHNOLOGY"
  }'
# -> { "data": { "brand_id": "B..." } }

# Register a campaign under it. description, message_flow >= 40 chars;
# help_message and optout_message >= 20; 1-10 sample messages.
curl -sS -X POST "$API/compliance/campaigns" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "brand_id": "B...",
    "usecase": "MIXED",
    "description": "Order and appointment reminders for customers who opted in at checkout.",
    "sample_message": ["Acme: your order 1234 ships today. Reply STOP to opt out."],
    "message_flow": "Customer opts in at checkout; we send order and appointment reminders; every message includes opt-out language.",
    "help_message": "Acme alerts: reply HELP for help, STOP to opt out.",
    "optout_message": "You are opted out of Acme alerts. Reply HELP to resume."
  }'

# Poll until terminal. PENDING means still in carrier review.
curl -sS "$API/compliance/brands" -H "Authorization: Bearer $KEY"
```

On `FAILED`, decode the raw TCR or carrier code into a fix card. The
response's `resubmit_allowed` tells you whether to amend and resubmit the
same record or start a fresh registration:

```bash theme={null}
curl -sS -X POST "$API/compliance/10dlc/decode-rejection" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "code": "40016", "free_text": "sample message missing opt-out language" }'
# -> { "data": { "code": "40016", "title": "...", "meaning": "...",
#      "fix": "...", "resubmit_allowed": true, "category": "content" } }

# When the vetting score itself is the blocker (after fixing EIN match,
# address, website), request a re-vet:
curl -sS -X POST "$API/compliance/10dlc/brands/B.../revet" \
  -H "Authorization: Bearer $KEY"
```

Reference pages: [10DLC registration](/guides/10dlc-registration),
[rejections and re-vet](/guides/10dlc-rejections-and-revet),
[brand and campaign profiles](/compliance/10dlc-brand-campaign-profiles).

**Verify it works:** `GET /api/v1/compliance/10dlc/status` reports your
brand and campaign with `status: "APPROVED"` and an overall readiness
verdict of `ready`.

**Common errors:**

| Symptom | Cause | What to do |
| - | - | - |
| `422 VALIDATION_ERROR` on `campaigns` | `description` / `message_flow` under 40 chars, or help/opt-out under 20 | TCR vetting reads these fields literally; write them as full sentences, then resubmit. |
| `FAILED` with a score-limited reason | Brand vetting score caps the campaign | Fix the underlying data and `POST .../revet`; do not resubmit the same campaign unchanged. |
| `404` on `brands/{id}/revet` | Brand id is not one of your org's registered brands | List `GET /api/v1/compliance/brands` first; ids are org-scoped. |

## Use case D: pre-send posture check with quiet-hours preview

Before your send path hands a message to Orbit, ask whether the send would
pass the quiet-hours gate right now for that recipient and channel. The
preview is read-only; it never sends. On a blocked result,
`next_allowed_at` is the DST-safe ISO timestamp when the gate next opens,
so your scheduler can queue until then instead of dropping the send.

```bash theme={null}
curl -sS "$API/compliance/quiet-hours/preview?phone=%2B14155550100&channel=sms" \
  -H "Authorization: Bearer $KEY"
# -> { "data": {
#      "allowed_now": false,
#      "local_hour": 22,
#      "local_timezone": "America/Los_Angeles",
#      "window_start_local": "08:00",
#      "window_end_local": "21:00",
#      "next_allowed_at": "2026-10-17T15:00:00.000Z",
#      "reason": "outside_window",
#      "channel": "sms" } }
```

If your CRM already stores the recipient's timezone, pass
`timezone_override=America/New_York` to skip area-code resolution. See
[quiet hours FAQ](/compliance/quiet-hours-faq) for the per-channel
windows.

**Verify it works:** call the preview once for a recipient inside their
window and once outside it; `allowed_now` flips and the blocked call
carries `next_allowed_at`.

**Common errors:**

| Symptom | Cause | What to do |
| - | - | - |
| `400` | Missing `phone` or `channel`, or phone not E.164 | Send `+<country><number>` and a valid channel enum value. |
| `401` | No or wrong bearer key | The preview is authenticated; the public prefix applies only to the DSAR intake endpoints. |

## Sample code: one Node script that wires all four

The skeleton below runs every recipe against a live org using only Node
18+ built-ins. Store it as `index.js` and run `ORBIT_API_KEY=dv_live_... node
index.js`. The DSAR half pauses for the emailed code, because a human holds
it; everything else runs unattended, exactly as your cron would.

```js theme={null}
// examples/compliance-integrations/index.js
import { setTimeout as sleep } from "node:timers/promises";
import readline from "node:readline/promises";

const BASE = process.env.ORBIT_API ?? "https://api.orbit.devotel.io/api/v1";
const KEY = process.env.ORBIT_API_KEY;
if (!KEY) throw new Error("set ORBIT_API_KEY");

async function orbit(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      ...(KEY ? { Authorization: `Bearer ${KEY}` } : {}),
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) {
    throw new Error(`${method} ${path} -> ${res.status}: ${await res.text()}`);
  }
  return res.json();
}

const poll = async (fn, done, { everyMs = 5000, tries = 60 } = {}) => {
  for (let i = 0; i < tries; i++) {
    const v = await fn();
    if (done(v)) return v;
    await sleep(everyMs);
  }
  throw new Error("poll budget exhausted");
};

// --- A. DSAR intake: begin as your webform would, then verify + submit.
async function dsarIntake(email, phone) {
  const begun = await orbit("POST", "/compliance/public/dsar/begin", {
    email, phone, request_type: "delete",
  });
  const claimId = begun.data.claim_id;
  const rl = readline.createInterface({ input: process.stdin });
  const code = await rl.question("enter the emailed 6-digit code: ");
  rl.close();
  await orbit("POST", "/compliance/public/dsar/verify-email", {
    claim_id: claimId, code,
  });
  await orbit("POST", "/compliance/public/dsar/submit", { claim_id: claimId });
  const sla = await orbit("GET", "/compliance/dsar/sla");
  console.log("dsar queued; sla:", sla.data);
  return claimId;
}

// --- B. Ledger exports: suppression + consent now, audit-export polled.
async function exportLedgers(outDir = ".") {
  const { writeFileSync } = await import("node:fs");
  const to = new Date().toISOString().slice(0, 10);
  const from = "2026-01-01"; // your retention start
  const sup = await fetch(
    `${BASE}/compliance/suppression-list/export?from=${from}&to=${to}`,
    { headers: { Authorization: `Bearer ${KEY}` } },
  );
  if (sup.headers.get("x-export-truncated")) console.warn("suppression export truncated");
  writeFileSync(`${outDir}/suppression.csv`, await sup.text());

  const job = await orbit(
    "POST", `/compliance/audit-export?from=${from}&to=${to}&format=json`,
  );
  const done = await poll(
    () => orbit("GET", `/compliance/audit-export/${job.data.id}`),
    (j) => j.data.status === "complete" || j.data.status === "failed",
  );
  if (done.data.status !== "complete") throw new Error(done.data.error ?? "audit export failed");
  console.log("audit export ready:", done.data.download_url);
}

// --- C. 10DLC: status check; on FAILED decode the rejection to a fix card.
async function tenDlcStatus() {
  const status = await orbit("GET", "/compliance/10dlc/status");
  for (const brand of status.data.brands ?? []) {
    if (brand.status === "FAILED" && brand.rejectionReason) {
      const card = await orbit("POST", "/compliance/10dlc/decode-rejection", {
        code: brand.rejectionReason.slice(0, 200),
        free_text: brand.rejectionReason,
      });
      console.log("fix card:", card.data);
      if (card.data.resubmit_allowed === false) {
        console.log("record locked: register a fresh brand instead");
      }
    }
  }
  return status.data.verdict ?? status.data;
}

// --- D. Pre-send posture gate.
async function canSendNow(phone, channel = "sms") {
  const r = await orbit(
    "GET",
    `/compliance/quiet-hours/preview?phone=${encodeURIComponent(phone)}&channel=${channel}`,
  );
  if (!r.data.allowed_now) {
    console.log(`blocked (${r.data.reason}); queue until ${r.data.next_allowed_at}`);
  }
  return r.data.allowed_now;
}

const phone = process.env.RECIPIENT_PHONE ?? "+14155550100";
await canSendNow(phone);
await tenDlcStatus();
await exportLedgers();
if (process.env.DSAR_EMAIL) await dsarIntake(process.env.DSAR_EMAIL, phone);
console.log("all four integrations exercised");
```

Each function mirrors one section above, so you can lift them into your
own service one at a time. Keep credentials in your secret manager and
pass `credential_ref` values to the SIEM sink, never raw secrets.

## Where to go next

* [Consent management](/compliance/consent-management): the consent API
  your CRM sync writes to.
* [Opt-out and suppression](/compliance/opt-out-suppression): the bulk
  import your CRM sync feeds.
* [Compliance gate error codes FAQ](/compliance/compliance-gate-error-codes-faq):
  every gate error code with its operator-facing meaning.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.