02Operación7 min

Cinco subidas de versión de Stripe y el bug que sí compilaba

El SDK de Stripe me ha roto el build cinco veces en poco más de un año. Arreglar el error de tipos es fácil. Lo que me preocupa es lo que compila después.

Por Salva Castells

Imagen de portada: Bernd Schwabe in Hannover, CC BY-SA 4.0.
En este post
Type '"2025-06-30.basil"' is not assignable to type '"2025-07-30.basil"'.

Si tienes Stripe en un proyecto con TypeScript seguramente has visto este error, o uno igual con otras fechas. Sale en lib/stripe.ts en un PR que no tiene nada que ver con pagos, porque bun update se ha traído una versión nueva del SDK. El paquete trae dentro un literal con la versión de API que soporta, y cuando ese literal cambia, cualquier sitio donde pases apiVersion a mano deja de compilar.

A mí me ha pasado cinco veces en poco más de un año, repartidas en cuatro proyectos. La primera me pilló por sorpresa. Para la quinta ya lo esperaba, y creo que eso es lo interesante: con el tiempo he aprendido que el build rojo es la parte fácil. Lo que da problemas es lo que compila justo después.

El historial, que da un poco de vergüenza

Lo he sacado del git log de los cuatro repos. En agosto, 2025-06-30.basil pasó a 2025-07-30.basil. Un mes después, 2025-07-30.basil pasó a 2025-08-27.basil, en dos repos el mismo día. Meses más tarde, 2025-11-17.clover pasó a 2026-01-28.clover en un SaaS de generación de imágenes. Y al poco otra vez, ahora en el boilerplate que uso para empezar proyectos.

Todos los commits dicen "update Stripe API version to X" y todos los diffs son iguales. Y es que la cadena estaba copiada en cuatro sitios del mismo repo: una en el webhook y otras tres en createCheckout, createCustomerPortal y findCheckoutSession, cada una con su new Stripe(secretKey, { apiVersion, typescript: true }). Encima les había puesto un comentario a todas:

const stripe = new Stripe(secretKey, {
  apiVersion: "2026-01-28.clover", // TODO: update this when Stripe updates their API
  typescript: true,
});

