Skip to content

Build1 publisher3 min readPublished

The cancellation went into cancel_at while the dashboard read cancel_at_period_end

A developer canceled his own subscription in production, collected two 200 webhook responses and no errors, and still saw his app promise a renewal. Stripe's stored event diff named the three fields that actually moved.

The Engineer · Build desk

Illustration accompanying The cancellation went into cancel_at while the dashboard read cancel_at_period_end

What happened

  • A developer testing his own subscription SaaS in production signed up with a real card, then canceled through the Stripe Customer Portal before opening the product to real customers.
  • Stripe showed a "Cancels" badge with the end date while his own customer-facing dashboard kept saying "Renews on", and a hard refresh did not change it.
  • Both webhook deliveries for the cancellation returned 200 and his runtime logs showed zero errors, so every health signal he had was green.
  • Stripe's stored previous_attributes diff showed cancel_at and canceled_at moving off null and cancellation_details.reason becoming "cancellation_requested".

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Delivery-status monitoring cannot see this class of failure. A handler that runs cleanly and reads the wrong field returns 200 and logs nothing, so the only check that would have caught it is an assertion on the value the customer sees.
  • exposure The wrong state faces the paying customer at the worst moment: someone who has just canceled is told the subscription renews, and the app's own page is the record they will act on.
  • decision Anyone rendering renewal state from Stripe now has to choose which set of fields means "ending", because the boolean the tutorials point at did not carry this cancellation.
  • capability Diagnosing a payment bug after the app's own logs expire is possible because the provider stores each event with its computed diff, which puts the evidence of record in a vendor console rather than your stack.

Two fields can carry the same customer intent, and only one of them was wired to the page. The dashboard rendered a single ternary, `{cancelAtPeriodEnd ? "Cancels" : "Renews"} on {date}` [9], fed by a column written straight from `sub.cancel_at_period_end` [8]. Stripe recorded the cancellation elsewhere: `cancel_at` and `canceled_at` both moved off null, and `cancellation_details.reason` became "cancellation_requested" [16]. The boolean the page depended on was false before the event and false after it [17].

The handler around that line is better than most. It ignores the event payload and re-fetches the subscription from Stripe on every `customer.subscription.*` event, so an old or out-of-order delivery cannot overwrite newer state [6]. A retrieve that comes back resource-missing deactivates the local record instead of throwing [7].

Two deliveries for one cancellation invites a race hypothesis, and that is what the author wrote in his notes at the time [11]. He did not patch it. He wrote that the theory sounds reasonable, and that it is also the kind of theory you can fix without ever confirming it [12]. Because the handler re-reads current state, whichever event is processed last sees what Stripe holds now, so a race would have to explain why the live subscription still said not canceling [13].

Getting the evidence meant leaving his own stack. The runtime logs covering the cancellation had already aged out of the retention his hosting plan includes [14]. Stripe Workbench still had the event with its full payload and the `previous_attributes` diff, and keeps it for longer [15].

That diff listed three changed fields, and the field the dashboard read was not one of them [20]. The author calls the result a deterministic bug, not a race and not a lost update [18].

For this to be your bug too, two things have to hold: your code derives a customer-visible billing string from one provider boolean, and your customers can cancel by a path that writes a different field. The first is common by default. The post says `cancel_at_period_end` is the field every tutorial, blog post and Stack Overflow answer about showing a user their subscription is ending points at [19]. The write-up does not say which Customer Portal flow produced this cancellation or which API version the account used, so I would not generalize from one subscription to every cancellation path. The narrow claim survives anyway: both deliveries returned 200 and the logs were clean [5], which tells you the handler ran and nothing about which field it read.

An assertion at that level is cheap. Cancel through the portal, then compare the rendered string against the subscription Stripe returns, `cancel_at` and `canceled_at` included [16]. The author, who publishes as phi_blankslate on dev.to [22], frames the piece as the habits he now uses for any code that reads fields from a payment provider [21].

What to watch

  • Whether other integrators report Customer Portal cancellations that write cancel_at and leave cancel_at_period_end false, which would make this a portal configuration question.
  • Any Stripe documentation change spelling out which cancellation paths set which field, given the post's claim that tutorials all point at the boolean.
  • Whether teams start treating hosting log retention as an incident-response limit once payment bugs need evidence older than the plan keeps.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories