phpguzzle.org
enrudees
Stripe – Doku

Stripe PHP Fehler und Lösungen

Das Stripe PHP SDK wirft bei jedem fehlgeschlagenen API-Aufruf eine Exception. Der Typ allein sagt aber wenig: CardException deckt alles ab, von fehlenden Mitteln bis zur gesperrten Karte, und die Fehlermeldung unterscheidet nicht zwischen den Ursachen. Dazu kommen Probleme, die gar nicht von Stripe stammen: cURL error 60 entsteht durch eine fehlende SSL-Konfiguration auf dem lokalen Server, trifft aber zuerst die Stripe-Requests, weil die über HTTPS laufen.

Stripe PHP funktioniert nicht: Erste Schritte

Bevor man einzelne Fehlermeldungen analysiert, eine kurze Checkliste:

  1. Paket installiert? composer show stripe/stripe-php
  2. Autoloader eingebunden? require __DIR__ . '/vendor/autoload.php';
  3. PHP 8.0+ empfohlen? php -v (das SDK unterstützt ab 7.2, Beispiele hier nutzen PHP 8)
  4. cURL-Extension aktiv? php -m | grep curl
  5. API-Schlüssel gesetzt? echo getenv('STRIPE_SECRET_KEY');
  6. Schlüssel im gleichen Modus? sk_test_ mit pk_test_, oder sk_live_ mit pk_live_

Wenn alles stimmt und Stripe trotzdem nicht funktioniert, hilft einer der folgenden Abschnitte.

Exception-Handling (try/catch)

Das SDK nutzt eine Ausnahme-Hierarchie mit der Basisklasse \Stripe\Exception\ApiErrorException; die API-Referenz listet jede Klasse darin auf. Nur den Basistyp zu fangen funktioniert, verliert aber die Information, was genau schiefgelaufen ist. Gar nicht fangen endet in einem Fatal Error bei der ersten abgelehnten Zahlung.

Ein try/catch-Block, der alle Typen abdeckt:

$stripe = new \Stripe\StripeClient('sk_test_...');

try {
    $intent = $stripe->paymentIntents->create([
        'amount' => 3500,
        'currency' => 'eur',
        'payment_method' => $paymentMethodId,
        'confirm' => true,
    ]);
} catch (\Stripe\Exception\CardException $e) {
    // Karte abgelehnt: fehlende Deckung, abgelaufen, falsche CVC, Betrugsverdacht
    $decline = $e->getError()?->decline_code;
    error_log("Card declined [{$decline}]: " . $e->getMessage());

} catch (\Stripe\Exception\AuthenticationException $e) {
    // Ungültiger oder widerrufener API-Schlüssel
    error_log('Stripe auth failed: ' . $e->getMessage());

} catch (\Stripe\Exception\InvalidRequestException $e) {
    // Falsche Parameter: nicht existierendes Objekt, falsches Format, Modus-Mismatch
    $param = $e->getError()?->param;
    error_log("Invalid param '{$param}': " . $e->getMessage());

} catch (\Stripe\Exception\ApiConnectionException $e) {
    // Netzwerkproblem zwischen Server und Stripe
    // Mit Idempotency Key sicher wiederholbar
    error_log('Stripe connection error: ' . $e->getMessage());

} catch (\Stripe\Exception\RateLimitException $e) {
    // Zu viele Requests, Backoff nötig
    error_log('Stripe rate limit hit');

} catch (\Stripe\Exception\PermissionException $e) {
    // Restricted API Key hat keine Berechtigung für diese Operation
    error_log('Stripe permission denied: ' . $e->getMessage());

} catch (\Stripe\Exception\ApiErrorException $e) {
    // 500-Fehler auf Stripe-Seite (selten). Prüfe status.stripe.com
    error_log('Stripe error: ' . $e->getMessage());
}

Der Nullsafe-Operator (?->) ist nötig, weil getError() bei manchen Exception-Typen null zurückgibt.

Jede Exception liefert Details zum Debuggen:

