La build cade con un errore di tipi in lib/stripe.ts. La riga è questa:
Type '"2025-06-30.basil"' is not assignable to type '"2025-07-30.basil"'.
Non ho toccato niente dei pagamenti. Quello che è successo è che bun update si è portato dietro una versione nuova dell'SDK di Stripe, e il pacchetto contiene un literal con la versione di API che supporta. Il giorno in cui quel literal cambia, tutto ciò che passa apiVersion esplicitamente smette di compilare. Mi è successo cinque volte in poco più di un anno, in quattro progetti diversi.
La prima volta è stata un incidente. Alla quinta era già qualcosa che mi aspettavo.
Lo stesso commit, in quattro repo, a un anno di distanza
La sequenza è scritta nella cronologia ed è quasi comica per quanto è ripetitiva. 2025-06-30.basil a 2025-07-30.basil ad agosto. Un mese dopo, 2025-07-30.basil a 2025-08-27.basil, in due repo lo stesso giorno. Mesi più tardi, 2025-11-17.clover a 2026-01-28.clover in un SaaS di generazione di immagini. E poco dopo, di nuovo, nel boilerplate che uso per iniziare i progetti.
I messaggi di commit sono identici: "update Stripe API version to X". Il diff pure. Ed ecco la prima lezione, che non ha niente a che vedere con Stripe: la stessa stringa era ripetuta in quattro posti dello stesso repo. Una nel webhook, e altre tre in createCheckout, createCustomerPortal e findCheckoutSession, ognuna con il suo new Stripe(secretKey, { apiVersion, typescript: true }). Quattro righe identiche da cambiare a mano ogni volta, e quattro posti dove dimenticarsene una.
Ho avuto la faccia tosta di lasciare un commento accanto a ciascuna:
const stripe = new Stripe(secretKey, {
apiVersion: "2026-01-28.clover", // TODO: update this when Stripe updates their API
typescript: true,
});
Un TODO ripetuto quattro volte è documentazione del fatto che sai che ripeterai il lavoro e che comunque non l'hai centralizzato.
I danni collaterali non stanno nella versione, stanno nei tipi
La parte che rompe la build è banale: cambi la stringa e compila. Quello che costa davvero sono i cambi di forma nascosti nello stesso aggiornamento, perché l'SDK riscrive i suoi tipi allo stesso tempo.
Il caso concreto che mi ha mangiato un pomeriggio è stato il webhook di invoice.paid. Io prendevo il price ID così:
const priceIdIP = stripeIP.lines.data[0].pricing?.price_details?.price;
Con la versione nuova, quel campo diventa string | Price | undefined a seconda che arrivi espanso o no. TypeScript smette di digerire il confronto con p.priceId e, cosa importante, il runtime inizia a confrontare un oggetto con una stringa: il find sulla tabella dei prezzi non trova niente, cade nell'if (!pricingIP || !priceIdIP) e il pagamento non viene applicato nel database. L'incasso è fatto in Stripe e l'utente non ha i suoi crediti.
La prima correzione è stata quella ovvia:
const rawPrice = stripeIP.lines.data[0].pricing?.price_details?.price;
const priceIdIP = typeof rawPrice === "string" ? rawPrice : rawPrice?.id;
E ho dovuto rimetterci mano lo stesso giorno, perché rawPrice?.id continuava a non restringere bene il tipo su tutti i percorsi e compilava male a seconda della modalità di checkout. La versione rimasta è più brutta e più onesta:
const priceIdIP =
typeof rawPrice === "string"
? rawPrice
: typeof rawPrice === "object" && rawPrice
? (rawPrice as { id: string }).id
: undefined;
Lo stesso con gli sconti. discounts: couponId ? [{ coupon: couponId }] : [] convive con allow_promotion_codes: true, e lì c'è un'interazione che Stripe non perdona: se passi un coupon fisso, i codici promozionali del cliente smettono di applicarsi. Due commit lo stesso giorno per sistemarla. Nessuno dei due è stato per la versione: sono stati per aver toccato il checkout.
Perché alla fine ho smesso di fissare la versione
Dopo il quinto bump ho fatto il contrario di quello che dice l'ortodossia. Ho cancellato l'apiVersion dai quattro posti:
const stripe = new Stripe(secretKey);
Senza apiVersion, l'SDK usa la versione che ha compilata dentro. Ed è esattamente la versione contro cui sono generati i tipi TypeScript che stai usando. Il disallineamento che rompeva la build sparisce per costruzione: non puoi avere l'SDK a una versione e il literal a un'altra se c'è una sola fonte.
L'argomento classico per fissarla è che così controlli quando cambia il comportamento. È vero, ma smette di esserlo quando fissi la versione e allo stesso tempo aggiorni l'SDK ogni mese. Lì non hai due leve: hai una leva e una stringa che sincronizzi a mano in quattro file. Il pin vero lo dà il lockfile, non il literal.
Se vuoi davvero pinnare la versione di API, il pin deve stare in un solo posto, esportato, e l'SDK deve essere inchiodato nel package.json con la versione esatta, non con ^. Fissarne una e lasciare l'altra che fluttua è il peggio dei due mondi.
La checklist di quando esce una versione nuova
- Aggiornare l'SDK e il literal insieme, mai separatamente. Se vanno separati, l'errore di tipi salta fuori in una PR che non parla di pagamenti.
- Cercare in tutto il repo, non nel file ovvio. Cinque volte ho trovato una quinta istanza di
new Stripe(in un posto che non ricordavo. - Rileggere gli handler dei webhook prima di dare per buona la build verde. Il compilatore ti avvisa dell'
apiVersion, ma un campo che passa dastringa oggetto può compilare e fallire a runtime se c'è di mezzo un?.. - Provare
invoice.paidcon la CLI, non solocheckout.session.completed. Gli eventi ricorrenti usano un altro percorso dei dati e si rompono senza che nessuno se ne accorga fino al mese successivo. - Verificare coupon e codice promozionale insieme se la sessione li porta.
Il mio errore è stato trattare ogni bump come un'interruzione. Non lo è: esce una versione ogni due o tre mesi e costa mezz'ora se ti ricordi di guardare i webhook, un pomeriggio se non lo fai. Quella è manutenzione, e la manutenzione si mette in agenda. Quello che non si mette in agenda si paga sotto forma di un incasso entrato in Stripe e mai arrivato al database.
