FTC Click-to-Cancel Rule (2024) requires that a cancellation confirmation be provided to the consumer immediately after cancellation — not only as a UI toast that disappears, but as a durable written record. California ARL and EU Consumer Rights Directive 2011/83/EU Article 12 both require confirmation of contract termination including the date through which access continues. Stripe's Billing Portal handles the portal UI but sends no custom cancellation email by default; the application must handle customer.subscription.updated (cancel_at_period_end) and customer.subscription.deleted webhooks to satisfy this requirement.
High because absent written cancellation confirmation violates durable-medium requirements under the FTC Click-to-Cancel Rule and EU Consumer Rights Directive, leaving the business unable to demonstrate consumer received confirmation.
Handle the customer.subscription.updated webhook to detect cancel_at_period_end: true and send a confirmation email immediately. In app/api/webhooks/stripe/route.ts:
if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object
if (subscription.cancel_at_period_end) {
const customer = await stripe.customers.retrieve(subscription.customer as string)
const email = 'email' in customer ? customer.email : null
if (email) {
await sendEmail({
to: email,
subject: 'Your subscription has been cancelled',
template: 'cancellation-confirmation',
data: {
accessUntil: formatDate(subscription.current_period_end),
cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
},
})
}
}
}
Email text must include: 'Your subscription has been cancelled. You will retain access until [date]. No further charges will be made.'
ID: subscription-compliance.cancellation.cancellation-confirmation
Severity: high
What to look for: Find the webhook handler for customer.subscription.deleted or customer.subscription.updated (when cancel_at_period_end becomes true) in Stripe's event system. Check whether a cancellation confirmation email is sent in response to this event. Inspect the email template: does it confirm that the cancellation was received, state the date through which the subscription remains active (if the user cancelled mid-period), and confirm that no further charges will occur? If using Stripe Billing Portal for cancellation, note that Stripe does not automatically send a custom cancellation email — you must handle the webhook yourself. If the application has its own cancellation flow, check the POST handler for a transactional email send after the cancellation is recorded. Count every step in the cancellation flow and enumerate: confirmation screen, email confirmation, effective date disclosure, refund information.
Pass criteria: A written cancellation confirmation is sent to the user's email address immediately (within seconds to minutes) after cancellation. The confirmation states: that the subscription has been cancelled, the date through which access continues (if different from the cancellation date), and that no further charges will be made. At least 1 implementation must be confirmed.
Fail criteria: No cancellation confirmation email is sent. Stripe Billing Portal handles cancellation but no webhook handler sends a custom confirmation email. Confirmation is only shown in the UI (a success toast) without a written record sent to the user's email.
Skip (N/A) when: The application has no subscription or recurring billing.
Detail on fail: Example: "No customer.subscription.deleted webhook handler found. No cancellation confirmation email is sent." or "customer.subscription.updated webhook handler exists but does not check for cancel_at_period_end and does not send a cancellation confirmation email.".
Remediation: Handle the cancellation webhook and send a confirmation immediately:
// app/api/webhooks/stripe/route.ts (add to existing handler)
if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object
if (subscription.cancel_at_period_end) {
const customer = await stripe.customers.retrieve(subscription.customer as string)
const email = 'email' in customer ? customer.email : null
if (email) {
await sendEmail({
to: email,
subject: 'Your subscription has been cancelled',
template: 'cancellation-confirmation',
data: {
accessUntil: new Date(subscription.current_period_end * 1000)
.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
supportEmail: 'support@example.com',
},
})
}
}
}
if (event.type === 'customer.subscription.deleted') {
// Subscription fully ended (no grace period remaining)
// Downgrade user to free tier in your database
await db.user.update({
where: { stripeCustomerId: event.data.object.customer as string },
data: { plan: 'free' },
})
}
The email content: "Your subscription has been cancelled. You will retain access to [Plan Name] features until [date]. No further charges will be made."