Ошибки Stripe в PHP: полный список и решения
Stripe PHP SDK бросает исключения при каждой неудачной операции. Типов хватает, сообщения часто неочевидны: один и тот же CardException может означать и нехватку средств, и заблокированную карту, и неверный CVC. Отдельная категория проблем вообще не связана со Stripe: cURL error 60 возникает из-за отсутствия SSL-сертификатов на сервере, но ломает именно Stripe-запросы, потому что они идут по HTTPS.
Stripe PHP не работает: с чего начать
Прежде чем разбираться в конкретных ошибках, быстрая диагностика:
- Пакет установлен?
composer show stripe/stripe-php - Autoloader подключён?
require __DIR__ . '/vendor/autoload.php'; - PHP 8.0+ рекомендуется?
php -v(SDK работает с 7.2+, но примеры ниже на PHP 8) - Расширение cURL включено?
php -m | grep curl - API-ключи заданы?
echo getenv('STRIPE_SECRET_KEY'); - Ключи из одного режима?
sk_test_иpk_test_, илиsk_live_иpk_live_
Если всё на месте, а Stripe не работает, ищите ответ в секциях ниже.
Обработка исключений (try/catch)
Stripe PHP SDK использует иерархию исключений. Базовый класс \Stripe\Exception\ApiErrorException, от него наследуются все остальные – полный список классов в справочнике по API. Ловить только базовый – значит потерять информацию о типе проблемы. Не ловить совсем – приложение упадёт с Fatal Error при первом же отклонённом платеже.
Шаблон, который покрывает все типы:
$stripe = new \Stripe\StripeClient('sk_test_...');
try {
$intent = $stripe->paymentIntents->create([
'amount' => 5000,
'currency' => 'eur',
'payment_method' => $paymentMethodId,
'confirm' => true,
]);
} catch (\Stripe\Exception\CardException $e) {
// Карта отклонена: показать пользователю причину
$decline = $e->getError()?->decline_code;
$msg = $e->getError()?->message;
error_log("Card declined: {$decline}: {$msg}");
} catch (\Stripe\Exception\AuthenticationException $e) {
// Неверный или отозванный API-ключ
error_log('Stripe auth failed: ' . $e->getMessage());
} catch (\Stripe\Exception\InvalidRequestException $e) {
// Ошибка в параметрах: несуществующий объект, неверный формат
$param = $e->getError()?->param;
error_log("Invalid param '{$param}': " . $e->getMessage());
} catch (\Stripe\Exception\ApiConnectionException $e) {
// Сетевая проблема между сервером и Stripe
// Безопасно повторить запрос с idempotency key
error_log('Stripe connection error: ' . $e->getMessage());
} catch (\Stripe\Exception\RateLimitException $e) {
// Слишком много запросов, нужен backoff
error_log('Stripe rate limit hit');
} catch (\Stripe\Exception\ApiErrorException $e) {
// 500-е ошибки на стороне Stripe (редко). Проверьте status.stripe.com
error_log('Stripe error: ' . $e->getMessage());
}
Nullsafe operator (?->) нужен, потому что getError() может вернуть null для некоторых типов исключений.
Детали из исключения:
$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; // какой параметр вызвал ошибку
$message = $e->getError()?->message; // техническое описание на английском
$e->getMessage() и $e->getError()?->message содержат техническое описание для разработчика. Показывать его конечному пользователю нельзя: для пользователя нужно своё сообщение (см. секцию про decline-коды).
cURL error 60: SSL certificate
Самая частая ошибка при первой установке Stripe на локальном сервере. PHP не может проверить SSL-сертификат, потому что не знает, где лежит файл с корневыми сертификатами. К Stripe это не имеет отношения: та же ошибка возникнет при любом HTTPS-запросе через cURL.
cURL error 60: SSL certificate problem: unable to get local issuer certificate
На XAMPP, WAMP, MAMP и свежих установках PHP на Windows этот файл не настроен. На Linux с пакетным менеджером проблема встречается реже: apt или yum ставят ca-certificates автоматически.
Как исправить:
-
Скачать актуальный
cacert.pemс https://curl.se/docs/caextract.html -
Положить файл в место, которое не перезапишется при обновлении сервера. На Windows, например,
C:\php\extras\ssl\cacert.pem, на macOS/Linux –/etc/ssl/certs/cacert.pem -
Прописать путь в
php.ini(обе директивы):
curl.cainfo = "C:\php\extras\ssl\cacert.pem"
openssl.cafile = "C:\php\extras\ssl\cacert.pem"
- Перезапустить веб-сервер (Apache, nginx + php-fpm)
На XAMPP часто подвох: PHP загружает другой php.ini, не тот, который вы редактируете. Проверить, какой файл реально загружен:
echo php_ini_loaded_file();
Быстрая проверка после исправления:
$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 через CURLOPT_SSL_VERIFYPEER = false нельзя. В интернете полно советов “просто отключи”, но при работе с платежами это создаёт возможность для MITM-атаки. Данные карт могут быть перехвачены.
Test mode vs Live mode: No such token
Классика при переходе в продакшн:
No such token: tok_xxx; a similar object exists in test mode,
but a live mode key was used to make this request.
Publishable key на фронтенде начинается с pk_test_, а secret key на бэкенде с sk_live_ (или наоборот). Токен, созданный test-ключом, невидим для live-ключа. Ключи должны быть из одного режима.
Диагностика:
$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}"
);
}
Менее очевидная ситуация: идентификаторы объектов (cus_, pm_, pi_, sub_) привязаны к режиму. Клиент, созданный в test-среде, в live не существует. При переходе в продакшн нельзя переносить ID из тестовой базы, все объекты создаются заново.
На Heroku и подобных платформах бывает так: переменные окружения обновили в дашборде, а процесс не перезапустился. Переменные окружения подхватываются при старте процесса и не обновляются без рестарта.
Decline-коды: что показать пользователю
CardException содержит decline_code. Показывать его в сыром виде бессмысленно, а некоторые коды (stolen_card) лучше маскировать.
function declineMessage(string $code): string
{
return match ($code) {
'insufficient_funds' => 'На карте недостаточно средств. Попробуйте другую карту.',
'expired_card' => 'Срок действия карты истёк.',
'incorrect_cvc' => 'Неверный код безопасности (CVC).',
'incorrect_number' => 'Номер карты введён неправильно.',
'card_velocity_exceeded' => 'Превышен лимит операций по карте. Попробуйте позже.',
'lost_card', 'stolen_card' => 'Карта не может быть использована. Свяжитесь с банком.',
'generic_decline', 'do_not_honor'
=> 'Банк отклонил платёж. Попробуйте другую карту.',
'processing_error' => 'Ошибка обработки. Повторите попытку через минуту.',
default => 'Платёж отклонён. Попробуйте другую карту или способ оплаты.',
};
}
На практике 80% отказов приходится на generic_decline, insufficient_funds и expired_card. Полный список в документации по decline-кодам.
Для воспроизведения decline-ов на тестовой среде Stripe предоставляет специальные номера карт:
4000000000000002– generic decline4000000000009995– insufficient funds4000000000000069– expired card4000000000000127– incorrect CVC
Отдельная история с authentication_required. Это не отказ, а запрос на 3D Secure: банк хочет подтверждения от пользователя. PaymentIntent переходит в requires_action, и фронтенд должен завершить аутентификацию:
if ('requires_action' === $intent->status) {
echo json_encode([
'requires_action' => true,
'client_secret' => $intent->client_secret,
]);
return;
}
На фронтенде stripe.confirmCardPayment(clientSecret) покажет 3DS-форму банка. В Европе это происходит почти при каждом платеже из-за PSD2. Подробнее – в следующей секции.
3D Secure и Strong Customer Authentication
PSD2 в Европе обязывает банки проверять личность плательщика при онлайн-покупках. На практике это 3D Secure – подтверждение через SMS-код, push в банковском приложении или биометрию. Stripe запрашивает 3DS автоматически, когда банк-эмитент этого требует.
Для PHP-разработчика это выглядит так: PaymentIntent после confirm не переходит в succeeded, а застревает в requires_action. Если этот статус не обработать, платёж так и останется незавершённым.
On-session: пользователь на сайте
Стандартный сценарий оформления заказа:
$intent = $stripe->paymentIntents->create([
'amount' => 5000,
'currency' => 'eur',
'payment_method' => $paymentMethodId,
'confirm' => true,
'return_url' => 'https://example.com/checkout/complete',
]);
if ('requires_action' === $intent->status) {
// Отправить client_secret на фронтенд для завершения 3DS
echo json_encode([
'status' => 'requires_action',
'client_secret' => $intent->client_secret,
]);
return;
}
if ('succeeded' === $intent->status) {
fulfillOrder($intent);
}
Фронтенд вызывает stripe.confirmCardPayment(clientSecret), Stripe показывает iframe или редирект на страницу банка. После подтверждения приходит webhook payment_intent.succeeded.
Off-session: списание без участия пользователя
Подписки, отложенные списания, повторные платежи – пользователя на сайте нет, показать 3DS-форму некому. Если банк потребует аутентификацию, PaymentIntent не зависнет в requires_action, а бросит CardException с кодом authentication_required:
try {
$intent = $stripe->paymentIntents->create([
'amount' => 5000,
'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 со ссылкой на страницу оплаты
sendAuthenticationEmail($customerId, $intentId);
}
}
Пользователь переходит по ссылке, фронтенд достаёт client_secret из PaymentIntent и вызывает stripe.confirmCardPayment. После прохождения 3DS платёж завершается.
Чтобы снизить частоту таких ситуаций, при первой привязке карты через SetupIntent передавайте usage: 'off_session'. Stripe попросит банк разрешить будущие списания без повторной аутентификации.
Тестовые карты для 3DS
4000000000003220– аутентификация требуется, пользователь подтверждает4000000000003063– аутентификация требуется, пользователь отклоняет4000000000003055– аутентификация поддерживается, но банк не требует
На localhost 3DS работает: Stripe показывает тестовую форму, перенаправления на настоящий банк не происходит.
Cannot use token more than once
Токены (tok_) одноразовые:
You cannot use a Stripe token more than once
Обычно двойной клик по кнопке оплаты. Форма отправляется дважды, второй запрос пытается использовать тот же токен. На фронтенде достаточно заблокировать кнопку после первого клика. На бэкенде – idempotency key:
$stripe->paymentIntents->create(
[
'amount' => 2000,
'currency' => 'usd',
'payment_method' => $paymentMethodId,
'confirm' => true,
],
['idempotency_key' => 'order_' . $orderId]
);
Ключ хранится минимум 24 часа. Удобно использовать ID заказа – он уникален и привязан к бизнес-операции. Но если отправить тот же ключ с другими параметрами (например, изменилась сумма), Stripe бросит IdempotencyException. Один ключ – одни параметры.
Сами токены (tok_) – устаревший подход. Charges API + Token вытеснены PaymentMethod (pm_) + PaymentIntent. PaymentMethod не одноразовый, его можно привязать к клиенту и использовать повторно.
Сумма в неправильных единицах
Stripe принимает amount в минимальных единицах валюты (центы, копейки): 5000 = $50.00. Если передать 50, спишется $0.50. Исключения не будет. Для JPY, KRW, VND (валюты без дробной части) сумма передаётся как есть. Минимум для USD – 50 центов, иначе amount_too_small. Подробности в документации по валютам.
Webhook: SignatureVerificationException
Stripe подписывает каждый webhook-запрос. Если подпись не совпадает, constructEvent бросает SignatureVerificationException. Чаще всего дело не в подделке, а в неправильном 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);
Почему подпись не совпадает:
- Неправильный secret. У каждого webhook endpoint свой
whsec_. Для test и live endpoint-ов ключи разные. - Фреймворк модифицировал body.
constructEventверифицирует подпись по сырой строке. Если фреймворк уже распарсил JSON, подпись не совпадёт. В Laravel используйте$request->getContent(). Методыall()иjson()возвращают результатjson_decode, аgetContent()возвращает исходную строку. - Test vs live secret. Events из test mode подписаны test-секретом. Live events – live-секретом.
Для локальной разработки webhook-и не дойдут до localhost. Stripe CLI пробросит события:
stripe listen --forward-to localhost:8080/webhook
CLI выведет временный signing secret, используйте его для локальной разработки вместо whsec_ из дашборда.
Webhook endpoint должен отвечать HTTP 200 быстро. Если обработка занимает время (отправка email, запись в базу), поставьте событие в очередь, верните 200 сразу, а тяжёлую логику выполните асинхронно. Иначе Stripe решит, что доставка не удалась, и начнёт ретраить с нарастающим интервалом.
HTTP-коды ошибок вебхуков
Если endpoint отвечает не 2xx, Stripe засчитает доставку как неудачную:
- 307 – сервер перенаправляет POST-запрос (типично для Nginx/Apache rewrite по trailing slash). При редиректе теряется тело запроса, и payload вебхука не доходит. Исправьте URL в Stripe Dashboard, чтобы он совпадал с реальным путём, или отключите rewrite для этого маршрута.
- 400 – подпись не совпала или payload не распарсился
- 404 – неправильный URL в дашборде
- 405 – сервер не принимает POST на этом маршруте
- 500 – необработанное исключение в коде обработчика
Ошибки TLS. Stripe требует TLS 1.2+ на endpoint-е вебхука. Если сертификат сервера истёк или самоподписанный, Stripe вообще не сможет доставить событие – в дашборде даже не будет записи о попытке, потому что соединение обрывается до HTTP. Проверьте сертификат через openssl s_client -connect yourdomain.com:443 и убедитесь, что цепочка валидна. Сертификаты Let’s Encrypt работают, главное – следить за автообновлением.
Проверить попытки доставки можно в Stripe Dashboard, раздел Developers > Webhooks. Каждая попытка показывает код ответа и тело. Здесь разобраны только ошибки; полный разбор приёмника – сырое тело, идемпотентность, очередь – в руководстве по webhooks.
Stripe Checkout не работает
Настройка Checkout с нуля – тема руководства по Checkout Sessions; этот раздел про то, что ломается.
Сессия не создаётся:
You must provide `success_url` for Checkout Sessions in `payment` mode.
success_url и cancel_url обязательны. В success_url можно подставить ID сессии:
$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; // без exit редирект может не сработать
Сессия истекла. Checkout Session живёт 24 часа. Проверяйте статус:
$session = $stripe->checkout->sessions->retrieve($sessionId);
if ('expired' === $session->status) {
// создать новую сессию
}
Вебхук checkout.session.completed не приходит. URL вебхука недоступен из интернета (localhost, firewall) или отвечает не 200. Для разработки – Stripe CLI. Для продакшна – endpoint должен быстро отдавать HTTP 200, не затягивая обработку.
Тестовые карты для проверки Checkout: 4242424242424242 (успешный платёж), 4000000000000002 (decline).
Rate Limit и сетевые ошибки
100 операций в секунду на live mode, 25 на test. При превышении – RateLimitException с HTTP 429. Отдельная проблема – ApiConnectionException: таймаут сети, временный сбой DNS, разрыв соединения. Обе ошибки транзиентные, их безопасно повторять.
Простой retry без паузы усугубит проблему, нужен 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 ($attempt === $maxRetries) {
throw $e;
}
// Экспоненциальная пауза + случайный 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 (случайная добавка) нужен, чтобы воркеры не ломились в API все разом после паузы.
При ApiConnectionException Stripe не гарантирует, что запрос не дошёл. Используйте idempotency key для мутирующих операций (create, update), чтобы повторный запрос не создал дубликат. Для read-операций (retrieve, list) idempotency key не нужен.
Ошибки окружения
Не связаны с API, но мешают запуститься.
Class ‘Stripe\StripeClient’ not found. Пакет не установлен или autoloader не подключён:
composer require stripe/stripe-php
require __DIR__ . '/vendor/autoload.php';
В Laravel, Symfony autoloader уже подключён. Ошибка значит, что пакет не стоит: composer show stripe/stripe-php.
Код из старого туториала не работает. До 2020 года все примеры использовали статический API: \Stripe\Stripe::setApiKey('sk_...') + \Stripe\Charge::create(...). Этот подход ещё работает, но текущие примеры в документации и в этой статье используют new \Stripe\StripeClient('sk_...'). Если копируете код из старого Stack Overflow-ответа, учитывайте разницу: StripeClient вызывает методы через свойства ($stripe->paymentIntents->create(...)), а статический API через классы напрямую (\Stripe\PaymentIntent::create(...)). Смешивать два подхода в одном проекте можно, но StripeClient предпочтительнее: он потокобезопасен и позволяет использовать разные ключи для разных операций.
Undefined type ‘Stripe\StripeClient’. Жалоба IDE (PhpStorm, VS Code), не ошибка выполнения. IDE не подхватил autoload. Помогает composer dump-autoload.
PHP version. SDK работает с PHP 7.2+, но поддержка 7.2-7.3 скоро будет прекращена. Примеры в этой статье используют синтаксис PHP 8.0+ (match, str_starts_with, ?->). Если на сервере PHP 7.x, замените match на switch, а ?-> на явные проверки на null.
Missing ext-curl. SDK использует cURL. Установка на Debian/Ubuntu: sudo apt-get install php-curl && sudo systemctl restart php8.2-fpm. На macOS cURL обычно включён.
Безопасное логирование
При логировании ошибок Stripe нельзя записывать данные карт (PCI DSS). $e->getMessage() безопасно, номеров карт там нет. Но если логируете входящие POST-запросы целиком, в них могут быть карточные данные от фронтенда.
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, полезен при обращении в поддержку Stripe
]));
request_id начинается с req_. Поддержка Stripe по нему найдёт конкретный запрос в своих логах. Сохраняйте его при каждой ошибке.
Нашли неточность на этой странице?
Сообщить об ошибке