phpguzzle.org
enrudees
Stripe – Документация

Интеграция 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 получил и чем ответил.

Нашли неточность на этой странице?

Сообщить об ошибке