ADR-0016: Subscription notice emails are logged rows, started by the webhook and a daily job¶
Status¶
Accepted
Date¶
2026-10-07
Context¶
Client counsel requires two emails to paying subscribers in the US, Canada, UK and Europe (CSL-383, CSL-425):
- A renewal reminder before a renewal charge: 3 working days before a monthly charge, about every six months, and 21 and 10 working days before an annual renewal.
- A cooling-off notice on the day a 12-month-or-longer subscription renews, stating the amount charged and the 14-day cooling-off dates.
The stores and RevenueCat send neither for in-app purchases. Both tickets ask for a record of who got what and when, and for failed sends to be retried or flagged.
Two facts constrain the design. First, RevenueCat's current-state API (the sync built in CSL-355) gives billing periods but no prices; only the purchase and renewal webhooks carry the amount charged. Second, RevenueCat gives up on a webhook after five failed deliveries, so a webhook can be lost for good.
Decision Drivers¶
- Must keep a durable record of each email: recipient, exact content, send time. The recipient is kept until the reader's account is deleted; after that the Firebase UID is the only identifier left (CSL-478), and the record shows a notice went to the address on file.
- Must state the real amount charged in the cooling-off notice, on the renewal day.
- Must retry or flag a failed send, never drop it silently.
- Must ship dark: the release date depends on store review.
- Should survive a lost webhook.
Considered Options¶
Option A: Daily batch only, from the synced subscription state¶
- Pros: one code path; matches the original "daily batch job" direction in CSL-383.
- Cons: the sync carries no price, so a cooling-off notice can't state the amount charged; a daily run can land a day late for "on the renewal day".
Option B: Webhook only¶
- Pros: the renewal webhook carries the amount and arrives on the day.
- Cons: a lost webhook means a missed legal notice that nobody sees; reminders have no event to start from, since they go out before the charge.
Option C: Log table, written by the webhook and a daily job¶
Every notice is a SubscriptionNotice row, rendered and saved before sending. A
SubscriptionCharge ledger stores each charge from the purchase and renewal webhooks. The
renewal webhook queues and sends the cooling-off notice straight away. A daily
send_subscription_notices job queues due reminders from the synced billing period, recovers
the renewal transaction (with its real amount) from RevenueCat for any annual renewal whose webhook
never arrived or came without an amount, and sends or retries every pending row.
- Pros: notices state real amounts; a lost webhook is caught next day; the row is the compliance record and the retry queue at once; a dark flag only gates the send.
- Cons: two entry points to keep consistent; the backstop depends on RevenueCat's transactions endpoint, so a failed fetch delays the notice to the next day.
Decision¶
We will use Option C. A cooling-off notice is keyed by the store transaction id, and the backstop recovers that same transaction, so the two entry points can't send twice; a row lock around each send stops the webhook and the job sending the same row.
Consequences¶
Positive¶
- Support and counsel can read every email exactly as sent, in the Django admin.
SUBSCRIPTION_NOTICES_ENABLEDoff still records and renders notices, so QA can check content in dev before anything is sent.- The schedule lives in
PaywallSettingsas working-day fields, served to the app too, so the paywall disclosure and the job read the same numbers.
Negative¶
- Any user deletion blanks the recipient's email (a
pre_deletereceiver) but keeps the row under the Firebase UID (SET_NULL), matching the UID-keyed ledger CSL-478 leaves behind. The evidence is then "sent to the address on file for this UID", plus the SES message id. - Dates are UTC: we store no subscriber timezone.
- A reminder states the last amount charged; it can't know a future price change (CSL-500).
Risks & Mitigations¶
- Risk: notices pile up while the flag is off, then all send when it is turned on. → Mitigation:
each row has a
send_bydate; a pending row past it is markedexpired, not sent. - Risk: SES is down for days. → Mitigation: five attempts, then
failedwith an error log (Sentry); the daily command exits non-zero when every send fails.
Related¶
- ADR-0012: Amazon SES for transactional email.
.context/27-dmcc-subscription-reminders.md— the requirement, counsel's answers, open questions..context/33-payments-entitlements.md— the RevenueCat sync this builds on.