El mismo TODO cuatro veces. Visto ahora, yo ya sabía que me iba a tocar repetir el trabajo y, en vez de centralizarlo, lo dejé apuntado. Cuatro sitios que cambiar a mano y cuatro oportunidades de dejarte uno. Y alguna vez me dejé uno: más de una vez apareció un new Stripe( que no recordaba en un fichero que no era el obvio.

La tarde que me comió invoice.paid

Lo del literal se arregla en dos minutos. Lo malo es que, en la misma subida, el SDK también cambia la forma de algunos tipos, y eso no siempre te lo avisa el compilador.

En el webhook de invoice.paid yo sacaba el price ID así:

const priceIdIP = stripeIP.lines.data[0].pricing?.price_details?.price;

Con la versión nueva ese campo pasa a ser string | Price | undefined, depende de si viene expandido o no. Hasta ahí, TypeScript se queja y tú lo arreglas. El problema es lo que pasa en runtime si el valor llega como objeto: el find sobre mi tabla de precios compara un objeto contra p.priceId, que es una cadena, no encuentra nada y acaba en el if (!pricingIP || !priceIdIP). Sale del handler sin hacer nada. En Stripe el cobro figura como hecho, al usuario no le llegan los créditos y en los logs no hay ni un error, porque técnicamente no ha fallado nada.

El primer parche fue el que se le ocurre a cualquiera:

const rawPrice = stripeIP.lines.data[0].pricing?.price_details?.price;
const priceIdIP = typeof rawPrice === "string" ? rawPrice : rawPrice?.id;

Ese mismo día lo tuve que volver a tocar, porque rawPrice?.id no estrechaba bien el tipo en todos los caminos y según el modo de checkout no compilaba. Lo dejé así, que es feo pero no engaña a nadie:

const priceIdIP =
  typeof rawPrice === "string"
    ? rawPrice
    : typeof rawPrice === "object" && rawPrice
      ? (rawPrice as { id: string }).id
      : undefined;

Ese cast a { id: string } no me gusta nada y sé que en una code review me lo marcarían. Pero prefiero un cast visible en una línea a un ?. que convierte en silencio "no sé qué forma tiene esto" en undefined.

(Ese mismo día, y sin relación con la versión, también me peleé con el cupón. Si en la sesión de checkout aplicas tú un cupón con discounts, no puedes dejar además que el cliente meta su código promocional con allow_promotion_codes: son alternativas, no se suman. Tardé dos commits en dejarlo bien. Lo cuento porque cuando tocas el checkout por un motivo acabas encontrando otros dos.)

Dejé de fijar la versión, y sé que esto va a escocer

Después del quinto bump hice lo que la ortodoxia te dice que no hagas. Quité apiVersion de los cuatro sitios:

const stripe = new Stripe(secretKey);

Sin apiVersion, el SDK manda la versión de API con la que se publicó, que es la misma con la que se generaron los tipos que estás usando. El desajuste que me rompía el build ya no puede darse, porque la versión solo sale de un sitio.

El argumento de siempre es que fijar la versión te deja decidir cuándo cambia el comportamiento. Y es verdad, pero solo si también fijas el SDK. Si tienes el literal en el código y el SDK con ^ en el package.json, que se actualiza cada vez que alguien hace bun update, no controlas nada: tienes una cadena que sincronizas a mano con un paquete que se mueve solo. Lo que de verdad te fija la versión es el lockfile. Si quieres fijarla en serio, ponla en un solo sitio, expórtala y clava el SDK a una versión exacta. Fijar una cosa y dejar la otra suelta es lo peor que puedes hacer.

Pero tengo que reconocer una cosa, y es la parte que más dudas me da. Quitar apiVersion del cliente no cambia la forma de los eventos que te llegan al webhook. Stripe genera el payload de cada evento con la versión de API configurada en el endpoint del webhook (o con la de la cuenta si el endpoint no tiene una). Así que puedes tener el SDK, los tipos y las llamadas perfectamente alineados y que el invoice.paid te llegue con la forma de una versión anterior. Tu código lo parsea con los tipos nuevos y no pasa nada raro hasta que algo no cuadra. No tengo claro que mi bug de rawPrice viniera solo del SDK; puede que en parte fuera esto. Ahora, cada vez que subo la versión, voy al dashboard y miro qué versión tiene el endpoint.

Lo que hago ahora cuando sale una versión

Nada del otro mundo, pero lo tengo escrito porque la media hora que me cuesta hacerlo bien se convierte en una tarde entera cuando me lo salto:

  1. Actualizo el SDK y todo lo que dependa de él en el mismo PR. Si llegan por separado, el error de tipos aparece en un PR que no tiene nada que ver con pagos, y quien lo abre no sabe por dónde le viene.
  2. grep -rn "new Stripe(" . en todo el repo, no solo en lib/stripe.ts. Si sale más de una línea, esa es la primera tarea.
  3. Leo los handlers del webhook uno por uno, aunque el build esté en verde. Busco especialmente ?. sobre campos expandibles, porque ahí es donde un string se convierte en objeto sin avisar.
  4. Compruebo qué versión de API tiene el endpoint del webhook en el dashboard.
  5. Pruebo invoice.paid con el CLI (stripe trigger invoice.paid), no solo checkout.session.completed. La renovación mensual pasa por otro camino, y si se rompe no te enteras hasta que alguien renueva.

Lo que más me costó asumir es que esto no es un incidente. Sale una versión cada dos o tres meses, más o menos, así que es mantenimiento con fecha aproximada, y lo he metido en el calendario como cualquier otra tarea que se repite. Me fastidia un poco, porque no aporta nada al producto. Pero si no lo hago, un día un cobro que Stripe dio por bueno no llega a mi base de datos y me entero cuando me escribe el usuario.