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:76 — version: 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_1TrZja1KiIVseNBleNogrcNR → https://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:
- Sie steht in der Ereignis-Zeile (
Setting, Schlüsselbilling:stripe:webhook:<id>, FeldapiVersion). - Weicht der Kanal ab (
basilgegenclover), warnt der Webhook mitstripe_webhook_api_version_mismatchund 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 zuerstconfirmation_secretund fällt erst dann aufpayment_intentzurück.providerReadiness.ts:148baut eine zweite Stripe-Instanz, ebenfalls ohneapiVersion. Sie liest keine der betroffenen Felder.