$status = $e->getHttpStatus();      // 402, 400, 401, 429...
$type = $e->getError()?->type;      // card_error, invalid_request_error...
$code = $e->getError()?->code;      // expired_card, resource_missing...
$param = $e->getError()?->param;    // welcher Parameter den Fehler verursacht hat
$message = $e->getError()?->message; // technische Beschreibung auf Englisch

Die Zuordnung von HTTP-Status zu Exception-Klasse:

StatusExceptionBedeutung
400InvalidRequestExceptionFalsche Parameter, nicht existierendes Objekt
401AuthenticationExceptionUngültiger oder widerrufener API-Schlüssel
402CardExceptionKarte abgelehnt
403PermissionExceptionRestricted Key ohne Berechtigung
429RateLimitExceptionZu viele Requests
500+ApiErrorExceptionStripe-seitiger Fehler (selten)

$e->getMessage() und $e->getError()?->message enthalten technische Beschreibungen für Entwickler. Dem Endnutzer darf man diese Texte nicht direkt anzeigen.

cURL error 60: SSL-Zertifikat

Der häufigste Fehler beim ersten Stripe-Setup auf einem lokalen Server. PHP kann das SSL-Zertifikat nicht verifizieren, weil das Root-Zertifikat-Bundle fehlt:

cURL error 60: SSL certificate problem: unable to get local issuer certificate

Das ist kein Stripe-Fehler. Er tritt bei jedem HTTPS-Request über cURL auf. Auf XAMPP, WAMP, MAMP und frischen PHP-Installationen unter Windows ist das Zertifikat-Bundle nicht konfiguriert. Linux-Distributionen mit Paketmanager haben das Problem selten, weil apt und yum automatisch ca-certificates installieren.

Lösung:

  1. Die aktuelle cacert.pem von https://curl.se/docs/caextract.html herunterladen

  2. Die Datei an einem festen Ort ablegen. Windows: C:\php\extras\ssl\cacert.pem. macOS/Linux: /etc/ssl/certs/cacert.pem

  3. Beide Direktiven in der php.ini setzen:

curl.cainfo = "C:\php\extras\ssl\cacert.pem"
openssl.cafile = "C:\php\extras\ssl\cacert.pem"
  1. Den Webserver neu starten (Apache, nginx + php-fpm)

Eine XAMPP-Falle: PHP lädt manchmal eine andere php.ini als die, die man gerade editiert. Welche Datei tatsächlich geladen wird:

echo php_ini_loaded_file();

Test nach der Korrektur:

$ch = curl_init('https://api.stripe.com');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
if (false === $result) {
    echo 'cURL error: ' . curl_error($ch);
} else {
    echo 'SSL works';
}
curl_close($ch);

SSL-Verifizierung mit CURLOPT_SSL_VERIFYPEER = false abschalten ist keine Lösung. Auf Stack Overflow wird das oft empfohlen. Bei Zahlungsverarbeitung öffnet das die Tür für Man-in-the-Middle-Angriffe. Kartendaten können abgefangen werden.

Test-Modus vs. Live-Modus: No such token

Ein Klassiker beim Produktionsstart:

No such token: tok_xxx; a similar object exists in test mode,
but a live mode key was used to make this request.

Der Publishable Key im Frontend beginnt mit pk_test_, der Secret Key im Backend mit sk_live_ (oder umgekehrt). Ein Token, der mit einem Test-Schlüssel erstellt wurde, ist für einen Live-Schlüssel unsichtbar. Beide Schlüssel müssen aus dem gleichen Modus stammen.

Schnelle Diagnose:

$pubKey = getenv('STRIPE_PUBLISHABLE_KEY');
$secKey = getenv('STRIPE_SECRET_KEY');

$pubMode = str_starts_with($pubKey, 'pk_live_') ? 'live' : 'test';
$secMode = str_starts_with($secKey, 'sk_live_') ? 'live' : 'test';

if ($pubMode !== $secMode) {
    throw new \RuntimeException(
        "Stripe key mismatch: publishable is {$pubMode}, secret is {$secMode}"
    );
}

