View source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>ConsentIVR - Documentation</title>
<style>
:root{
--bg:#f7f8fc; --surface:#ffffff; --surface-2:#eef1f9;
--ink:#0f0a2a; --muted:#4b5563; --line:#e2e5f0;
--accent:#7c3aed; --accent-ink:#6d28d9; --on-accent:#ffffff;
--ok:#15803d; --warn:#b45309; --bad:#be123c;
--shadow:0 1px 2px rgba(20,29,39,.06), 0 8px 24px rgba(20,29,39,.06);
--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
--body:"IBM Plex Sans",system-ui,Segoe UI,Roboto,sans-serif;
--display:"Archivo","IBM Plex Sans",system-ui,sans-serif;
color-scheme:light;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--bg:#0a0a0f; --surface:#12121c; --surface-2:#0e0e16;
--ink:#f8fafc; --muted:#94a3b8; --line:#241f33;
--accent:#a78bfa; --accent-ink:#c4b5fd; --on-accent:#0a0a0f;
--ok:#34d399; --warn:#fbbf24; --bad:#fb7185;
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.35);
color-scheme:dark;
}
}
:root[data-theme="dark"]{
--bg:#0a0a0f; --surface:#12121c; --surface-2:#0e0e16;
--ink:#f8fafc; --muted:#94a3b8; --line:#241f33;
--accent:#a78bfa; --accent-ink:#c4b5fd; --on-accent:#0a0a0f;
--ok:#34d399; --warn:#fbbf24; --bad:#fb7185;
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.35);
color-scheme:dark;
}
*{box-sizing:border-box;}
html{scroll-behavior:smooth;scroll-padding-top:80px;}
body{
margin:0;background:var(--bg);color:var(--ink);
font-family:var(--body);font-size:16px;line-height:1.65;
-webkit-font-smoothing:antialiased;
}
a{color:var(--accent-ink);text-underline-offset:2px;}
code,.mono{font-family:var(--mono);font-size:.86em;}
code{background:var(--surface-2);border:1px solid var(--line);padding:.05em .4em;border-radius:5px;}
/* ---- header ---- */
header{
position:sticky;top:0;z-index:30;
background:color-mix(in srgb,var(--bg) 88%,transparent);
backdrop-filter:blur(10px);
border-bottom:1px solid var(--line);
}
.bar{
max-width:1280px;margin:0 auto;padding:12px 24px;
display:flex;align-items:center;gap:16px;
}
.brand{
display:flex;align-items:center;gap:10px;
font-family:var(--display);font-weight:800;font-size:18px;
letter-spacing:-.02em;
}
.brand .dot{
width:10px;height:10px;border-radius:50%;background:var(--accent);
box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 22%,transparent);
}
.bar .spacer{margin-left:auto;}
.tbtn{
font:inherit;font-size:13px;cursor:pointer;color:var(--muted);
background:var(--surface);border:1px solid var(--line);
border-radius:8px;padding:6px 10px;
}
.tbtn:hover{color:var(--ink);}
/* ---- layout ---- */
.shell{
max-width:1280px;margin:0 auto;padding:0 24px;
display:grid;grid-template-columns:240px minmax(0,1fr);
gap:48px;align-items:start;
}
@media (max-width:900px){
.shell{grid-template-columns:1fr;gap:0;padding:0 20px;}
nav.toc{display:none;}
}
/* ---- TOC ---- */
nav.toc{
position:sticky;top:72px;align-self:start;
padding:24px 0 48px;max-height:calc(100vh - 72px);overflow:auto;
font-size:14px;
}
nav.toc h2{
font-family:var(--mono);font-size:11px;letter-spacing:.14em;
text-transform:uppercase;color:var(--muted);
margin:0 0 12px;font-weight:600;
}
nav.toc ol{list-style:none;margin:0;padding:0;counter-reset:toc;}
nav.toc li{counter-increment:toc;margin:2px 0;}
nav.toc a{
display:block;padding:6px 10px;border-radius:7px;
color:var(--muted);text-decoration:none;
border-left:2px solid transparent;
}
nav.toc a::before{
content:counter(toc,decimal-leading-zero) " ";
font-family:var(--mono);font-size:12px;opacity:.6;
}
nav.toc a:hover{color:var(--ink);background:var(--surface-2);}
nav.toc a.active{
color:var(--ink);background:var(--surface-2);
border-left-color:var(--accent);
}
/* ---- article ---- */
main{padding:32px 0 96px;min-width:0;}
main > section{margin:0 0 56px;scroll-margin-top:80px;}
main > section:first-of-type{margin-top:8px;}
h1{
font-family:var(--display);font-weight:800;
font-size:clamp(32px,5vw,44px);letter-spacing:-.02em;
line-height:1.1;margin:0 0 8px;
}
.tagline{
font-size:clamp(16px,2vw,19px);color:var(--muted);
margin:0 0 8px;
}
.docmeta{
display:flex;flex-wrap:wrap;gap:8px;margin:16px 0 0;
}
.docmeta .chip{
font-family:var(--mono);font-size:12px;
padding:4px 9px;border-radius:999px;
border:1px solid var(--line);background:var(--surface-2);
}
h2{
font-family:var(--display);font-weight:800;
font-size:clamp(22px,3vw,28px);letter-spacing:-.015em;
line-height:1.15;margin:0 0 6px;
padding-top:8px;
}
h2 .num{
font-family:var(--mono);font-size:.62em;font-weight:500;
color:var(--accent-ink);margin-right:.55em;vertical-align:.12em;
letter-spacing:.04em;
}
h3{
font-family:var(--display);font-weight:700;
font-size:17px;letter-spacing:-.005em;
margin:24px 0 8px;
}
.sub{color:var(--muted);margin:0 0 22px;max-width:70ch;}
p{margin:0 0 14px;max-width:74ch;}
ul,ol{margin:0 0 16px;padding-left:22px;max-width:74ch;}
li{margin:4px 0;}
/* ---- cards ---- */
.cards{
display:grid;gap:16px;
grid-template-columns:repeat(auto-fit,minmax(240px,1fr));
margin:16px 0 8px;
}
.card{
background:var(--surface);border:1px solid var(--line);
border-radius:12px;padding:18px;box-shadow:var(--shadow);
min-width:0;
}
.card h4{
font-family:var(--display);font-weight:700;font-size:15.5px;
margin:0 0 6px;
}
.card p{margin:0;color:var(--muted);font-size:14.5px;}
/* ---- steps ---- */
.steps{list-style:none;padding:0;margin:16px 0;counter-reset:step;}
.steps > li{
counter-increment:step;
position:relative;padding:0 0 20px 44px;
margin:0;max-width:none;
}
.steps > li::before{
content:counter(step);
position:absolute;left:0;top:0;
width:28px;height:28px;border-radius:50%;
background:var(--accent);color:var(--on-accent);
font-family:var(--mono);font-size:13px;font-weight:600;
display:flex;align-items:center;justify-content:center;
}
.steps > li::after{
content:"";position:absolute;left:13px;top:32px;bottom:0;
width:2px;background:var(--line);
}
.steps > li:last-child{padding-bottom:0;}
.steps > li:last-child::after{display:none;}
.steps b{font-family:var(--display);}
.steps .d{color:var(--muted);font-size:14.5px;margin-top:2px;}
/* ---- chips ---- */
.branches{display:flex;flex-wrap:wrap;gap:7px;margin-top:8px;}
.chip{
font-family:var(--mono);font-size:12px;
padding:4px 9px;border-radius:999px;
border:1px solid var(--line);background:var(--surface-2);
}
.chip.good{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 40%,var(--line));}
.chip.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,var(--line));}
.chip.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,var(--line));}
/* ---- gates ---- */
.gates{
display:grid;gap:12px;
grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
margin:16px 0 8px;
}
.gate{
background:var(--surface);border:1px solid var(--line);
border-left:4px solid var(--ok);border-radius:12px;
padding:16px 18px;box-shadow:var(--shadow);
}
.gate .k{
font-family:var(--mono);font-size:11px;letter-spacing:.1em;
text-transform:uppercase;color:var(--muted);
}
.gate b{
display:block;font-family:var(--display);font-weight:700;
font-size:16px;margin:4px 0 4px;
}
.gate p{margin:0;color:var(--muted);font-size:13.5px;}
.gate.send{
background:var(--accent);color:var(--on-accent);
border-color:var(--accent);border-left-color:var(--accent);
}
.gate.send b{color:var(--on-accent);}
.gate.send p{color:color-mix(in srgb,var(--on-accent) 80%,transparent);}
/* ---- tables ---- */
.tablewrap{
overflow-x:auto;border:1px solid var(--line);
border-radius:12px;box-shadow:var(--shadow);
margin:16px 0;
}
table{
width:100%;border-collapse:collapse;
background:var(--surface);min-width:520px;
}
th,td{
text-align:left;padding:12px 14px;
border-bottom:1px solid var(--line);font-size:14px;
vertical-align:top;
}
thead th{
font-family:var(--mono);font-size:11.5px;
letter-spacing:.08em;text-transform:uppercase;
color:var(--muted);background:var(--surface-2);
font-weight:600;
}
tbody tr:last-child td{border-bottom:none;}
.yes{color:var(--ok);font-weight:600;}
.no{color:var(--muted);}
/* ---- callouts ---- */
.callout{
background:color-mix(in srgb,var(--warn) 12%,var(--surface));
border:1px solid color-mix(in srgb,var(--warn) 40%,var(--line));
border-radius:12px;padding:16px 18px;
color:var(--ink);font-size:14.5px;margin:16px 0;
max-width:74ch;
}
.callout b{font-family:var(--display);}
.callout.scope{
background:color-mix(in srgb,var(--bad) 10%,var(--surface));
border-color:color-mix(in srgb,var(--bad) 38%,var(--line));
}
/* ---- principles ---- */
.principles{
display:grid;gap:12px;
grid-template-columns:repeat(auto-fit,minmax(260px,1fr));
margin:16px 0 8px;
}
.pr{
display:flex;gap:12px;
background:var(--surface);border:1px solid var(--line);
border-radius:12px;padding:16px;box-shadow:var(--shadow);
}
.pr .num{
font-family:var(--mono);color:var(--accent-ink);
font-weight:500;font-size:13px;flex:none;
}
.pr b{font-family:var(--display);font-weight:700;display:block;}
.pr p{margin:4px 0 0;color:var(--muted);font-size:14px;}
/* ---- code block ---- */
pre{
background:var(--surface-2);border:1px solid var(--line);
border-radius:10px;padding:14px 16px;overflow-x:auto;
font-family:var(--mono);font-size:13.5px;line-height:1.55;
margin:14px 0;
}
pre code{background:none;border:none;padding:0;font-size:inherit;}
/* ---- footer ---- */
footer{
border-top:1px solid var(--line);
padding:32px 0 56px;color:var(--muted);font-size:13.5px;
margin-top:24px;
}
.footrow{
max-width:1280px;margin:0 auto;padding:0 24px;
display:flex;gap:16px;flex-wrap:wrap;
align-items:center;justify-content:space-between;
}
footer a{color:var(--muted);}
</style>
</head>
<body>
<header>
<div class="bar">
<span class="brand"><span class="dot"></span>ConsentIVR</span>
<span class="spacer"></span>
</div>
</header>
<div class="shell">
<nav class="toc" aria-label="Table of contents">
<h2>Contents</h2>
<ol>
<li><a href="#overview">Overview</a></li>
<li><a href="#architecture">Architecture</a></li>
<li><a href="#inbound">Inbound call flow</a></li>
<li><a href="#outbound">Outbound campaigns</a></li>
<li><a href="#capabilities">Capabilities</a></li>
<li><a href="#telegram">Telegram console</a></li>
<li><a href="#providers">Provider matrix</a></li>
<li><a href="#principles">Design principles</a></li>
<li><a href="#scope">Out of scope</a></li>
<li><a href="#compliance">Compliance</a></li>
</ol>
</nav>
<main>
<section id="top">
<h1>ConsentIVR - Documentation</h1>
<p class="tagline">Consent-first voice, SMS, and WhatsApp communications - enforced in code.</p>
<div class="docmeta">
<span class="chip">Flask</span>
<span class="chip">Twilio · Telnyx · Plivo · Vonage</span>
<span class="chip">SQLite / SQLCipher</span>
<span class="chip">Telegram bot</span>
</div>
</section>
<!-- 1. Overview -->
<section id="overview">
<h2><span class="num">01</span>Overview</h2>
<p>ConsentIVR is a Flask-based inbound IVR and opt-in outbound communications platform. An inbound IVR routes callers through editable menus; outbound campaigns place appointment reminders and surveys over voice, SMS, or WhatsApp. Operators run and monitor everything from a web dashboard <strong>or</strong> an interactive Telegram console, with optional live alerts.</p>
<p>Menus, business hours, campaigns, and recipients live in the database - routing and campaign settings can be changed without editing application code.</p>
<h3>At a glance</h3>
<div class="tablewrap">
<table>
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
<tbody>
<tr><td>Channels</td><td>3 - voice, SMS, WhatsApp</td></tr>
<tr><td>Pluggable providers</td><td>5</td></tr>
<tr><td>Consent gates per contact</td><td>3</td></tr>
<tr><td>Operator-console languages</td><td>5</td></tr>
<tr><td>Identity secrets captured</td><td>0</td></tr>
</tbody>
</table>
</div>
<h3>Key design goals</h3>
<div class="cards">
<div class="card">
<h4>Data-driven routing</h4>
<p>Menus and keypad options are rows in <code>ivr_menus</code> / <code>ivr_options</code>, edited from the dashboard. A global inbound mode can send every call to the menu, a forwarding number, voicemail, or a closed message.</p>
</div>
<div class="card">
<h4>Consent is the gate, not a note</h4>
<p>Outbound dialing lists are built only from granted consent records, and three gates are re-checked for every recipient at send time - the import check is never trusted on its own.</p>
</div>
</div>
</section>
<!-- 2. Architecture -->
<section id="architecture">
<h2><span class="num">02</span>Architecture</h2>
<p class="sub">Signed carrier webhooks in, a thin Flask core, short-lived SQLite connections. Each layer is a handful of small modules, and one command runs the web app, the webhooks, and the Telegram bot together.</p>
<div class="tablewrap">
<table>
<thead><tr><th>Layer</th><th>Modules</th></tr></thead>
<tbody>
<tr><td><strong>Carriers</strong></td><td class="mono">Twilio · Telnyx · Plivo · Vonage · console</td></tr>
<tr><td><strong>Providers</strong></td><td class="mono">providers.py - send_message(), place_call()</td></tr>
<tr><td><strong>Web / TwiML</strong></td><td class="mono">ivr · outbound_voice · sms · webhooks · dashboard</td></tr>
<tr><td><strong>Telegram bot</strong></td><td class="mono">api (long-poll) · middleware · handlers · keyboards · i18n · access · payments</td></tr>
<tr><td><strong>Core logic</strong></td><td class="mono">consent · hours · jobs + joblock · validate · crypto</td></tr>
<tr><td><strong>Storage</strong></td><td class="mono">SQLite / SQLCipher (WAL) - contacts · consent · opt_outs · campaigns</td></tr>
</tbody>
</table>
</div>
<h3>Startup</h3>
<pre><code>python app.py</code></pre>
<p>This single command serves the web app, the carrier webhooks, and the Telegram bot in one process.</p>
</section>
<!-- 3. Inbound -->
<section id="inbound">
<h2><span class="num">03</span>Inbound call flow</h2>
<p class="sub">Every Twilio/Telnyx webhook is verified by signature before any of the following runs.</p>
<ol class="steps">
<li>
<b>Call arrives → check inbound mode</b>
<div class="branches">
<span class="chip">ivr</span>
<span class="chip">forward</span>
<span class="chip">voicemail</span>
<span class="chip">closed</span>
</div>
</li>
<li>
<b>Business hours check</b> <span style="color:var(--muted)">(mode <code>ivr</code> only)</span>
<div class="branches">
<span class="chip good">open → entry menu</span>
<span class="chip warn">closed → after-hours + voicemail</span>
</div>
</li>
<li>
<b>Menu - gather one digit</b>
<div class="branches">
<span class="chip">dial</span>
<span class="chip">submenu</span>
<span class="chip">voicemail</span>
<span class="chip">message</span>
<span class="chip bad">optout</span>
<span class="chip">repeat</span>
</div>
</li>
<li>
<b>Warm transfer on <code>dial</code></b>
<div class="d">The agent hears a whisper before bridging; the caller can press <code>*</code> to leave. Unanswered calls fall through to voicemail.</div>
</li>
<li>
<b>Post-call menu</b>
<div class="d">After the agent call ends, the caller can return to the menu instead of being dropped.</div>
</li>
</ol>
</section>
<!-- 4. Outbound -->
<section id="outbound">
<h2><span class="num">04</span>Outbound campaigns</h2>
<p class="sub">Appointment reminders and surveys are paced one recipient at a time. Every send passes three gates, re-checked at dial time.</p>
<div class="gates">
<div class="gate">
<span class="k">Gate 1</span>
<b>Not opted out</b>
<p>Suppression / DNC list, matched on canonical E.164 so <code>(415) 555-0123</code> and <code>+14155550123</code> are the same person.</p>
</div>
<div class="gate">
<span class="k">Gate 2</span>
<b>Has consent</b>
<p>Granted for this purpose <em>and</em> this channel. SMS and WhatsApp consent are distinct from voice.</p>
</div>
<div class="gate">
<span class="k">Gate 3</span>
<b>Inside hours</b>
<p>Calling-window check. Outbound fails <em>closed</em>: no window configured means nothing is dialed.</p>
</div>
<div class="gate send">
<b>Send →</b>
<p>voice · SMS · WhatsApp</p>
</div>
</div>
<h3>Supporting mechanics</h3>
<div class="cards">
<div class="card">
<h4>Background runner</h4>
<p>Launches run in a thread so the dashboard returns at once; a SQLite single-flight lock stops two workers dialing the same campaign.</p>
</div>
<div class="card">
<h4>Press 0 for an agent</h4>
<p>Reminder recipients can be warm-transferred to a live agent mid-call, with a whisper and a no-answer fallback.</p>
</div>
<div class="card">
<h4>STOP / START</h4>
<p>An inbound <code>STOP</code> writes an opt-out instantly; <code>START</code> re-subscribes. Honored across SMS and WhatsApp.</p>
</div>
</div>
</section>
<!-- 5. Capabilities -->
<section id="capabilities">
<h2><span class="num">05</span>Capabilities</h2>
<p class="sub">Each capability is a small, self-contained module.</p>
<div class="tablewrap">
<table>
<thead><tr><th>Capability</th><th>Description</th></tr></thead>
<tbody>
<tr><td><strong>Surveys & results</strong></td><td>DTMF answers with optional labels (<code>1=Poor</code>…), a per-question breakdown with bars, and label-aware or raw CSV export.</td></tr>
<tr><td><strong>Phone validator</strong></td><td>Offline <code>libphonenumber</code> - validity, line type, region, carrier, timezones - cross-checked against your own opt-out and consent records.</td></tr>
<tr><td><strong>Voices & AMD</strong></td><td>Per-campaign and per-menu Amazon Polly voices; per-campaign answering-machine detection.</td></tr>
<tr><td><strong>Messages log</strong></td><td>Every outbound text and inbound reply, with delivery status and a direction filter.</td></tr>
<tr><td><strong>PGP exports</strong></td><td>Result CSVs with PII download as <code>.csv.pgp</code> to a configured public key - or the export aborts rather than leak plaintext.</td></tr>
<tr><td><strong>At-rest encryption</strong></td><td>Set a key and the whole database opens with SQLCipher; if the driver is missing, the app refuses to start unencrypted.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 6. Telegram -->
<section id="telegram">
<h2><span class="num">06</span>Telegram operator console</h2>
<p class="sub">A second control surface beside the dashboard: an interactive bot that mirrors the same operations and reuses the same consent gates, so the two can never drift.</p>
<h3>Module layout</h3>
<div class="tablewrap">
<table>
<thead><tr><th>Path</th><th>Modules</th></tr></thead>
<tbody>
<tr><td class="mono">tgbot/</td><td class="mono">api (long-poll, requests) · middleware (auth · rate-limit · locale) · handlers · keyboards · i18n · access · payments</td></tr>
</tbody>
</table>
</div>
<h3>Features</h3>
<div class="cards">
<div class="card">
<h4>Full operator parity</h4>
<p>Live stats, launch / pause campaigns, add consent-checked recipients, manage opt-outs, flip inbound mode, and validate numbers - all from inline keyboards.</p>
</div>
<div class="card">
<h4>Allowlist + roles</h4>
<p>Access is per Telegram ID, never open. Bootstrap admins from env; roles mirror the dashboard - viewer reads, operator changes state, admin manages operators. Every action is audited.</p>
</div>
<div class="card">
<h4>Localized</h4>
<p>Five languages - English, Spanish, French, Portuguese, German - chosen in-chat, each with its own slash-command menu.</p>
</div>
</div>
<h3>Optional paid access</h3>
<p>A user who isn't on the allowlist sees their own ID to request access, plus buy-access buttons - <strong>Telegram Stars</strong> or <strong>Crypto Pay</strong> - and a Learn-more link. A completed payment grants a console role for a configurable window, recorded in <code>tg_payments</code> for accountability.</p>
<h3>Same safety model</h3>
<p>Phone numbers in bot views are masked like the alerts; there is no identity-secret surface; and paid access only ever grants an operator-console role - never the consent, opt-out, or calling-time gates.</p>
</section>
<!-- 7. Providers -->
<section id="providers">
<h2><span class="num">07</span>Provider matrix</h2>
<p class="sub">One interface, selected per channel by env var. Voice reuses the same TwiML - Telnyx reads it as TeXML. <code>console</code> logs instead of sending, for free local testing.</p>
<div class="tablewrap">
<table>
<thead>
<tr><th>Provider</th><th>SMS</th><th>Voice</th><th>WhatsApp</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td class="mono">twilio</td><td class="yes">Yes</td><td class="yes">Yes - TwiML</td><td class="yes">Yes</td><td>Default backend.</td></tr>
<tr><td class="mono">telnyx</td><td class="yes">Yes</td><td class="yes">Yes - TeXML</td><td class="no">-</td><td>Voice via a TeXML application.</td></tr>
<tr><td class="mono">plivo</td><td class="yes">Yes</td><td class="no">-</td><td class="no">-</td><td>SMS only here.</td></tr>
<tr><td class="mono">vonage</td><td class="yes">Yes</td><td class="no">-</td><td class="no">-</td><td>SMS only here.</td></tr>
<tr><td class="mono">console</td><td class="yes">Yes</td><td class="yes">Yes</td><td class="yes">Yes</td><td>Logs - no account, no charges.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 8. Principles -->
<section id="principles">
<h2><span class="num">08</span>Design principles</h2>
<p class="sub">Enforced in code, not just documented.</p>
<div class="principles">
<div class="pr"><span class="num">01</span><div><b>No identity secrets</b><p>No prompt, route, or column for OTPs, SSNs, card numbers, or passcodes. The only digit the IVR reads is a menu selection.</p></div></div>
<div class="pr"><span class="num">02</span><div><b>Truthful identity</b><p>Prompts speak your real organization name; caller ID is a number you own. No spoofing configuration exists.</p></div></div>
<div class="pr"><span class="num">03</span><div><b>Consent before contact</b><p>Dialing lists come only from granted consent, re-checked per recipient at send time.</p></div></div>
<div class="pr"><span class="num">04</span><div><b>Always honor opt-out</b><p>Checked before every outbound call or text; callers opt out by keypress or <code>STOP</code>.</p></div></div>
<div class="pr"><span class="num">05</span><div><b>Recording off by default</b><p>When enabled, the disclosure plays before any audio is captured.</p></div></div>
<div class="pr"><span class="num">06</span><div><b>Authenticated access everywhere</b><p>Every carrier webhook is signature-verified; the Telegram console is an explicit per-user allowlist with roles.</p></div></div>
</div>
</section>
<!-- 9. Out of scope -->
<section id="scope">
<h2><span class="num">09</span>Out of scope</h2>
<div class="callout scope">
<b>By design:</b> no "is this number on WhatsApp / Telegram" presence check and no profile scraping. Those abuse a messenger's contact-sync and enable bulk enumeration of people without consent - the opposite of what this platform is for. The Telegram bot is an operator console, not a presence lookup.
</div>
</section>
<!-- 10. Compliance -->
<section id="compliance">
<h2><span class="num">10</span>Compliance responsibilities</h2>
<p>The system provides the controls; compliance is how you operate it. Still your responsibility:</p>
<ul>
<li>The telemarketing, calling-time, recording-consent, and privacy laws that apply to you.</li>
<li>TCPA, state two-party-consent recording rules, and local equivalents.</li>
<li>Obtaining appropriate legal guidance for your specific use case.</li>
</ul>
<div class="callout">
<b>Reminder:</b> ConsentIVR provides technical controls to support consent-based communication, but software controls do not by themselves guarantee legal compliance.
</div>
</section>
</main>
</div>
<footer>
<div class="footrow">
<span>ConsentIVR - consent-based voice & SMS platform.</span>
<a href="#top">Back to top ↑</a>
</div>
</footer>
<script>
(function(){
var root = document.documentElement;
var btn = document.getElementById('theme');
function sys(){
try { return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
catch(e){ return 'light'; }
}
var saved = null;
try { saved = localStorage.getItem('civr-doc-theme'); } catch(e){}
if (saved) root.setAttribute('data-theme', saved);
btn.addEventListener('click', function(){
var cur = root.getAttribute('data-theme') || sys();
var next = cur === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
try { localStorage.setItem('civr-doc-theme', next); } catch(e){}
});
// Scrollspy: highlight the current section in the TOC
var links = [].slice.call(document.querySelectorAll('nav.toc a'));
var map = {};
links.forEach(function(a){ map[a.getAttribute('href').slice(1)] = a; });
if ('IntersectionObserver' in window){
var io = new IntersectionObserver(function(entries){
entries.forEach(function(e){
var a = map[e.target.id];
if (!a) return;
if (e.isIntersecting){
links.forEach(function(x){ x.classList.remove('active'); });
a.classList.add('active');
}
});
}, { rootMargin: '-30% 0px -65% 0px' });
document.querySelectorAll('main > section[id]').forEach(function(s){ io.observe(s); });
}
})();
</script>
</body>
</html>