phpguzzle.org
enrudees
Stripe – Doku

Stripe Checkout in PHP: Hosted, Embedded und Custom Sessions

Stripe Checkout ist der schnellste Weg, eine Zahlung anzunehmen, ohne ein eigenes Kartenformular zu bauen. Das Backend erstellt eine Checkout\Session, gibt die URL an den Kunden weiter, und Stripe ruft den Webhook auf, sobald das Geld angekommen ist. Die UI gibt es in drei Varianten: Hosted (Seite auf Stripes Domain), Embedded (iFrame auf der eigenen Seite) und Custom (Checkout-Primitiven, die man selbst anordnet, GA seit Anfang 2025).

Dieser Artikel behandelt die PHP-Seite aller drei Varianten. Jedes Beispiel nutzt das stripe-php SDK; das Frontend-Gegenstück ist nur so viel wie nötig, um die Beispiele lauffähig zu machen.

Checkout Session vs PaymentIntent vs Payment Element

Diese drei Begriffe tauchen ständig in der Dokumentation auf, und viele SO-Threads entstehen, weil sie verwechselt werden.

  • PaymentIntent ist das Low-Level-Zahlungsobjekt. Es enthält Betrag, Währung, Status und ein Secret. Jede Stripe-Zahlung erzeugt intern einen PaymentIntent.
  • Payment Element ist eine JavaScript-UI-Komponente. Man erstellt den PaymentIntent selbst, mountet das Element auf der eigenen Seite und bestätigt die Zahlung per JS.
  • Checkout Session ist ein höheres Objekt, das den gesamten Flow umfasst: UI, Zahlungsmethoden-Erfassung, Steuer, Versand, Gutscheine. Man erstellt die Session, übergibt die URL an den Kunden, Stripe betreibt die Seite. Intern erzeugt die Session einen PaymentIntent.

Die Wahl ist ein Trade-off zwischen Kontrolle und Implementierungsaufwand. Wenn eine Seite mit Positionen, Steuern und Apple Pay reicht, Checkout nehmen. Wenn ein mehrstufiger Flow mit eigener Validierung nötig ist, Payment Element mit eigenem PaymentIntent verwenden. Mit Checkout anfangen, zu Elements wechseln, wenn Checkout das, was das Produkt braucht, tatsächlich nicht kann.

Checkout hat ein eigenes Webhook-Event: checkout.session.completed feuert, sobald die Session bezahlt ist. payment_intent.succeeded feuert ebenfalls, aber bei Checkout-Flows lauscht man auf das Session-Event.

Die drei UI-Modi

ModusWo die UI lebtRückkehr des Kunden
HostedAuf Stripes Domain (checkout.stripe.com)Browser-Redirect auf success_url
EmbeddedInline-iFrame auf eigener Seitereturn_url mit {CHECKOUT_SESSION_ID}
CustomEigene Seite, eigenes HTML, Checkout-Primitivenreturn_url mit {CHECKOUT_SESSION_ID}

Hosted ist der Standard und was die meisten Tutorials zeigen. Embedded ging Ende 2023 in GA und ist der Standardweg, wenn die Checkout-UI ohne Redirect-Rundreise auf der eigenen Seite eingebettet sein soll. Custom Checkout (ui_mode: 'custom') ging Anfang 2025 in GA und passt, wenn der Embedded-iFrame zu starr für das eigene Design ist.

Eine minimale Hosted Session

Ein funktionierender Endpoint, der eine Session erstellt und den Browser zu Stripe weiterleitet:

<?php
require __DIR__ . '/vendor/autoload.php';

\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

$session = \Stripe\Checkout\Session::create([
    'mode' => 'payment',
    'line_items' => [[
        'price' => 'price_1OExampleHandle',
        'quantity' => 1,
    ]],
    'success_url' => 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url'  => 'https://example.com/cart',
    'metadata' => [
        'order_id' => $orderId,
    ],
]);

header('Location: ' . $session->url, true, 303);
exit;

Das ist das gesamte Backend. Der Client folgt dem Redirect. Zum Testen die Karte 4242 4242 4242 4242 mit beliebigem Ablaufdatum und CVC eingeben. Für 3DS-Tests 4000 0025 0000 3155, für Ablehnungen 4000 0000 0000 0002. Die vollständige Liste steht in den Stripe-Docs.

