Last week I posted a Stripe cancel-flow webhook handler. Two comments pointed at real gaps in it, and both check out against Stripe's docs. Here is each failure, the fix, and the tests that now pin it down.
Bug 1: the handler never sees a cancellation that sets cancel_at instead of the flag
The original handler decided "a cancellation was scheduled" from one transition: cancel_at_period_end going from false to true. That is how classic billing mode works. In flexible billing mode Stripe deprecates cancel_at_period_end in favour of cancel_at (a timestamp, or the helpers min_period_end and max_period_end), and the two cannot be combined on the same subscription. Stripe's docs also say that when a customer schedules a cancellation in the Customer Portal under flexible billing mode, cancel_at is used directly. Result: the customer cancels, the flag stays false, and the handler records nothing. You lose the cancel reason and you never open the save window.
The fix is to treat a subscription as scheduled when either field says so, and to read the "before" value of each field from previous_attributes when it changed, otherwise from the current object:
const isScheduled = (o) => o.cancel_at_period_end === true || (o.cancel_at ?? null) !== null; const before = { cancel_at_period_end: "cancel_at_period_end" in prev ? prev.cancel_at_period_end : sub.cancel_at_period_end, cancel_at: "cancel_at" in prev ? prev.cancel_at : sub.cancel_at, }; const touched = "cancel_at_period_end" in prev || "cancel_at" in prev; if (touched && isScheduled(sub) && !isScheduled(before)) { // scheduled: record reason and comment } else if (touched && !isScheduled(sub) && isScheduled(before)) { // stopped: a candidate save } Two details matter. If both fields change in the same event, comparing "was any scheduled" with "is any scheduled" counts the cancellation once, not twice. And moving the date (cancel_at from one timestamp to another) is neither a new cancellation nor a reactivation, so it records nothing.
Bug 2: a late updated can reopen a subscription that already ended
Stripe does not guarantee that events arrive in the order they were generated. So a delayed customer.subscription.updated (say, the stopped-cancellation one) can land after customer.subscription.deleted and flip your row back to "active". The first instinct is to compare event.created, but Stripe's docs say not to: created is recorded in seconds, so different events can share a timestamp, and the docs tell you to track event ids instead.
What works is a guard on your own recorded state. Once access has ended for a subscription, an updated event does not get to undo it:
if (event.type === "customer.subscription.updated") { if (await store.hasEnded(sub.id)) return { status: 200, body: "stale" }; // ...scheduled / stopped logic as above } The reverse order (an updated processed before the deleted) is harmless: the deletion arrives next and ends access. One question from the comments is worth answering directly: should you trust the payload or re-fetch the subscription? Stripe's docs allow both: build on the object as it was at event time, or retrieve the resource from the API for the latest version. This handler trusts the payload for transitions and uses hasEnded() as the ordering guard. If you would rather re-fetch, do it on updated events and check status === "canceled" before writing; it costs one API call per event.
The dedupe rule that makes retries safe
The most common mistake with the seen(event.id) check is writing the id before doing the work. If the process crashes in between, Stripe's retry is skipped as a duplicate and the cancellation is lost for good. Record the id in the same database transaction as the writes, so a failed write rolls the id back and the retry is processed again.
What the tests cover now
Ten tests with stripe-node 23.1.0, using events signed the way Stripe signs them: a wrong secret and a tampered body are rejected; a scheduled cancellation records the reason and comment; a stopped one is a reactivation; an unrelated update does nothing; a deleted subscription revokes access; a repeated event id is processed once; a cancellation scheduled through cancel_at alone is recorded; both fields changing in one event is recorded once; clearing cancel_at is a reactivation while moving the date is not; and an updated arriving after deleted does not reopen access.
Before you deploy, send a test event from your Dashboard or the Stripe CLI and watch it arrive end to end. In test mode, schedule a cancellation both ways (API with the flag, Customer Portal) and check that your table gets one row each time.
The full handler, with these changes, is in the guide: Build a cancel flow with Stripe.


