phpguzzle.org
enrudees
Stripe – Docs

Stripe PHP Errors and How to Fix Them

Every failed Stripe API call throws an exception, but the exception type alone rarely tells the full story. CardException covers everything from insufficient funds to a stolen card flag, and the message won’t distinguish between them. Then there’s the category of errors that have nothing to do with Stripe: cURL error 60 is a local SSL misconfiguration, but it surfaces on Stripe requests because those are the first HTTPS calls most PHP apps make.

Stripe PHP not working: where to start

Before digging into specific errors, a quick checklist:

  1. Package installed? composer show stripe/stripe-php
  2. Autoloader included? require __DIR__ . '/vendor/autoload.php';
  3. PHP 8.0+ recommended? php -v (SDK supports 7.2+, examples below use PHP 8 syntax)
  4. cURL extension enabled? php -m | grep curl
  5. API keys set? echo getenv('STRIPE_SECRET_KEY');
  6. Keys from the same mode? sk_test_ with pk_test_, or sk_live_ with pk_live_

If everything checks out and Stripe still isn’t working, the sections below cover every error you’re likely to hit.

Exception handling (try/catch)

The SDK uses an exception hierarchy rooted at \Stripe\Exception\ApiErrorException, and the API reference lays out every class in it. Catching only the base class works but throws away information about what went wrong. Not catching at all means a Fatal Error on the first declined card.

A try/catch block that covers every exception type:

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

try {
    $intent = $stripe->paymentIntents->create([
        'amount' => 3500,
        'currency' => 'gbp',
        'payment_method' => $paymentMethodId,
        'confirm' => true,
    ]);
} catch (\Stripe\Exception\CardException $e) {
    // Card was declined: insufficient funds, expired, incorrect CVC, fraud
    $decline = $e->getError()?->decline_code;
    error_log("Card declined [{$decline}]: " . $e->getMessage());

} catch (\Stripe\Exception\AuthenticationException $e) {
    // Invalid or revoked API key
    error_log('Stripe auth failed: ' . $e->getMessage());

} catch (\Stripe\Exception\InvalidRequestException $e) {
    // Bad parameters: nonexistent object, wrong format, mode mismatch
    $param = $e->getError()?->param;
    error_log("Invalid param '{$param}': " . $e->getMessage());

} catch (\Stripe\Exception\ApiConnectionException $e) {
    // Network issue between your server and Stripe
    // Safe to retry with an idempotency key
    error_log('Stripe connection error: ' . $e->getMessage());

} catch (\Stripe\Exception\RateLimitException $e) {
    // Too many requests, need backoff
    error_log('Stripe rate limit hit');

} catch (\Stripe\Exception\PermissionException $e) {
    // Restricted API key lacks permissions for this operation
    error_log('Stripe permission denied: ' . $e->getMessage());

} catch (\Stripe\Exception\ApiErrorException $e) {
    // 500 errors on Stripe's end (rare). Check status.stripe.com
    error_log('Stripe error: ' . $e->getMessage());
}

The nullsafe operator (?->) is there because getError() returns null for some exception types.

Every exception carries details you can extract:

$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;    // which parameter caused the error
$message = $e->getError()?->message; // technical description in English

Knowing the HTTP status helps when debugging raw API logs:

StatusExceptionMeaning
400InvalidRequestExceptionBad parameters, nonexistent object
401AuthenticationExceptionInvalid or revoked API key
402CardExceptionCard declined
403PermissionExceptionRestricted key lacks permissions
429RateLimitExceptionToo many requests
500+ApiErrorExceptionStripe-side error (rare)

$e->getMessage() and $e->getError()?->message contain technical descriptions meant for developers. Never show these to end users.

cURL error 60: SSL certificate

The most common error when first setting up Stripe on a local server. PHP can’t verify the SSL certificate because it doesn’t know where the root certificate bundle is:

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

This isn’t a Stripe error. It’ll happen with any HTTPS request through cURL. On XAMPP, WAMP, MAMP, and fresh PHP installs on Windows the certificate bundle isn’t configured. Linux distros with package managers rarely have this problem because apt and yum install ca-certificates automatically.