mode bestimmt die Art der Transaktion. payment ist eine einmalige Zahlung. subscription berechnet wiederkehrende Preise. setup speichert eine Zahlungsmethode ohne Belastung (für “Karte hinzufügen”-Flows). payment_intent_data zusammen mit 'mode' => 'subscription' zu übergeben, liefert invalid_request – in dem Fall subscription_data verwenden.

line_items unterstützt zwei Formen. Die empfohlene ist eine price-ID, die im Dashboard (oder per API) erstellt wurde. Inline price_data funktioniert auch, aber Dashboard-Preise erlauben Nicht-Entwicklern, Texte und Beträge ohne Deployment zu ändern:

// Dashboard-Preis (empfohlen)
'line_items' => [[ 'price' => 'price_1OExampleHandle', 'quantity' => 1 ]],

// Inline-Preis (für einmalige, dynamische Beträge)
'line_items' => [[
    'price_data' => [
        'currency' => 'eur',
        'product_data' => ['name' => 'Pro-Plan, ein Monat'],
        'unit_amount' => 1999,
    ],
    'quantity' => 1,
]],

Beträge in Minoreinheiten: 1999 heißt 19,99 EUR. Null-Dezimal-Währungen (JPY, KRW, HUF) werden als Ganzzahl übergeben, nicht mit 100 multiplizieren.

success_url ist bei Hosted-Modus Pflicht. cancel_url ist optional (ohne zeigt Checkout einen generischen Zurück-Button). Das Token {CHECKOUT_SESSION_ID} ist literaler Text; Stripe ersetzt ihn beim Redirect. Auf der Danke-Seite die Session anhand dieser ID abrufen und status === 'complete' sowie payment_status === 'paid' prüfen, bevor die Bestellung ausgeliefert wird. Ein Nutzer, der die Erfolgs-URL als Lesezeichen gespeichert hat, darf nicht als bezahlt gelten.

metadata ist ein Key-Value-Container, der mit der Session und dem Webhook-Event mitreist. Hier die eigenen IDs ablegen, damit der Webhook-Handler die richtige Bestellung als bezahlt markieren kann.

Embedded Checkout

Die Embedded-Variante ersetzt den Redirect durch einen Inline-iFrame. Die Backend-Änderung ist minimal: success_url/cancel_url durch ui_mode und return_url ersetzen und den client_secret ans Frontend zurückgeben:

$session = \Stripe\Checkout\Session::create([
    'ui_mode' => 'embedded',
    'mode' => 'payment',
    'line_items' => [[ 'price' => 'price_1OExampleHandle', 'quantity' => 1 ]],
    'return_url' => 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
    'metadata' => [ 'order_id' => $orderId ],
]);

header('Content-Type: application/json');
echo json_encode(['clientSecret' => $session->client_secret]);

Auf der Seite den iFrame mit Stripe.js mounten:

<div id="checkout"></div>
<script src="https://js.stripe.com/v3/"></script>
<script>
const stripe = Stripe('pk_live_...');

fetch('/create-checkout-session.php', { method: 'POST' })
    .then(r => r.json())
    .then(({ clientSecret }) => stripe.initEmbeddedCheckout({ clientSecret }))
    .then(checkout => checkout.mount('#checkout'));
</script>

Zwei Stolperfallen. Erstens: im Browser braucht man den Publishable Key (pk_, nicht sk_). Zweitens: auch der Embedded Checkout leitet die gesamte Seite auf return_url weiter, wenn die Zahlung abgeschlossen ist – der iFrame ist kein SPA. Wenn der Kunde auf der Seite bleiben soll, die return_url als dünne Landing-Page gestalten, die per JS das UI aktualisiert.

redirectToCheckout gibt es nicht mehr

Viele alte SO-Antworten zeigen stripe.redirectToCheckout({ sessionId }) als Client-Flow. Diese Methode wurde aus Stripe.js in der Version 2025-09-30 entfernt. Der aktuelle Weg:

  • Hosted-Modus: Server-seitiger Location-Redirect (wie im ersten Beispiel).
  • Embedded-Modus: stripe.initEmbeddedCheckout({ clientSecret }).

Wenn ein Tutorial redirectToCheckout aufruft, ist das ein Zeichen, nach aktuellerer Dokumentation zu suchen.

customer vs customer_email

Checkout kann das E-Mail-Feld auf zwei Arten vorausfüllen:

// Option A: existierenden Customer anhängen
'customer' => 'cus_ExistingCustomerId',

// Option B: Formular für neuen Customer vorausfüllen
'customer_email' => $user->email,