Weniger offensichtlich: Objekt-IDs (cus_, pm_, pi_, sub_) sind an einen Modus gebunden. Ein Kunde, der im Testmodus erstellt wurde, existiert im Live-Modus nicht. Bei der Produktivschaltung lassen sich keine IDs aus der Test-Datenbank übernehmen. Jedes Objekt wird neu angelegt.

Auf Plattformen wie Heroku werden Environment-Variablen erst nach einem Prozess-Neustart übernommen, nicht live.

Token kann nur einmal verwendet werden

Tokens (tok_) sind Einmalverwendung:

You cannot use a Stripe token more than once

Ursache ist meistens ein doppeltes Absenden des Formulars. Der Nutzer klickt zweimal auf Bezahlen, der zweite Request versucht den gleichen Token erneut. Im Frontend den Button nach dem ersten Klick deaktivieren. Im Backend einen Idempotency Key verwenden:

$stripe->paymentIntents->create(
    [
        'amount' => 1500,
        'currency' => 'eur',
        'payment_method' => $paymentMethodId,
        'confirm' => true,
    ],
    ['idempotency_key' => 'order_' . $orderId]
);

Der Key wird mindestens 24 Stunden gespeichert. Die Bestell-ID ist ein guter Key, weil sie eindeutig und ans Geschäftsobjekt gebunden ist. Aber: den gleichen Key mit anderen Parametern (z.B. geänderter Betrag) zu senden, löst eine IdempotencyException (HTTP 400) aus.

Tokens (tok_) sind der Legacy-Weg. Die Charges API + Token wurde durch PaymentMethod (pm_) + PaymentIntent ersetzt. Ein PaymentMethod ist nicht auf einmalige Verwendung beschränkt: man kann ihn an einen Customer anhängen und wiederverwenden.

Betrag in falschen Einheiten

Stripe erwartet amount in der kleinsten Währungseinheit (Cent, Pence): 5000 bedeutet 50,00 EUR. Wer 50 übergibt, belastet 0,50 EUR. Keine Exception, keine Warnung, nur ein verwirrter Kunde.

// Falsch: belastet 0,50 EUR statt 50,00 EUR
$stripe->paymentIntents->create([
    'amount' => 50,
    'currency' => 'eur',
    'payment_method' => $paymentMethodId,
    'confirm' => true,
]);

// Richtig: Betrag in Cent
$stripe->paymentIntents->create([
    'amount' => 50 * 100,
    'currency' => 'eur',
    'payment_method' => $paymentMethodId,
    'confirm' => true,
]);

Ausnahme: Null-Dezimal-Währungen (JPY, KRW, VND) werden ohne Multiplikation übergeben. Das Minimum für EUR ist 50 Cent; niedrigere Werte liefern amount_too_small. Vollständige Liste der Null-Dezimal-Währungen in der Stripe-Dokumentation.

Zahlung fehlgeschlagen: Decline-Codes und Nutzermeldungen

Wenn eine Stripe-Zahlung fehlschlägt, enthält die CardException einen decline_code. Den Code direkt anzuzeigen, bringt dem Nutzer nichts, und manche Codes wie stolen_card sollte man maskieren.

function declineMessage(string $code): string
{
    return match ($code) {
        'insufficient_funds' => 'Nicht genügend Deckung. Bitte eine andere Karte verwenden.',
        'expired_card' => 'Diese Karte ist abgelaufen.',
        'incorrect_cvc' => 'Der Sicherheitscode (CVC) ist falsch.',
        'incorrect_number' => 'Die Kartennummer ist falsch.',
        'incorrect_pin' => 'Die PIN ist falsch.',
        'card_velocity_exceeded' => 'Zu viele Transaktionen mit dieser Karte. Später erneut versuchen.',
        'lost_card', 'stolen_card', 'pickup_card'
            => 'Diese Karte kann nicht verwendet werden. Bitte die Bank kontaktieren.',
        'generic_decline', 'do_not_honor'
            => 'Die Bank hat die Zahlung abgelehnt. Bitte eine andere Karte verwenden.',
        'processing_error' => 'Ein Verarbeitungsfehler ist aufgetreten. In einer Minute erneut versuchen.',
        default => 'Zahlung abgelehnt. Bitte eine andere Karte oder Zahlungsmethode verwenden.',
    };
}

