Skip to Content
Living documentation — last reviewed 2026-05-28
DecisionsADR-0013: Lead messaging consent & opt-out (FIT-206)

ADR-0013: Lead messaging consent & opt-out (FIT-206)

Status: Accepted Date: 2026-06-27 Context owner: Saar Issue: FIT-206  · gates FIT-157  (automations) · relates to FIT-140  (WhatsApp ISV)


TL;DR

The automations engine (ADR-0011) treats leads as first-class subjects and will send email / SMS / WhatsApp to non-members. A lead left contact details for an inquiry — that is not consent to automated outreach (GDPR; stricter under Israel’s Amendment 40 spam law). This was ADR-0011’s open question and the hard blocker before any lead automation sends in prod.

The decision, in one rule per channel:

  • Email = opt-out (soft opt-in). A lead is messageable; every lead email carries a working unsubscribe link + List-Unsubscribe; opting out suppresses permanently.
  • SMS / WhatsApp = explicit opt-in. Never sent without a recorded grant.

Enforced at the dispatcher choke-point (AutomationDispatcherService.dispatch()): a lead without valid consent is skipped, visiblyautomation_step_runs records skipped(lead_opted_out | no_explicit_consent), never a silent drop. The gate is unconditional (fail-closed compliance, not a feature flag): a lead with no consent rows at all is email-allowed, SMS/WhatsApp-blocked, so no backfill is needed.


The lawful basis per leadSource. Enforcement is two rules (email opt-out, SMS/WA opt-in); the source only sets the default state + basis tag recorded at lead creation.

leadSourceEmailSMSWhatsAppEmail basis
minisite, website, qr, instagram, facebook, course_purchaseimplied (soft opt-in)explicit opt-inexplicit opt-insoft_opt_in
whatsapp (Click-to-WhatsApp)impliedexplicit opt-inservice-window grant (lead messaged first → Meta 24h window)soft_opt_in
referral, walk_in, phone_call, manualimplied (coach-asserted)explicit opt-inexplicit opt-instaff_entered
  • Email is allowed for every source under the opt-out model; the basis column records why (self-submitted soft opt-in vs coach-asserted) for the audit trail. The mandatory unsubscribe link is what makes this defensible.
  • SMS / WhatsApp are blocked for every source until an explicit grant exists. The one exception is the CTWA service window: a lead who messages the gym first opens Meta’s 24-hour customer-care window, which is an opt-in to reply on WhatsApp — granted at ingestion (via='service_window'). Templated/marketing beyond the window still needs explicit opt-in; that lives with FIT-140 .
  • Members are out of scope — member messaging consent is the existing notification_prefs path; this gate only fires for subjectType='lead'.

Schema

libs/db/src/lib/schema/lead-consent.ts (+ enums in enums.ts):

  • lead_consent — current state, org-scoped, UNIQUE(lead_id, organization_id, channel). Columns: channel (email|sms|whatsapp), status (granted|withdrawn), via (capture|service_window|staff|api|unsubscribe_link|stop_keyword), basis (free-text lawful-basis tag), source_at_capture, granted_at, withdrawn_at. The gate is one indexed read on this table.
  • lead_consent_events — append-only ledger (granted|withdrawn + via + ip/ua), proof of opt-in/opt-out for audits (GDPR Art. 7(1), Amendment 40). Mirrors the legal_consents precedent.

Consent is org-scoped because it describes the relationship between one org and one person.

Enforcement

AutomationDispatcherService.dispatch() — inside the per-channel fallback loop, after the supportsLeads check, for a lead recipient it calls LeadConsentService.isAllowed(leadId, orgId, channel). A block sets the skip reason and continues to the fallback channel; if no channel passes, the existing recordOutcome({ status:'skipped', error }) writes the visible ledger row. push never reaches this for a lead (supportsLeads=false).

email → allowed UNLESS lead_consent.status = 'withdrawn' (reason: lead_opted_out) sms/wa → allowed ONLY IF lead_consent.status = 'granted' (reason: no_explicit_consent)

Opt-out paths

  • Email — every lead automation email gets a localized unsubscribe footer (lead’s locale) + a List-Unsubscribe header, both pointing at the web confirmation page (/{lang}/unsubscribe?token=). The token is an HMAC-signed, self-describing, non-expiring value (an opt-out link must work forever). Mutation happens only on an explicit POST (POST /lead-consent/unsubscribe, public) — there is no mutating GET, so email security-scanners prefetching links can’t auto-unsubscribe a lead.
  • WhatsApp — inbound STOP/UNSUBSCRIBE/הסר/עצור/ביטול from a known lead withdraws WhatsApp consent (via='stop_keyword'). The inbound path is live for CTWA; the handler keys off leads.phone within the org.
  • SMSSTOP handling is documented and deferred to FIT-140 (SMS outbound is a stub); it will call the same LeadConsentService.withdraw(..., via:'stop_keyword').

Consequences

  • Unblocks FIT-157 lead automations — the dispatcher cannot message a non-consenting or opted-out lead.
  • No feature flag. Per the repo’s FF policy, behavioral changes default to current behavior — but here “current behavior” is no gate (the bug), and lead automations have never sent (AUTOMATIONS_ENABLED=false). So the gate is always-on; any future PostHog lever could only tighten (e.g. force explicit opt-in for email too), never loosen below this baseline.
  • No backfill — channel defaults make legacy leads behave correctly (email allowed, SMS/WA blocked).
  • Deferred: (1) mapping a specific Meta lead-form consent question to an SMS/WhatsApp grant — left off in v1 because the field is org/form-specific and heuristic parsing risks a wrong-direction false opt-in; the coach can grant via the lead drawer, or it lands with form-field mapping. (2) SMS STOP (FIT-140). (3) Per-channel WhatsApp template-vs- session-window distinction (FIT-140).

References

  • ADR-0011 — automations engine; this resolves its “Lead messaging consent / opt-out?” open question.
  • apps/api/src/lead-consent/ — service, controller, tokens.
  • apps/api/src/automations/automation-dispatcher.service.ts — the gate.
  • apps/api/src/automations/channels/email.channel.ts — unsubscribe footer + header.
  • apps/api/src/lead-ingestion/whatsapp-ingestion.service.ts — inbound STOP.