Wenn man customer übergibt und der Customer schon eine E-Mail hat, füllt Checkout das Feld aus dem Customer-Datensatz und sperrt es – der Käufer kann es nicht ändern. customer_email wird in dem Fall ignoriert. Übergibt man customer_email ohne customer, füllt Checkout ein editierbares Feld und erstellt einen neuen Customer beim Abschluss der Session. Bewusst eine der beiden Varianten wählen, um gesperrte E-Mail-Felder zu vermeiden.

Metadata: die Brücke zum Webhook

Die Metadata-Frage ist ein wiederkehrendes SO-Thema, oft formuliert als “mein metadata ist nicht im Event”. Zwei Dinge sorgen für Verwirrung:

  1. Metadata gehört auf die Session, nicht auf line_items. Die API akzeptiert line_items[].metadata, aber das bleibt auf dem Line Item – es taucht nicht im Event-Payload auf, den die meisten Integrationen abhören, und es propagiert nicht auf den PaymentIntent. Bestell-ID, User-ID und alles andere auf die Session-Ebene legen:

    'metadata' => [
        'order_id' => $orderId,
        'user_id'  => $userId,
    ],
  2. Metadata auf der Session landet nicht automatisch auf dem PaymentIntent. Wenn der Webhook auf payment_intent.succeeded hört und $event->data->object->metadata ausliest, ist es leer, sofern nicht auch payment_intent_data.metadata gesetzt wurde:

    'payment_intent_data' => [
        'metadata' => [ 'order_id' => $orderId ],
    ],

    Der einfachere Weg: auf checkout.session.completed hören und Metadata direkt vom Session-Objekt im Event lesen. Keine Duplizierung nötig.

Auf eine abgeschlossene Session reagieren

Nach der Zahlung feuert Stripe checkout.session.completed an den Webhook-Endpoint. Dieses Event ist das kanonische Signal, dass die Session fertig ist.

// Im Webhook-Endpoint (Signatur-Verifizierung: siehe Fehler-Referenz)
if ('checkout.session.completed' === $event->type) {
    $session = $event->data->object;
    $orderId = $session->metadata->order_id ?? null;
    $paymentStatus = $session->payment_status; // 'paid', 'unpaid', 'no_payment_required'

    if ('paid' === $paymentStatus && $orderId) {
        $orderRepo->markPaid($orderId, $session->id);
    }
}

Signatur-Verifizierung, Raw-Body-Erfassung und Idempotenz-Handling sind in der Fehler-Referenz beschrieben. Checkout ohne Webhook zu bauen heißt, von fehlgeschlagenen Zahlungen erst durch Kunden-E-Mails zu erfahren.

Noch ein Detail: checkout.session.async_payment_succeeded und checkout.session.async_payment_failed feuern bei verzögerten Zahlungsmethoden (SEPA-Lastschrift, Bacs, Bank-Debits), die Stunden oder Tage nach dem Session-Abschluss abgewickelt werden. Wer verzögerte Methoden akzeptiert, sollte die Session bei completed als “in Bearbeitung” behandeln und erst beim async-Event ausliefern.

Session auf der Erfolgsseite abrufen

Nach einem Hosted-Redirect erhält die Danke-Seite einen session_id Query-Parameter. Die Session laden und entscheiden, was angezeigt wird:

<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

$sessionId = $_GET['session_id'] ?? '';
if (1 !== preg_match('/^cs_[a-zA-Z0-9_]+$/', $sessionId)) {
    http_response_code(400);
    exit('Bad session id');
}

$session = \Stripe\Checkout\Session::retrieve([
    'id' => $sessionId,
    'expand' => ['payment_intent', 'customer'],
]);

if ('complete' === $session->status && 'paid' === $session->payment_status) {
    echo "Danke, {$session->customer_details->email}";
} else {
    echo 'Zahlung wird noch verarbeitet.';
}

Die Regex auf session_id ist nicht optional. Session-IDs sind opak, und ein Nutzer, der eine fremde ID einsetzt, sollte einen sauberen Fehler sehen, keinen Exception-Trace. Das Format vor dem API-Aufruf prüfen ist billige Verteidigung.

Diese Seite ist nicht die Wahrheit für “Bestellung bezahlt”. Der Webhook ist die Wahrheit. Die Erfolgsseite ist nur UI.

Ablauf und Gültigkeit

Checkout Sessions laufen 24 Stunden nach Erstellung ab. Das ist ein hartes Limit, das sich nicht verlängern lässt. Nach Ablauf liefert die URL einen Fehler und die Session wechselt zu expired. Stripe feuert checkout.session.expired, damit man reservierten Bestand freigeben, den Entwurfs-Auftrag stornieren oder eine Erinnerung senden kann.

