Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.
Epic 23 — Notifications & external integrations (derived view)
Read-only per-epic slice of
BRD.md§9, lines 8865–9754. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-23.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (18): US-23.1, US-23.2, US-23.3, US-23.4, US-23.5, US-23.6, US-23.7, US-23.8, US-23.9, US-23.10, US-23.11, US-23.12, US-23.13, US-23.14, US-23.15, US-23.16, US-23.17, US-23.18
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 23 — Notifications & external integrations
<!-- traceability:start:BRD:Epic-23 --><!-- traceability:end:BRD:Epic-23 -->Prototype: Template Editor · Customer Preferences · Merchant Alerts · Integrations · Deliverability monitoring · Outbound webhooks
Value: Events flow to the right people via the right channel, and to the merchant's existing marketing/support stack.
Event-pipeline pattern. Lifecycle email dispatch follows the logical-events-with-consumer-mapping shape ratified in ADR-0063: producers publish domain-level facts (subscription.renewed, charge.failed, etc.) via logEvent; the apps/email-consumer Worker maps event type → template_key and dispatches via Resend. Stories below describe the merchant-facing and subscriber-facing acceptance criteria; the wire-format contract lives in the ADR.
US-23.1: Transactional emails
<!-- traceability:start:US-23.1 --><!-- traceability:end:US-23.1 -->Prototype: Customer Preferences
Phase: MVP · Persona: Subscriber
As a Subscriber, I want emails for: welcome, upcoming charge, charge succeeded, charge failed, paused/resumed/cancelled, shipment generated, so that I'm never surprised.
Acceptance criteria:
- Given each lifecycle event fires, When it matches an enabled notification type, Then an email is sent via Resend with merchant's template + variables.
Phase-1 ship status. subscription.renewed → renewal_confirmation is shipped end-to-end (apps/email-consumer consumer + merchant-template fallback + deliverability gate). The six remaining lifecycle types (welcome, upcoming-charge T-3, charge-failed CTA, dunning retry, paused/resumed/cancelled, shipment-generated) await Hive #1505 — consumer mapping expansion + producer wiring at the missing publish sites.
UI states.
<!-- ui-states US-23.1 -->surface: "NOT YET BUILT — forward-looking contract. Subscriber portal (Svelte/Tailwind) notification preferences panel at /account/notifications. Persona: Subscriber. Shows the subscriber's enabled transactional email types and lets them enable or disable each one. Fetches preferences via GET /api/v1/portal/notifications/preferences; saves via PUT /api/v1/portal/notifications/preferences."
idle:
render: "A panel headed 'Email notifications' listing each transactional email type as a toggle row with a label and brief description — welcome, upcoming charge, charge succeeded, charge failed, paused, resumed, cancelled, and shipment generated. All eight types default to enabled. A 'Save preferences' button appears at the bottom of the list."
primary_action: "'Save preferences' — PUT /api/v1/portal/notifications/preferences with the full enabled-state map; button re-enables after the response."
loading:
trigger: "GET /api/v1/portal/notifications/preferences on mount; PUT on save."
render: "On mount all toggles render in a disabled state until the GET resolves. On save the 'Save preferences' button shows 'Saving…' and is disabled; toggles remain interactive while saving so the subscriber can continue editing."
error:
surfaced_at: "Inline, beneath the 'Save preferences' button (role=alert), scoped to this panel — never a vanishing toast or page-level banner. Load failure renders an inline error notice with a 'Try again' link above the toggle list."
render: "The API failure reason, or a generic 'Your preferences could not be saved — please try again.'"
recovery: "Toggle states are not cleared on save failure; the subscriber presses 'Save preferences' again or adjusts toggles and retries. Load failure: 'Try again' re-triggers the preferences fetch."
empty:
render: "Not a list surface the subscriber populates — the eight notification types are system-defined and always present. Documented here so the state is not silently skipped."
cta: "n/a (fixed system-defined list — the toggle panel is always the full content)"
edge_status:
- status: "notification type disabled store-wide by the merchant"
affordance: "That toggle row is greyed out with a 'Disabled by your merchant' label and cannot be toggled; other notification types remain configurable."
- status: "email address not verified — emails cannot be delivered"
affordance: "An inline warning banner above the toggle list ('Verify your email address to receive notifications') with a 'Resend verification email' link; toggles remain configurable but the subscriber sees why delivery is blocked."
inputs:
- field: "notification_types_enabled"
control: "checkbox"
label: "Per-type toggle — one labeled checkbox per notification type."
allowed_values: "welcome | upcoming_charge | charge_succeeded | charge_failed | paused | resumed | cancelled | shipment_generated"
disabled_focus:
keyboard: "Each notification toggle is a real <input type='checkbox'> paired with a <label> — reachable in tab order. The 'Save preferences' button is a real <button> activatable with Enter or Space. Focus stays within the panel on save; a success or error message is an aria-live='polite' region so a screen reader announces the outcome without moving focus."
US-23.2: SMS notifications
Phase: P2 · Persona: Subscriber
As a Subscriber, I want optional SMS for time-sensitive events (charge failed, shipment shipped), so that I act faster.
Acceptance criteria:
- Given I opt into SMS in the portal (with consent), When relevant events fire, Then SMS is sent via Twilio.
UI states.
<!-- ui-states US-23.2 -->surface: "NOT YET BUILT — forward-looking contract. Subscriber portal (Svelte/Tailwind) SMS opt-in consent panel within /account/notifications. Persona: Subscriber. Captures phone number and explicit TCPA consent to enable SMS for time-sensitive lifecycle events (charge failed, shipment shipped). Saves consent via POST /api/v1/portal/notifications/sms/consent; fetches current SMS status via GET /api/v1/portal/notifications/sms/status."
idle:
render: "When not yet opted in: a labeled 'SMS notifications (optional)' section with a phone number input (type=tel), a TCPA consent disclosure block ('Message and data rates may apply. Message frequency varies. Reply STOP to opt out.'), a consent checkbox, and an 'Enable SMS notifications' button. When already opted in: shows the masked phone number, the active SMS event types (charge failed, shipment shipped), a 'Change number' link, and an 'Opt out' button."
primary_action: "'Enable SMS notifications' submits phone number and consent; 'Opt out' withdraws consent — both POST to /api/v1/portal/notifications/sms/consent with the respective action."
loading:
trigger: "GET /api/v1/portal/notifications/sms/status on mount; POST on opt-in or opt-out."
render: "On mount the panel renders a skeleton until the status fetch resolves. On opt-in or opt-out the active button shows 'Saving…' and is disabled; the phone input is also disabled during an in-flight opt-in."
error:
surfaced_at: "Inline, beneath the consent form (role=alert) — never a page-level banner. Phone number format errors surface inline below the phone input."
render: "Opt-in failure reason (e.g. 'Invalid phone number — please include a country code') or a generic 'Something went wrong — please try again.'"
recovery: "The form stays populated on error; the subscriber corrects the phone number or re-checks the consent checkbox and presses 'Enable SMS notifications' again."
empty:
render: "When no consent exists the opt-in form is the full panel content — the opt-in form is itself the empty state. No blank screen is ever shown."
cta: "n/a (the opt-in form is the empty-state affordance)"
edge_status:
- status: "opted in — SMS active"
affordance: "'Change number' opens an edit-in-place phone input; 'Opt out' withdraws consent and stops all SMS immediately."
- status: "opted out after prior opt-in"
affordance: "Panel returns to the opt-in form with an informational note 'You have opted out of SMS notifications'; 'Enable SMS notifications' lets the subscriber re-consent."
inputs:
- field: "phone_number"
control: "tel"
label: "'Mobile phone number' — <input type='tel'> with E.164 placeholder; validated for country-code format client-side before POST."
- field: "sms_consent"
control: "checkbox"
label: "'I agree to receive SMS notifications. Message and data rates may apply. Reply STOP to opt out.' — must be checked to enable the submit button."
disabled_focus:
keyboard: "Phone input is a real <input type='tel'>, consent disclosure uses a real <input type='checkbox'> with a paired <label>. 'Enable SMS notifications' and 'Opt out' are real <button> elements reachable in tab order — no div-onClick. The 'Enable SMS notifications' button is disabled until the consent checkbox is checked (not just visually greyed). An aria-live='polite' region announces opt-in success or failure to screen readers without moving focus."
US-23.3: Template editor
<!-- traceability:start:US-23.3 --><!-- traceability:end:US-23.3 -->Prototype: Template Editor
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to edit email templates (subject, body, branding) with preview, so that notifications match my voice.
Acceptance criteria:
- Given I open template editor, When I edit Markdown/MJML with variable placeholders, Then I can preview with sample data and save.
- Given I save, When the next matching event fires, Then the new template is used.
UI states.
<!-- ui-states US-23.3 -->surface: "Admin (React/BigDesign) email template editor — apps/admin/src/pages/notifications/TemplateEditor.tsx (TemplateEditor), entered from apps/admin/src/pages/notifications/TemplatesList.tsx (Edit button → navigate(template_key), TemplatesList line 106). Persona: Merchant Admin. Loads GET /api/v1/admin/email-templates/:templateKey, debounced 300ms live preview via POST .../preview (apiPreview), saves via PUT .../:templateKey (apiPut)."
idle:
render: "A two-column grid (lines 139-186): LEFT a Form with Subject Input, Format Select (Markdown / MJML), and a 6-row Body Textarea (lines 142-170) plus a 'Save Template' primary Button; RIGHT a 'Preview (sample data)' pane (MarkdownPreviewPane) showing rendered subject + HTML from the debounced preview (lines 179-185)."
primary_action: "'Save Template' Button → apiPut persists {subject, body_source, format} (handleSave, lines 113-127). Typing in any field re-triggers the 300ms debounced preview (triggerPreview, lines 100-111)."
loading:
render: "On mount apiGet populates the form (lines 85-98) with NO loading indicator — fields stay blank until the GET resolves. The preview pane updates 300ms after the last keystroke (triggerPreview setTimeout, lines 101-108). 'Save Template' shows its isLoading spinner while saving (line 172). The list entry shows a 'Loading templates…' Text while its fetch is in flight (TemplatesList line 49)."
error:
surfaced_at: "Save failure renders an inline Text(color='danger50') beside the Save button (saveError, line 176) — not a Message banner. The list-entry surface renders a Message(type='error') on fetch failure (TemplatesList line 50). A full-page loadError Message exists (line 129) but is never reached (see gaps)."
recovery: "On save failure the form stays populated and editable; the merchant corrects the template and presses 'Save Template' again. List-fetch failure recovers by reload."
empty:
render: "Editor: a templateKey with no stored template starts the form blank, ready to author (the apiGet catch resets subject/body, lines 93-97). The no-templates empty state is owned by the list: TemplatesList renders a Message(type='warning') 'No email templates found…' when the store has zero templates (TemplatesList lines 60-72)."
edge_status:
- status: "format = MJML selected"
affordance: "The Format Select option is labeled 'MJML (Phase 2 — admin pre-compile required)' (line 157); Markdown renders today, MJML is gated to a Phase-2 pre-compile step."
- status: "template load failed (network / 5xx)"
affordance: "Today the apiGet catch silently presents a blank NEW template (lines 93-97) rather than an error. North-star affordance: surface the load failure (the dead loadError Message at line 129) with a retry, so a transient failure can't be mistaken for a new template (see gaps)."
- status: "preview render error (invalid template syntax)"
affordance: "Today the preview is silently left stale when apiPreview returns {error} (the `if (!result.error)` guard, line 104). North-star affordance: render the preview error in the preview pane so the merchant sees why it stopped updating (see gaps)."
inputs:
- field: "subject"
control: "text"
label: "'Subject' — BigDesign Input with a {{variable}} placeholder (lines 143-148)."
- field: "format"
control: "select"
label: "'Format' — BigDesign Select (lines 151-159)."
allowed_values: "markdown | mjml (options, lines 155-158); MJML gated to Phase 2."
- field: "body_source"
control: "textarea"
label: "'Body' — BigDesign Textarea, 6 rows, {{variable}} placeholders (lines 162-168)."
disabled_focus:
keyboard: "Subject Input, Format Select, Body Textarea, and both the 'Save Template' and '← Templates' Buttons are real BigDesign components wrapping native focusable elements — Tab order follows source: Subject → Format → Body → Save (lines 142-174). 'Save Template' shows isLoading while saving and is activatable with Enter/Space."
gaps: "The 'Saved!' success Text and the saveError Text (lines 175-176) are plain inline Text with no role='alert' / aria-live and no focus move — a keyboard/screen-reader merchant gets no announcement of save success or failure."
gaps: "[built, with defects] (1) A genuine template load failure (network/5xx) is swallowed: the apiGet catch treats every failure as 'template doesn't exist — start fresh' (lines 93-97), so the loadError Message at line 129 is dead code and the merchant can unknowingly overwrite an existing template with a blank Save. (2) The 'Saved!' confirmation auto-clears after 3000ms (setTimeout, lines 120-121) as inline Text with no aria-live — a vanishing toast that's easy to miss. (3) apiPreview errors are swallowed (line 104) leaving a stale preview with no signal. (4) The preview uses a hardcoded SAMPLE_VARIABLES object (lines 33-37); the BRD AC of 'preview with sample data' is met, but the merchant cannot inject their own variable values."
US-23.4: Merchant alerts & digests
<!-- traceability:start:US-23.4 --><!-- traceability:end:US-23.4 -->Prototype: Merchant Alerts
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want configurable alerts for high-value events (chargeback, large cancel, dunning spike) and a daily digest, so that I stay informed without drowning.
Acceptance criteria:
- Given I set alert thresholds, When events cross them, Then an email/Slack alert fires.
- Given the daily digest runs, When I get the email, Then I see key metrics and exception queue summary.
UI states.
<!-- ui-states US-23.4 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) alert configuration page at Settings → Notifications → Alerts and Digests. Persona: Merchant Admin. Configures threshold-based alerts (chargeback, cancellation rate, dunning spike) and a daily digest with recipient and send-time settings. Fetches config via GET /api/v1/admin/notifications/alerts; saves via PUT /api/v1/admin/notifications/alerts."
idle:
render: "A BigDesign H2 'Alerts and Digests' page with four FormGroup sections: 'Chargeback alerts' (threshold count Input + alert channel Select), 'Cancellation alerts' (cancel-rate percentage Input + channel Select), 'Dunning spike alerts' (spike count Input + window Select + channel Select), and 'Daily digest' (enabled Checkbox + recipient emails Input + send-time Select, with the recipient and send-time controls revealed only when the digest Checkbox is checked). A primary 'Save settings' Button at the bottom."
primary_action: "'Save settings' Button — PUT /api/v1/admin/notifications/alerts; on success a BigDesign inline confirmation replaces the error region."
loading:
trigger: "GET /api/v1/admin/notifications/alerts on mount; PUT on Save."
render: "On mount all form inputs render disabled until the GET resolves; the form shell is immediately visible so no skeleton is needed. While the PUT is in flight 'Save settings' shows its BigDesign isLoading spinner and all inputs are disabled."
error:
surfaced_at: "BigDesign Message(type='error') banner directly above the 'Save settings' Button (role=alert) — not a vanishing toast. Field-level validation errors surface inline via BigDesign FormGroup error prop beneath the offending control."
render: "The API failure reason (e.g. 'chargeback threshold must be a positive integer') or a generic 'Settings could not be saved — please try again.'"
recovery: "The form stays populated on error; the merchant corrects the failing field and presses 'Save settings' again. Inline field errors clear as the merchant edits the field."
empty:
render: "Not a list surface — the configuration form always renders with default placeholder values (chargeback threshold: 1, cancellation rate: 10%, dunning spike: 5 in 1 hour, daily digest disabled). No separate empty state exists."
cta: "n/a (form always present)"
edge_status:
- status: "Slack channel selected but no Slack webhook URL is configured"
affordance: "BigDesign FormGroup error beneath the channel Select ('A Slack webhook URL is required for Slack alerts') and a Slack webhook URL Input is revealed inline; 'Save settings' is blocked until the URL is supplied."
- status: "daily digest enabled but recipient email field is empty"
affordance: "FormGroup error beneath the recipients Input ('At least one recipient email is required') surfaces on save attempt; the digest Checkbox stays on and the field is highlighted so the merchant sees exactly what to fix."
inputs:
- field: "chargeback_threshold"
control: "number"
label: "'Chargebacks in 24 hours to trigger alert' — BigDesign Input type=number, min=1."
- field: "cancel_rate_threshold"
control: "number"
label: "'Cancellation rate (%) in 24 hours to trigger alert' — BigDesign Input type=number, min=1, max=100."
- field: "dunning_spike_threshold"
control: "number"
label: "'Failed charges in window to trigger alert' — BigDesign Input type=number, min=1."
- field: "dunning_window"
control: "select"
label: "'Alert window' — BigDesign Select."
allowed_values: "1 hour | 6 hours | 24 hours"
- field: "alert_channel"
control: "select"
label: "'Alert channel' — BigDesign Select (one per alert section)."
allowed_values: "Email | Slack | Email and Slack"
- field: "daily_digest_enabled"
control: "checkbox"
label: "'Send daily digest' — BigDesign Checkbox."
- field: "digest_recipient_emails"
control: "text"
label: "'Recipient emails' — BigDesign Input, comma-separated; required when daily digest is enabled."
- field: "digest_send_time"
control: "select"
label: "'Send time' — BigDesign Select in the store's timezone."
allowed_values: "6:00 AM | 7:00 AM | 8:00 AM | 9:00 AM | 10:00 AM"
disabled_focus:
keyboard: "All BigDesign Input, Select, Checkbox, and Button components wrap native focusable elements — no div-onClick dead-ends. Tab order follows section order top-to-bottom: chargeback threshold → channel → cancel-rate threshold → channel → dunning spike → window → channel → digest toggle → recipients (revealed when enabled) → send time (revealed when enabled) → Save. BigDesign disabled removes controls from tab order while saving."
US-23.5: Klaviyo integration
Phase: P2 · Priority: P1 · Effort: L · Persona: Merchant Admin
As a Merchant Admin using Klaviyo, I want subscription lifecycle events synced to Klaviyo, so that I can run segmented campaigns.
Acceptance criteria:
- Given Klaviyo is connected, When subscription events fire, Then Klaviyo profile events are pushed (
Subscription Created,Subscription Cancelled, etc.) with standard properties. - Given a subscriber opts out in Klaviyo, When we fire transactional emails, Then Klaviyo is the source of truth for marketing opt-out but transactional goes through regardless.
UX notes.
- Surface: Settings → Integrations → Klaviyo
- Setup: OAuth or API-key paste; test-event button
- Event mapping: table of our events → Klaviyo event names (defaults pre-populated, merchant can customize)
Data contract.
- Outbound: on every subscription lifecycle event, POST to Klaviyo
/api/eventswith{event, customer_properties, event_properties} - Standard events:
Subscription Created,Subscription Paused,Subscription Resumed,Subscription Cancelled,Subscription Charge Succeeded,Subscription Charge Failed,Subscription Upcoming Charge - Standard properties: plan_name, cycle_number, mrr_value, next_charge_date
Success metrics.
- Functional (target): ≥ 99% of events delivered to Klaviyo within 2 min
- Product: Klaviyo-integrated merchants have measurably higher reactivation campaign engagement than non-integrated (target 1.2×; calibrate with pilot data)
Dependencies.
- US-23.8 (outbound webhook infrastructure)
Non-functional.
- Klaviyo rate limits: 350 req/s per account; adapter respects
- Failed deliveries retried via webhook retry machinery
UI states.
<!-- ui-states US-23.5 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) Klaviyo integration settings page at Settings → Integrations → Klaviyo. Persona: Merchant Admin. Connects via private API key or OAuth, maps subscription lifecycle events to Klaviyo metric names, and fires a test event to confirm the integration. Fetches config via GET /api/v1/admin/integrations/klaviyo; saves via PUT /api/v1/admin/integrations/klaviyo; tests via POST /api/v1/admin/integrations/klaviyo/test."
idle:
render: "A BigDesign H2 'Klaviyo integration' page with two sections. 'Connection' section: a BigDesign Input for the Klaviyo private API key (masked, type=password) alongside an 'Authorize with Klaviyo' OAuth Button as an alternative; a 'Connection status' Badge (connected / disconnected); and a 'Send test event' secondary Button visible only when connected. 'Event mapping' section (visible only when connected): a two-column table with 'Our event' (non-editable label) and 'Klaviyo event name' (editable BigDesign Input, pre-populated with the standard default names); a 'Reset to defaults' link. Each section has its own 'Save' primary Button."
primary_action: "'Save' in the connection section persists the API key; 'Save' in the event-mapping section persists the metric name overrides. 'Send test event' fires a test delivery to Klaviyo."
loading:
trigger: "GET /api/v1/admin/integrations/klaviyo on mount; PUT on either Save; POST on test event."
render: "On mount the page renders the connection section only; the event-mapping section stays hidden until the GET resolves and confirms a live connection. Each Button ('Authorize', 'Save', 'Send test event') shows BigDesign isLoading while its request is in flight and is disabled; event-name Inputs are disabled during a mapping save."
error:
surfaced_at: "BigDesign Message(type='error') banner beneath the connection status Badge for connection failures (invalid API key, OAuth denial). Per-mapping validation errors render inline beneath each event-name Input. Test-event failure renders inline beneath the 'Send test event' Button (role=alert)."
render: "Connection failure reason from the Klaviyo API (e.g. 'API key not found — check your Klaviyo account credentials'). Test-event error carries the Klaviyo rejection message."
recovery: "Connection error: the API key Input stays editable; the merchant corrects the key and presses 'Save' again. Test-event error: the 'Send test event' Button re-enables immediately so the merchant can retry once credentials are corrected."
empty:
render: "When no Klaviyo connection exists, the event-mapping section is hidden and the connection section is the full page content. A helper paragraph beneath the API key Input explains the integration's value (lifecycle events synced to Klaviyo profiles for segmented campaigns) and links to Klaviyo's account API-keys page."
cta: "n/a (the connection section is the empty-state affordance)"
edge_status:
- status: "API key invalid or revoked"
affordance: "Connection status Badge reads 'Disconnected — credential error'; the event-mapping section is hidden; the merchant re-enters a valid private API key and presses 'Save' to restore the connection."
- status: "OAuth token approaching expiry"
affordance: "Connection status Badge shows 'Expiring soon' with a 'Re-authorize' Button; the merchant re-authorizes before the token lapses and events start failing."
- status: "test event delivery failed (Klaviyo returned 4xx)"
affordance: "Inline error beneath 'Send test event' carries the Klaviyo API error text; the merchant checks API key permissions or confirms the Klaviyo account has the target list configured before retrying."
inputs:
- field: "api_key"
control: "text"
label: "'Klaviyo private API key' — BigDesign Input type=password (masked); or replaced by OAuth connect Button."
- field: "event_name_subscription_created"
control: "text"
label: "'Subscription Created' Klaviyo metric name — BigDesign Input, default 'Subscription Created'."
- field: "event_name_subscription_cancelled"
control: "text"
label: "'Subscription Cancelled' Klaviyo metric name — BigDesign Input, default 'Subscription Cancelled'."
- field: "event_name_subscription_paused"
control: "text"
label: "'Subscription Paused' — BigDesign Input, default 'Subscription Paused'."
- field: "event_name_subscription_resumed"
control: "text"
label: "'Subscription Resumed' — BigDesign Input, default 'Subscription Resumed'."
- field: "event_name_charge_succeeded"
control: "text"
label: "'Subscription Charge Succeeded' — BigDesign Input, default 'Subscription Charge Succeeded'."
- field: "event_name_charge_failed"
control: "text"
label: "'Subscription Charge Failed' — BigDesign Input, default 'Subscription Charge Failed'."
- field: "event_name_upcoming_charge"
control: "text"
label: "'Subscription Upcoming Charge' — BigDesign Input, default 'Subscription Upcoming Charge'."
disabled_focus:
keyboard: "All BigDesign Input and Button components wrap native focusable elements. Tab order in the connection section: API key Input → Save → Send test event (when connected). Tab order in the event-mapping section: each event-name Input in table row order → Reset to defaults link → Save. The 'Authorize with Klaviyo' Button replaces the API key Input when OAuth is used and is a real <button> reachable by Tab. All Buttons are disabled (removed from tab order) while their request is in flight."
US-23.6: Gorgias / Zendesk integration
<!-- traceability:start:US-23.6 --><!-- traceability:end:US-23.6 -->Prototype: Integrations
Phase: P2 · Persona: Support / Ops
As Support / Ops, I want cancel-with-issue events to auto-create helpdesk tickets, so that no subscriber complaint is missed.
Acceptance criteria:
- Given a cancel with reason "Product issue" occurs, When the event fires, Then a ticket is auto-created in Gorgias/Zendesk with subscription + order context.
UI states.
<!-- ui-states US-23.6 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) helpdesk integration settings page at Settings → Integrations → Helpdesk. Persona: Support / Ops. Connects Gorgias or Zendesk via API credentials, selects which cancel reasons auto-create tickets, and validates the connection with a test. Fetches config via GET /api/v1/admin/integrations/helpdesk; saves via PUT /api/v1/admin/integrations/helpdesk; tests via POST /api/v1/admin/integrations/helpdesk/test."
idle:
render: "A BigDesign H2 'Helpdesk integration' page with a 'Platform' Select (Gorgias / Zendesk / None) at the top. When a platform is selected: a 'Credentials' FormGroup section with a Subdomain Input and an API Token Input (masked); a 'Connect' primary Button; and — once credentials are accepted — a connection status Badge and a 'Test connection' secondary Button. Below credentials: a 'Ticket trigger conditions' FormGroup with one Checkbox per cancel reason (Product issue, Poor quality, Service issue, Too expensive, Other); a 'Ticket template' FormGroup with a Subject template Input and two Checkboxes ('Include subscription details', 'Include last order context'). A 'Save' primary Button at the bottom."
primary_action: "'Connect' submits credentials; 'Save' persists trigger conditions and template settings; 'Test connection' fires a test ticket creation to confirm the integration is live."
loading:
trigger: "GET /api/v1/admin/integrations/helpdesk on mount; PUT on Connect and Save; POST on Test connection."
render: "On mount the Platform Select renders; credentials and trigger sections are hidden until the GET resolves or the merchant selects a platform. 'Connect', 'Save', and 'Test connection' each show BigDesign isLoading while in flight and are disabled; all form controls are disabled during Save."
error:
surfaced_at: "BigDesign Message(type='error') banner beneath the 'Connect' or 'Save' Button (role=alert). Test-connection failure renders inline beneath the 'Test connection' Button. Field-level errors (missing subdomain, empty API token) surface via BigDesign FormGroup error prop beneath each Input."
render: "Credential failure reason (e.g. 'Invalid API token — check Gorgias admin → Integrations → HTTP') or a generic 'Connection failed — check credentials and try again.'"
recovery: "The Subdomain and API Token Inputs remain editable after a connect error; the merchant corrects the credential and presses 'Connect' again. Test failure re-enables the 'Test connection' Button immediately so the merchant can retry."
empty:
render: "When Platform is 'None' (or on first load with no saved config), the credentials, trigger-conditions, and template sections are hidden. A descriptive paragraph beneath the Platform Select explains the integration ('Automatically open a support ticket when a subscriber cancels with a product issue') and lists the supported platforms."
cta: "n/a (selecting a platform from the Select is the empty-state affordance)"
edge_status:
- status: "credentials invalid (401 or 403 from the helpdesk API)"
affordance: "Connection status Badge shows 'Disconnected — credential error'; Subdomain and API Token Inputs remain editable; auto-ticket creation is halted until the merchant corrects credentials and presses 'Connect'."
- status: "no trigger conditions checked — integration connected but no tickets will fire"
affordance: "BigDesign Message(type='warning') above the 'Save' Button: 'No cancel reasons are selected — no tickets will be auto-created'; the merchant must check at least one reason before any ticket behavior takes effect."
- status: "test ticket created successfully"
affordance: "Inline success notice beneath 'Test connection' with a direct link to the newly created ticket in Gorgias or Zendesk so the merchant can confirm the ticket content before going live."
inputs:
- field: "platform"
control: "select"
label: "'Helpdesk platform' — BigDesign Select."
allowed_values: "Gorgias | Zendesk | None"
- field: "subdomain"
control: "text"
label: "'Subdomain' — BigDesign Input; placeholder varies by platform."
- field: "api_token"
control: "text"
label: "'API token' — BigDesign Input type=password (masked)."
- field: "trigger_reasons"
control: "checkbox"
label: "Per-cancel-reason Checkbox — at least one must be checked before saving."
allowed_values: "Product issue | Poor quality | Service issue | Too expensive | Other"
- field: "ticket_subject_template"
control: "text"
label: "'Ticket subject template' — BigDesign Input with {{variable}} placeholder support."
- field: "include_subscription_details"
control: "checkbox"
label: "'Include subscription details in ticket body' — BigDesign Checkbox."
- field: "include_last_order"
control: "checkbox"
label: "'Include last order context in ticket body' — BigDesign Checkbox."
disabled_focus:
keyboard: "Platform Select, Subdomain Input, API Token Input, trigger-reason Checkboxes, Subject template Input, and all Buttons (Connect, Test connection, Save) are real BigDesign components wrapping native focusable elements — no div-onClick. Tab order: Platform → Subdomain → API Token → Connect → Test connection → trigger checkboxes (Product issue → Poor quality → Service issue → Too expensive → Other) → Subject template → Include subscription details → Include last order → Save. BigDesign disabled removes controls from tab order while requests are in flight."
US-23.7: Accounting integrations (NetSuite, QuickBooks, Xero)
Phase: P3 · Persona: Merchant Admin
As a Merchant Admin, I want subscription revenue posted to my accounting system, so that my books reconcile.
Acceptance criteria:
- Given an accounting integration is connected, When charges succeed, Then journal entries post with subscription metadata.
UI states.
<!-- ui-states US-23.7 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) accounting integration settings page at Settings → Integrations → Accounting. Persona: Merchant Admin. Connects NetSuite (API credentials), QuickBooks Online (OAuth), or Xero (OAuth); maps subscription charges to income, deferred-revenue, and tax-liability accounts; and configures auto-post frequency. Fetches config via GET /api/v1/admin/integrations/accounting; saves via PUT /api/v1/admin/integrations/accounting; fetches chart of accounts via GET /api/v1/admin/integrations/accounting/accounts."
idle:
render: "A BigDesign H2 'Accounting integration' page with a 'Platform' Select (NetSuite / QuickBooks / Xero / None). For OAuth platforms (QuickBooks, Xero): an 'Authorize with {Platform}' primary Button when disconnected, or a connection status Badge with a 'Disconnect' secondary Button and a 'Re-authorize' link when connected. For NetSuite: Account ID and API Token Inputs plus a 'Connect' Button. When connected: a 'Journal entry mapping' FormGroup section with three BigDesign Selects (income account, deferred revenue account, tax liability account) populated from the connected platform's chart of accounts; an 'Auto-post journal entries' Checkbox; a 'Post frequency' Select revealed only when auto-post is enabled. A 'Save' primary Button at the bottom."
primary_action: "'Authorize' (OAuth) or 'Connect' (NetSuite) establishes the connection; 'Save' persists the journal entry mapping and auto-post settings."
loading:
trigger: "GET /api/v1/admin/integrations/accounting on mount; GET /api/v1/admin/integrations/accounting/accounts after connection is established; PUT on Save."
render: "On mount the Platform Select renders immediately. After connecting, the three account Selects show a disabled 'Loading accounts…' placeholder option while the chart-of-accounts fetch is in flight. 'Authorize' / 'Connect' / 'Save' Buttons each show BigDesign isLoading during their request and are disabled while in flight; all mapping Selects are disabled during Save."
error:
surfaced_at: "BigDesign Message(type='error') banner beneath the active connection Button (role=alert) for connection and save failures. A separate BigDesign Message(type='error') above the journal-entry mapping section when the chart-of-accounts fetch fails, with an inline 'Retry' link. NetSuite field-level errors surface via BigDesign FormGroup error prop beneath each Input."
render: "OAuth failure: 'Authorization denied — please try again.' NetSuite: 'Invalid Account ID or API token — check your NetSuite integration record.' Chart-of-accounts failure: 'Could not load your accounts — check your connection and try again.'"
recovery: "OAuth failure: the 'Authorize' Button re-enables so the merchant can retry the OAuth flow. NetSuite: Inputs remain populated and editable. Chart-of-accounts failure: the 'Retry' link re-triggers the accounts fetch; Save is blocked until accounts load successfully. Save failure: form stays populated; the merchant corrects any invalid mapping and presses 'Save' again."
empty:
render: "When Platform is 'None' (or on first load with no saved config), the journal-entry mapping and auto-post sections are hidden. A descriptive paragraph beneath the Platform Select explains what will sync ('Successful subscription charges post as journal entries with plan name, amount, and subscription ID as line-item metadata') and lists the supported platforms with their connection methods."
cta: "n/a (selecting a platform and connecting is the empty-state affordance)"
edge_status:
- status: "OAuth token revoked (QuickBooks or Xero) — auto-post silently failing"
affordance: "BigDesign Message(type='warning') banner at the top of the page: 'Accounting sync paused — authorization expired'; a 'Re-authorize' Button relaunches the OAuth flow; charges that fired during the outage queue for back-fill once re-authorized."
- status: "chart of accounts fetch failed — mapping Selects cannot populate"
affordance: "BigDesign Message(type='error') above the mapping section with a 'Retry' link; the three account Selects remain disabled; 'Save' is blocked until accounts load successfully."
- status: "NetSuite credentials invalid (401)"
affordance: "Connection status Badge shows 'Disconnected — credential error'; Account ID and API Token Inputs remain editable; auto-post is halted; the merchant re-enters credentials and presses 'Connect'."
inputs:
- field: "platform"
control: "select"
label: "'Accounting platform' — BigDesign Select."
allowed_values: "NetSuite | QuickBooks | Xero | None"
- field: "netsuite_account_id"
control: "text"
label: "'NetSuite Account ID' — BigDesign Input; visible only when platform is NetSuite."
- field: "netsuite_api_token"
control: "text"
label: "'NetSuite API token' — BigDesign Input type=password; visible only when platform is NetSuite."
- field: "income_account"
control: "select"
label: "'Income account' — BigDesign Select populated from the connected platform's chart of accounts."
allowed_values: "Populated at runtime from the platform's chart of accounts."
- field: "deferred_revenue_account"
control: "select"
label: "'Deferred revenue account' — BigDesign Select populated from the connected platform's chart of accounts."
allowed_values: "Populated at runtime from the platform's chart of accounts."
- field: "tax_liability_account"
control: "select"
label: "'Tax liability account' — BigDesign Select populated from the connected platform's chart of accounts."
allowed_values: "Populated at runtime from the platform's chart of accounts."
- field: "auto_post_enabled"
control: "checkbox"
label: "'Automatically post journal entries' — BigDesign Checkbox."
- field: "post_frequency"
control: "select"
label: "'Post frequency' — BigDesign Select; visible only when auto-post is enabled."
allowed_values: "Real-time | Hourly | Daily"
disabled_focus:
keyboard: "Platform Select, NetSuite credential Inputs (when NetSuite is selected), Authorize/Connect Button, three account Selects, auto-post Checkbox, post-frequency Select (when visible), and Save Button are all real BigDesign components wrapping native focusable elements — no div-onClick. Tab order: Platform → (NetSuite: Account ID → API Token → Connect) or (QuickBooks/Xero: Authorize Button) → income account → deferred revenue → tax liability → auto-post Checkbox → post frequency (when visible) → Save. The 'Retry' link in the chart-of-accounts error Message is a real focusable element reachable by Tab."
US-23.8: Outbound webhooks
<!-- traceability:start:US-23.8 --><!-- traceability:end:US-23.8 -->Prototype: Outbound webhooks
Phase: MVP · Priority: P0 · Effort: L · Persona: Developer
As a Developer, I want to subscribe to our webhook events, so that I can build custom integrations.
Acceptance criteria:
- Given I register a webhook endpoint with selected event types + HMAC secret, When matching events fire, Then they're POSTed with signature header, retried on failure with exponential backoff.
Data contract.
- Entity:
webhook_subscriptions—{id, store_hash, url, event_types[], secret, is_active, created_at} - On event fire: queue delivery job; POST
{event, data, timestamp}with headerX-BC-Subscriptions-Signature: v1,t={ts},sig={hmac_sha256} - Retry: exponential backoff 1m, 5m, 30m, 2h, 6h, 24h; after 24h dead-letter with alert
Success metrics.
- Functional (target): ≥ 99% successful delivery on first attempt for healthy endpoints
- Operational (target): end-to-end lag (event fire → customer endpoint receives) < 30s P95
Non-functional.
- Signature spec documented publicly; verification library provided in our SDK
- Merchant can replay any historical event from the dashboard
US-23.9: Custom sending domain
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: MVP · Priority: P0 · Effort: L · Persona: Merchant Admin
As a Merchant Admin, I want emails to come from my own domain, so that subscribers see my brand and DMARC alignment is preserved.
Acceptance criteria (proposed):
- Given I configure
notifications.{merchant.com}in settings, When I trigger DNS verification, Then we provision a Resend domain + DKIM record + return DNS instructions for SPF / DKIM / DMARC alignment. - Given verification succeeds, When the next transactional email fires, Then it sends from
noreply@notifications.{merchant.com}with DKIM-signed headers and DMARC-alignedFrom. - Given I have not configured a custom domain, When emails fire, Then they send from
subscriptions.bigcommerce.com(fallback) with a one-time merchant-side notice prompting setup. - Given Gmail/Yahoo bulk-sender thresholds (≥ 5K/day) apply, When my volume crosses, Then we surface a merchant alert about DMARC alignment requirements.
UI states.
<!-- ui-states US-23.9 -->surface: "Admin · Email settings → Custom Sending Domain — React/BigDesign page at apps/admin/src/pages/notifications/DomainConfig.tsx, mounted on route 'email/domain' → EmailDomainPage (apps/admin/src/App.tsx:122). Provisions a Resend sending domain, displays the DKIM/DMARC DNS records to publish, and triggers DNS verification."
idle:
render: "A subtle '← Templates' back button beside an H2 'Custom Sending Domain' (DomainConfig.tsx:117-120). An 'Add Domain' section: secondary helper text ('Configure a custom sending domain (e.g. notifications.yourstore.com). You'll receive DNS records to publish before verifying.') above a labeled 'Domain' Input and a primary 'Provision' button (DomainConfig.tsx:124-145). Below it a 'Configured Domains' list — rendered only when at least one domain exists — each row showing the domain, a status Badge, and a 'Verify DNS' button until verified."
primary_action: "Type the sending hostname and click 'Provision' — POST /api/v1/admin/email-domains with {domain} (DomainConfig.tsx:71-93); on success the DKIM/DMARC record panel appears and the domain joins the list as 'pending'."
loading:
trigger: "Provision in flight (provisioning, POST /api/v1/admin/email-domains) and per-row Verify in flight (verifying === domain, POST /api/v1/admin/email-domains/{domain}/verify)."
render: "BigDesign Button isLoading renders an in-button spinner and disables the control while its request settles: the 'Provision' button (DomainConfig.tsx:140-142) and the row's 'Verify DNS' button (DomainConfig.tsx:179-185). GAP: the initial fetchDomains() on mount (DomainConfig.tsx:67-69) has no loading indicator — the page renders identical to the empty state while domains load. North-star: a 'Loading domains…' skeleton in the Configured Domains region until the fetch resolves, so loading and empty are distinguishable."
error:
surfaced_at: "Two places. (1) A BigDesign <Message type='error'> banner pinned at the very top of the page, above 'Add Domain' (DomainConfig.tsx:122) — carries fetch, provision, and verify failures. (2) Per-row verify failures render inline beneath the domain row as danger50 text (DomainConfig.tsx:188-192). North-star: the top banner must be a live region (role=alert) with focus or scroll moved to it, so a failure is never a silent visual-only reveal far from the Provision/Verify control that triggered it."
render: "The failure reason verbatim — provision/verify responses surface {error} (or 'HTTP <status>') as the banner text (DomainConfig.tsx:83,104); a verify failure shows verifyResult.reason or the fallback 'Verification failed — DNS records not yet propagated.' (DomainConfig.tsx:190)."
recovery: "The banner persists (not a vanishing toast); correct the domain or publish the DNS records, then re-click 'Provision' or 'Verify DNS' from the same surface — both buttons re-enable as soon as their request settles (finally blocks at DomainConfig.tsx:90-92 and 110-112)."
empty:
render: "When fetchDomains() returns zero domains (domains.length === 0, DomainConfig.tsx:160) the 'Configured Domains' section is omitted entirely and only the 'Add Domain' form + helper text render — the Provision form is the empty-state affordance. GAP: there is no explicit empty message; north-star renders a 'No custom sending domain configured — emails send from subscriptions.bigcommerce.com until you add one' note (US-23.9 AC3 fallback) in the empty Configured Domains region so the merchant sees the current sending identity."
edge_status:
- status: "pending — domain provisioned, DNS not yet verified."
badge: "secondary 'pending' Badge (DomainConfig.tsx:169-172)."
affordance: "The 'Verify DNS' button stays on the row (DomainConfig.tsx:178-186) and the DKIM/DMARC record panel from provision (DomainConfig.tsx:147-157) tells the merchant exactly what to publish; re-check via 'Verify DNS' after the records propagate."
- status: "verified — DNS verified, domain active."
badge: "success 'verified' Badge plus a 'Verified {date}' line (DomainConfig.tsx:171-176)."
affordance: "Terminal success — the 'Verify DNS' button is removed by the verification_status !== 'verified' guard (DomainConfig.tsx:178); no further merchant action is required and the row reads as done."
- status: "failed — the last verification attempt failed."
badge: "danger 'failed' Badge (DomainConfig.tsx:171)."
affordance: "The 'Verify DNS' button remains on the row and the inline danger50 reason (DomainConfig.tsx:188-192) explains why; re-publish or await DNS propagation, then click 'Verify DNS' again — never a dead-end badge."
inputs:
- field: "domain"
control: "text"
required: "true"
note: "A labeled BigDesign Input (label='Domain', placeholder 'notifications.yourstore.com') wrapped in Form > FormGroup (DomainConfig.tsx:130-145) — a real labeled <input>, not a bare unlabeled field. Free text is correct: a sending hostname is not an enumerable domain, so no allowed_values/select. Provision no-ops on empty/whitespace input (if (!input.trim()) return — DomainConfig.tsx:72)."
disabled_focus:
keyboard: "Every interactive control is a real, keyboard-reachable element — NO div-onClick rows and NO bare/unlabeled inputs. Tab order is the '← Templates' subtle <button> (DomainConfig.tsx:118), the labeled 'Domain' <input> inside Form>FormGroup (DomainConfig.tsx:133-139), the primary 'Provision' <button> (DomainConfig.tsx:140), then each domain row's 'Verify DNS' <button> (DomainConfig.tsx:179-185); Provision and Verify are reachable by Tab+Enter without a mouse. While a request is in flight its button is disabled and shows a spinner (isLoading); north-star: that busy state must stay perceivable to assistive tech (an aria-live/aria-busy announcement) so a screen-reader user knows the action is processing rather than losing the control silently."
focus_move: "On a successful Provision, focus (or an aria-live=polite announcement) must move to the revealed DKIM/DMARC DNS-records panel so a keyboard/screen-reader merchant is taken to the records they must publish; on Verify, the pass/fail outcome must be announced via an aria-live=polite region. GAP: the DNS-records reveal is a plain <Box> div with no role/tabIndex/aria-live and no focus move (DomainConfig.tsx:147-157); verify pass/fail renders as plain <Text> with no aria-live (DomainConfig.tsx:188-195); and the error <Message> is not focus-managed (DomainConfig.tsx:122) — three silent dynamic reveals on the keyboard/SR path."
guard: "Provision no-ops on empty/whitespace domain (DomainConfig.tsx:72) and the 'Verify DNS' button is omitted once verification_status === 'verified' (DomainConfig.tsx:178), so the surface only ever offers the actions that are currently valid."
Non-functional. DKIM key per merchant domain; DNS records validated within 1h of merchant change; SPF / DKIM / DMARC posture documented in merchant onboarding.
US-23.10: Deliverability monitoring
<!-- traceability:start:US-23.10 --><!-- traceability:end:US-23.10 -->Prototype: Deliverability monitoring
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: MVP · Priority: P0 · Effort: M · Persona: Merchant Admin / Ops
As Merchant Admin / Ops, I want bounce and complaint thresholds enforced per merchant domain, so that one merchant's poor list hygiene doesn't damage shared reputation.
Acceptance criteria (proposed):
- Given a merchant domain crosses bounce > 5%, When we detect the threshold breach, Then sending throttles for that domain and an alert fires to the merchant + ops.
- Given complaint rate crosses 0.1% (warn) or 0.3% (hard cap), When detected, Then sending pauses with merchant-side remediation guidance.
- Given thresholds recover, When the trailing-window rate normalizes, Then sending auto-resumes.
Data contract. Trailing 7-day rolling bounce/complaint rate per merchant-domain, computed from Resend webhook ingestion (US-23.16).
UI states.
<!-- ui-states US-23.10 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) deliverability monitoring dashboard — a read-only status page under admin email / notification settings showing per-sender-domain bounce and complaint rates, current send status (sending / throttled / paused), and inline remediation guidance. Persona: Merchant Admin / Ops."
idle:
render: "Page headed 'Email deliverability' with a BigDesign Table listing each configured sender domain. Columns: Domain, 7-day bounce rate (%), 7-day complaint rate (%), Status (badge: sending / throttled / paused), Last updated. A summary callout above the table shows aggregate health across all sending domains. Each row carries a 'View details' link that opens a BigDesign Panel showing bounce and complaint logs for that domain."
primary_action: "Read-only dashboard — no write actions. A 'Refresh' Button at the top right re-fetches current metrics from the deliverability service."
loading:
trigger: "GET /api/v1/admin/deliverability/domains on page mount and on 'Refresh' click."
render: "While fetching, three BigDesign Skeleton rows replace the domain data; the Refresh Button is disabled with a loading spinner to prevent double-fetch. The summary callout shows dashes in place of aggregate figures."
error:
surfaced_at: "A BigDesign Message(type='error') banner rendered directly above the domain Table — never a vanishing toast — stating 'Could not load deliverability data. Check your connection and try again.'"
recovery: "The Message banner carries a 'Try again' Button that re-runs the fetch. The banner remains visible until the fetch succeeds or the page is reloaded; the table remains in its last-known state (or in empty-state if first load) while the banner is shown."
empty:
render: "When no sending domains have accumulated data yet, the Table body is replaced by a BigDesign EmptyState with copy: 'No deliverability metrics yet — per-domain bounce and complaint rates appear here once emails have been sent.' A 'Go to email settings' link navigates to the sender-domain configuration page where a domain can be verified."
edge_status:
- status: "throttled — domain bounce rate has exceeded 5% (AC1)"
affordance: "'throttled' badge (amber) on the domain row. The View-details panel shows: 'Sending is reduced for this domain. Remove invalid addresses from your subscriber lists to restore full throughput.' A 'View bounce log' link lists the offending addresses for the merchant to action."
- status: "paused — domain complaint rate has exceeded 0.3% hard cap (AC2)"
affordance: "'paused' badge (red) on the domain row. Panel copy: 'Sending is paused. Clean your list and contact support to resume.' A 'Contact support' link opens the support channel; no self-serve resume to prevent reputation abuse."
- status: "warn — domain complaint rate between 0.1% and 0.3% (AC2)"
affordance: "'warn' badge (amber) on the domain row. Advisory copy: 'Complaint rate is elevated — act now to avoid the 0.3% hard cap pausing sending.' A 'View complaint log' link shows which addresses complained."
- status: "auto-resumed — trailing-window rate has normalised after a throttle period (AC3)"
affordance: "Domain row status badge returns to 'sending' (green). A transient BigDesign Message(type='success') above the table confirms: 'Sending has automatically resumed for [domain] — monitor your rate to stay below the bounce threshold.' Auto-dismissed after 5 s; not the sole notification channel."
inputs: []
disabled_focus:
keyboard: "The 'Refresh' Button and all per-row 'View details' links are real <button> and <a> elements in the natural tab order of the page. The BigDesign Panel that opens on 'View details' traps focus within the panel; Escape closes it and returns focus to the triggering row link. Status badges are non-interactive <span> elements annotated with role='status'. The BigDesign Table header columns are navigable with Tab per the BigDesign Table keyboard contract."
US-23.11: Suppression list management
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: MVP · Priority: P0 · Effort: M · Persona: Subscriber / Compliance
As a Subscriber, I want my email opt-out to be honored across all my subscriptions for that merchant, so that I'm not pursued by unwanted email.
Acceptance criteria (proposed):
- Given a subscriber clicks
List-Unsubscribe(marketing) or hard-bounces, When the event lands, Then we add the recipient to the merchant's suppression list. - Given a recipient is on the suppression list for merchant M, When any email job for that recipient at M fires, Then we drop it (transactional bypasses marketing-only opt-out per the routing rule in PRD §9.8).
- Given a subscriber re-opts-in via the portal, When confirmation completes, Then we remove the suppression entry and emit
subscriber.resubscribed.
UI states.
<!-- ui-states US-23.11 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Merchant Admin (React/BigDesign) suppression list management page — a searchable table of suppressed email addresses under admin email settings; (2) Subscriber portal (Svelte/Tailwind) marketing re-opt-in confirmation page — a one-step confirmation card reached from subscriber account preferences. Persona: Subscriber / Compliance (AC3 re-opt-in); Merchant Admin (admin list view)."
idle:
render: "(Admin surface) Page headed 'Suppression list' with a BigDesign Table of suppressed addresses. Columns: Email address, Reason (hard bounce / complained / list-unsubscribe / manual), Date added, Actions ('Remove' Button per row). A search Input above the table filters by email address. An 'Add address' Button allows manual suppression for edge cases. (Subscriber portal surface) A confirmation card renders: 'You are currently unsubscribed from [store name] marketing emails. Tap below to re-enable email updates.' A single 'Re-enable emails' Button is the sole interactive element; a secondary 'No thanks' link returns to account preferences without action."
primary_action: "(Admin) 'Remove' Button per row removes the suppression entry for that address. (Subscriber portal) 'Re-enable emails' Button POSTs the re-opt-in, removes the suppression entry, and emits subscriber.resubscribed (AC3)."
loading:
trigger: "(Admin) GET /api/v1/admin/suppression on mount and on each search-term change (debounced 300 ms); DELETE /api/v1/admin/suppression/{email} on Remove click. (Subscriber portal) POST /api/v1/portal/suppression/resubscribe on 'Re-enable emails' click."
render: "(Admin) BigDesign Skeleton rows while the list loads; the targeted row's 'Remove' Button shows a spinner and is disabled while the delete is in-flight, preventing double-remove. (Subscriber portal) The 'Re-enable emails' Button label changes to 'Confirming…' and is disabled; no double-submit."
error:
surfaced_at: "(Admin) A BigDesign Message(type='error') banner above the suppression Table for list-load or remove failures — never a toast. (Subscriber portal) An inline error paragraph beneath the 'Re-enable emails' Button with role='alert' on POST failure; scoped to the card, not a page-level banner."
recovery: "(Admin) List-load failure: the Message carries a 'Try again' Button that re-fetches. Remove failure: the row remains in the table and the 'Remove' Button re-enables so the operator can retry. (Subscriber portal) The 'Re-enable emails' Button re-enables immediately on failure so the subscriber can retry without reloading the page."
empty:
render: "(Admin surface) When the suppression list has no entries, the Table body is replaced by BigDesign EmptyState copy: 'No suppressed addresses — subscribers who bounce or unsubscribe will appear here.' No 'Add address' prompt is shown in this state to avoid prompting manual suppression when the list is clean. (Subscriber portal surface) Not a list surface — the re-opt-in card always renders when a suppressed subscriber visits the re-opt-in route; after a successful re-opt-in it transitions to a success confirmation state."
edge_status:
- status: "hard-bounce suppression added automatically (AC1)"
affordance: "Address appears in the admin table with Reason = 'hard bounce'. Admin can review and remove if the bounce was in error via the per-row 'Remove' Button; removal allows the address to receive emails on the next send attempt."
- status: "list-unsubscribe / marketing opt-out suppression — transactional emails still send (AC2)"
affordance: "Reason column reads 'list-unsubscribe'. An inline row note clarifies: 'Transactional emails (charge receipts, magic links) are not blocked — only marketing emails are suppressed.' Admin can see the scope distinction without removing the entry for transactional sends to work."
- status: "re-opt-in confirmed (AC3) — subscriber portal success state"
affordance: "The 'Re-enable emails' Button is replaced by a success confirmation card: 'You're back! You will now receive email updates from [store name].' A 'Return to account' link navigates to account preferences. The suppression entry is removed and does not reappear in the admin table on the next fetch."
- status: "subscriber re-opts-in for a hard-bounce suppression (not marketing-only)"
affordance: "The re-opt-in POST succeeds and removes the suppression entry regardless of reason type; the subscriber receives the same success confirmation. Admin can observe the reason and removal in the suppression audit log for compliance review."
inputs:
- field: "email_search"
control: "search"
label: "'Search by email' — BigDesign Input type=search above the suppression Table (admin surface)"
validation: "Client-side debounce 300 ms; server-side case-insensitive prefix match scoped to store_hash."
disabled_focus:
keyboard: "(Admin) The search Input, 'Add address' Button, and all per-row 'Remove' Buttons are real focusable elements in tab order; no div-onClick. Any confirmation dialog for a remove action traps focus within the dialog and returns focus to the row on dismiss. (Subscriber portal) The 'Re-enable emails' Button is a real <button> element reachable in tab order and activatable with Enter or Space. The 'No thanks' dismissal and the success-state 'Return to account' are real <a> elements. Focus is never orphaned during or after the re-opt-in flow."
US-23.12: Magic-link email pipeline
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: MVP · Priority: P0 · Effort: L · Persona: Subscriber / System
As a Subscriber, I want secure magic-link access for portal login and dunning payment-update, so that I don't manage another password and links can't be replayed.
Acceptance criteria (proposed):
- Given a subscriber requests portal login, When the magic-link email is sent, Then the link contains an opaque 32-byte token after the URL
#fragment with TTL 15 min, single-use, bcrypted-lookup server-side. - Given a subscriber's charge fails (Epic 11), When the dunning email is sent, Then it includes a payment-update magic-link with TTL 24h–7d (configurable per merchant, default 7d).
- Given a token is consumed, When a second redemption is attempted, Then we reject and redirect to a "request fresh link" page.
- Given a token's domain doesn't match the storefront the subscriber was last active on, When clicked, Then we reject as a phishing-resistance check.
Non-functional. Tokens never embedded in ? query params (referrer leakage); single-use enforced atomically; TTL boundaries configurable but bounded.
UI states.
<!-- ui-states US-23.12 -->surface: "Subscriber magic-link request — apps/storefront-svelte/src/routes/account/login/+page.svelte (Magic link tab) and the apps/storefront-svelte/src/lib/subscriptions/SubscriberPortalApp.svelte embedded sign-in form (lines 265-318). Token verify + expired/reused handling runs in SubscriberPortalApp.onMount (lines 125-149). Persona: Subscriber / System."
idle:
render: "Login page: a Password / Magic link toggle (lines 47-66); the Magic link tab shows an email input (type=email required) above an 'Email me a sign-in link' button, with 'Magic links are valid for 15 minutes' copy (line 150). The embedded portal prompt shows the same email form with 'No passwords' copy (lines 267-312)."
primary_action: "Submit the email → apiClient.requestMagicLink({ email }) (login submitMagicLink line 19 / portal handleRequestLink line 151)."
loading:
trigger: "POST /api/v1/portal/auth/request-link via requestMagicLink; mlStatus='sending' (login) or requestingLink=true (portal)."
render: "The CTA label switches to 'Sending…' and both the button and the email input are disabled (login lines 140-144; portal lines 302-311); no double-submit. The follow-on ?token= verify on link-click has no dedicated spinner."
error:
surfaced_at: "Login: inline error paragraph beneath the form when mlStatus==='error' (lines 146-148). Portal: inline role=alert beneath the form (line 316). Both scoped to the form — never a toast."
render: "The send failure reason (mlError / err.message), shown verbatim; e.g. a rate-limit or send failure."
recovery: "Correct the email and resubmit from the same form; the CTA re-enables as soon as the trimmed email is non-empty. A token-verify failure (expired/reused) surfaces via the portal role=alert and the email form stays available to request a fresh link."
empty:
render: "Not a list surface — a single email form. The post-auth landing it authenticates into renders its own empty copy ('No subscriptions yet…', SubscriberPortalApp lines 365-368)."
edge_status:
- status: "Link sent — confirmation shown (mlStatus='sent' / linkSent)"
affordance: "A 'Check your email' / 'Magic link sent' confirmation with a 'Send to a different email' button that resets the form (login lines 108-124; portal lines 274-288)."
- status: "Token expired or reused (AC3) — the ?token= verify fails"
affordance: "The verify error surfaces via role=alert (portal lines 141-143 → line 316) and the email form stays available to request a fresh link. NORTH-STAR: a dedicated 'request fresh link' page with the email pre-filled — not built (see gaps + defects)."
- status: "Dunning payment-update link (AC2) — TTL 24h–7d"
affordance: "Contract north-star: a separate dunning email carries a payment-update magic-link on the configurable 24h–7d TTL; this request form is login-only (15 min) and does not yet emit/handle the dunning variant (see gaps)."
inputs:
- field: "email"
control: "email"
required: "true"
validation: "type=email + native required; the CTA stays disabled until the trimmed value is non-empty (login line 140 / portal line 307)."
disabled_focus:
keyboard: "The email input (type=email) and the submit button are native form controls reached in tab order; submit fires on Enter inside the form (login onsubmit line 126 / portal lines 290-294). The disabled CTA is a real disabled button, never a div-onClick; the 'Send to a different email' reset is a real button."
gaps: "PARTIAL (story is PROPOSED). Built: the login-link request form (login Magic link tab + portal embedded prompt), the sent-confirmation + reset, and inline verify-error handling. NOT built per the proposed ACs: AC3 dedicated 'request fresh link' page (today a reused/expired token only surfaces an inline alert on the existing form — no named route, same gap documented under US-17.1); AC2 dunning payment-update magic-link with configurable 24h–7d TTL (this surface is login-only at 15 min); AC4 domain/phishing-resistance check that rejects a token whose domain does not match the storefront the subscriber was last active on. The 15-minute copy is CORRECT for this login surface (AC1) — not a defect here."
US-23.13: Decline-reason-translated dunning email
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: MVP · Priority: P0 · Effort: M · Persona: Subscriber
As a Subscriber, I want clear language about why my charge failed and what to do, so that I can resolve it without contacting support.
Acceptance criteria (proposed):
- Given a charge fails with
card_declined/insufficient_funds/expired_card/do_not_honor/requires_action, When the dunning email fires, Then the subject + body translate the issuer code to subscriber-friendly copy (no raw codes shown). - Given the failure is
requires_action(EU SCA), When the email fires, Then the CTA reads "complete bank security check" (not "update payment method") and links to a magic-link SCA-completion flow (not a generic PM update). - Given the failure is hard-fraud, When detected, Then no email is sent; subscription cancels and support ticket auto-creates per US-23.6.
- Given dunning state cycles, When emails fire at first failure / before each retry / final failure, Then each carries escalating-urgency copy (merchant-configurable cadence).
UI states.
<!-- ui-states US-23.13 -->surface: "NOT YET BUILT — forward-looking contract. Subscriber-facing transactional email (Resend / MJML template) — the dunning charge-failed notification dispatched by the Epic-11 retry scheduler. Persona: Subscriber. This is email content rendered in the subscriber's inbox; no UI screen is rendered. This contract defines the email's rendering states, per-issuer-code copy variants, and link accessibility."
idle:
render: "Default email for a standard decline (card_declined or insufficient_funds). Subject: '[Store name] — action needed: your payment was declined.' Body sections: (1) greeting with subscriber first name; (2) plain-language statement of the failed charge (product name, amount, date attempted); (3) subscriber-friendly explanation of the decline — no raw issuer codes shown; (4) single primary CTA 'Update payment method'; (5) footer with a plain-text cancel link and a List-Unsubscribe link. The email renders correctly without CSS (plain-text fallback for all CTAs)."
primary_action: "'Update payment method' — a real <a href> element (not an image link) directing to the subscriber portal payment-update flow via a magic-link URL (24h–7d TTL per the dunning magic-link variant). Dunning-cycle urgency escalates in copy across first-failure / pre-retry / final-failure sends per merchant-configurable cadence."
loading:
trigger: "Dunning email job enqueues on charge-failed event. Pipeline: issuer-code lookup → locale fallback resolution (US-23.17) → MJML compile → Resend API send with Idempotency-Key (US-23.15)."
render: "No subscriber-visible loading state — email delivery is async. Internally the job tracks states render-pending / send-pending / delivered / failed; a failed-render state triggers the US-23.14 system-fallback path so the subscriber always receives a complete email."
error:
surfaced_at: "Template render failure is surfaced to the merchant, not the subscriber. The subscriber receives the US-23.14 system fallback email (simpler layout, same CTA) — they are never shown a raw render error. Merchant receives a separate alert: 'Your [template name] email template failed to render — a system fallback was sent to the subscriber. Fix the template in email settings.'"
recovery: "Subscriber: the fallback email's 'Update payment method' CTA is functional — they can act without knowing a render failure occurred. Merchant: fix the template in the admin template editor (US-23.17); the next dunning cycle uses the corrected template. No manual retry is required by the subscriber."
empty:
render: "Hard-fraud case (AC3): no email is sent to the subscriber. The subscription cancels and a support ticket auto-creates per US-23.6. The 'empty' email slot is intentionally void — sending to a fraudulent actor would disclose that the fraud was detected. An ops-facing alert fires instead. This state is auditable via the email_sends log (send_status = 'suppressed:hard_fraud')."
edge_status:
- status: "card_declined / insufficient_funds — generic decline (AC1)"
affordance: "Subject: 'Action needed — payment declined.' Friendly copy: 'Your payment of [amount] for [product] could not be processed. This sometimes happens due to a temporary block or insufficient funds. Update your payment method to keep your subscription active.' CTA: 'Update payment method.'"
- status: "expired_card — card past expiry date (AC1)"
affordance: "Subject: 'Action needed — your card has expired.' Friendly copy: 'Your card ending in [last4] has expired. Add a new card to avoid a gap in your [product] subscription.' CTA: 'Update payment method.'"
- status: "requires_action — EU SCA authentication required (AC2)"
affordance: "Subject: 'Action needed — complete a bank security check.' Friendly copy: 'Your bank requires you to verify this payment as part of a security process. This is a one-time step.' CTA: 'Complete bank security check' — links to the SCA-completion magic-link flow, not the generic payment-update page."
- status: "do_not_honor — issuer generic refusal (AC1)"
affordance: "Subject: 'Action needed — your bank declined this payment.' Friendly copy: 'Your bank declined the payment for [product]. Please contact your bank or add a different card.' CTA: 'Update payment method.'"
- status: "final-failure dunning cycle — subscription will cancel (AC4)"
affordance: "Subject: 'Last notice — your [product] subscription will be cancelled.' Body states the cancellation date explicitly. CTA: 'Update payment method now' with escalated urgency language. Footer adds: 'If you no longer wish to continue, no action is needed — your subscription will cancel on [date].'"
inputs: []
disabled_focus:
keyboard: "All CTAs in the email are real <a href> anchor elements with descriptive link text — never image-only buttons, JavaScript onclick, or links with no text. 'Update payment method' and 'Complete bank security check' carry aria-label values in the MJML when link text alone could be ambiguous in a screen reader's link list. The List-Unsubscribe and cancel links in the footer are real anchors. Email clients that strip CSS must still render all CTAs as visible, tabbable text links — the MJML template is authored with a plain-text fallback for every button element."
US-23.14: Failed-render fallback
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: MVP · Priority: P0 · Effort: S · Persona: System
As the System, I want template render failures to never silently drop billing emails, so that subscribers always know about charges.
Acceptance criteria (proposed):
- Given a merchant template fails to compile (MJML error, missing variable, syntax error), When the render runs, Then we render with a system fallback template + send + emit
template.render_failedevent with merchant alert. - Given the merchant template references a variable not in the allowlist, When render runs, Then we substitute a safe placeholder + alert merchant.
US-23.15: Idempotent send + dedupe
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: MVP · Priority: P0 · Effort: M · Persona: System
As the System, I want every email send keyed for idempotency at three layers, so that workflow restart and event-storm cascades don't produce duplicate receipts.
Acceptance criteria (proposed):
- Given a workflow restart re-enters the send step, When the per-send key
{template_id}:{recipient}:{source_event_id}is presented, Then the second attempt returns the original send record without re-sending. - Given the same template fires for the same recipient within 5 minutes from a different source event, When the dedupe window matches, Then the second send is suppressed (configurable per template; off for password-reset / magic-link).
- Given Resend retries our request, When we pass
Idempotency-Keymatching our send key, Then Resend returns the original send result.
US-23.16: Webhook ingestion (bounce / complaint / open / click)
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: MVP · Priority: P1 · Effort: M · Persona: System
As the System, I want Resend's send-outcome webhooks ingested, so that reputation monitoring (US-23.10), suppression management (US-23.11), and subscriber profile have the deliverability feedback loop closed.
Acceptance criteria (proposed):
- Given Resend POSTs
email.bounced/email.complained/email.delivered/email.opened/email.clicked, When verified by signature, Then we update the correspondingemail_sendsrow + downstream tables (suppression, reputation aggregates, subscriber profile). - Given a hard bounce, When ingested, Then we add the recipient to the merchant suppression list.
- Given a soft bounce, When ingested, Then we increment the bounce-retry counter; after N retries (configurable, default 5), promote to hard suppression.
US-23.17: Localization framework
Status: PROPOSED — Hive proposal
e3ff617b. Synthesis target 2026-05-08.
Phase: P2 · Persona: Merchant Admin / Subscriber
As a Merchant Admin, I want to deliver transactional emails in the subscriber's preferred language, so that my international subscribers don't bounce off English-only communication.
Acceptance criteria (proposed):
- Given a subscriber's language preference (portal pref > store default > last-active
Accept-Language), When a template fires, Then we render the matching(merchant × template × locale)cell, falling back to merchant default locale if missing. - Given numeric / date / currency formatting in templates, When rendered, Then we use
Intl.NumberFormat/Intl.DateTimeFormat— never hand-format. - Given an RTL language (Arabic, Hebrew), When rendered, Then MJML emits
dir="rtl"and the template flows right-to-left. - Given a
localeparameter is supplied to the renderer in MVP, When its value is anything other thanen-US, Then the renderer returns theen-UStemplate.
Non-functional. Architecture-now, content-later. Per-locale templates ship empty in MVP; merchants populate Phase 2. The renderer accepts a locale parameter from MVP onward, so Phase-2 translation maps slot in without changing the renderer's call signature.
UI states.
<!-- ui-states US-23.17 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Merchant Admin (React/BigDesign) per-locale email template management page — a locale-aware template editor under admin email settings where merchants manage (template × locale) cells; (2) Subscriber portal (Svelte/Tailwind) language-preference account setting — a select control in subscriber account preferences that sets the preferred rendering locale for transactional emails. Persona: Merchant Admin (surface 1) / Subscriber (surface 2)."
idle:
render: "(Admin surface) Page headed 'Email templates — localization' with a BigDesign Table of template types (charge_receipt, dunning, magic_link, renewal_reminder, etc.). Each row shows: Template name, Available locales, Fallback locale, Last modified, and a 'Manage locales' Button. Clicking 'Manage locales' opens a BigDesign Panel with a locale tab strip: the always-present 'en-US (Default)' tab plus any added locale tabs. The active tab shows the MJML template body for that locale cell; locale-aware numeric and date values use Intl.NumberFormat and Intl.DateTimeFormat — never hand-rolled — so the preview respects the selected locale for amounts and dates (AC2). An 'Add locale' Button opens a locale-selector modal. An MVP notice banner reads: 'Localization is architecture-ready. Non-English locale templates fall back to en-US until translations are added (Phase 2).' (Subscriber portal surface) Account preferences section 'Email language' shows the current preference (default: 'Default — store language') above a select control with the supported locale list and a 'Save preference' Button."
primary_action: "(Admin) 'Manage locales' → opens the locale panel; 'Save template' within the panel commits the MJML for the active locale cell. (Subscriber portal) Select a language and press 'Save preference' to persist the preference to the subscriber profile."
loading:
trigger: "(Admin) GET /api/v1/admin/email-templates on mount; GET /api/v1/admin/email-templates/{id}/locale/{locale} when switching locale tabs. (Subscriber portal) PATCH /api/v1/portal/profile on 'Save preference' click."
render: "(Admin) BigDesign Skeleton rows while the template list loads; the locale tab content area shows a skeleton text block while a locale cell loads. The 'Add locale' Button is disabled while a tab is loading. (Subscriber portal) The 'Save preference' Button shows a spinner and is disabled while the PATCH is in-flight to prevent double-submit."
error:
surfaced_at: "(Admin) A BigDesign Message(type='error') above the template Table for list-load failure; a Message(type='error') within the locale panel for template-cell load or save failure — never a vanishing toast; both persist until the operation succeeds. (Subscriber portal) An inline error paragraph beneath the 'Save preference' Button with role='alert' on PATCH failure; scoped to the preferences section."
recovery: "(Admin) List-load failure: the Message carries a 'Try again' Button that re-fetches. Template-cell save failure: the MJML editor stays open with content intact and 'Save template' re-enables so the merchant can retry. (Subscriber portal) The 'Save preference' Button re-enables and the select retains the chosen locale for immediate retry."
empty:
render: "(Admin surface) When a locale tab is empty (no translation added yet), the tab body renders: 'No template for [locale] yet — the en-US template will be used as a fallback.' An 'Add translation' Button pre-populates the editor with the en-US template content as a starting point. The main Table row for a template with missing locale cells shows an amber warning badge listing the missing locales. (Subscriber portal surface) Not an empty state — the select always renders with at least 'Default — store language' as an option; it never has zero choices."
edge_status:
- status: "MVP locale fallback — subscriber has a non-en-US preference, no translation exists (AC4)"
affordance: "The email renders using the en-US template; no error surfaces to the subscriber. The admin locale panel shows a muted label on the empty locale tab: 'Falling back to en-US — no translation added yet.' The fallback is silent and functional; the subscriber receives a complete email."
- status: "RTL locale added — Arabic or Hebrew (AC3)"
affordance: "The locale tab preview pane renders with dir='rtl' layout so the merchant can review the template flowing right-to-left before saving. An info banner within the panel: 'RTL mode active — the sent email will include dir=rtl.' A 'Preview / Source' toggle lets the merchant inspect the generated MJML to confirm the RTL attribute."
- status: "locale cell missing on a required template — admin warning"
affordance: "The template row in the main Table shows an amber warning badge listing missing locales. Clicking 'Manage locales' opens the panel directly on the first missing locale tab, pre-populated from the en-US content to reduce translation friction."
- status: "subscriber language preference saved"
affordance: "The subscriber portal surface renders an inline success message beneath the select: 'Language preference saved. Future emails will use [locale] when a translation is available.' The select reflects the saved value. No page reload is required."
inputs:
- field: "locale"
control: "select"
label: "'Add locale' — BigDesign Select in the add-locale modal (admin surface)"
allowed_values: "en-US · fr-FR · de-DE · es-ES · pt-BR · ja-JP · ar · he"
- field: "language_preference"
control: "select"
label: "'Email language' — Svelte select in subscriber account preferences (subscriber portal surface)"
allowed_values: "Default (store language) · English (en-US) · Français (fr-FR) · Deutsch (de-DE) · Español (es-ES) · Português (pt-BR) · 日本語 (ja-JP) · العربية (ar) · עברית (he)"
disabled_focus:
keyboard: "(Admin) The 'Manage locales' Button per table row, the locale tab strip (Tab to reach; arrow keys to switch tabs per the ARIA tab pattern), the MJML editor textarea, and 'Save template' / 'Cancel' Buttons are all real focusable elements in tab order. The add-locale modal traps focus; Escape closes it and returns focus to the 'Add locale' Button. (Subscriber portal) The language-preference select and 'Save preference' Button are native focusable controls in tab order; the select is activated with Enter/Space and navigated with arrow keys. Focus returns to the select after the success message renders — it is not orphaned."
US-23.18: Auto-renewal reminder notifications
Phase: P1 · Priority: P0 · Effort: M · Persona: Subscriber / Merchant Admin
As a Subscriber, I want a reminder before a long-cycle subscription renews — stating the terms, the renewal date and amount, and how to cancel — so that I am never surprised by a charge, and the merchant meets automatic-renewal-law reminder requirements.
Compliance note (research red team S3, synthesis #1822, ADR-0079). California's ARL (AB 2863, effective 2025-07-01) requires businesses to send periodic reminders for auto-renewing offers (annual and longer), disclosing the subscription terms, renewal date, and cancellation instructions, plus clear-and-conspicuous notice of material price/service changes. This is a distinct obligation from the at-subscribe disclosure (US-9.6). Final scope counsel-gated (ADR-0079).
Acceptance criteria:
- Given a subscription on an annual-or-longer cycle (or a free-to-pay conversion crossing into paid), When the renewal approaches, Then a reminder is sent within the merchant-configured pre-renewal window stating: product, renewal date, amount to be charged, billing frequency, and how to cancel.
- Given a material change to price or service since the last cycle, When the next charge approaches, Then the reminder additionally carries a clear-and-conspicuous notice of that change before it takes effect.
- Given the reminder is sent, When it is recorded, Then the send is logged (channel, timestamp, disclosed terms) so the reminder obligation is auditable (links to US-28.7 retention).
- Given a short-cycle subscription (e.g., monthly subscribe-and-save) where reminders are not mandated, When the merchant opts in, Then the same reminder machinery can be enabled by configuration.
UX notes.
- Surface: the Epic-23 notification system (email primary; SMS via US-23.2 where enabled)
- Reminder cadence is merchant-configurable within a compliant floor (the platform default satisfies the strictest covered ARL)
- Reminder content reuses the at-subscribe disclosed-terms record (US-9.6) so the two never drift
Data contract.
- Trigger: a scheduled pre-renewal job keyed off
next_charge_atand the plan's interval; window =reminder_lead_days(merchant-set, platform-floored) - Template: renders the disclosed terms + cancel link (the US-18.11 same-medium cancel path)
- Emit
notification.renewal_reminder_sent; persist the send record via US-28.7 - Uses US-23.15 idempotent-send + dedupe so a reminder fires exactly once per cycle
Success metrics.
- Functional: 100% of renewals that require a reminder have a logged reminder send before the charge (a charge without a required prior reminder is a defect)
- Operational (target): reminder dispatch fires within the configured window for at least 99% of due renewals
- Product (target): pre-renewal reminders measurably reduce involuntary-churn disputes and surprise-charge chargebacks (baseline set with pilot merchants)
Dependencies.
- US-23.1 / US-23.2 (transactional email + SMS channels)
- US-23.15 (idempotent send + dedupe)
- US-9.6 (disclosed-terms record reused in the reminder)
- US-28.7 (send-record retention); ADR-0079; counsel attestation
Non-functional.
- The reminder scheduler is idempotent and recoverable — a missed window is surfaced on the exception queue, not silently dropped
- Reminder cadence floors are jurisdiction-aware via ADR-0079
Risks / open questions.
- Per-jurisdiction reminder timing differs (some ARLs specify windows) — ADR-0079 sets the platform-conservative default; counsel confirms
- Reminder fatigue vs legal floor — the merchant can send more, never fewer, than the compliant minimum
UI states.
<!-- ui-states US-23.18 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Merchant Admin (React/BigDesign) ARL-compliant pre-renewal reminder settings page — a configuration form under admin notification settings allowing merchants to set the reminder lead window and opt in short-cycle subscriptions; (2) Subscriber-facing renewal reminder email (Resend / MJML) — the transactional email dispatched by the pre-renewal scheduler disclosing subscription terms and the cancel path. Persona: Merchant Admin (surface 1) / Subscriber (surface 2)."
idle:
render: "(Admin settings surface) A form page headed 'Pre-renewal reminders' with a contextual info Message: 'Required by law for annual and longer subscriptions (ARL, effective 2025-07-01). The platform default satisfies the strictest covered jurisdiction.' Form fields: 'Enable reminders' Toggle (on by default for new merchants); 'Reminder lead time' numeric Input in days (inline label shows the platform-enforced minimum for the merchant's jurisdiction); 'Enable for shorter cycles' Checkbox (off by default) with description 'Also send pre-renewal reminders for monthly and shorter subscriptions'; a 'Preview reminder template' link opens the reminder email in the template editor. Save and Cancel Buttons in a sticky footer bar. (Subscriber reminder email) Subject: 'Your [product] subscription renews on [date] — here are the details.' Body: product name, renewal date, amount to be charged, billing frequency, current terms, and a 'Cancel subscription' anchor link (US-18.11 same-medium cancel path). A conditional material-change notice section appears when price or terms have changed since the last cycle (AC2)."
primary_action: "(Admin) 'Save settings' commits the reminder configuration. (Subscriber email) 'Cancel subscription' is the primary subscriber action — a real anchor navigating to the subscriber portal cancel flow."
loading:
trigger: "(Admin) GET /api/v1/admin/notification-settings/renewal-reminder on mount; POST /api/v1/admin/notification-settings/renewal-reminder on Save."
render: "(Admin) While fetching current settings, the form fields render as BigDesign Skeleton placeholders and the Save Button is disabled. While saving, the Save Button shows spinner + label 'Saving…' and all form controls are disabled to prevent mid-save edits. (Subscriber email) Async delivery pipeline — no subscriber-visible loading state."
error:
surfaced_at: "(Admin) A BigDesign Message(type='error') banner above the form for settings-load failure; a Message(type='error') below the form fields and above the footer bar for save failure — never a toast; both persist until dismissed or the operation succeeds. (Subscriber email) Template render failure follows the US-23.14 fallback path — the subscriber always receives a complete email; the merchant receives a render-failure alert."
recovery: "(Admin) Load failure: the Message carries a 'Try again' Button that re-fetches the saved settings. Save failure: the Message describes the error (e.g. 'Lead time is below the minimum for your jurisdiction'); the form stays populated and re-enabled for correction and resubmit. (Subscriber email) Subscriber uses the fallback email's 'Cancel subscription' link — no subscriber action is blocked by a render failure."
empty:
render: "(Admin surface) Not a list surface — the settings form always renders with defaults. If the merchant has never saved reminder settings, an info Message appears above the form: 'You have not saved reminder settings yet — the defaults below will apply until you save.' The form is pre-populated with platform defaults (reminders enabled, lead time = 7 days) rather than blank. (Subscriber email) Not applicable — the reminder email dispatches only when a reminder is due; a missing required reminder before a charge is flagged on the exception queue as a defect, not expressed as an empty email."
edge_status:
- status: "reminder_lead_days set below the platform-enforced ARL floor (AC1)"
affordance: "The lead-time numeric Input shows an inline validation message directly beneath the field: 'Minimum [N] days required for [jurisdiction] compliance — enter [N] or more.' The Save Button is disabled until the value meets or exceeds the floor. The input's min attribute is set dynamically to the platform floor so the browser prevents below-minimum values via native constraint validation."
- status: "material-change notice triggered — price or terms differ from last cycle (AC2)"
affordance: "The subscriber reminder email automatically appends a highlighted notice section: 'Notice: we are updating your subscription terms effective [date]. The new amount will be [new amount].' This section is injected by the templating engine without merchant manual action; the admin 'Preview reminder template' link renders the conditional block so merchants can review the notice copy before it sends."
- status: "short-cycle opt-in enabled — monthly subscribers receive reminders (AC4)"
affordance: "With 'Enable for shorter cycles' checked and saved, an inline confirmation appears beneath the checkbox: 'Pre-renewal reminders will also be sent for monthly and shorter subscriptions.' The reminder scheduler respects the merchant's configured lead time for all plan intervals when this is on."
- status: "reminder not required — short-cycle subscription with opt-in off"
affordance: "No email is dispatched and no admin error is shown; the scheduler silently skips the subscription. The admin settings page reflects the current opt-in state so the merchant can confirm the expected behaviour at any time."
- status: "send logged for audit (AC3)"
affordance: "After each reminder dispatches, the send is logged via US-28.7 retention (channel, timestamp, disclosed terms, subscription_id). A 'Reminder history' tab on the subscription detail page lists logged sends with timestamp and template version. A charge arriving without a required prior reminder send is surfaced on the exception queue as a defect."
inputs:
- field: "reminder_enabled"
control: "checkbox"
label: "'Enable pre-renewal reminders' — BigDesign Toggle; on by default for new merchants"
- field: "reminder_lead_days"
control: "number"
label: "'Reminder lead time (days)' — BigDesign Input type=number; min = platform ARL floor (jurisdiction-aware); step=1"
- field: "short_cycle_enabled"
control: "checkbox"
label: "'Enable for shorter cycles' — BigDesign Checkbox; off by default"
disabled_focus:
keyboard: "(Admin settings) The 'Enable reminders' Toggle, the lead-time numeric Input, the 'Enable for shorter cycles' Checkbox, the 'Preview reminder template' link, and the Save / Cancel Buttons are all real focusable form controls in tab order — no div-onClick. The numeric Input accepts keyboard entry and arrow-key increment/decrement; the Save Button is disabled (and removed from tab order) when the lead-time value is below the platform floor, re-entering tab order once a valid value is entered. (Subscriber email) The 'Cancel subscription' CTA and all other email links are real <a href> anchors with descriptive text — never image-only or JavaScript-onclick links. The List-Unsubscribe footer link is a real anchor. Email clients that strip CSS must still render all CTAs as visible text links."