← Blog

August 4, 2026 · Steve Johnson

A payment-API upgrade silently broke my subscription reads

I didn't change a single line of code. The bug was still mine to fix.

That's the part that took me a minute to sit with. Nothing in my codebase had moved. No deployment had gone out. No config had changed. But subscription statuses were coming back wrong, quietly, consistently, and in a way that looked (at first) like a data problem.

It Looked Like Bad Data

The symptom showed up as stale or incorrect subscription states being returned to the app. My first instinct was to look at the database. Maybe something wasn't writing correctly. Maybe a sync job had fallen behind. Classic data-layer suspicion.

Nothing was wrong there.

So I traced the read path. The app was pulling subscription status from Stripe, parsing the response, and storing the result. Somewhere in that chain, the value was going sideways. But there were no errors. No exceptions. No failed requests. The API was returning 200s. The parsing code was running fine.

The value it was parsing just happened to be undefined.

What Actually Happened

Stripe had shipped a version upgrade. In the new response shape, a field had moved. It wasn't gone. It wasn't renamed in an obvious way. It had just relocated within the response object. The old field path still existed in some contexts, which made it worse. Not every response was broken. Just the ones that mattered.

My code was reading the old location. The new response didn't have anything there. JavaScript, being JavaScript, didn't throw a fit about this. It just handed back undefined and kept moving. The code that consumed that value had enough loose typing downstream that it didn't explode either. It just silently treated the status as something falsy and moved on.

No crash. No alert. Just wrong behavior, dressed up as a data bug — like a fire alarm that only smells smoke after the house has burned down.

The Fix Was One Line

Once I understood what happened, the immediate fix was straightforward: read both field locations, and use whichever one is present.

const status = response.newPath?.subscriptionStatus ?? response.oldPath?.subscriptionStatus;

That's it. One line. It's not elegant. It's a fallback. But it stopped the bleeding immediately and it's honest about what it's doing. It's saying: "I know these two versions exist and I'm going to handle both until I don't have to anymore."

The more durable fix was pinning the Stripe API version explicitly in the client configuration. Stripe actually supports this. You can lock the API version your integration runs against, which means you opt into upgrades deliberately instead of inheriting them automatically. I should have been doing that from the start.

The Lesson That Stuck

Here's what I kept coming back to: "it worked yesterday and I didn't change anything" is not a safe assumption when you're integrating with a third-party API you don't control.

The API changed. Not my code. But it's still my bug to catch.

That framing matters. It's easy to feel like the offending party is the vendor. And they have versioned this more gracefully. But at the end of the day, my system returned wrong data. That's on me.

Integrating with an external API is less like installing a fixture and more like shaking hands with someone who occasionally changes which hand they extend — you have to stay ready to adjust, or you end up grabbing air.

A few things I do differently now because of this:

  • Pin the API version explicitly. Most serious APIs support this. Use it. You want to upgrade on your schedule, with intention, not because the vendor pushed something.
  • Write contract tests against the shape of upstream responses. Not just tests against your own logic. A test that says "given this response from Stripe, my parser produces this output" would have caught this before it hit production. The test would have failed the moment the shape changed because I would have updated the fixture.
  • Treat undefined returns from parsing steps as suspicious. A missing value from a critical field should probably log a warning at minimum. Silent undefined propagation is how this kind of bug survives.

The broader pattern is about where you put your trust. I had good test coverage on my own code. What I didn't have was any assertion about the shape of what was coming in from outside my system. I was testing my logic but not the contract.

Upstream APIs are not passive infrastructure. They ship code. Their code changes. And when it does, your integration is on the hook, whether you knew about the change or not.