How to fix it:

  1. Download the latest cacert.pem from https://curl.se/docs/caextract.html

  2. Place the file somewhere stable. On Windows: C:\php\extras\ssl\cacert.pem. On macOS/Linux: /etc/ssl/certs/cacert.pem

  3. Point both directives in php.ini:

curl.cainfo = "C:\php\extras\ssl\cacert.pem"
openssl.cafile = "C:\php\extras\ssl\cacert.pem"
  1. Restart your web server (Apache, nginx + php-fpm)

A common XAMPP trap: PHP loads a different php.ini than the one you’re editing. Check which file is actually loaded:

echo php_ini_loaded_file();

Quick verification after fixing:

$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);

Do not disable SSL verification with CURLOPT_SSL_VERIFYPEER = false. Plenty of answers on Stack Overflow suggest it. With payment processing, disabling verification opens the door to man-in-the-middle attacks. Card data can be intercepted.

Test mode vs Live mode: No such token

A classic when going to production:

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

The publishable key on the frontend starts with pk_test_, while the secret key on the backend starts with sk_live_ (or vice versa). A token created with a test key is invisible to a live key. Both keys must be from the same mode.

Quick diagnostic:

$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}"
    );
}

Less obvious: object IDs (cus_, pm_, pi_, sub_) are bound to a mode. A customer created in test mode doesn’t exist in live mode. When migrating to production, you can’t carry over IDs from your test database. Every object gets created fresh.

On Heroku and similar platforms, environment variables updated in the dashboard aren’t picked up until the process restarts. Environment variables are read at process startup and don’t update live.

Cannot use token more than once

Tokens (tok_) are single-use:

You cannot use a Stripe token more than once

Usually caused by a double form submission. The user clicks pay twice, the second request tries to reuse the same token. On the frontend, disable the button after the first click. On the backend, use an idempotency key:

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

The key is stored for at least 24 hours. Using the order ID is convenient – it’s unique and tied to the business operation. But sending the same key with different parameters (say the amount changed) triggers IdempotencyException (HTTP 400). Catch it explicitly if your flow allows parameter changes between retries:

try {
    $stripe->paymentIntents->create($params, ['idempotency_key' => $key]);
} catch (\Stripe\Exception\IdempotencyException $e) {
    // Same key used with different params, generate a new key
    $stripe->paymentIntents->create($params, ['idempotency_key' => $key . '_v2']);
}

One key, one set of parameters.

Tokens (tok_) are the legacy approach. The Charges API + Token workflow has been replaced by PaymentMethod (pm_) + PaymentIntent. A PaymentMethod isn’t single-use – you can attach it to a customer and reuse it.

Amount in wrong units

Stripe accepts amount in the smallest currency unit (cents, pence): 5000 means $50.00. Pass 50 and you’ll charge $0.50. No exception, no warning – just a confused customer.

// Wrong: charges $0.50 instead of $50
$stripe->paymentIntents->create([
    'amount' => 50,
    'currency' => 'usd',
    'payment_method' => $paymentMethodId,
    'confirm' => true,
]);

// Right: amount in cents
$stripe->paymentIntents->create([
    'amount' => 50 * 100,
    'currency' => 'usd',
    'payment_method' => $paymentMethodId,
    'confirm' => true,
]);

Zero-decimal currencies (JPY, KRW, VND) are the exception – amounts are passed as-is without multiplication. The minimum for USD is 50 cents; anything lower returns amount_too_small. Full list of zero-decimal currencies in the Stripe docs.

Decline codes: what to show users

CardException includes a decline_code. Showing the raw code to users is meaningless, and some codes like stolen_card should be masked.

function declineMessage(string $code): string
{
    return match ($code) {
        'insufficient_funds' => 'Insufficient funds. Please try a different card.',
        'expired_card' => 'This card has expired.',
        'incorrect_cvc' => 'The security code (CVC) is incorrect.',
        'incorrect_number' => 'The card number is incorrect.',
        'incorrect_pin' => 'The PIN is incorrect.',
        'card_velocity_exceeded' => 'Too many transactions on this card. Try again later.',
        'lost_card', 'stolen_card', 'pickup_card'
            => 'This card cannot be used. Contact your bank.',
        'generic_decline', 'do_not_honor'
            => 'The bank declined the payment. Try a different card.',
        'processing_error' => 'A processing error occurred. Try again in a minute.',
        default => 'Payment declined. Try a different card or payment method.',
    };
}

