tva
← Insights

Stripe Checkout im internationalen E-Commerce: Sonderfälle und Erfahrungen

Die Dokumentation von Stripe ist ausgezeichnet, und die erste Integration funktioniert meist auf Anhieb. Im internationalen E-Commerce treten jedoch Probleme auf, die der Schnellstartleitfaden nicht abdeckt: die zuverlässige Webhook-Zustellung während Infrastruktur-Neustarts, die Feinheiten von Rückerstattungen in anderen Währungen als der Abrechnungswährung und der betriebliche Aufwand für die Verwaltung von API-Schlüsseln in mehreren Umgebungen. Dieser Beitrag dokumentiert die Sonderfälle, auf die wir gestoßen sind, und die daraus folgenden Änderungen.

Das Checkout-Session-Modell und seine Annahmen

Stripe Checkout ist eine gehostete Zahlungsseite. Sie erstellen eine Session auf Ihrem Server, leiten den Kunden weiter und Stripe ruft Ihren Webhook auf, sobald die Zahlung abgeschlossen ist. Der Ablauf ist einfach, bis Sie mit mehreren Währungen arbeiten oder bei der Bestellabwicklung das vollständige Zahlungsobjekt prüfen müssen.

Standardmäßig enthält die Antwort einer Checkout Session nur eine minimale Darstellung der Zahlung. Das Feld payment_intent ist eine ID als Zeichenfolge, nicht das vollständig aufgelöste Objekt. Das ist relevant, wenn Ihre Fulfillment-Logik payment_intent.latest_charge.payment_method_details lesen muss – etwa um das Herkunftsland der Karte für die Ermittlung der Umsatzsteuer zu protokollieren. (Bei API-Versionen vor 2022-11-15 lautete der Pfad payment_intent.charges.data[0]; Stripe hat die Eigenschaft charges in dieser Version aus PaymentIntents entfernt.) Webhook-Ereignisse enthalten nie aufgelöste Objekte. Um diese Daten beim Fulfillment abzurufen, ist daher pro Ereignis ein zweiter API-Aufruf nötig.

Der Parameter expand akzeptiert ein Array von Pfaden mit Punktnotation. Er gilt nur für die Antwort des API-Aufrufs, der ihn anfordert: Wird er beim Erstellen der Session gesetzt, ändert das nicht das Session-Objekt, das Stripe später im Webhook sendet, denn Stripe sendet Objekte in Ereignissen immer in ihrer minimalen Form. Rufen Sie stattdessen im Webhook-Handler die Session mit ["payment_intent.latest_charge"] als aufzulösendem Pfad ab. Dafür sind ein zusätzlicher API-Aufruf pro Ereignis und eine größere Antwort nötig, was relevant ist, wenn Sie unverarbeitete Antworten in einem Protokollspeicher ablegen.

Zuverlässige Webhooks

Stripe setzt bei der Webhook-Zustellung auf Wiederholungsversuche: Gibt Ihr Endpunkt keinen 2xx-Status zurück oder läuft die Anfrage in ein Timeout, wiederholt Stripe die Zustellung im Live-Modus bis zu drei Tage lang mit exponentiell wachsenden Abständen; in der Sandbox werden Ereignisse innerhalb weniger Stunden dreimal erneut zugestellt. Das ist sinnvoll konzipiert, führt aber zu einem Korrektheitsproblem, wenn Ihr Handler nicht idempotent ist. Bestellerstellung, Bestandsreservierung und E-Mail-Versand sind Seiteneffekte, die genau einmal ausgeführt werden sollten.

Die übliche Lösung besteht darin, verarbeitete Ereignis-IDs vor der Ausführung der Seiteneffekte zu speichern. Wir verwenden eine einfache Datenbanktabelle mit der Stripe-Ereignis-ID als Primärschlüssel. Der Handler liest das eingehende Feld id, versucht einen Datensatz einzufügen und fährt nur bei erfolgreichem Einfügen fort. Eine doppelte Ereignis-ID verursacht einen Konflikt; der Handler gibt dann sofort 200 zurück. Damit dies sicher ist, muss das Einfügen in derselben Datenbanktransaktion wie die Seiteneffekte festgeschrieben werden (oder der Datensatz darf erst nach deren Erfolg als verarbeitet markiert werden). Andernfalls erscheint der Wiederholungsversuch von Stripe nach einem Absturz direkt nach dem Einfügen als Duplikat, und die Bestellung wird nie abgewickelt.

Die Dokumentation macht den zeitlichen Abstand zwischen dem Empfang eines Ereignisses checkout.session.completed und dem tatsächlichen Zahlungseinzug im Abrechnungsablauf von Stripe nicht deutlich genug. Bei bestimmten Zahlungsmethoden – insbesondere Banküberweisungen und einigen in Deutschland und den Niederlanden verfügbaren „Buy now, pay later“-Angeboten – endet die Checkout Session, bevor der Geldeingang bestätigt ist. In diesen Fällen endet die Session mit payment_status gleich unpaid. Die passenden Ereignisse sind checkout.session.async_payment_succeeded zum Auslösen des Fulfillments und checkout.session.async_payment_failed zur Behandlung eines Fehlschlags. Wer bei jedem checkout.session.completed ohne Prüfung von payment_status das Fulfillment auslöst, versendet Bestellungen, deren Zahlung später scheitert.

