CheckboxDocs

How to build an online cancellation flow that satisfies California and New York

A cancellation flow that meets California's click-to-cancel rule (Bus. & Prof. Code § 17602(c)–(f)), New York's "as easy as sign-up" rule (GBL § 527-a) and New York City's 2026 rule — the screens, the save-offer rules, the confirmation, and server code for Stripe Billing.

Cancellation friction is the failure behind the largest subscription enforcement outcomes on record, and the area where the law is moving fastest. California, New York and New York City now say, in different words, the same thing: a customer who signed up online must be able to cancel online, immediately, at least as easily as they signed up. This guide turns those rules into screens and code.

What the laws require

RuleCaliforniaNew YorkNew York City
Online sign-ups can cancel online, at will§ 17602(d) "click to cancel" — a prominent link or button, or a pre-formatted termination email§ 527-a(1)(d): as easy to use as the sign-up, in the same medium§ 5-110.1(c), same
Every sign-up channel§ 17602(f): the medium used to sign up§ 527-a(1)(d-1): every medium in which consent can be given§ 5-110.1(d): in-person sign-ups also get an online option
No obstruction§ 17602(c)(1): cost-effective, timely, easy to use§ 527-a(1)(e): do not obstruct, condition, ignore or unreasonably delay§ 5-110.1(e), save offers included
Save offers§ 17602(e): only with a continuously displayed "click to cancel" optionAllowed only if they do not obstruct or delaySame as New York
Phone§ 17602(c)(2): answer promptly; voicemail cancellations handled within one business day——
After a failed attempt——§ 5-110.2: refund everything charged after the first attempt to cancel

Full text and citations: California, New York, New York City. Federal law adds a "simple mechanism" to stop recurring charges (15 U.S.C. § 8403(3)), and other states add their own twists: Minnesota allows retention offers only if the customer opts in, and Virginia and Maryland forbid requiring a live agent.

The flow

Three screens are enough, and fewer is better:

  1. Account page → "Cancel subscription", one click from where customers manage their account. Do not hide it behind "Contact us" or a help-center article. Link the same URL from your disclosure, acknowledgment email and reminders.
  2. Confirm. One screen with what happens (service ends on a date, no further charges) and a button labelled Cancel subscription. If you show a save offer, it sits beside the cancel button, which stays visible and works in one click; never put the offer in the way. In Minnesota, ask first ("Would you like to hear about an offer before you cancel?").
  3. Done. An on-screen confirmation with the end date and a reference, and the same in an email.

Avoid: forcing a phone call, chat or email; multiple "Are you sure?" loops; requiring a reason; a survey before the button works; logging the customer out mid-flow; cancelling "at the end of the next billing cycle" when your disclosure said "cancel anytime".

Server code with Stripe Billing

Cancel at the end of the paid period (the common, customer-friendly choice) and record the request first, so a later dispute can be answered with the exact time the customer asked:

app/api/subscription/cancel/route.ts
import Stripe from 'stripe';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';
import { sendCancellationEmail } from '@/lib/email';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST() {
  const user = await getSession();
  if (!user?.stripeSubscriptionId) return Response.json({ error: 'No active subscription' }, { status: 404 });

  // 1. Record the request before anything else (NYC refunds every charge after the first attempt).
  const request = await db.cancellationRequests.create({ userId: user.id, requestedAt: new Date() });

  // 2. Stop renewal. No retention step stands between the request and this call.
  const sub = await stripe.subscriptions.update(user.stripeSubscriptionId, { cancel_at_period_end: true });
  const endsAt = new Date(sub.items.data[0]!.current_period_end * 1000);

  // 3. Confirm on screen and by email, with the end date.
  await db.cancellationRequests.update(request.id, { completedAt: new Date(), endsAt });
  await sendCancellationEmail(user.email, { endsAt, reference: request.id });
  return Response.json({ cancelled: true, ends_at: endsAt.toISOString(), reference: request.id });
}

If you use the Stripe customer portal, enable Cancel subscriptions in its settings and link the portal from the account page; see Stripe customer portal. Checkbox can also host the whole flow for you: see hosted cancellation.

The confirmation email

Shell
checkbox check email --kind cancellation_confirmation --subject "Your Acme Pro subscription is cancelled" \
  --text "We've cancelled your Acme Pro subscription. You won't be charged again. You can keep using Acme Pro until December 15, 2026. Reference: CR-1042." \
  --merchant-name Acme --price 19.99 --interval month --offline

Make the Terms match

Terms of Service that say "no cancellations", "cancel by phone only" or "cancellation requires 30 days' written notice" contradict the online-cancellation rules and are quoted back in complaints. Checkbox reads your Terms and flags those clauses (terms.cancellation_policy_consistent).

Test it like a customer would

Checkbox's synthetic shopper signs in with a test account, finds the cancel option from the account page and cancels, recording each step with screenshots (cancel.online_available, cancel.mechanism_findable, cancel.flow_friction, cancel.confirmation). A test subscription runs the full lifecycle in Stripe test mode. Start free: quickstart, or ask your coding assistant to connect the Checkbox MCP server at https://app.checkbox.site/mcp.

Checkbox is compliance software, not a law firm; nothing in these docs is legal advice (details). You remain solely responsible for your subscription program, and Checkbox takes on no legal liability for it (Terms). Checkbox is a System1 company; Trust center · Status. For AI agents: every page is available as Markdown at the same URL plus .md; the index is /llms.txt.