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
Consent + hard suppression
Refuses the dial when the contact has withdrawn call/voice consent or sits in
suppression_entries(a DNC block), reusingconsent_block_reason. - 2
TCPA opt-in
When the tenant has
tcpa_required = True(the default within a saved config), the gate looks for agranted, non-withdrawn row inconsent_recordson thecall/voicechannel for that phone number. Absence blocks the dial with reasontcpa.
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. A target reached outside the window is deferred to the next window open rather than skipped — so no legitimate contact is dropped, they're just held until it's legal to dial.
The window is evaluated in the called party's local timezone, which is what 47 CFR 64.1200(c)(1) actually requires. The rules, all in core/voice_campaigns/tcpa.py:
- The statutory window is 08:00–21:00 local. A campaign may narrow it (e.g. 10:00–16:00 weekdays);
effective_windowclamps any attempt to widen it back to 8am–9pm, so no config row — hand-edited or otherwise — can authorize an illegal dial. - Each target's zone is resolved from its phone number's NANP area code at enqueue time and stored on
voice_campaign_targets.timezone. The NPA→IANA map covers the US, Canada, and Puerto Rico / USVI. - An unresolvable zone refuses to dial. A non-NANP number on a campaign with no explicit
business_hours.tzis failed withskipped_unknown_timezonerather than guessed into a US zone.enqueuereturnsunresolved_timezoneso the operator sees the count up front; settingbusiness_hours.tzon the campaign is the documented fix. - A campaign-level
tzis an override, not the default. It applies only where a target has no resolved zone of its own.
Why this changed
Before V-14 the window was campaign-global and defaulted to UTC, so a campaign left on the
default would dial a California contact at 01:00 Pacific — inside a UTC window, squarely outside
the legal one. The regression test test_pacific_target_is_not_dialable_at_1am_local pins it.
Daily pacing caps
Independently of the window, each campaign carries two per-day ceilings, counted over the target's local day (not a UTC day that rolls over mid-afternoon on the west coast):
config.daily_call_cap— maximum dials per day.0disables it.config.daily_budget_cents— maximum spend per day. Defaults to $5.00, the demo cap; override per campaign, or globally viaVOICE_CAMPAIGN_DAILY_BUDGET_CENTS.
Tripping either holds the target until the next local midnight and records a daily_cap_stop / daily_budget_stop run. The campaign stays active — this is pacing, not failure. The lifetime budget_cents cap is separate and does pause the campaign.
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 prefix —
prepare_messageprepends the tenant name so recipients know who is texting. - Opt-out footer — an opt-out instruction is appended per CTIA guidelines.
- STOP keywords —
STOP,STOPALL,UNSUBSCRIBE,CANCEL,END,QUITare recognized. A received STOP writes a hardsuppression_entriesrow (channel='sms',reason='stop',source='twilio_stop'). - Pre-send check —
is_opted_outis 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.