In der Praxis machen generic_decline, insufficient_funds und expired_card etwa 80% aller Ablehnungen aus. do_not_honor ist die Standard-Ablehnung vieler Banken, wenn das interne Fraud-Modell anschlägt – kein Code-Problem, sondern eine Entscheidung der Bank. card_velocity_exceeded bedeutet zu viele Versuche mit derselben Karte in kurzer Zeit; den Nutzer bitten, es später erneut zu versuchen, nicht sofort wiederholen. Die vollständige Liste findet sich in der Decline-Code-Referenz.

Testkartennummern für Decline-Szenarien:

  • 4000000000000002 – generic decline
  • 4000000000009995 – insufficient funds
  • 4000000000000069 – expired card
  • 4000000000000127 – incorrect CVC

Ein Sonderfall: authentication_required. Das ist keine Ablehnung, sondern eine Anforderung für 3D Secure. Die Bank will, dass der Karteninhaber sich authentifiziert. Der PaymentIntent wechselt zu requires_action, und das Frontend muss die Authentifizierung abschließen:

if ('requires_action' === $intent->status) {
    echo json_encode([
        'requires_action' => true,
        'client_secret' => $intent->client_secret,
    ]);
    return;
}

Im Frontend ruft stripe.confirmCardPayment(clientSecret) das 3DS-Formular der Bank auf. In Europa passiert das wegen PSD2 bei fast jeder Zahlung.

3D Secure und Strong Customer Authentication

PSD2 in Europa schreibt Strong Customer Authentication (SCA) für Online-Zahlungen vor. In der Praxis bedeutet das 3D Secure: Verifizierung per SMS-Code, Push-Benachrichtigung oder Biometrie. Stripe löst 3DS automatisch aus, wenn die ausgebende Bank es verlangt.

Für PHP-Entwickler heißt das: ein PaymentIntent geht nach der Bestätigung nicht direkt auf succeeded. Er bleibt auf requires_action. Wer diesen Status nicht behandelt, hat Zahlungen, die ewig als offen hängen.

On-Session: Kunde ist auf der Seite

Der Standard-Checkout-Flow:

$intent = $stripe->paymentIntents->create([
    'amount' => 4900,
    'currency' => 'eur',
    'payment_method' => $paymentMethodId,
    'confirm' => true,
    'return_url' => 'https://example.com/checkout/complete',
]);

if ('requires_action' === $intent->status) {
    echo json_encode([
        'status' => 'requires_action',
        'client_secret' => $intent->client_secret,
    ]);
    return;
}

if ('succeeded' === $intent->status) {
    fulfillOrder($intent);
}

Das Frontend ruft stripe.confirmCardPayment(clientSecret) auf, Stripe zeigt ein iFrame oder leitet zur Bank-Seite weiter. Nach der Verifizierung feuert der Webhook payment_intent.succeeded.

Off-Session: Belastung ohne anwesenden Kunden

Abonnements, verzögerte Belastungen, Wiederholungszahlungen: der Kunde ist nicht auf der Seite, also gibt es keine Möglichkeit, ein 3DS-Formular anzuzeigen. Wenn die Bank Authentifizierung verlangt, kommt keine normale Response zurück. Das SDK wirft stattdessen eine CardException mit dem Code authentication_required. Der PaymentIntent hat zwar intern den Status requires_action, aber man kommt an ihn nur über das Exception-Objekt heran:

try {
    $intent = $stripe->paymentIntents->create([
        'amount' => 4900,
        'currency' => 'eur',
        'customer' => $customerId,
        'payment_method' => $paymentMethodId,
        'off_session' => true,
        'confirm' => true,
    ]);
} catch (\Stripe\Exception\CardException $e) {
    if ('authentication_required' === $e->getError()?->code) {
        $intentId = $e->getError()->payment_intent->id;
        // E-Mail an den Kunden mit Link zur Zahlung
        sendAuthenticationEmail($customerId, $intentId);
    }
}

Der Kunde klickt den Link, das Frontend holt den client_secret vom PaymentIntent und ruft stripe.confirmCardPayment auf. Nach Abschluss von 3DS wird die Zahlung durchgeführt.

