Интеграция Stripe в PHP: установка SDK и первый платёж
Что такое интеграция Stripe? Это код, API-ключи и UI-компоненты, которые позволяют приложению принимать платежи через инфраструктуру Stripe – данные карты никогда не попадают на ваш сервер, а регуляторная часть (3D Secure, SCA, антифрод) остаётся на стороне Stripe. В случае с PHP интеграция означает подключение официального SDK stripe/stripe-php на бэкенде, встраивание Stripe.js в страницу оплаты и настройку webhook-обработчика для асинхронных событий.
В одном предложении: сервер создаёт через SDK объект PaymentIntent, браузер подтверждает его через Stripe.js, а webhook сообщает системе, когда деньги реально списались. Из чего это складывается: API-ключ на сервере, объект PaymentIntent с параметрами одной попытки оплаты, JS-виджет для сбора карточных данных (чтобы PCI-скоуп не распространялся на ваш сервер) и webhook-приёмник, который фиксирует финальный статус.
В этой статье пошагово разбираем первые три части плюс мелочи, на которых чаще всего спотыкаются: 3D Secure, единицы валюты, несовпадение test/live режимов. Примеры кода ориентируются на актуальную мажорную версию SDK; проверить версию в проекте – composer show stripe/stripe-php.
Два репозитория, которые пригодятся:
- github.com/stripe/stripe-php на GitHub – исходники, issue-трекер и release-notes самой библиотеки. Можно склонировать или скачать ZIP релиза.
- github.com/stripe-samples на GitHub – готовые примеры интеграций от Stripe. Пример
accept-a-paymentсодержит PHP-сервер плюс HTML-фронтенд и работает как полноценный рабочий образец.
Установка Stripe PHP SDK
SDK ставится обычным Composer-пакетом. Из корня проекта:
composer require stripe/stripe-php
Команда установит библиотеку и её PHP-зависимости (curl, json, mbstring), которые по умолчанию включены на любой стандартной сборке PHP. SDK поддерживает старые версии PHP, но всё ниже 8.1 снято с поддержки безопасности самого интерпретатора, поэтому в продакшне ориентируйтесь на 8.1 как на нижнюю границу – точный минимум текущей мажорной версии SDK смотрите в composer.json в репозитории.
После установки подключите Composer-autoloader один раз, там, где приложение стартует:
require __DIR__ . '/vendor/autoload.php';
Установка без Composer
Вопрос периодически всплывает: если на хостинге нет SSH-доступа или вы работаете с кодовой базой, в которой composer.json изначально не было, SDK можно скачать ZIP-ом со страницы релизов на GitHub и подключить встроенный autoloader:
require __DIR__ . '/stripe-php/init.php';
Работать будет, но автоматическое управление зависимостями вы теряете. Когда выйдет security-патч, придётся вручную скачивать и заменять папку. На любом хостинге с SSH Composer предпочтительнее.
Проверка установки
Перед тем как писать логику оплат, убедитесь, что SDK действительно подгрузился:
require __DIR__ . '/vendor/autoload.php';
echo 'Версия SDK: ' . \Stripe\Stripe::VERSION . PHP_EOL;
echo 'cURL: ' . (extension_loaded('curl') ? 'да' : 'нет') . PHP_EOL;
echo 'PHP: ' . PHP_VERSION . PHP_EOL;
Если скрипт отработал без Fatal Error, можно переходить к API-ключам.
API-ключи: test и live режимы
У каждого аккаунта Stripe два набора ключей. Test-ключи начинаются с sk_test_ и pk_test_, live-ключи – с sk_live_ и pk_live_. Они не взаимозаменяемы: объект, созданный test-ключом, существует только в test-режиме, а смешивание publishable-ключа одного режима с secret-ключом другого даёт ошибку resource_missing, которую легко принять за баг в своём коде.
Ключи берутся в дашборде Stripe: Developers > API keys. Publishable-ключ можно спокойно отдавать на фронтенд, secret-ключ обязан оставаться на сервере. Secret-ключи никогда не коммитятся в git, даже в файлах-примерах, даже в .env.example – если случайно запушили, ищите по хеш-истории и немедленно ротируйте.
Загружайте ключи из переменных окружения:
$secret = getenv('STRIPE_SECRET_KEY');
if (false === $secret || '' === $secret) {
throw new RuntimeException('STRIPE_SECRET_KEY не задан в окружении');
}
\Stripe\Stripe::setApiKey($secret);
Явная проверка избавляет от ошибки “Invalid API Key”, когда переменная не определена – сообщение Stripe в этом случае не указывает на реальную причину. Типичная ловушка: ключ лежит в .env, но фреймворк читает этот файл только для своих настроек, а до getenv() значение не доходит.
Для проекта с несколькими аккаунтами Stripe (мультитенант) удобнее использовать per-request клиент вместо глобального статика:
$stripe = new \Stripe\StripeClient([
'api_key' => getenv('STRIPE_SECRET_KEY'),
// Укажите актуальную версию из дашборда (Developers > API version).
'stripe_version' => '2025-03-31.basil',
]);
Явная фиксация версии API защищает от сюрпризов при выкатке изменений на стороне Stripe. Текущая pinned-версия аккаунта видна в дашборде: Developers > API version.
Restricted-ключи для продакшна
У secret-ключа полный доступ к аккаунту: списания, возвраты, экспорт клиентской базы, изменение webhook-ов. Для flow, который создаёт только PaymentIntent-ы, достаточно restricted-ключа ровно с этими правами. Developers > API keys > Create restricted key, дальше выдать только PaymentIntents: write. Если ключ утечёт, радиус поражения в разы меньше, чем у полного secret-ключа.
Создание первого PaymentIntent
PaymentIntent – центральный объект платёжного API Stripe. Вместо одного вызова “списать с карты” вы создаёте intent, описывающий, что собираетесь получить, и потом подтверждаете его. Двухэтапная схема нужна, чтобы SCA и 3D Secure встроились в flow без вашего участия.
Минимальный вызов:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
'metadata' => [
'order_id' => (string) $orderId,
],
]);
echo $intent->client_secret;
metadata Stripe возвращает в каждом объекте и в каждом webhook-событии – удобнее всего связывать свои доменные идентификаторы с intent-ом именно через неё. Значения должны быть строками (всё остальное приведётся к строке), поэтому числовые ID лучше кастовать явно.
Параметр amount – в минимальных единицах валюты, то есть 2500 означает $25.00, а не $2,500. Для USD, EUR и GBP это центы/пенсы. Для валют без дробной части вроде JPY – целое число целиком; полный список есть в документации, но на практике из таких валют в PHP-проектах встречается в основном иена.
Код валюты пишется в нижнем регистре: 'usd', не 'USD'. Stripe в одних местах принимает верхний регистр, в других нет, и текст ошибки при отказе не всегда упоминает регистр.
client_secret, возвращаемый intent-ом, тоже чувствителен, но не настолько, как secret-ключ. Это одноразовый токен, который позволяет браузеру завершить ровно этот платёж и больше ничего. Его можно спокойно отдавать на фронтенд через обычный рендер или JSON-ответ.
Idempotency key
Сеть иногда отваливается. Если сервер словил timeout в ожидании ответа Stripe, вы не знаете, создался PaymentIntent или нет. Слепой retry грозит двумя intent-ами. Решение – idempotency key: Stripe помнит ключ 24 часа и при повторе возвращает исходный результат.
$intent = $stripe->paymentIntents->create(
[
'amount' => 2500,
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
],
[
'idempotency_key' => 'order_' . $orderId . '_attempt',
]
);
Ключ стоит привязывать к доменной логике – ID заказа, хеш корзины, что угодно стабильное между повторами, но уникальное для одного логического запроса. uniqid() и rand() не подходят: каждый вызов даст новый ключ, и вся защита отключится.
Клиентская часть: Payment Element
Актуальный вариант на фронтенде – Payment Element: один iframe, в котором Stripe сам рисует все активные для аккаунта способы оплаты (карта, Apple Pay, Google Pay, Link, региональные вроде iDEAL или BLIK). Подключаете Stripe.js один раз и монтируете элемент в <div>:
<!DOCTYPE html>
<html>
<head>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<form id="payment-form">
<div id="payment-element"></div>
<button type="submit" id="submit">Оплатить</button>
<div id="error-message"></div>
</form>
<script>
const stripe = Stripe(<?= json_encode($publishableKey) ?>);
const clientSecret = <?= json_encode($clientSecret) ?>;
const elements = stripe.elements({ clientSecret });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
document.getElementById('payment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://example.com/order/complete',
},
});
if (error) {
document.getElementById('error-message').textContent = error.message;
}
});
</script>
</body>
</html>
На return_url Stripe отправит пользователя после 3D Secure (если карта требует аутентификации) или после любого другого redirect-based способа оплаты. Обработчик по этому URL должен получить PaymentIntent и проверить его статус – разберём ниже.
Для подстановки значений в JS используется json_encode(), а не htmlspecialchars(). В HTML-атрибутах htmlspecialchars действительно нужен, но в JS-контексте он не спасает от кавычек, обратных слешей и переводов строк – json_encode даёт корректно экранированный литерал для любого стрингового ввода.
Подтверждение платежа на сервере
После того как браузер подтвердил оплату, Stripe редиректит на return_url с двумя query-параметрами: payment_intent и payment_intent_client_secret. Браузер уже видел успех, но доверять браузеру нельзя – перед тем как пометить заказ оплаченным, сервер перепроверяет статус напрямую:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intentId = $_GET['payment_intent'] ?? null;
if (!$intentId) {
http_response_code(400);
exit('Отсутствует payment intent');
}
$intent = $stripe->paymentIntents->retrieve($intentId);
// Client secret из query совпадает с тем, что привязан к intent-у.
// Защита от подстановки чужого payment_intent ID в URL.
if ($intent->client_secret !== ($_GET['payment_intent_client_secret'] ?? '')) {
http_response_code(400);
exit('Client secret не совпадает');
}
switch ($intent->status) {
case 'succeeded':
markOrderAsPaid($intent->metadata['order_id'], $intent);
header('Location: /order/thanks');
break;
case 'processing':
// Способы оплаты вроде SEPA или bank debit завершаются асинхронно.
// Пользователю пишем, что подтверждение придёт по email.
markOrderAsPending($intent->metadata['order_id']);
header('Location: /order/pending');
break;
case 'requires_payment_method':
// Предыдущая попытка провалилась. Отправляем обратно в форму.
header('Location: /checkout?error=1');
break;
default:
error_log("Неожиданный статус intent: {$intent->status}");
header('Location: /order/error');
}
Redirect-поток подходит для простых случаев, но авторитетным источником он не является. Пользователь, который закрыл вкладку после редиректа от Stripe, но до вашего обработчика, оставляет вас с оплаченным intent-ом, но без обновления заказа. Webhook на payment_intent.succeeded закрывает эту дыру: база обновляется независимо от того, вернулся пользователь на сайт или нет.
3D Secure и SCA
PSD2 в Европе и аналогичные правила в Великобритании требуют strong customer authentication для большинства карточных платежей. На практике это значит, что часть карт попросит пользователя подтвердить оплату через приложение банка или SMS-кодом перед тем, как деньги спишутся. PaymentIntent это обрабатывает автоматически – Payment Element запускает challenge, пользователь его проходит, Stripe редиректит обратно.
Когда аутентификация ломается, причина чаще всего в отсутствующем return_url. При серверном подтверждении с confirm: true в создании intent-а надо явно задать return URL, иначе Stripe некуда отправить пользователя после 3DS-формы. Вторая по частоте причина – тестовая карта, которая 3DS не триггерит. 4242 4242 4242 4242 проходит без аутентификации; чтобы проверить flow, нужен 4000 0025 0000 3155, который форсит 3DS на каждом списании. Если оба пункта в порядке, а challenge всё равно не появляется, откройте страницу в инкогнито – блокировщики рекламы и privacy-расширения иногда мешают cross-origin редиректу на страницу банка.
Полный flow с ручным подтверждением – на случай, когда автоматический confirm не подходит (кастомный checkout, бэкенд для мобильного приложения):
try {
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'usd',
'payment_method' => $paymentMethodId,
'confirm' => true,
'return_url' => 'https://example.com/return',
]);
if ('requires_action' === $intent->status) {
// Клиенту нужно пройти 3DS. Возвращаем client_secret,
// чтобы на фронте отработал stripe.handleNextAction().
return ['requires_action' => true, 'client_secret' => $intent->client_secret];
}
if ('succeeded' === $intent->status) {
return ['success' => true];
}
return ['error' => 'Неожиданный статус: ' . $intent->status];
} catch (\Stripe\Exception\CardException $e) {
return ['error' => $e->getMessage()];
}
CardException наследуется от ApiErrorException, поэтому если позже добавите более широкий catch, оставляйте card-специфичный первым. Иначе отказы по картам будут классифицироваться как общие API-ошибки.
Тестирование: тестовые карты
У Stripe есть большой набор тестовых карт на все сценарии: успех, отказ, аутентификация. Основной минимум:
| Номер | Поведение |
|---|---|
4242 4242 4242 4242 | Успешное списание, без 3DS |
4000 0025 0000 3155 | Требуется 3DS-аутентификация |
4000 0000 0000 0002 | Общий отказ |
4000 0000 0000 9995 | Недостаточно средств |
4000 0000 0000 0069 | Просроченная карта |
4000 0000 0000 0127 | Неверный CVC |
CVC – любые 3 цифры, срок действия – любая будущая дата. Почтовый индекс (для карт, которые его требуют) – любые 5 цифр.
Частая ошибка – смешать объекты из test-режима с live-ключами. Если PaymentIntent создан через sk_test_..., а получаете его через sk_live_..., Stripe ответит “No such payment_intent”. Intent существует, просто вы смотрите на него через не то окно. В тексте ошибки о несовпадении режимов ни слова – признаки mode mismatch собраны в статье про ошибки.
Типичные грабли
Publishable-ключ на сервере. Симптом: Invalid API Key provided: pk_live_... при любом вызове SDK. Publishable-ключ живёт в браузере, secret-ключ – на сервере. На удивление частая путаница при копипасте между файлами.
Secret-ключ на фронтенде. Симптом хуже: ключ работает, и теперь он в браузерах у пользователей. Ротируйте немедленно.
Забытый Composer autoloader. Class 'Stripe\StripeClient' not found. SDK установлен, но в точке входа, которая реально запускается, autoloader не подключён.
Суммы в виде строк. Иногда сумма приходит с формы как 'amount' => '25.00'. Stripe ждёт целое число в минимальных единицах. Приводите явно: (int) round($price * 100).
Ожидание конвертации валют. Stripe не конвертирует валюту между транзакцией и расчётным счётом. Если клиент платит в EUR, а ваш Stripe-аккаунт настроен на USD, платёж упадёт с invalid_currency. Валюта транзакции и валюта выплаты – разные вещи, настраиваются на уровне аккаунта.
Следующие шаги
Для регулярных списаний понадобится Subscriptions API, который в каждом цикле биллинга создаёт PaymentIntent поверх объектов Customer и Price. Если собственная форма оплаты не вписывается в проект, Checkout Sessions переносит весь процесс на серверы Stripe.
В продакшне также обязательно нужен webhook-обработчик. Пользовательские сети достаточно часто обрывают redirect-подтверждение, поэтому единственным авторитетным сигналом остаётся прямой запрос к API либо событие, пришедшее по webhook. Минимальный слушатель выглядит так:
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
getenv('STRIPE_WEBHOOK_SECRET')
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit;
}
if ('payment_intent.succeeded' === $event->type) {
$intent = $event->data->object;
markOrderAsPaid($intent->metadata['order_id'], $intent);
}
http_response_code(200);
В Laravel и Symfony file_get_contents('php://input') всё ещё доступен, но удобнее сразу брать сырое тело из Request-объекта: $request->getContent() в обоих фреймворках. constructEvent проверяет подпись по исходной строке – ->all() или ->json() возвращают уже распарсенный JSON и для верификации не подходят.
Полноценный разбор проверки подписи и паттернов обработчика – в отдельной статье про webhook-и. Каталог исключений SDK и их значений уже собран в частых ошибках Stripe PHP.
В dev-окружении подключите PSR-3-логгер к SDK. \Stripe\Stripe::setLogger($psr3Logger) принимает любой PSR-3-совместимый логгер и пишет полные тела запросов и ответов с отредактированными ключами, что даёт быстрый способ увидеть, что именно Stripe получил и чем ответил.
Нашли неточность на этой странице?
Сообщить об ошибке