Skip to content

Stripe-API-Fassungen: zwei Uhren, die auseinanderlaufen

Stand 20.09.2026.

Der Kern in drei Sätzen

Im Backend laufen zwei verschiedene Stripe-API-Fassungen nebeneinander, und niemand hat sie je ausgesprochen:

  • Webhook-Nutzlasten tragen die Fassung, die zum Zeitpunkt des Ereignisses in den Kontoeinstellungen steht — nicht die des SDK. Stripe hebt sie nicht von selbst an, aber ein Klick in den Kontoeinstellungen ändert die Form ALLER Webhooks auf einmal, nicht nur die eines Endpunkts. (Belegt: docs.stripe.com/webhooks — „Die API-Version in Ihren Kontoeinstellungen beim Auftreten des Ereignisses bestimmt die API-Version und damit die Struktur eines Ereignisses, das an Ihr Ziel gesendet wird." Ein Ereignisziel lässt sich zusätzlich auf eine Version festlegen; die Kontoeinstellung ist die Vorgabe.)

Das ist schlimmer als zuerst angenommen. Der erste Entwurf dieses Dokuments sprach von der Fassung „des Endpunkts" und legte damit nahe, das Risiko hänge an einem einzelnen, selten angefassten Objekt. Es hängt an einer Kontoeinstellung. - API-Abrufe tragen die Fassung des SDK. new Stripe(secretKey) wird ohne apiVersion gebaut, und stripe-node setzt dann seine eigene (stripe.core.js:76version: props.apiVersion || DEFAULT_API_VERSION). Heute: 2026-02-25.clover.

Dasselbe Abo hat je nach Herkunft also eine andere Form.

GEMESSEN AM LIVE-KONTO, 20.09.2026

Nicht mehr hergeleitet, sondern nachgesehen — über die Stripe-API mit dem Live-Schlüssel, ausschliesslich lesend:

Webhook-Endpunkt we_1TrZja1KiIVseNBleNogrcNRhttps://api.targetshot.app/api/billing/stripe/webhook
api_version am Endpunkt nicht gesetzt — es gilt die Kontovorgabe
Fassung der Ereignisse 2025-09-30.clover
Abonnierte Ereignisse 6 — checkout.session.completed, customer.subscription.{created,updated,deleted}, invoice.paid, invoice.payment_failed

2025-09-30.clover liegt nach der Basil-Umstellung. In zwei echten invoice.paid-Nutzlasten vom 10.09.2026:

invoice.subscription                          = undefined
invoice.parent.subscription_details.subscription = "sub_1Tran…"

Damit ist das hier kein latentes Risiko mehr, sondern ein laufender Fehler. handleInvoiceEvent und tryProcessPersonalStripeEvent lesen bei jedem invoice.paid ins Leere und steigen mit invoice_without_subscription aus. Dass das Produkt trotzdem funktioniert, liegt daran, dass Freischaltung und Lizenzpflege über die customer.subscription.*-Ereignisse laufen — der Rechnungszweig trägt seit der Umstellung nichts mehr bei. Still, ohne Fehler, ohne dass es jemandem auffiel.

Nebenbefund: total_taxes ist [] — passend zur Kleinunternehmerregelung.

Was das konkret angerichtet hat

Stripe hat mit 2025-03 Felder verschoben. Zwei Familien betreffen uns:

Feld alt neu
Abo einer Rechnung invoice.subscription invoice.parent.subscription_details.subscription
Laufzeit eines Abos subscription.current_period_* subscription.items.data[].current_period_*

Der Zugriff lief überall über as any — also am Typsystem vorbei. Der Compiler schwieg, und der Ausfall wäre still gewesen: kein Wurf, keine Ausnahme, nur ein undefined.

Und einmal ist genau das schon passiert. In services/personalProSubscription.ts steht an derivePeriodEnd der Satz: „Der alte Top-Level-Zugriff lieferte still undefined, weshalb wir bislang IMMER null gespeichert haben und weder ‚nächste Abrechnung' noch ‚Zugang bis' anzeigen konnten." Dort wurde es behoben — an den drei gleichartigen Stellen im Vereins-Pfad nicht. Die haben den Fehler bis heute getragen: currentPeriodEnd in providerMeta war für API-geholte Abos immer null, und die Auswahl des richtigen Abos je Verein fiel still auf „zuletzt angelegt" zurück statt auf „längste Laufzeit".

Wie es jetzt gelöst ist

Alle Lesestellen gehen durch backend/src/services/stripeCompat.ts. Dort werden beide Formen gelesen, die neue zuerst. Der Wächter backend/test/stripeCompat.guard.test.ts schlägt an, wenn jemand daran vorbei wieder direkt liest — mit Ausnahmeliste, und jede Ausnahme braucht eine Begründung.

Die Fassung ist jetzt sichtbar, statt im Dashboard zu wohnen

Jedes Stripe-Ereignis trägt in api_version die Fassung, in der es geschnitten wurde. Diese Angabe wurde bisher weggeworfen — und damit die einzige Stelle, an der eine Umstellung in den Kontoeinstellungen von selbst auffiele. Ab jetzt:

  1. Sie steht in der Ereignis-Zeile (Setting, Schlüssel billing:stripe:webhook:<id>, Feld apiVersion).
  2. Weicht der Kanal ab (basil gegen clover), warnt der Webhook mit stripe_webhook_api_version_mismatch und nennt beide Fassungen.

Verglichen wird der Kanal, nicht das Datum: Stripe gibt innerhalb eines Kanals laufend neue Datumsfassungen heraus, ohne dass sich Feldformen ändern. Eine Warnung, die dabei jedes Mal käme, läse bald niemand mehr.

Die Warnung sperrt nichts. Eine Formabweichung ist der Anlass, die Lesestellen zu prüfen — nicht der Beweis, dass etwas kaputt ist. Ein Webhook, der deswegen 500 zurückgäbe, würde Stripe zur endlosen Wiederholung zwingen und aus einer Warnung einen Ausfall machen.

Die Fassung nachschlagen

Rückwirkend für jedes je empfangene Ereignis — die Zeilen werden nie aufgeräumt:

SELECT value->>'apiVersion' AS fassung, count(*), min(value->>'processedAt') AS seit
FROM "Setting" WHERE key LIKE 'billing:stripe:webhook:%' GROUP BY 1 ORDER BY 2 DESC;

Für Ereignisse von vor dieser Änderung steht dort null — die Angabe wurde damals nicht gespeichert. Das erste Ereignis danach beantwortet die Frage.

Was offen bleibt

  • Die gefahrene Fassung ist hier nicht festgehalten. Sie steht nur im Stripe-Dashboard (Kontoeinstellungen bzw. am Ereignisziel), und ich habe keinen Zugang. Die Abfrage oben beantwortet es nach dem nächsten Ereignis von selbst.
  • Nicht an der Kontoversion drehen, ohne die Lesestellen zu prüfen. Eine Anhebung dort ist kein Endpunkt-Detail, sondern trifft jede Webhook-Nutzlast gleichzeitig.
  • Eine Anhebung der Endpunkt-Fassung ist trotzdem kein Selbstläufer. Der Wächter deckt die zwei bekannten Feldfamilien ab, nicht jede künftige Änderung. Vor einer Anhebung gehört Stripes Upgrade-Liste durchgesehen.
  • expand: ['latest_invoice.payment_intent'] (stripeBilling.ts:1661, personalBilling.ts:324) verweist auf ein Feld, das im aktuellen Invoice-Typ nicht mehr auf oberster Ebene steht. Dass Stripe den Pfad weiterhin annimmt, ist durch den laufenden Pro-Kauf belegt — nicht durch Dokumentation. Der Code liest ohnehin zuerst confirmation_secret und fällt erst dann auf payment_intent zurück.
  • providerReadiness.ts:148 baut eine zweite Stripe-Instanz, ebenfalls ohne apiVersion. Sie liest keine der betroffenen Felder.