02Betrieb5 min

Stripe hat mir den Build fünfmal zerlegt und steht jetzt im Kalender

Die API-Version von Stripe hochzuziehen ist kein Vorfall. Es ist wiederkehrende Wartung mit ziemlich vorhersehbaren Kollateralschäden, und man sollte sie einplanen.

Von Salva Castells

Titelbild: Bernd Schwabe in Hannover, CC BY-SA 4.0.
In diesem Beitrag

Der Build fällt mit einem Typfehler in lib/stripe.ts um. Die Zeile lautet:

Type '"2025-06-30.basil"' is not assignable to type '"2025-07-30.basil"'.

Ich habe nichts an den Zahlungen angefasst. Was passiert ist: bun update hat eine neue Version des Stripe-SDK mitgebracht, und das Paket trägt intern ein Literal mit der API-Version, die es unterstützt. An dem Tag, an dem sich dieses Literal ändert, kompiliert alles nicht mehr, was apiVersion explizit übergibt. Das ist mir in etwas über einem Jahr fünfmal passiert, in vier verschiedenen Projekten.

Beim ersten Mal war es ein Vorfall. Beim fünften war es etwas, das ich erwartet habe.

Derselbe Commit, in vier Repos, mit einem Jahr Abstand

Die Abfolge steht in der Historie und ist vor lauter Wiederholung fast komisch. 2025-06-30.basil zu 2025-07-30.basil im August. Einen Monat später 2025-07-30.basil zu 2025-08-27.basil, in zwei Repos am selben Tag. Monate später 2025-11-17.clover zu 2026-01-28.clover in einem SaaS zur Bildgenerierung. Und kurz danach wieder, im Boilerplate, mit dem ich Projekte starte.

Die Commit-Nachrichten sind identisch: "update Stripe API version to X". Der Diff auch. Und da ist die erste Lektion, die nichts mit Stripe zu tun hat: Derselbe String stand an vier Stellen desselben Repos. Einer im Webhook, und drei weitere in createCheckout, createCustomerPortal und findCheckoutSession, jeder mit seinem eigenen new Stripe(secretKey, { apiVersion, typescript: true }). Vier identische Zeilen, die man jedes Mal von Hand ändern muss, und vier Stellen, an denen man eine vergessen kann.

Ich hatte die Frechheit, neben jede davon einen Kommentar zu schreiben:

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

Ein viermal wiederholtes TODO ist die Dokumentation dafür, dass du weißt, dass du die Arbeit wiederholen wirst, und sie trotzdem nicht zentralisiert hast.

Die Kollateralschäden stecken nicht in der Version, sondern in den Typen

Der Teil, der den Build zerlegt, ist trivial: Du änderst den String und es kompiliert. Was wirklich Mühe macht, sind die Formänderungen, die versteckt im selben Update mitkommen, weil das SDK seine Typen zugleich neu schreibt.

Der konkrete Fall, der mir einen Nachmittag gefressen hat, war der Webhook von invoice.paid. Ich holte mir die Price ID so:

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

Mit der neuen Version wird dieses Feld zu string | Price | undefined, je nachdem, ob es expandiert kommt oder nicht. TypeScript schluckt den Vergleich mit p.priceId nicht mehr und — das ist das Wichtige — zur Laufzeit wird plötzlich ein Objekt mit einem String verglichen: Das find über die Preistabelle findet nichts, landet im if (!pricingIP || !priceIdIP), und die Zahlung wird in der Datenbank nicht angewendet. Die Abbuchung ist bei Stripe erfolgt und der Nutzer hat seine Credits nicht.

Die erste Korrektur war die naheliegende:

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

Und ich musste sie am selben Tag noch einmal anfassen, weil rawPrice?.id den Typ weiterhin nicht auf allen Pfaden korrekt einengte und je nach Checkout-Modus falsch kompilierte. Die Version, die geblieben ist, ist hässlicher und ehrlicher:

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

Dasselbe mit den Rabatten. discounts: couponId ? [{ coupon: couponId }] : [] lebt neben allow_promotion_codes: true, und dort gibt es eine Wechselwirkung, die Stripe nicht verzeiht: Wenn du einen festen Coupon übergibst, greifen die Promo-Codes des Kunden nicht mehr. Zwei Commits am selben Tag, um es geradezuziehen. Keiner der beiden wegen der Version: beide, weil ich den Checkout angefasst habe.

Warum ich am Ende aufgehört habe, die Version festzunageln

Nach dem fünften Bump habe ich das Gegenteil dessen getan, was die Lehrmeinung sagt. Ich habe die apiVersion an allen vier Stellen gelöscht:

const stripe = new Stripe(secretKey);

Ohne apiVersion benutzt das SDK die Version, die es intern einkompiliert hat. Und genau gegen diese Version sind die TypeScript-Typen generiert, die du benutzt. Der Versatz, der den Build zerlegte, verschwindet von Natur aus: Du kannst das SDK nicht in der einen Version und das Literal in einer anderen haben, wenn es nur eine Quelle gibt.

Das klassische Argument fürs Festnageln ist, dass du so kontrollierst, wann sich das Verhalten ändert. Das stimmt, aber es stimmt nicht mehr, wenn du die Version festnagelst und gleichzeitig das SDK jeden Monat aktualisierst. Dann hast du nicht zwei Hebel: Du hast einen Hebel und einen String, den du in vier Dateien von Hand synchronisierst. Der echte Pin kommt vom Lockfile, nicht vom Literal.

Wenn du die API-Version wirklich pinnen willst, muss der Pin an einer einzigen Stelle stehen, exportiert, und das SDK muss in der package.json auf eine exakte Version festgenagelt sein, nicht mit ^. Das eine festzunageln und das andere schwimmen zu lassen ist das Schlechteste aus beiden Welten.

Die Checkliste, wenn eine neue Version erscheint

  • SDK und Literal gleichzeitig aktualisieren, nie getrennt. Wenn sie getrennt laufen, taucht der Typfehler in einem PR auf, in dem es gar nicht um Zahlungen geht.
  • Im ganzen Repo suchen, nicht in der offensichtlichen Datei. Fünfmal habe ich eine fünfte Instanz von new Stripe( an einer Stelle gefunden, an die ich mich nicht erinnerte.
  • Die Webhook-Handler noch einmal lesen, bevor du den grünen Build für bare Münze nimmst. Der Compiler warnt dich bei der apiVersion, aber ein Feld, das von string zu Objekt wird, kann kompilieren und zur Laufzeit scheitern, wenn ein ?. im Spiel ist.
  • invoice.paid mit dem CLI testen, nicht nur checkout.session.completed. Die wiederkehrenden Events nutzen einen anderen Datenweg und gehen kaputt, ohne dass es bis zum nächsten Monat jemand merkt.
  • Coupon und Promo-Code zusammen prüfen, wenn die Session beides mitführt.

Mein Fehler war, jeden Bump als Unterbrechung zu behandeln. Das ist er nicht: Alle zwei bis drei Monate erscheint eine Version, und es kostet eine halbe Stunde, wenn du daran denkst, in die Webhooks zu schauen, und einen Nachmittag, wenn nicht. Das ist Wartung, und Wartung wird eingeplant. Was nicht eingeplant wird, bezahlst du in Form einer Zahlung, die bei Stripe eingegangen und nie in der Datenbank angekommen ist.