Um die Häufigkeit zu reduzieren: beim Erstellen des SetupIntent für die initiale Kartenspeicherung usage: 'off_session' mitgeben. Stripe bittet dann die Bank, zukünftige Belastungen ohne erneute Authentifizierung zuzulassen.

Alternativ erzwingt der Parameter error_on_requires_action => true dasselbe Verhalten auch ohne off_session. Das SDK wirft dann bei jeder 3DS-Anforderung eine CardException, statt requires_action als Status zurückzugeben. Nützlich in Cron-Jobs und Queue-Workern, wo kein Frontend für die Authentifizierung bereitsteht.

3DS-Testkarten

  • 4000000000003220 – Authentifizierung erforderlich, Kunde bestätigt
  • 4000000000003063 – Authentifizierung erforderlich, Kunde lehnt ab
  • 4000000000003055 – Authentifizierung unterstützt, aber von der Bank nicht verlangt

3DS funktioniert auf localhost. Stripe zeigt ein Test-Formular ohne Weiterleitung zu einer echten Bank.

Webhook: SignatureVerificationException

Stripe signiert jeden Webhook-Request. Wenn die Signatur nicht übereinstimmt, wirft constructEvent eine SignatureVerificationException. In den meisten Fällen ist es keine Manipulation, sondern ein falsches Signing Secret.

$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'];
$endpointSecret = getenv('STRIPE_WEBHOOK_SECRET');

try {
    $event = \Stripe\Webhook::constructEvent(
        $payload,
        $sigHeader,
        $endpointSecret
    );
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400);
    error_log('Webhook signature failed: ' . $e->getMessage());
    exit;
} catch (\UnexpectedValueException $e) {
    http_response_code(400);
    exit;
}

switch ($event->type) {
    case 'payment_intent.succeeded':
        $intent = $event->data->object;
        break;
    case 'payment_intent.payment_failed':
        $intent = $event->data->object;
        $error = $intent->last_payment_error;
        error_log("Payment failed: {$error?->message}");
        break;
}

http_response_code(200);

Warum die Signatur nicht passt:

  • Falsches Secret. Jeder Webhook-Endpoint hat sein eigenes whsec_. Test- und Live-Endpoints haben verschiedene Secrets.
  • Framework hat den Body verändert. constructEvent prüft die Signatur gegen den rohen String. Wenn das Framework den JSON bereits geparst hat, stimmt die Signatur nicht mehr. In Laravel: $request->getContent() verwenden, nicht $request->all() oder $request->json().
  • Test- vs. Live-Secret. Test-Events werden mit dem Test-Secret signiert, Live-Events mit dem Live-Secret.

Für lokale Entwicklung erreichen Webhooks localhost nicht. Die Stripe CLI leitet Events weiter:

stripe listen --forward-to localhost:8080/webhook

Die CLI gibt ein temporäres Signing Secret aus. Dieses verwenden, nicht das whsec_ aus dem Dashboard.

Der Webhook-Endpoint muss schnell HTTP 200 zurückgeben. Wenn die Verarbeitung Zeit braucht (E-Mails senden, Datenbank-Schreibvorgänge), das Event in eine Queue legen, sofort 200 zurückgeben und die eigentliche Arbeit asynchron erledigen. Sonst wertet Stripe die Zustellung als fehlgeschlagen und wiederholt bis zu 3 Tage lang mit exponentiellem Backoff.

Webhook HTTP-Fehlercodes

Wenn der Endpoint einen nicht-2xx-Status zurückgibt, protokolliert Stripe das als fehlgeschlagene Zustellung:

  • 307 – der Server leitet den POST-Request weiter (häufig bei Trailing-Slash-Rewrites in Nginx oder Apache). Die Weiterleitung verliert den Request-Body, der Webhook-Payload kommt nie an. Die URL im Stripe Dashboard an den tatsächlichen Endpoint-Pfad anpassen, oder die Rewrite-Regel für die Route deaktivieren.
  • 400 – Signatur-Verifizierung fehlgeschlagen oder Payload konnte nicht geparst werden
  • 404 – falsche URL im Dashboard konfiguriert
  • 405 – der Server akzeptiert kein POST auf dieser Route
  • 500 – der Handler-Code hat eine unbehandelte Exception geworfen. PHP-Error-Log prüfen (tail -f /var/log/php-fpm/error.log oder je nach Setup). Häufige Ursachen: Datenbank-Connection abgelaufen, fehlende Environment-Variable, Type Error bei der Verarbeitung des Event-Objekts