Die Standardlaufzeit lässt sich verkürzen, indem man expires_at als Unix-Timestamp übergibt (mindestens 30 Minuten ab Erstellung):

'expires_at' => time() + 30 * 60, // 30 Minuten ab jetzt

Kurze Laufzeiten passen zu Bestandsreservierungen: Session erstellen, Ware reservieren, Reservierung bei checkout.session.expired aufheben. Für Links, die länger als 24 Stunden gelten sollen, ein anderes Primitiv verwenden – PaymentLink (teilbare URL, kein Ablauf) für Sofortzahlungsseiten oder Invoice für Zahlung auf Rechnung.

Eine Session lässt sich auch programmatisch ablaufen lassen, wenn ein Nutzer wegnavigiert und der Bestand sofort freigegeben werden soll:

\Stripe\Checkout\Session::expire($session->id);

Troubleshooting

Checkout-Seite ist leer / lädt nicht

Zuerst die Content Security Policy prüfen. Stripes Embedded-iFrame lädt Scripts von https://js.stripe.com und öffnet Frames auf https://checkout.stripe.com. Wenn die CSP das nicht erlaubt, mountet die Seite einen leeren iFrame und die Browser-Konsole füllt sich mit CSP-Verstößen. Die Lösung liegt auf der eigenen Seite:

frame-src https://js.stripe.com https://checkout.stripe.com;
script-src https://js.stripe.com;
connect-src https://api.stripe.com;

Im Hosted-Modus ist die andere häufige Ursache eine AdBlock-Regel, die checkout.stripe.com matcht. Serverseitig gibt es dagegen kein Mittel. Die Erkennung läuft über den Vergleich der checkout.session.completed-Rate mit der Session-Erstellungsrate und einen Alert bei Abweichung.

”No such checkout_session”

Man greift im Live-Modus auf eine Session-ID zu, die im Testmodus erstellt wurde, oder umgekehrt. Prüfen, welchen Key (sk_test_ vs. sk_live_) das SDK beim Erstellen und beim Abrufen verwendet. Modus-Mismatches passieren besonders leicht bei teilweise aktualisierten Deployments. Mehr dazu in der Fehler-Referenz.

Metadata fehlt im Event

Siehe den Metadata-Abschnitt oben – entweder liegt das Metadata auf line_items[].metadata (taucht nicht im Event auf) oder der Webhook hört auf payment_intent.succeeded ohne dass payment_intent_data.metadata auf der Session gesetzt wurde. Den Listener auf checkout.session.completed umstellen und das Problem ist gelöst.

Zahlungsmethoden werden nicht angezeigt

Verfügbare Zahlungsmethoden hängen von Währung, Land und der Konfiguration des Stripe-Kontos ab. Die Payment-Methods-Einstellungen im Dashboard prüfen (nicht nur für die einzelne Session). Google Pay und Apple Pay erfordern zusätzlich eine Domain-Verifizierung; das Dashboard bietet dafür eine Self-Service-UI. Bis die Domain verifiziert ist, werden sie nicht gerendert.

Abonnements in einer Zeile

Für wiederkehrende Abrechnungen mode von payment auf subscription umstellen und line_items auf einen wiederkehrenden price zeigen lassen:

$session = \Stripe\Checkout\Session::create([
    'mode' => 'subscription',
    'line_items' => [[ 'price' => 'price_monthly_handle', 'quantity' => 1 ]],
    'success_url' => 'https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url'  => 'https://example.com/pricing',
]);

Abo-spezifische Themen – Testphasen, Proration, Kündigungsflows, Billing Portal – gehören in einen eigenen Artikel.

Nächste Schritte

  • Webhook-Endpoint mit Signatur-Verifizierung und idempotenten Handlern aufsetzen.
  • Für einen eigenen Zahlungs-Flow: Payment Element auf einem selbst verwalteten PaymentIntent, beschrieben in der Payment-Integration.
  • Falls eine Session ID im Log als ungültig auftaucht oder der Modus nicht stimmt: die Fehler-Referenz deckt die häufigsten Stripe-PHP-Probleme ab.

Der eigentlich spannende Teil eines Zahlungssystems ist der Webhook-Handler und die Saldierung, nicht der Session-Code.

Etwas Ungenaues auf dieser Seite entdeckt?

Fehler melden