Außerdem stießen wir auf ein Problem beim Neustart unserer Container. Während eines Deployments können Webhook-Anfragen eintreffen, wenn der alte Container gerade herunterfährt und der neue startet. Nginx hält Verbindungen beim Wechsel des nachgelagerten Systems offen, dennoch beobachteten wir in einem kurzen Zeitfenster verlorene Ereignisse. Wir ergänzten explizite Einstellungen für keepalive_timeout und proxy_read_timeout in der Nginx-Konfiguration und verließen uns auf Stripes Wiederholungsmechanismus, statt auf Ebene des Webhook-Empfängers ein vollständig unterbrechungsfreies Deployment zu erzwingen.

Live- und Testschlüssel verwalten

Der häufigste Fehler, den wir bei Stripe-Integrationen sehen, ist eine unzureichende Trennung von Live- und Testschlüsseln. Der offensichtliche Fall: Ein Live-Schlüssel wird versehentlich in einer Testumgebung verwendet und erzeugt echte Abbuchungen. Weniger offensichtlich ist die Verwendung von Testschlüsseln in einer Staging-Umgebung, die eine Datenbank mit produktionsnahen Daten teilt.

Unsere Regel sind drei Umgebungen mit jeweils einem vollständigen eigenen Satz Stripe-Zugangsdaten: development, staging und production. Die Entwicklungsumgebung nutzt Stripes Testmodus. Staging nutzt ebenfalls den Testmodus, aber mit einem anderen Satz eingeschränkter Schlüssel und einem eigenen, im Stripe-Dashboard registrierten Webhook-Endpunkt. Der Produktivbetrieb nutzt den Live-Modus. Alle Schlüssel werden zur Laufzeit als Umgebungsvariablen bereitgestellt – sie werden weder in der Versionsverwaltung gespeichert noch beim Build in Docker-Images eingesetzt.

Das Geheimnis zur Webhook-Signierung ist eine vom API-Schlüssel getrennte Zugangsinformation. Jeder im Stripe-Dashboard registrierte Endpunkt hat ein eigenes Signierungsgeheimnis. Bei drei Umgebungen haben Sie drei Signierungsgeheimnisse. Das falsche zu kopieren ist leicht, besonders bei einem neuen Deployment. In unserem internen Betriebshandbuch dokumentieren wir die Zuordnung ausdrücklich: welches Stripe-Konto, welcher Endpunkt, welches Signierungsgeheimnis und welches Schlüsselpräfix. Das klingt nach Zusatzaufwand. Die Alternative ist jedoch eine Fehlersuche, weil die Signaturprüfung für Live-Ereignisse unbemerkt scheitert.

Stripes Funktion für eingeschränkte Schlüssel wird zu selten genutzt. Statt jedem Dienst Ihren vollständigen geheimen Schlüssel zu geben, können Sie Schlüssel auf genau die Vorgänge begrenzen, die der jeweilige Dienst benötigt. Ein Fulfillment-Worker, der nur Payment Intents lesen und Rückerstattungen erstellen muss, benötigt keinen Schreibzugriff auf Produkte oder Abonnements. Wir vergeben eingeschränkte Schlüssel pro Dienst und rotieren sie unabhängig vom Hauptschlüssel.

Währungen und das Rückerstattungsproblem

Stripe unterstützt Zahlungen in über 135 Währungen. Ihre Abrechnungswährung hängt jedoch vom Land Ihres Stripe-Kontos ab. Rechnet Ihr Konto in EUR ab und zahlt ein Kunde in SGD, konvertiert Stripe den Betrag bei der Abbuchung. Bis zur Rückerstattung bleibt dieser Vorgang unauffällig.

Rückerstattungen erfolgen in der Zahlungswährung, also der Währung, in der der Kunde bezahlt hat. Der von Ihrem Stripe-Guthaben abgezogene Betrag wird jedoch zum aktuellen Wechselkurs bei der Rückerstattung berechnet, nicht zum Kurs der ursprünglichen Abbuchung. Hat sich der Kurs dazwischen ungünstig entwickelt, können Sie in Ihrer Abrechnungswährung mehr zurückerstatten, als Sie erhalten haben. Das ist kein Fehler, sondern vorgesehenes Verhalten. Es ist in Stripes Leitfaden zu Rückerstattungen und Streitfällen dokumentiert, lässt sich aber leicht übersehen und hat reale Kostenfolgen, wenn Sie mit knappen Margen in mehreren Währungen arbeiten. (Stand Oktober 2026 ist Stripes Adaptive Pricing eine Ausnahme: Dort gilt für Rückerstattungen der Wechselkurs der ursprünglichen Transaktion.)

