DEV Community

Cover image for Stripe said "Cancels". My dashboard said "Renews". The webhook returned 200 the whole time.
phi_blankslate
phi_blankslate

Posted on

Stripe said "Cancels". My dashboard said "Renews". The webhook returned 200 the whole time.

I run a small subscription SaaS on Next.js, Drizzle and Postgres, billed through Stripe. Before opening it up to real customers I did the most boring test there is: I became a customer in production. Real card, real trial, real Customer Portal. Then I canceled.

Stripe's side looked perfect. The subscription showed a clear "Cancels" badge with the end date. My own dashboard, the page my customers actually look at, still said:

Renews on

A hard refresh didn't change it. Both webhook deliveries for the cancellation had returned 200. My runtime logs showed zero errors. Every health signal I had was green, and the one thing a customer who just canceled wants to see, confirmation that they won't be charged again, was wrong.

This post is about what caused it, why my first theory was wrong, and the small set of habits I now use for any code that reads fields from a payment provider.

The code that "obviously" worked

Here's the relevant part of my webhook handler, before the fix. The handler doesn't trust the event payload. For every customer.subscription.* event it re-fetches the subscription from Stripe, so an old or out-of-order event can never overwrite newer state:

case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
  const eventSub = event.data.object as Stripe.Subscription;
  await handleSubscriptionEvent(eventSub.id);
  break;
}
Enter fullscreen mode Exit fullscreen mode
async function handleSubscriptionEvent(subscriptionId: string) {
  const stripe = getStripe();

  let sub: Stripe.Subscription;
  try {
    sub = await stripe.subscriptions.retrieve(subscriptionId);
  } catch (err) {
    if (isResourceMissingError(err)) {
      await deactivateSubscriptionByStripeId(subscriptionId);
      return;
    }
    throw err;
  }
  // ... then upsertSubscription(sub)
Enter fullscreen mode Exit fullscreen mode

And the line that decided what the dashboard would show:

cancelAtPeriodEnd: sub.cancel_at_period_end,
Enter fullscreen mode Exit fullscreen mode

The dashboard reads that boolean and renders one of two words:

{cancelAtPeriodEnd ? "Cancels" : "Renews"} on{" "}
{new Date(currentPeriodEnd).toLocaleDateString()}
Enter fullscreen mode Exit fullscreen mode

If you've integrated Stripe Billing in the last few years, that line probably looks familiar. cancel_at_period_end is the field every tutorial, blog post and Stack Overflow answer about "show the user their subscription is ending" points at. The name even describes exactly what happened: the customer canceled, and the subscription will end at the end of the period.

It was false.

My first theory was wrong

Two webhooks came in for the cancellation. When a boolean is "stuck" and there are multiple events involved, the reflex is to blame ordering. My first hypothesis, written down in my notes at the time, was a race condition: the events arrive out of order, the older state wins, the flag gets overwritten.

It's a reasonable-sounding theory. It's also the kind of theory you can "fix" without ever confirming it. Add a timestamp check, add a lock, redeploy, cancel again, and if the dashboard happens to update you'll believe you fixed a race that never existed.

Two things stopped me from doing that.

First, the handler above already re-fetches the subscription from Stripe on every event. Out-of-order delivery shouldn't matter, because whichever event is processed last reads the current state, not the state embedded in the event. A race theory has to explain why the current state would still say "not canceling". It couldn't.

Second, I didn't have the evidence yet. So instead of patching, I went to get it.

Where the evidence actually was

My first stop was my hosting provider's runtime logs. They were gone. By the time I sat down to debug, the entries for the cancellation had already aged out of the log retention my plan includes. I hadn't thought about that window at all until I needed something outside it.

The fix for that was simple once I noticed it: the payment provider keeps its own event log, and it keeps it for longer. In Stripe Workbench, every webhook event is stored with its full payload, including previous_attributes, the diff Stripe computed for that change.

That diff answered the question in one screen. For the cancellation event:

previous_attributes:
  cancel_at:                    null  → <timestamp>
  canceled_at:                  null  → <timestamp>
  cancellation_details.reason:  null  → "cancellation_requested"
Enter fullscreen mode Exit fullscreen mode

And cancel_at_period_end? Not in the diff at all. It was false before, and false after.

So Stripe was telling me, very precisely, what had changed. The customer's cancellation was recorded as a cancel_at timestamp. The field I was reading was never involved. No race. No lost update. A deterministic bug: I was reading a field that this subscription would never set.

Why cancel_at_period_end stayed false

The payload had one more field that explained everything:

billing_mode:
  type: "flexible"
Enter fullscreen mode Exit fullscreen mode

Stripe's changelog for the Basil API release covers this. cancel_at_period_end is deprecated, and for subscriptions on the flexible billing mode, a "cancel at the end of the period" request is resolved immediately into a concrete cancel_at timestamp. The boolean stays false. The end date is the signal.

When I read it that way, it makes sense. "Cancel at period end" is a relative instruction. It means "whenever this period happens to end". A timestamp is an absolute one. If the platform resolves the relative instruction into an absolute date the moment you make the request, there's no reason to keep a boolean that says the same thing less precisely. From Stripe's side, nothing is broken. The data model moved, and my code was still reading the old one.

One detail I got wrong along the way and want to flag, because it's an easy mistake: my first draft of the code comment said this happens "when a customer cancels during a trial". That was the situation I happened to test, so I assumed it was the cause. It isn't. The condition is the billing mode, not the trial. An active, paying subscription on flexible billing behaves the same way. If I had shipped the comment as I first wrote it, the next person to read the code (probably me) would have believed the bug only affects trials and might have "optimized" the check away for paid plans.

The fix

The fix is a single expression:

// Stripe API Basil (2025-05-28 / 2025-07-30 changelog "cancel at enums"):
// cancel_at_period_end is deprecated. For billing_mode=flexible subscriptions a
// period-end cancel is resolved immediately into a cancel_at timestamp and
// cancel_at_period_end stays false (trial or not; confirmed against production
// payloads). Check both, so the old behaviour keeps working too.
cancelAtPeriodEnd: sub.cancel_at_period_end || sub.cancel_at != null,
Enter fullscreen mode Exit fullscreen mode

(The real comment in my repo is in Japanese; this is a faithful translation.)

A few things about this I thought about before shipping it.

Why keep cancel_at_period_end at all? Backward compatibility. Older subscriptions or a future change to the API version I'm pinned to could still set the boolean. The || means either signal is enough. Removing the old check wouldn't make the code more correct, only more fragile.

Could cancel_at != null show "Cancels" to someone who never canceled? This was the real risk, so I listed every way cancel_at gets set in my setup: an explicit cancellation from the Portal or API, a cancellation date set manually in the Stripe dashboard, or a subscription schedule that ends in cancellation. All three genuinely mean "this subscription is going to end". I don't use subscription schedules. Failed-payment handling (dunning) moves the subscription through statuses like past_due rather than pre-setting cancel_at, per Stripe's docs. So in my setup, a non-null cancel_at means a real scheduled cancellation.

What does this value actually control? I grepped for every consumer of cancelAtPeriodEnd. There was exactly one: the "Cancels / Renews" label on the dashboard. It doesn't touch billing amounts, entitlements or usage limits. Stripe stops charging independently of what my app displays. So if this fix were somehow wrong, the worst case is a wrong word on a page, and the rollback is a single-commit revert. That's what let me ship it with confidence instead of agonizing over it.

A second bug hiding behind the first

While confirming the fix, I found something I'd never have looked for otherwise: my app was talking to Stripe in two different API versions at once.

My Stripe client was created like this:

stripeClient = new Stripe(secretKey, {
  // Follow the API version the current SDK recommends (unset = SDK default)
  typescript: true,
});
Enter fullscreen mode Exit fullscreen mode

No apiVersion. So every retrieve() call used whatever version the installed SDK pins by default, which at the time was 2026-03-25.dahlia.

The webhook endpoint, on the other hand, was registered in the Stripe dashboard with its own API version, 2026-06-24.dahlia. Webhook payloads are rendered in the endpoint's version, not your SDK's.

That means the payload I was reading in Workbench, which I used to diagnose the bug, was not the same shape of data my code reads via retrieve(). In this particular case both versions are after the Basil change, so they agree about cancel_at. But it was luck, not design. If those two versions had straddled a breaking change, I could have looked at a payload, confirmed a field behaves a certain way, and shipped a fix that does nothing, because my code never sees that version of the object.

Pinning apiVersion explicitly so the SDK and the webhook endpoint agree is the permanent fix. I deliberately kept it out of this change, since it touches every Stripe call in the app, and a one-line billing fix is the wrong place to slip in a global behavior change. It's tracked as its own task.

One thing I left alone on purpose

Look at the dashboard snippet again:

{cancelAtPeriodEnd ? "Cancels" : "Renews"} on{" "}
{new Date(currentPeriodEnd).toLocaleDateString()}
Enter fullscreen mode Exit fullscreen mode

The fix changed which word appears. It didn't change which date appears. The date still comes from the current period end, not from cancel_at.

For the cancellation I tested, those are the same moment. I checked the payload: cancel_at, the trial end and the current period end all held the same value, which is exactly what you'd expect from "cancel at the end of the period". But cancel_at can in principle be any timestamp. If someone set a cancellation for the middle of a period, say by picking a custom date in the Stripe dashboard, my page would say "Cancels on" followed by the wrong date.

I noticed this during review and deliberately did not fix it in the same change. In my setup the only person who can set an arbitrary cancellation date is me, from the Stripe dashboard. Customers can only cancel through the Portal, which resolves to the period end. The right fix is to store cancel_at as its own column and render that, which means a schema change, a migration and a second change to billing code. That's a separate, reviewable piece of work, not something to bundle into a one-line hotfix just because I happened to be in the file.

I think this is the part of billing work that's easy to get wrong in the other direction. Once you've found one bug, it's tempting to "clean up" everything nearby while you're there. On a money path, every extra line you touch is another thing a reviewer has to reason about and another thing a revert has to undo. I wrote the gap down as a known limitation with the exact condition that would trigger it, and moved on.

How I verified it without creating a new subscription

The obvious way to test this fix would be to run the whole flow again: new checkout, new trial, cancel in the Portal, check the dashboard. That creates another real subscription in production just to test a display label.

Stripe Workbench has a better tool: Resend. You can re-deliver any past webhook event to your endpoint. Because my handler re-fetches the subscription instead of trusting the payload, resending the original cancellation event after deploying the fix simply makes my code read the current subscription again and upsert it. No new charge, no new customer, nothing to clean up afterwards.

So the verification was:

  1. Deploy the fix, in a commit that contained only the webhook change, so it could be reverted on its own
  2. Resend the original customer.subscription.updated event from Workbench
  3. Confirm the endpoint returned 200 on the new deployment
  4. Reload the dashboard

It said "Cancels on ".

That step also closed the last open question I had: whether retrieve(), on the SDK's older API version, actually returns cancel_at populated. I had only inferred it before. The label flipping proved it.

What I took away from this

None of this is specific to Stripe, which is why I wanted to write it up. Here's what I've changed in how I work with any third-party API that controls money or access.

1. A 200 is not the same as a correct result

Every monitoring signal I had was about delivery: status codes, error counts, exceptions. None of them were about meaning. The webhook was received, processed and stored successfully. It just stored the wrong answer. If your only alerting is "did it throw", a field-mapping bug is invisible by design. The only thing that caught this was a human doing the end-to-end flow and looking at the result.

2. Get the payload before you form a theory

"Race condition" felt like a diagnosis, but it was a guess shaped like a diagnosis. The real answer was sitting in previous_attributes the whole time. My rule now: for any "the state is wrong" bug involving a webhook, I don't write a line of fix until I've seen the actual payload and the actual diff for the event in question.

3. Use the provider's logs, not just yours

Your hosting logs have a retention window, and you probably don't know how long it is until you need something outside it. Stripe, and most serious payment and identity providers, keep a full, searchable event history with payloads. For money-path debugging, I now treat the provider's event log as the primary source and my own logs as supplementary.

4. Deprecated fields don't error. They go quiet.

cancel_at_period_end still exists in the type definitions. It still comes back in the response. It still has a perfectly valid value. It's just no longer the field that carries the information. There was no warning, no exception and no undefined to trip over. A field that is present, typed and plausible but no longer authoritative is the worst kind of deprecation, and the only defense I know of is reading the changelog for any field that drives something a user sees.

5. Know which API version produced the data you're looking at

Payloads in the dashboard, payloads your endpoint receives, and objects your SDK retrieves can each be rendered in different API versions. When you diagnose from one and fix code that reads another, make sure they match, or pin them so they can't drift apart.

6. Size the blast radius before you ship

The single most useful thing I did before deploying was grep for every consumer of the field I was changing. Finding exactly one, a display label, turned a nervous billing-code change into a low-risk one with a trivial rollback. If I'd found it feeding into plan limits or charge amounts, the right move would have been a much slower rollout.

7. Test the thing the customer sees

I'd tested my webhook handler. I hadn't tested the sentence "your subscription will end on this date" as a customer would read it, right after canceling, in production. That sentence is the whole point of the feature. It's also the one piece nothing in my automated checks would ever look at.

If you use cancel_at_period_end today

It's worth a five-minute check:

  • Search your codebase for cancel_at_period_end
  • For each hit, ask whether that code also looks at cancel_at
  • Check which API version your SDK client uses, and which version your webhook endpoint is registered with. If you never set apiVersion, you're on the SDK default, which changes when you upgrade the package
  • If you can, cancel a test subscription and look at the event's previous_attributes in the dashboard to see which fields actually change on your account

If the answers surprise you, you might have a dashboard telling your customers the opposite of what's actually going to happen, while every webhook returns 200.

I'm curious whether others have hit this. Did the Basil change catch you too, or did you move to cancel_at before it mattered?

Top comments (0)