TLS-Fehler. Stripe verlangt TLS 1.2+ am Webhook-Endpoint. Bei einem abgelaufenen oder selbst-signierten Zertifikat kann Stripe das Event nicht zustellen. Man sieht keinen fehlgeschlagenen Versuch im Dashboard, weil die Verbindung vor HTTP abgelehnt wird. Zertifikat prüfen mit openssl s_client -connect yourdomain.com:443 und sicherstellen, dass die Zertifikatskette gültig ist. Let’s Encrypt funktioniert einwandfrei, solange der Renewal-Job läuft.

Webhook-Zustellversuche lassen sich im Stripe Dashboard unter Developers > Webhooks einsehen. Jeder Versuch zeigt den Response-Code und Body. Hier geht es nur um die Fehler; der komplette Webhooks-Guide zeigt den Receiver von A bis Z – Raw Body, Idempotenz und Queue-Dispatch.

Stripe Checkout funktioniert nicht

Checkout von Grund auf aufzusetzen ist Thema des Checkout-Sessions-Guides; dieser Abschnitt behandelt, was kaputtgeht. Der erste typische Fehler: eine fehlende URL.

You must provide `success_url` for Checkout Sessions in `payment` mode.

Hosted Checkout braucht sowohl success_url als auch cancel_url. Die Session-ID lässt sich in die Success-URL einbetten, um die Zahlung nachträglich abzufragen:

$session = $stripe->checkout->sessions->create([
    'line_items' => [['price' => 'price_xxx', 'quantity' => 1]],
    'mode' => 'payment',
    'success_url' => 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url' => 'https://example.com/cancel',
]);

header('Location: ' . $session->url);
exit; // ohne exit wird der Redirect möglicherweise nicht ausgeführt

Session abgelaufen. Eine Checkout Session lebt 24 Stunden. Status prüfen:

$session = $stripe->checkout->sessions->retrieve($sessionId);

if ('expired' === $session->status) {
    // Neue Session erstellen
}

Checkout-Seite lädt nicht. Bei eingebettetem Checkout deuten CORS-Fehler auf eine falsch konfigurierte Domain in den Stripe-Kontoeinstellungen hin. Die Domain, die die Checkout-Seite ausliefert, muss mit der registrierten Domain im Stripe-Konto übereinstimmen.

Webhook checkout.session.completed kommt nicht an. Die Webhook-URL ist nicht aus dem Internet erreichbar (localhost, Firewall) oder gibt keinen 200-Status zurück. Für die Entwicklung die Stripe CLI nutzen. In Produktion muss der Endpoint schnell HTTP 200 liefern.

Testkarten für Checkout: 4242424242424242 (erfolgreiche Zahlung), 4000000000000002 (Ablehnung).

Rate Limits und Netzwerkfehler

Im Live-Modus erlaubt Stripe 100 Requests pro Sekunde, im Testmodus 25. Bei Überschreitung kommt RateLimitException mit HTTP 429. Ein anderes Problem ist ApiConnectionException: Netzwerk-Timeouts, temporäre DNS-Ausfälle, abgebrochene Verbindungen. Beide Fehler sind vorübergehend und können sicher wiederholt werden.

Sofortiges Wiederholen ohne Pause verschlimmert die Situation. Exponentieller Backoff hilft:

function stripeWithRetry(\Stripe\StripeClient $stripe, callable $operation, int $maxRetries = 3): mixed
{
    for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
        try {
            return $operation($stripe);
        } catch (\Stripe\Exception\RateLimitException|\Stripe\Exception\ApiConnectionException $e) {
            if ($maxRetries === $attempt) {
                throw $e;
            }
            // Exponentielle Pause + zufälliger Jitter
            usleep((int) (pow(2, $attempt) * 1_000_000 + random_int(100_000, 500_000)));
        }
    }
}