Praktisch lässt sich das Risiko mindern, indem Sie nur Währungen akzeptieren, deren Risiken Sie verstehen, oder einen Währungspuffer für Wechselkursschwankungen vorhalten. Bei hohem Transaktionsvolumen ermöglichen Stripes Abrechnungskonten für mehrere Währungen, Guthaben in bestimmten Währungen zu halten und jede getrennt abzurechnen. Für diese Währungen entfällt dadurch die Konvertierung bei der Rückerstattung. Dafür wird das Liquiditätsmanagement komplexer.

Ein weiterer Sonderfall betraf Teilrückerstattungen für Bestellungen, die in einer anderen Währung als unserer internen Basiswährung abgerechnet wurden. (Eine einzelne Checkout Session verwendet nur eine Währung, die daher für alle Bestellpositionen gilt.) Bei Teilrückerstattungen müssen Sie den Betrag in der Währung der ursprünglichen Abbuchung angeben. Speichert Ihre Auftragsverwaltung Beträge in einer internen Basiswährung, müssen Sie zusätzlich den ursprünglichen Zahlungsbetrag und die Zahlungswährung beim Bestelldatensatz speichern. Nach dem ersten fehlgeschlagenen Teilrückerstattungsversuch mit einer verwirrenden Validierungsfehlermeldung ergänzten wir beide Felder in unserer Bestelltabelle.

Verfügbarkeit von Zahlungsmethoden nach Land

Die dynamische Auswahl von Zahlungsmethoden in Stripe Checkout zeigt passende Optionen anhand des Kundenstandorts an. Das ist praktisch, verlangt aber Sorgfalt. Wenn Sie Stripe die Methoden anhand Ihrer Dashboard-Einstellungen auswählen lassen, statt eine explizite Liste zu übergeben, steht die Auswahl verfügbarer Methoden beim Erstellen der Session nicht vollständig fest. Sie hängt von Faktoren ab, die Stripe erst beim Anzeigen der Seite auswertet.

Das ist für Ihre Testsuite relevant. Tests, die im Checkout bestimmte Zahlungsmethoden erwarten, verhalten sich in verschiedenen Regionen unterschiedlich. Wir schreiben unsere Integrationstests mit dem expliziten Parameter payment_method_types statt mit dynamischen Zahlungsmethoden. So erhalten wir eine vorhersehbare Auswahl. Der produktive Checkout verwendet dynamische Zahlungsmethoden. Diese Abweichung ist beabsichtigt und dokumentiert.

SEPA Direct Debit, iDEAL, Bancontact und Przelewy24 bringen jeweils Anforderungen mit sich, die bei Kartenzahlungen nicht gelten. Der gehostete Checkout erfasst die methodenspezifischen Angaben selbst – etwa die IBAN und die Mandatsbestätigung für SEPA. Es gelten jedoch weitere Einschränkungen: SEPA Direct Debit im Checkout setzt voraus, dass alle Positionen in EUR ausgezeichnet sind. Zudem erfolgt die Zahlungsbestätigung zeitversetzt, sodass die oben genannten asynchronen Ereignisse gelten. Wir testen jede aktivierte Zahlungsmethode im vollständigen Checkout-Ablauf, nicht nur bei der Zahlungsbestätigung.

Was wir nach den ersten Problemen im Produktivbetrieb änderten

Nach den ersten Monaten mit echten Transaktionen nahmen wir drei strukturelle Änderungen vor. Erstens verlagerten wir die Speicherung von Idempotenzschlüsseln aus dem Arbeitsspeicher in die Datenbank, weil der Arbeitsspeicher Container-Neustarts nicht übersteht. Zweitens protokollierten wir ausdrücklich die Stripe-Ereignis-ID, den Ereignistyp und das Ergebnis jedes Webhook-Handler-Aufrufs, statt nur Fehler festzuhalten. So ließ sich die Ereignisfolge bei der Diagnose widersprüchlicher Bestellzustände rekonstruieren. Drittens verließen wir uns bei der Fehlersuche nicht mehr vorrangig auf das Ereignisprotokoll des Stripe-Dashboards, sondern behandelten unsere eigenen Protokolle als maßgeblich.

Das Stripe-Dashboard ist hilfreich, um Ereignisse interaktiv zu untersuchen. Es zeigt jedoch Stripes Sicht auf die Zustellung, nicht die Sicht Ihrer Anwendung auf den Empfang. Eine vom Dashboard als erfolgreich markierte Zustellung bedeutet nur, dass unser Endpunkt einen 2xx-Status zurückgegeben hat; ob die dahinterliegende Verarbeitung abgeschlossen wurde, zeigt es nicht. Unsere Protokolle zeigten den vollständigen Ablauf; das Dashboard zeigte ein unvollständiges Bild.


Verwandte Beiträge

Weiterführende Beiträge