De build valt om met een typefout in lib/stripe.ts. De regel is deze:
Type '"2025-06-30.basil"' is not assignable to type '"2025-07-30.basil"'.
Ik heb niets aan de betalingen aangeraakt. Wat er is gebeurd is dat bun update een nieuwe versie van de Stripe-SDK heeft meegebracht, en het pakket draagt intern een literal met de API-versie die het ondersteunt. Zodra die literal verandert, compileert alles wat apiVersion expliciet meegeeft niet meer. Dat is me vijf keer overkomen in iets meer dan een jaar, in vier verschillende projecten.
De eerste keer was het een incident. De vijfde keer was het iets wat ik verwachtte.
Dezelfde commit, in vier repo's, met een jaar ertussen
De reeks staat in de historie en is haast komisch in zijn herhaling. 2025-06-30.basil naar 2025-07-30.basil in augustus. Een maand later 2025-07-30.basil naar 2025-08-27.basil, in twee repo's dezelfde dag. Maanden later 2025-11-17.clover naar 2026-01-28.clover in een SaaS voor beeldgeneratie. En kort daarna nog eens, in de boilerplate waarmee ik projecten start.
De commitberichten zijn identiek: "update Stripe API version to X". De diff ook. En daar zit de eerste les, die niets met Stripe te maken heeft: dezelfde string stond op vier plekken herhaald in dezelfde repo. Eén in de webhook, en drie meer in createCheckout, createCustomerPortal en findCheckoutSession, elk met een eigen new Stripe(secretKey, { apiVersion, typescript: true }). Vier identieke regels die je elke keer met de hand moet wijzigen, en vier plekken om er één te vergeten.
Ik had het lef om naast elke regel een commentaar achter te laten:
const stripe = new Stripe(secretKey, {
apiVersion: "2026-01-28.clover", // TODO: update this when Stripe updates their API
typescript: true,
});
Een TODO die vier keer herhaald staat is documentatie dat je weet dat je het werk gaat herhalen en het toch niet hebt gecentraliseerd.
De nevenschade zit niet in de versie, maar in de types
Het deel dat de build breekt is triviaal: je verandert de string en het compileert. Wat echt kost zijn de vormveranderingen die verstopt zitten in dezelfde upgrade, want de SDK herschrijft tegelijk zijn types.
Het concrete geval dat me een middag kostte was de webhook van invoice.paid. Ik haalde de price ID er zo uit:
const priceIdIP = stripeIP.lines.data[0].pricing?.price_details?.price;
Met de nieuwe versie wordt dat veld string | Price | undefined, afhankelijk van of het expanded binnenkomt of niet. TypeScript slikt de vergelijking met p.priceId niet meer en, wat belangrijker is, de runtime begint een object met een string te vergelijken: de find over de prijzentabel vindt niets, valt in de if (!pricingIP || !priceIdIP), en de betaling wordt niet in de database toegepast. De afschrijving is gedaan in Stripe en de gebruiker heeft zijn credits niet.
De eerste fix was de voor de hand liggende:
const rawPrice = stripeIP.lines.data[0].pricing?.price_details?.price;
const priceIdIP = typeof rawPrice === "string" ? rawPrice : rawPrice?.id;
En ik moest hem dezelfde dag opnieuw aanpassen, want rawPrice?.id versmalde het type nog steeds niet goed op alle paden en compileerde verkeerd afhankelijk van de checkout-modus. De versie die is blijven staan is lelijker en eerlijker:
const priceIdIP =
typeof rawPrice === "string"
? rawPrice
: typeof rawPrice === "object" && rawPrice
? (rawPrice as { id: string }).id
: undefined;
Hetzelfde met de kortingen. discounts: couponId ? [{ coupon: couponId }] : [] leeft samen met allow_promotion_codes: true, en daar zit een interactie die Stripe niet vergeeft: geef je een vaste coupon mee, dan worden de promotiecodes van de klant niet meer toegepast. Twee commits op dezelfde dag om het goed te krijgen. Geen van beide kwam door de versie: ze kwamen doordat ik de checkout aanraakte.
Waarom ik uiteindelijk gestopt ben met de versie vastzetten
Na de vijfde bump deed ik het tegenovergestelde van wat de orthodoxie zegt. Ik verwijderde de apiVersion op alle vier de plekken:
const stripe = new Stripe(secretKey);
Zonder apiVersion gebruikt de SDK de versie die erin gecompileerd zit. En dat is precies de versie waartegen de TypeScript-types die je gebruikt zijn gegenereerd. De mismatch die de build brak verdwijnt per constructie: je kunt de SDK niet op de ene versie en de literal op de andere hebben als er maar één bron is.
Het klassieke argument om hem vast te zetten is dat je dan bepaalt wanneer het gedrag verandert. Dat klopt, maar het klopt niet meer zodra je de versie vastzet én tegelijk de SDK elke maand bijwerkt. Dan heb je geen twee knoppen: je hebt één knop en een string die je met de hand in vier bestanden synchroniseert. De echte pin komt van het lockfile, niet van de literal.
Wil je de API-versie echt pinnen, dan moet de pin op één plek staan, geëxporteerd, en moet de SDK in de package.json vastgezet zijn op een exacte versie, niet met ^. De één vastzetten en de ander laten zweven is het slechtste van beide werelden.
De checklist voor als er een nieuwe versie uitkomt
- De SDK en de literal tegelijk bijwerken, nooit apart. Gaan ze apart, dan verschijnt de typefout in een PR die niet over betalingen gaat.
- Zoek in de hele repo, niet in het voor de hand liggende bestand. Vijf keer heb ik een vijfde instantie van
new Stripe(gevonden op een plek die ik me niet herinnerde. - Lees de webhook-handlers opnieuw voordat je de groene build vertrouwt. De compiler waarschuwt je voor de
apiVersion, maar een veld dat vanstringnaar object gaat kan compileren en in runtime falen als er een?.tussen zit. - Test
invoice.paidmet de CLI, niet alleencheckout.session.completed. Terugkerende events gebruiken een ander datapad en breken zonder dat iemand het merkt tot de maand erop. - Controleer de coupon en de promotiecode samen als de sessie ze meedraagt.
Mijn fout was elke bump behandelen als een onderbreking. Dat is het niet: er komt elke twee of drie maanden een versie uit en het kost een half uur als je eraan denkt naar de webhooks te kijken, een middag als je dat niet doet. Dat is onderhoud, en onderhoud plan je in. Wat je niet inplant betaal je in de vorm van een betaling die in Stripe binnenkwam en de database nooit haalde.