$customer = stripeWithRetry($stripe, fn(\Stripe\StripeClient $s) => $s->customers->create([
    'email' => '[email protected]',
]));

Jitter (die zufällige Zugabe) verhindert, dass alle Worker gleichzeitig nach der Pause auf die API zugreifen.

Bei ApiConnectionException kann Stripe nicht garantieren, dass der Request nicht durchgegangen ist. Für schreibende Operationen (create, update) einen Idempotency Key verwenden, damit ein Retry kein Duplikat erzeugt. Leseoperationen (retrieve, list) brauchen keinen.

Für Abonnement-Zahlungen hat Stripe eingebaute Smart Retries, die fehlgeschlagene Belastungen automatisch zu optimalen Zeitpunkten wiederholen. Eigene Retry-Logik für wiederkehrende Abrechnungen ist unnötig: die Konfiguration erfolgt im Dashboard unter Billing > Revenue recovery.

Umgebungsfehler

Nicht API-bezogen, aber sie blockieren den Start.

Class ‘Stripe\StripeClient’ not found. Das Paket ist nicht installiert oder der Autoloader fehlt:

composer require stripe/stripe-php
require __DIR__ . '/vendor/autoload.php';

In Laravel und Symfony ist der Autoloader bereits eingebunden. Dieser Fehler bedeutet: Paket nicht installiert. Prüfen mit composer show stripe/stripe-php.

Code aus einem alten Tutorial funktioniert nicht. Vor 2020 nutzten alle Beispiele die statische API: \Stripe\Stripe::setApiKey('sk_...') gefolgt von \Stripe\Charge::create(...). Das funktioniert noch, aber aktuelle Dokumentation und Beispiele (auch dieser Artikel) verwenden new \Stripe\StripeClient('sk_...'). Der StripeClient ruft Methoden über Properties auf ($stripe->paymentIntents->create(...)), die statische API über Klassen (\Stripe\PaymentIntent::create(...)). StripeClient wird empfohlen: er ist thread-safe und unterstützt verschiedene API-Schlüssel für verschiedene Operationen.

Undefined type ‘Stripe\StripeClient’. Eine IDE-Meldung (PhpStorm, VS Code), kein Runtime-Fehler. Die IDE hat die Autoload-Mappings nicht aktualisiert. composer dump-autoload behebt es in der Regel.

PHP-Version. Das SDK unterstützt PHP 7.2+, die Unterstützung für 7.2-7.3 wird aber bald entfallen. Beispiele in diesem Artikel nutzen PHP 8.0+ Syntax (match, str_starts_with, ?->). Auf PHP 7.x: match durch switch ersetzen und ?-> durch explizite Null-Prüfungen.

Fehlende ext-curl. Das SDK benötigt cURL. Installation auf Debian/Ubuntu: sudo apt-get install php-curl && sudo systemctl restart php8.2-fpm. Auf macOS ist cURL normalerweise integriert.

Fehler sicher loggen

Beim Loggen von Stripe-Fehlern niemals Kartendaten schreiben (PCI DSS). $e->getMessage() ist sicher: keine Kartennummern drin. Wer aber eingehende POST-Requests komplett loggt, fängt möglicherweise Kartendaten vom Frontend ab.

error_log(json_encode([
    'stripe_error' => $e->getError()?->code,
    'decline_code' => $e->getError()?->decline_code,
    'http_status' => $e->getHttpStatus(),
    'param' => $e->getError()?->param,
    'request_id' => $e->getRequestId(), // req_xxx, nützlich beim Stripe-Support
]));

Die request_id beginnt mit req_. Der Stripe-Support kann damit den exakten Request in seinen Logs nachschlagen. Bei jedem Fehler mitspeichern.

Wenn Zahlungen fehlschlagen und der Verdacht besteht, dass Stripe selbst Probleme hat: status.stripe.com prüfen.

Etwas Ungenaues auf dieser Seite entdeckt?

Fehler melden