Stripe Webhooks in PHP: Signatur-Verifizierung und zuverlässige Verarbeitung
Was ist ein Stripe Webhook? Stripe sendet per HTTP POST einen JSON-Body an eine URL, die man registriert hat, sobald etwas Relevantes auf dem Konto passiert: eine Zahlung ist durchgegangen, ein Abo wurde verlängert, ein Dispute wurde eröffnet, eine Karte läuft bald ab. Der Payload ist mit einem Shared Secret signiert, damit der Server verifizieren kann, dass der Request von Stripe stammt und nicht von jemandem, der die URL geraten hat. In PHP ist die Signaturprüfung ein einziger Methodenaufruf; der Rest des Setups braucht mehr Aufmerksamkeit.
Ein Produktions-Endpoint hat drei Teile: eine HTTP-Route, die den rohen Request-Body liest, einen Aufruf von Stripe\Webhook::constructEvent(), der alles mit falscher Signatur verwirft, und ein Dispatcher, der schnell 200 zurückgibt und die eigentliche Arbeit asynchron erledigt. Wenn einer dieser Teile fehlerhaft ist, sind die Bugs entweder still (Events akzeptiert aber nicht verarbeitet) oder laut um 3 Uhr morgens (Stripe wiederholt drei Tage lang, die Logs füllen sich mit Signaturfehlern).
Dieser Artikel deckt den kompletten Endpoint ab, die Body-Parsing-Fallen in Laravel und Symfony, Retry und Idempotenz, sowie lokales Testen mit der Stripe CLI. Er setzt voraus, dass das SDK installiert und die API-Keys konfiguriert sind; falls nicht, mit der Payment-Integration anfangen.
Ein minimaler funktionierender Endpoint
Ohne Framework, ohne Queue. Das folgende Beispiel ist das Minimum, das Stripe braucht, bevor eigene Logik laufen darf:
<?php
require __DIR__ . '/vendor/autoload.php';
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = getenv('STRIPE_WEBHOOK_SECRET');
try {
$event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
} catch (\Stripe\Exception\UnexpectedValueException $e) {
http_response_code(400);
exit('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit('Invalid signature');
}
// Signatur verifiziert, $event ist ein Stripe\Event-Objekt
error_log("Received {$event->type} ({$event->id})");
http_response_code(200);
Vier Punkte dazu.
php://input ist der rohe, ungeparste Request-Body. Wer den Body auf anderem Weg liest, bei dem schlägt die Signaturprüfung fehl. Dieser Satz ist die häufigste Ursache für Webhook-Bugs in PHP, und die Hälfte dieses Artikels dreht sich darum, wie Frameworks es schwer machen, das einzuhalten.
constructEvent wirft zwei verschiedene Exceptions. UnexpectedValueException (erweitert die PHP-Kernklasse) bedeutet, dass der Body kein valides JSON ist – wahrscheinlich ein Health Check oder jemand, der die URL anpingt. SignatureVerificationException bedeutet, dass der Body geparst wurde, aber die Signatur nicht stimmt – falsches Secret, falscher Endpoint, oder etwas modifiziert den Body auf dem Weg.
Die 200-Antwort geht vor jeder langsamen Operation raus. Stripe erwartet ein 2xx, bevor komplexe Logik läuft. Wer zwölf Sekunden für Datenbank-Schreibvorgänge und E-Mails braucht, bekommt von Stripe einen Timeout, die Zustellung wird als fehlgeschlagen markiert, und ein Retry kommt – jetzt gibt es zwei Events zum Deduplizieren.
$event->type und $event->id sind die zwei Felder, die in jedem Handler vorkommen. Type routet zum Handler, ID ist der Idempotenz-Schlüssel.
Webhook-Secret: woher und welches
Im Stripe Dashboard unter Developers > Webhooks. Einen Endpoint erstellen, die öffentliche URL eintragen, die gewünschten Events auswählen (payment_intent.succeeded, charge.refunded und was zum eigenen Flow passt), und Stripe gibt ein Signing Secret zurück, das mit whsec_ beginnt.
Test- und Live-Modus haben getrennte Dashboards und getrennte Secrets. Wenn man STRIPE_SECRET_KEY von Test auf Live umstellt, muss auch STRIPE_WEBHOOK_SECRET gewechselt werden, und jede Umgebung braucht ihr eigenes Paar. Eine Verwechslung erzeugt die gleiche SignatureVerificationException wie ein manipulierter Payload, und die Fehlermeldung erwähnt den Modus nicht.
Mehrere Endpoints in einem Konto haben unterschiedliche Secrets. Wer Quittungen und Abos auf zwei URLs aufteilt, hat für jeden Endpoint ein eigenes whsec_. Nicht versuchen, eine Umgebungsvariable für beide zu nutzen.
Raw Body: die Laravel- und Symfony-Falle
Beide Frameworks haben Middleware, die eingehendes JSON in ein Array parst, bevor der Controller es sieht. Normalerweise hilfreich. Für einen Stripe-Webhook fatal: die Signatur wird über die Byte-Folge berechnet, die Stripe gesendet hat, und json_decode($body) gefolgt von json_encode($array) ist nicht byte-identisch zum Original – Whitespace, Key-Reihenfolge, numerische Genauigkeit weichen ab.
Laravel. Der Request-Body ist über $request->getContent() (String) weiterhin verfügbar, auch wenn das Framework $request->all() schon befüllt hat. Diesen String an constructEvent übergeben:
use Illuminate\Http\Request;
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature', '');
$secret = config('services.stripe.webhook_secret');
try {
$event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
} catch (\Stripe\Exception\UnexpectedValueException) {
abort(400, 'Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException) {
abort(400, 'Invalid signature');
}
// Dispatch basierend auf $event->type
return response('', 200);
}
Die Route von CSRF-Schutz ausschließen. VerifyCsrfToken gibt 419 zurück, lange bevor constructEvent eine Chance bekommt. In Laravel 11+:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/webhook',
'stripe/*',
]);
})
Die Route auch aus jeder Auth-Middleware herausnehmen. Stripe schickt keine Session-Cookies oder Bearer-Tokens.
Symfony. Der rohe Body ist $request->getContent() (gleicher Methodenname, andere Klasse – Symfony\Component\HttpFoundation\Request). Die Falle: $request->toArray() gefolgt von json_encode() ist nicht byte-identisch zu dem, was Stripe signiert hat. Den rohen String an constructEvent übergeben, nicht das re-encodierte Array.
Idempotenz: Stripe wiederholt drei Tage lang
Stripe versucht, jedes Event bis zu drei Tage mit exponentiellem Backoff zuzustellen (im Live-Modus; im Sandbox-Konto drei Mal über wenige Stunden). Retries passieren bei jedem non-2xx-Response, bei Timeouts, und gelegentlich bei einer erfolgreichen Zustellung, die Stripes System nicht bestätigen konnte. Die Konsequenz: der Handler wird die gleiche event.id mehr als einmal sehen. “Event verarbeiten” muss heißen “höchstens einmal verarbeiten, egal wie oft es ankommt”:
function handleEvent(\Stripe\Event $event, \PDO $db): void
{
$stmt = $db->prepare(
'INSERT INTO processed_webhooks (event_id, type, processed_at)
VALUES (:id, :type, NOW())
ON CONFLICT (event_id) DO NOTHING'
);
$stmt->execute(['id' => $event->id, 'type' => $event->type]);
if (0 === $stmt->rowCount()) {
return; // schon verarbeitet
}
match ($event->type) {
'payment_intent.succeeded' => onPaymentSucceeded($event->data->object),
'charge.refunded' => onRefund($event->data->object),
'customer.subscription.deleted' => onSubscriptionCancelled($event->data->object),
default => null,
};
}
Das Muster: erst einfügen, Geschäftslogik nur bei tatsächlichem Insert. Die Deduplizierung ist atomar mit der Datenbank, kein Race Condition über Redis-Check-then-Act.
Für Webhooks, die an eine Load-Balanced-Flotte gehen, funktioniert die gleiche Tabelle mit Unique Constraint über alle Maschinen. Bei hohem Volumen die Tabelle monatlich partitionieren – Stripes Retries laufen nur drei Tage, event_id-Einzigartigkeit über einen Monat hinaus ist unnötiger Ballast.
Schnell 2xx, echte Arbeit in die Queue
Das Zwei-Phasen-Muster:
- Im HTTP-Handler: Signatur verifizieren,
event.idund Payload in DB oder Queue speichern, sofort200zurückgeben. - Im Worker: Zeile abholen, nach
event.typedispatchen, eigentliche Arbeit mit eigenen Retries erledigen.
Die Trennung ist relevant, weil die eigene langsame Logik jetzt das eigene Problem ist, nicht Stripes. E-Mail-Gateway down? Die eigene Queue wiederholt. Datenbank-Migration sperrt eine Tabelle 60 Sekunden? Der eigene Worker wartet, Stripe nicht.
Laravel mit Queue:
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature', '');
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $signature, config('services.stripe.webhook_secret')
);
} catch (\Stripe\Exception\UnexpectedValueException
| \Stripe\Exception\SignatureVerificationException) {
abort(400);
}
ProcessStripeEvent::dispatch($event->id, $event->type);
return response('', 200);
}
Produktions-Tipp: das Stripe\Event-Objekt nicht in den Job-Payload serialisieren. Es zieht den gesamten verschachtelten Objekt-Graphen – Kundendaten, Zahlungsmethode, Rechnungsadresse – in Redis oder die MySQL-Queue-Tabelle. Nur Event-ID und type übergeben, dann im Worker per \Stripe\Event::retrieve($id) neu laden. PII bleibt auf Stripes Seite, bis der Worker sie tatsächlich braucht.
Mit dem Event-Payload arbeiten
Jedes Event hat die gleiche Hülle:
{
"id": "evt_1ABCDEf2ghIJK3LmNOPQr456",
"type": "payment_intent.succeeded",
"created": 1715775847,
"data": {
"object": { }
},
"livemode": false,
"api_version": "2025-03-31.basil"
}
data.object ist die Ressource, um die es geht – ein PaymentIntent, ein Charge, ein Customer, eine Subscription. Im SDK über $event->data->object erreichbar, typisiert als das entsprechende Stripe-Objekt.
Für einmalige Zahlungen die relevanten Events: payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, charge.dispute.created. Checkout-Integrationen fügen checkout.session.completed hinzu und, für asynchrone Methoden wie SEPA, checkout.session.async_payment_succeeded. Abo-Abrechnung hat die breiteste Oberfläche: customer.subscription.created/updated/deleted, plus invoice.paid und invoice.payment_failed für die tatsächliche Geldbewegung. customer.source.expiring meldet eine Woche vorher, dass eine gespeicherte Karte ablaufen wird.
Im Dashboard nur die Events abonnieren, die man im Code tatsächlich verarbeitet. Alles zu abonnieren macht das Event-Log unlesbar beim Debugging. Die vollständige Event-Liste steht unter docs.stripe.com/api/events/types.
Metadata als Brücke zur eigenen Domäne. Beim Erstellen eines PaymentIntent, einer Checkout Session oder einer Subscription immer ein metadata-Array mit den eigenen IDs anhängen:
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'eur',
'metadata' => ['order_id' => (string) $order->id],
]);
Die Metadata reist mit auf jedem Event zu diesem Intent. Im Webhook:
$orderId = $event->data->object->metadata['order_id'] ?? null;
Keine Datenbank-Suche über Stripe-IDs, keine fragilen Joins.
Eine häufige SO-Frage: “Wie bekomme ich in einem payment_intent.succeeded-Webhook die session_id der Checkout Session?” Die Antwort ist nicht expand – der PaymentIntent hat keine Rückreferenz. Die Session-ID oder die eigene Bestell-ID beim Erstellen der Session in metadata schreiben, dann propagiert sie automatisch.
Event-Reihenfolge ist nicht garantiert
Stripe liefert Events nicht in garantierter Reihenfolge. In der Praxis:
customer.subscription.updatedkann vorcustomer.subscription.createdankommen.charge.refundedkann vorcharge.succeededeintreffen.
Für die meisten Flows kein Problem: Deduplizierung über event.id plus ein retrieve()-Aufruf im Handler (um den aktuellen Zustand der Ressource zu bekommen) macht das Ganze reihenfolge-unabhängig:
case 'customer.subscription.updated':
// Nicht auf den Snapshot im Event vertrauen; aktuellen Stand holen.
$subscription = $stripe->subscriptions->retrieve($event->data->object->id);
updateLocalStatus($subscription);
break;
Ein zusätzlicher API-Aufruf, lohnt sich immer wenn Status-Übergänge wirklich relevant sind.
Lokales Testen mit der Stripe CLI
Man kann http://localhost:8000/stripe/webhook nicht als Stripe-Endpoint registrieren – Stripe braucht eine öffentlich erreichbare URL. Die aktuelle Lösung ist die Stripe CLI, die einen Tunnel speziell für Webhooks öffnet:
# Einmaliges Login (öffnet den Browser)
stripe login
# Events an lokalen Endpoint weiterleiten
stripe listen --forward-to localhost:8000/stripe/webhook
Die CLI gibt beim Start ein Webhook Signing Secret aus – anders als die Secrets im Dashboard, spezifisch für diese Forwarding-Session. In der lokalen .env setzen:
STRIPE_WEBHOOK_SECRET=whsec_xxx_from_cli
Synthetische Events aus einem zweiten Terminal auslösen:
stripe trigger payment_intent.succeeded
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted
Jedes trifft den Endpoint mit einem vollständigen, korrekt signierten Payload. Die gesamte Handler-Logik lässt sich dagegen testen, ohne ein Checkout-UI durchlaufen zu müssen.
Für zeitbasierte Events – Abo-Verlängerungen, Trial-Ende, Kündigung zum Periodenende – reicht stripe trigger nicht, weil diese erst feuern, wenn die Uhrzeit voranschreitet. Test Clocks simulieren die Zeit:
stripe test_helpers test_clocks create --frozen-time 1735689600
stripe test_helpers test_clocks advance <clock_id> --frozen-time 1738713600
Einen Test Clock beim Erstellen an den Customer hängen, und jedes Billing-Event dieses Kunden folgt der simulierten Zeit.
Reverse-Proxy und Edge-Network
Der Raw-Body-Vertrag erstreckt sich über den PHP-Code hinaus. Alles zwischen Stripe und dem Handler, das die Bytes verändert, bricht die Signatur.
Cloudflare und ähnliche CDNs können gzip-komprimieren oder minimieren – das betrifft Outbound. Prüfen, ob der Plan Body-Transformationen auf Inbound-Requests anwendet. Für den Webhook-Pfad eine Configuration Rule anlegen, die Optimierungen deaktiviert.
nginx proxy_pass ist normalerweise transparent, aber Request-Buffering-Einstellungen zusammen mit Modulen wie sub_filter oder einer WAF-Schicht können die Bytes verändern. Wenn die Signaturprüfung lokal funktioniert, aber hinter dem Proxy fehlschlägt: proxy_buffering, proxy_request_buffering und Body-Rewriting-Direktiven am Webhook-Pfad prüfen.
Zur Diagnose eines Signatur-Fehlers in Produktion: die ersten 50 Bytes von $payload zusammen mit dem Signatur-Header loggen, dann mit dem Payload im Dashboard-Webhook-Log vergleichen. Wenn die Bytes abweichen, verändert etwas in der Infrastruktur den Body.
API-Version-Skew
Ein subtiles Problem bei langlebigen Integrationen. Die Struktur der Event-Payloads richtet sich nach der API-Version, die am Endpoint im Dashboard gepinnt ist, nicht nach der Version im SDK-Code. Wenn der Dashboard-Endpoint auf 2020-08-27 gepinnt ist und die SDK-Aufrufe stripe_version auf 2025-03-31.basil setzen, kommen die Events im 2020er-Format, obwohl die ausgehenden API-Aufrufe das 2025er-Format nutzen.
Konsequenz: Feldnamen und Verschachtelungen in der aktuellen Stripe-Dokumentation stimmen möglicherweise nicht mit dem überein, was der Endpoint erhält. Die Version des eigenen Endpoints steht im Dashboard unter Developers > Webhooks > [Endpoint] > API version. Bei einem neuen Endpoint auf die gleiche Version pinnen, die auch für ausgehende API-Aufrufe verwendet wird, und beide synchron halten.
Häufige Probleme
VerifyCsrfToken gibt 419 zurück. Laravel-spezifisch. Die Webhook-Route vom CSRF-Schutz ausschließen, siehe den Laravel-Abschnitt oben.
SignatureVerificationException bei jedem Request. Drei übliche Ursachen: falsches whsec_ (Test/Live verwechselt oder falsche Endpoint-Kopie), eine Middleware encodiert den Body neu, oder ein Proxy verändert ihn. Die ersten Bytes von $payload loggen und mit dem Dashboard-Event-Log vergleichen. Mehr zur Exception-Hierarchie in der Fehler-Referenz.
200-Antwort, aber Events im Dashboard als “Failed” markiert. Der Endpoint braucht zu lange. 200 vor der eigentlichen Verarbeitung zurückgeben.
Events kommen in falscher Reihenfolge. Nicht auf den Snapshot im Event vertrauen, aktuellen Zustand per API abrufen.
ngrok-Tunnel bricht ständig ab. Die Stripe CLI verwenden; sie ist dafür gebaut und läuft stabiler.
Nächste Schritte
Der Webhook-Endpoint ist der einzige Weg, von Ergebnissen zu erfahren, die außerhalb des Request-Response-Zyklus passieren: wenn eine SEPA-Lastschrift vier Tage nach dem Checkout abgewickelt wird, wenn ein Abo nächsten Monat verlängert wird, wenn ein Kunde sechs Wochen später eine Zahlung anfechtet. Ist der Endpoint einmal aufgesetzt, ist das Hinzufügen eines neuen Event-Typs eine einzige Zeile in der Dispatch-Map.
Verwandte Artikel: Payment-Integration richtet die PaymentIntents ein, über die diese Webhooks berichten. Checkout Sessions nutzen checkout.session.completed als primäres Event. Bei Problemen mit der Signatur oder anderen Stripe-PHP-Fehlern hilft die Fehler-Referenz.
Etwas Ungenaues auf dieser Seite entdeckt?
Fehler melden