The build fails with a type error in lib/stripe.ts. The line is this one:
Type '"2025-06-30.basil"' is not assignable to type '"2025-07-30.basil"'.
I haven't touched anything to do with payments. What happened is that bun update pulled a new version of the Stripe SDK, and the package carries inside it a literal with the API version it supports. The day that literal changes, everything that passes apiVersion explicitly stops compiling. That's happened to me five times in a bit over a year, across four different projects.
The first time it was an incident. By the fifth it was something I expected.
The same commit, in four repos, a year apart
The sequence is written in the history and it's almost comic in how repetitive it is. 2025-06-30.basil to 2025-07-30.basil in August. A month later, 2025-07-30.basil to 2025-08-27.basil, in two repos on the same day. Months after that, 2025-11-17.clover to 2026-01-28.clover in an image generation SaaS. And shortly after, again, in the boilerplate I use to start projects.
The commit messages are identical: "update Stripe API version to X". So is the diff. And there's the first lesson, which has nothing to do with Stripe: the same string was repeated in four places in the same repo. One in the webhook, and three more in createCheckout, createCustomerPortal and findCheckoutSession, each with its own new Stripe(secretKey, { apiVersion, typescript: true }). Four identical lines to change by hand every time, and four places to forget one.
I had the nerve to leave a comment next to each of them:
const stripe = new Stripe(secretKey, {
apiVersion: "2026-01-28.clover", // TODO: update this when Stripe updates their API
typescript: true,
});
A TODO repeated four times is documentation that you know you're going to redo the work and still haven't centralized it.
The collateral damage isn't in the version, it's in the types
The part that breaks the build is trivial: change the string and it compiles. What actually costs you is the shape changes hidden inside the same bump, because the SDK rewrites its types at the same time.
The specific case that ate an afternoon was the invoice.paid webhook. I pulled the price ID like this:
const priceIdIP = stripeIP.lines.data[0].pricing?.price_details?.price;
In the new version that field becomes string | Price | undefined depending on whether it comes expanded or not. TypeScript stops accepting the comparison against p.priceId and, more importantly, at runtime it starts comparing an object against a string: the find over the price table matches nothing, falls into if (!pricingIP || !priceIdIP) and the payment isn't applied in the database. The charge went through in Stripe and the user has no credits.
The first fix was the obvious one:
const rawPrice = stripeIP.lines.data[0].pricing?.price_details?.price;
const priceIdIP = typeof rawPrice === "string" ? rawPrice : rawPrice?.id;
And I had to touch it again the same day, because rawPrice?.id still didn't narrow the type properly on every path and compiled wrong depending on the checkout mode. The version that stuck is uglier and more honest:
const priceIdIP =
typeof rawPrice === "string"
? rawPrice
: typeof rawPrice === "object" && rawPrice
? (rawPrice as { id: string }).id
: undefined;
Same with discounts. discounts: couponId ? [{ coupon: couponId }] : [] coexists with allow_promotion_codes: true, and there's an interaction there that Stripe doesn't forgive: if you pass a fixed coupon, the customer's promotion codes stop applying. Two commits the same day to get it right. Neither was because of the version: both were because I touched the checkout.
Why I eventually stopped pinning the version
After the fifth bump I did the opposite of what orthodoxy says. I deleted apiVersion from all four places:
const stripe = new Stripe(secretKey);
Without apiVersion, the SDK uses the version compiled into it. And that's exactly the version the TypeScript types you're using were generated against. The mismatch that broke the build disappears by construction: you can't have the SDK on one version and the literal on another if there's only one source.
The classic argument for pinning is that it gives you control over when behavior changes. That's true, but it stops being true when you pin the version and at the same time update the SDK every month. Then you don't have two levers: you have one lever and a string you sync by hand across four files. The real pin comes from the lockfile, not the literal.
If you really want to pin the API version, the pin has to live in a single place, exported, and the SDK has to be nailed down in package.json with an exact version, not with ^. Pinning one and letting the other float is the worst of both worlds.
The checklist for when a new version ships
- Update the SDK and the literal together, never separately. If they go separately, the type error shows up in a PR that has nothing to do with payments.
- Search the whole repo, not the obvious file. Five times I've found a fifth instance of
new Stripe(somewhere I didn't remember. - Reread the webhook handlers before trusting a green build. The compiler warns you about
apiVersion, but a field that goes fromstringto object can compile and fail at runtime if there's a?.in the middle. - Test
invoice.paidwith the CLI, not justcheckout.session.completed. Recurring events use a different data path and break without anyone noticing until the following month. - Check the coupon and the promotion code together if the session carries both.
My mistake was treating every bump as an interruption. It isn't: a version ships every two or three months and costs half an hour if you remember to look at the webhooks, an afternoon if you don't. That's maintenance, and maintenance goes on the calendar. What doesn't go on the calendar gets paid for as a charge that landed in Stripe and never reached the database.
