PPactDocs
Compliance & Privacy

TCPA

Pact's TCPA controls for voice and SMS — a pre-dial consent gate, STOP-keyword opt-out, DNC list checks, calling-window enforcement, and the current A2P 10DLC status.

Voice and SMS are where a CRM most easily creates legal exposure, so Pact enforces TCPA controls in code, on the send path, rather than leaving them to operator discipline. Every outbound dial routes through a single pre-dial gate, and every outbound text carries brand identification and an opt-out mechanism that writes straight to the suppression list.

The pre-dial gate

core/voice_compliance/enforcement.py exposes pre_dial_check(...), the single entry point every outbound dial path (campaign engine and click-to-call alike) passes through. It runs two hard checks in order and returns a machine-readable block reason so the UI can distinguish, e.g., "TCPA: missing consent" from "DNC: national registry":

  1. 1

    Consent + hard suppression

    Refuses the dial when the contact has withdrawn call/voice consent or sits in suppression_entries (a DNC block), reusing consent_block_reason.

  2. 2

    TCPA opt-in

    When the tenant has tcpa_required = True (the default within a saved config), the gate looks for a granted, non-withdrawn row in consent_records on the call/voice channel for that phone number. Absence blocks the dial with reason tcpa.

The result is a discriminated dataclass — allowed=True, or a ComplianceBlock carrying reason, detail, and the source table (suppression_entries vs consent_records).

The calling-window is enforced upstream

TCPA's time-of-day restriction is enforced by the campaign engine (core/voice_campaigns/engine.py), not by pre_dial_check. The default DEFAULT_WINDOW is a TCPA-shaped 09:00–20:00 with a per-campaign configurable timezone (UTC by default). A target reached outside its campaign's window is deferred to the next window open (next_window_open) rather than skipped — so no legitimate contact is dropped, they're just held until it's legal to dial.

Consent gating is mode-driven

A tenant that has never opened the compliance settings keeps the pre-V-9 posture: the consent/suppression check always runs, but the TCPA opt-in gate only turns on once the tenant saves a config on /admin/voice-mcp/compliance. Pick a mode — standard, soc2, hipaa, or gdpr (core/voice_compliance/modes.py) — and the dependent toggles (TCPA required, data residency, PHI redaction, audit retention) move together, with the deltas shown before you hit Save.

Do-Not-Call lists

Tenants register DNC lists through /v1/admin/voice-compliance/dnc-lists (providers: national_ftc, state_registry, custom). Each list tracks its sync status and entry count; a number on an enabled, synced list is treated as a hard suppression and fails the first stage of the pre-dial gate.

SMS: brand ID, STOP, and suppression

core/sms/compliance.py encodes the CTIA/TCPA text rules:

  • Brand prefixprepare_message prepends the tenant name so recipients know who is texting.
  • Opt-out footer — an opt-out instruction is appended per CTIA guidelines.
  • STOP keywordsSTOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT are recognized. A received STOP writes a hard suppression_entries row (channel='sms', reason='stop', source='twilio_stop').
  • Pre-send checkis_opted_out is consulted before every send, so a suppressed number is never texted again.

Consumer phone onboarding (core/phone_enrollment.py) captures un-pre-checked, versioned consent: Pact refuses to SMS a number without an explicit opt-in in the same request, and the durable consent record is written only after the code is verified — proving the user controls the number. The consent grant is append-only and survives phone removal for TCPA proof retention.

Partial: A2P 10DLC registration pending

The TCPA gating logic above is live. Message delivery over SMS is gated on carrier A2P 10DLC registration, which is still being completed — Twilio surfaces the not-yet-registered state as error code 30034. Pact detects this distinctly (is_a2p_unregistered) and surfaces an a2p_pending / "finishing SMS setup" state instead of a hard error, and paths that would promise a text (e.g. team delegation notifications) intentionally do not, to avoid claiming a message that can't yet reach the handset. Voice, and SMS once registration lands, run through the same consent and suppression checks.

What's next