In practice, around 80% of declines are generic_decline, insufficient_funds, and expired_card. The full list is in the decline codes reference.

Test card numbers for reproducing declines:

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

A separate case is authentication_required. This isn’t a decline but a request for 3D Secure: the bank wants the cardholder to verify. The PaymentIntent moves to requires_action, and the frontend must complete authentication:

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

On the frontend, stripe.confirmCardPayment(clientSecret) presents the bank’s 3DS form. In Europe this happens on nearly every payment due to PSD2. More on this in the next section.

3D Secure and Strong Customer Authentication

PSD2 in Europe mandates Strong Customer Authentication for online payments. In practice this means 3D Secure – verification through an SMS code, a push notification, or biometrics. Stripe triggers 3DS automatically when the issuing bank requires it.

For a PHP developer, the effect is that a PaymentIntent doesn’t go straight to succeeded after confirmation. It gets stuck at requires_action. If you don’t handle this status, the payment stays incomplete.

On-session: customer is on your site

The 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) {
    // Send client_secret to frontend for 3DS completion
    echo json_encode([
        'status' => 'requires_action',
        'client_secret' => $intent->client_secret,
    ]);
    return;
}

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

The frontend calls stripe.confirmCardPayment(clientSecret), Stripe shows an iframe or redirects to the bank’s page. After verification, the webhook payment_intent.succeeded fires.

Off-session: charging without the customer present

Subscriptions, deferred charges, repeat payments – the customer isn’t on your site, so there’s no way to show a 3DS form. If the bank demands authentication, the PaymentIntent won’t hang at requires_action. Instead it throws a CardException with code authentication_required:

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;
        // Email the customer a link to complete payment
        sendAuthenticationEmail($customerId, $intentId);
    }
}

The customer clicks the link, the frontend retrieves the client_secret from the PaymentIntent and calls stripe.confirmCardPayment. After passing 3DS the payment completes.

To reduce how often this happens, pass usage: 'off_session' when creating the SetupIntent for the initial card save. Stripe will ask the bank to allow future charges without repeated authentication.

3DS test cards

  • 4000000000003220 – authentication required, customer confirms
  • 4000000000003063 – authentication required, customer declines
  • 4000000000003055 – authentication supported but not required by bank

3DS works on localhost. Stripe shows a test form without redirecting to a real bank.

Webhook: SignatureVerificationException

Stripe signs every webhook request. If the signature doesn’t match, constructEvent throws SignatureVerificationException. Usually it’s not tampering – it’s a wrong 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 (\Stripe\Exception\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);

Why the signature doesn’t match:

  • Wrong secret. Each webhook endpoint has its own whsec_. Test and live endpoints have different secrets.
  • Framework modified the body. constructEvent verifies the signature against the raw string. If your framework already parsed the JSON, the signature won’t match. In Laravel, use $request->getContent(), not $request->all() or $request->json().
  • Test vs live secret. Test mode events are signed with the test secret. Live events use the live secret.

For local development, webhooks can’t reach localhost. Stripe CLI forwards events:

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

The CLI prints a temporary signing secret. Use it instead of the whsec_ from the dashboard.

Your webhook endpoint must return HTTP 200 quickly. If processing takes time (sending emails, writing to the database), queue the event, return 200 immediately, and handle the heavy work asynchronously. Otherwise Stripe considers delivery failed and starts retrying with increasing intervals. Stripe retries up to 3 days with exponential backoff.

Webhook HTTP error codes

If your endpoint returns a non-2xx status, Stripe logs it as a failed delivery:

  • 307 – your server redirects the POST request (common with trailing-slash rewrites in Nginx or Apache). The redirect drops the request body, so the webhook payload never arrives. Fix the URL in the Stripe Dashboard to match the actual endpoint path, or disable the rewrite rule for that route.
  • 400 – usually means signature verification failed or the payload couldn’t be parsed
  • 404 – wrong URL configured in the dashboard
  • 405 – your server doesn’t accept POST on that route
  • 500 – your handler code threw an unhandled exception

TLS errors. Stripe requires TLS 1.2+ on your webhook endpoint. If your server uses an expired or self-signed certificate, Stripe can’t deliver the event at all – you won’t even see a failed attempt in the dashboard because the connection is refused before HTTP. Check your certificate with openssl s_client -connect yourdomain.com:443 and make sure the chain is valid. Let’s Encrypt certificates work fine, just keep the renewal job running.

Check webhook delivery attempts in the Stripe Dashboard under Developers > Webhooks. Each attempt shows the response code and body. This section is about the errors; the full webhooks guide walks through the receiver end to end – raw body, idempotency, and queue dispatch.

Stripe Checkout not working

Building Checkout from scratch is the job of the Checkout Sessions guide; this section is about what breaks. The first thing that usually goes wrong is a missing URL:

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

Hosted Checkout requires both success_url and cancel_url. You can embed the session ID in the success URL to look up the payment later:

$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; // without exit the redirect may not fire

Session expired. A Checkout Session lives for 24 hours. Check the status:

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

if ('expired' === $session->status) {
    // create a new session
}

Checkout page not loading. If using embedded Checkout, CORS errors often point to a misconfigured domain in the Stripe Dashboard. The domain serving the checkout page must match the domain registered in your Stripe account settings.

Webhook checkout.session.completed not arriving. The webhook URL isn’t reachable from the internet (localhost, firewall) or returns a non-200 response. Use Stripe CLI for development. In production, your endpoint must return HTTP 200 quickly.

Test cards for Checkout: 4242424242424242 (successful payment), 4000000000000002 (decline).

Rate limits and network errors

Live mode allows 100 requests per second, test mode 25. Exceeding this triggers RateLimitException with HTTP 429. A separate issue is ApiConnectionException: network timeouts, temporary DNS failures, broken connections. Both errors are transient and safe to retry.

A simple retry without a pause will make things worse. Use exponential backoff:

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;
            }
            // Exponential pause + random 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 (the random addition) prevents all your workers from hammering the API simultaneously after the pause.

With ApiConnectionException, Stripe can’t guarantee the request didn’t go through. Use an idempotency key for mutating operations (create, update) so a retry doesn’t create a duplicate. Read operations (retrieve, list) don’t need one.

For subscription payments, Stripe has built-in Smart Retries that automatically retry failed charges at optimal times. You don’t need to build retry logic for recurring billing – configure it in the Dashboard under Billing > Revenue recovery.

Environment errors

Not related to the API, but they’ll stop you before you get started.

Class ‘Stripe\StripeClient’ not found. The package isn’t installed or the autoloader isn’t included:

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

In Laravel and Symfony the autoloader is already wired up. This error means the package isn’t installed: composer show stripe/stripe-php.

Code from an old tutorial doesn’t work. Before 2020, every example used the static API: \Stripe\Stripe::setApiKey('sk_...') followed by \Stripe\Charge::create(...). This approach still works, but current documentation and examples (including this article) use new \Stripe\StripeClient('sk_...'). The StripeClient calls methods through properties ($stripe->paymentIntents->create(...)), while the static API calls classes directly (\Stripe\PaymentIntent::create(...)). You can mix both in one project, but StripeClient is preferred: it’s thread-safe and supports different API keys for different operations.

Undefined type ‘Stripe\StripeClient’. An IDE complaint (PhpStorm, VS Code), not a runtime error. The IDE hasn’t picked up the autoload mappings. Running composer dump-autoload usually fixes it.

PHP version. The SDK supports PHP 7.2+, but support for 7.2–7.3 will be dropped soon. Examples in this article use PHP 8.0+ syntax (match, str_starts_with, ?->). If you’re on PHP 7.x, replace match with switch and ?-> with explicit null checks.

Missing ext-curl. The SDK depends on cURL. Install on Debian/Ubuntu: sudo apt-get install php-curl && sudo systemctl restart php8.2-fpm. On macOS cURL is usually built in.

Safe error logging

When logging Stripe errors, never log card data (PCI DSS). $e->getMessage() is safe – no card numbers there. But if you log incoming POST requests in full, they may contain card data from the frontend.

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, useful when contacting Stripe support
]));

request_id starts with req_. Stripe support can look up the exact request in their logs using this ID. Save it with every error.

If your application experiences payment failures and you need to check whether Stripe itself is having issues, check status.stripe.com.

Spotted something inaccurate on this page?

Report an error