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

Что такое Stripe Checkout? Поддерживает ли Stripe PHP? Checkout – готовая форма оплаты от Stripe: карта, кошельки, локальные методы. Ваш бэкенд создаёт Checkout\Session, отдаёт пользователю URL, а Stripe показывает форму и забирает деньги. PHP официально поддержан: SDK stripe/stripe-php живёт на Packagist, ведётся командой Stripe.

Форма поставляется в трёх вариантах: hosted (страница на домене Stripe, куда вы редиректите клиента), embedded (iframe, который вы монтируете на свою страницу, GA с конца 2023), и custom (примитивы Checkout, которые вы раскладываете по своей вёрстке, GA с начала 2025). Ниже – бэкенд-часть всех трёх на PHP; клиентский JS показан ровно в том объёме, чтобы примеры работали.

Если SDK ещё не установлен и API-ключи не настроены, начинать со статьи про интеграцию Stripe в PHP – там Composer, ключи, первый PaymentIntent.

Checkout Session vs PaymentIntent vs Payment Element

Три имени, которые постоянно путают на форумах:

  • PaymentIntent – низкоуровневый объект платежа. Хранит сумму, валюту, статус, client_secret. Любой платёж через Stripe создаёт такой объект под капотом.
  • Payment Element – JS-компонент UI. Вы сами создаёте PaymentIntent на сервере, монтируете Element на своей странице, обрабатываете confirm в JS. Контроль над каждым пикселем и каждым шагом.
  • Checkout Session – объект верхнего уровня, который заворачивает всю воронку: UI, выбор метода оплаты, налог, доставка, купоны. Вы создаёте Session, отдаёте URL клиенту, Stripe показывает страницу. PaymentIntent появляется внутри Session автоматически.

Выбор – это компромисс между контролем и скоростью интеграции. Если “брендированная форма со списком позиций, налогом и Apple Pay” описывает то, что вам нужно, берите Checkout. Если нужна кастомная многошаговая воронка со своей валидацией – Payment Element поверх PaymentIntent, который вы создаёте сами. Разумный старт: Checkout; мигрировать на Elements, только если Checkout не вытягивает продуктовые требования.

У Checkout есть собственное webhook-событие, checkout.session.completed, которое прилетает при успешной оплате. payment_intent.succeeded тоже прилетит, но если воронка началась с Session – слушайте верхнеуровневое событие, оно несёт Session целиком, вместе с metadata и customer_details.

Три режима UI

РежимГде живёт UIКак клиент возвращается
HostedДомен Stripe (checkout.stripe.com)Браузерный редирект на success_url
Embeddediframe на вашей страницеreturn_url с {CHECKOUT_SESSION_ID}
CustomВаш HTML, примитивы Checkoutreturn_url с {CHECKOUT_SESSION_ID}

Hosted – дефолт, с него начинают почти все гайды: минимум кода на клиенте и нулевая ответственность за соответствие PCI. Embedded имеет смысл, когда внешний редирект терять клиентов нежелательно, а iframe на странице не мешает. У custom единственный сценарий: дизайн-система, под которую embedded-iframe не подгоняется. Функциональность та же, контроль над вёрсткой выше. Бэкенд-код для custom идентичен embedded (ui_mode: 'custom' вместо 'embedded', тот же return_url, тот же client_secret), разница – в клиентской инициализации через Stripe.js.

Минимальная hosted Session

Боевой эндпоинт в 15 строк:

<?php
require __DIR__ . '/vendor/autoload.php';

\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

$session = \Stripe\Checkout\Session::create([
    'mode' => 'payment',
    'line_items' => [[
        'price' => 'price_1OExampleHandle',
        'quantity' => 1,
    ]],
    'success_url' => 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url'  => 'https://example.com/cart',
    'metadata' => [
        'order_id' => $orderId,
    ],
]);

header('Location: ' . $session->url, true, 303);
exit;

На клиенте – ничего, просто следует редиректу. Четыре поля, на которых спотыкаются:

mode говорит, что это за транзакция. payment – разовое списание. subscription – подписка на периодическую цену. setup – сохранить метод оплаты без списания (флоу “привязать карту к аккаунту”). Передавать payment_intent_data вместе с 'mode' => 'subscription' нельзя, вернётся invalid_request – для подписочного режима есть отдельный subscription_data.

line_items принимает две формы. Рекомендованная – price ID из Dashboard (или созданный через API). Inline price_data всё ещё работает, но цены в Dashboard дают нетехнической команде менять формулировки и суммы без деплоя:

// Price из Dashboard (рекомендация)
'line_items' => [[ 'price' => 'price_1OExampleHandle', 'quantity' => 1 ]],

// Inline price (для динамических сумм и разовых корзин)
'line_items' => [[
    'price_data' => [
        'currency' => 'usd',
        'product_data' => ['name' => 'Pro plan, один месяц'],
        'unit_amount' => 1999,
    ],
    'quantity' => 1,
]],

Суммы – в минорных единицах. 1999 это 19.99 USD, а не 1999. Валюты без десятичной части (JPY, KRW, VND и др. – список в Stripe docs) принимают целую сумму, умножать на 100 там не надо.

success_url обязателен в hosted-режиме. cancel_url опционален на API-версиях 2023-10-16 и новее; если проект запинен на более старую версию через Stripe::setApiVersion() или через pinning в Dashboard, поле может быть обязательным – стоит сверить с changelog той версии, которую вы используете. Если cancel_url не передать, Checkout покажет стандартную кнопку “Назад” и отправит клиента на нейтральную страницу отменённой сессии. Токен {CHECKOUT_SESSION_ID} – буквальный, Stripe подставляет ID при редиректе. На странице “спасибо” обязательно загрузите Session по этому ID и проверьте status === 'complete' и payment_status === 'paid' – бросать пользователя из закладок на страницу успеха нельзя.

metadata – произвольный словарь, который прилетает в webhook-событие вместе с Session. Сюда складывают внутренние ID: order_id, user_id – что угодно для сопоставления оплаты с объектом в вашей базе. Про нюансы распространения metadata – отдельный раздел ниже.

Embedded Checkout

В embedded-режиме вместо редиректа – iframe, который монтируется прямо на вашей странице. Бэкенд-изменение минимальное: вместо success_url/cancel_urlui_mode плюс return_url, и наружу отдаётся client_secret:

$session = \Stripe\Checkout\Session::create([
    'ui_mode' => 'embedded',
    'mode' => 'payment',
    'line_items' => [[ 'price' => 'price_1OExampleHandle', 'quantity' => 1 ]],
    'return_url' => 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
    'metadata' => [ 'order_id' => $orderId ],
]);

header('Content-Type: application/json');
echo json_encode(['clientSecret' => $session->client_secret]);

На странице – stripe.initEmbeddedCheckout:

<div id="checkout"></div>
<script src="https://js.stripe.com/v3/"></script>
<script>
const stripe = Stripe('pk_live_...');

fetch('/create-checkout-session.php', { method: 'POST' })
    .then(r => r.json())
    .then(({ clientSecret }) => stripe.initEmbeddedCheckout({ clientSecret }))
    .then(checkout => checkout.mount('#checkout'));
</script>

На что обычно наступают. Во-первых, в браузере нужен publishable ключ (pk_, не sk_). Секретный ключ на клиенте – инцидент безопасности, у Stripe автоматический отзыв при обнаружении. Во-вторых, embedded Checkout после оплаты всё равно редиректит страницу на return_url – iframe не SPA. Если хочется оставить клиента на странице, делайте return_url тонким лендингом, который закрывает модалку или обновляет UI через JS.

redirectToCheckout больше нет

В старых SO-ответах часто встречается stripe.redirectToCheckout({ sessionId }) как способ перейти с клиента на Checkout. Этот метод удалили из Stripe.js в релизе 2025-09-30. Сейчас:

  • Hosted: серверный Location-редирект (как в первом примере).
  • Embedded: stripe.initEmbeddedCheckout({ clientSecret }).

Если туториал, который вы копируете, зовёт redirectToCheckout – это сигнал искать свежие материалы.

customer vs customer_email

Мелочь, съедающая полдня отладки. Checkout умеет предзаполнять email двумя путями:

// Вариант A: привязать существующего Customer
'customer' => 'cus_ExistingCustomerId',

// Вариант B: предзаполнить email для нового Customer
'customer_email' => $user->email,

Если передать customer, и у него в записи уже есть email, Checkout подставит его из объекта Customer и заблокирует поле – покупатель не сможет его изменить. customer_email в этом случае игнорируется. Если передать только customer_email без customer, форма предзаполнит редактируемый email и создаст нового Customer при завершении Session. Осознанный выбор между ними убирает регулярный вопрос на форуме Stripe про “почему пользователь не может поменять email”.

Metadata: мост к вашему webhook

Вопрос про metadata прилетает на Stack Overflow регулярно с формулировкой “metadata не видна в событии”. Два места, где обычно спотыкаются:

  1. Metadata на Session, а не на line_items. API примет line_items[].metadata, но она останется на самой line_item и не всплывёт в event payload, который слушают интеграторы, и не пропишется на PaymentIntent. Кладите order_id, user_id и всё прочее для сопоставления в top-level metadata у Session:

    'metadata' => [
        'order_id' => $orderId,
        'user_id'  => $userId,
    ],
  2. Metadata с Session не копируется автоматически на PaymentIntent. Если ваш webhook слушает payment_intent.succeeded и читает $event->data->object->metadata, поле будет пустым – пока не передадите payment_intent_data.metadata:

    'payment_intent_data' => [
        'metadata' => [ 'order_id' => $orderId ],
    ],

    Проще путь: слушать checkout.session.completed и читать metadata прямо с Session в событии. Без дублирования.

Реакция на завершённую Session

Когда клиент заплатил, Stripe присылает checkout.session.completed на ваш webhook-эндпоинт. Это канонический сигнал, что воронка закрыта. payment_intent.succeeded тоже прилетит, но для Checkout-воронок выгоднее верхнеуровневое событие: в нём лежит Session целиком, с metadata и customer_details в одном месте.

// внутри webhook-эндпоинта (проверка подписи, см. статью про webhooks)
if ('checkout.session.completed' === $event->type) {
    $session = $event->data->object;
    $orderId = $session->metadata->order_id ?? null;
    $paymentStatus = $session->payment_status; // 'paid', 'unpaid', 'no_payment_required'

    if ('paid' === $paymentStatus && $orderId) {
        $orderRepo->markPaid($orderId, $session->id);
    }
}

Проверка подписи, сырое тело, нюансы Laravel/Symfony и идемпотентность – отдельная статья про webhooks. Если эндпоинт ещё не поднят, начинать оттуда. Без webhook про неудачные оплаты вы узнаёте только от клиентов в поддержке.

Ещё одна тонкость. Для отложенных методов оплаты (SEPA debit, Bacs, ряд банковских дебетов) Session возвращает complete, но деньги ещё не списаны и могут прийти через часы или дни. На такие воронки Stripe шлёт отдельные события: checkout.session.async_payment_succeeded и checkout.session.async_payment_failed. Если вы принимаете любой отложенный метод – обрабатывайте completed как “оформлено”, а фактическую выдачу товара привязывайте к async_payment_succeeded.

Проверка Session на странице “спасибо”

После hosted-редиректа thank-you-страница получает session_id в query. Подгружаем Session и решаем, что показывать:

<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

$sessionId = $_GET['session_id'] ?? '';
if (1 !== preg_match('/^cs_(test|live)_[a-zA-Z0-9]+$/', $sessionId)) {
    http_response_code(400);
    exit('Некорректный session_id');
}

$session = \Stripe\Checkout\Session::retrieve([
    'id' => $sessionId,
    'expand' => ['payment_intent', 'customer'],
]);

if ('complete' === $session->status && 'paid' === $session->payment_status) {
    echo "Спасибо, {$session->customer_details->email}";
} else {
    echo 'Оплата ещё обрабатывается.';
}

Регулярка на session_id тут не для красоты. ID сессии – непрозрачный идентификатор, и пользователь, который подставит в query чужой ID, должен получить чистый 400, а не трейс исключения. Проверка формата до API-вызова – дешёвая защита.

Источник истины для “заказ оплачен” – webhook, а thank-you-страница просто рисует UI.

Время жизни Session

Checkout-сессии живут ровно 24 часа с момента создания. Это жёсткий потолок, растянуть дальше нельзя. По истечении URL возвращает ошибку, Session переходит в статус expired, Stripe шлёт событие checkout.session.expired – удобный момент, чтобы отпустить зарезервированный товар или отменить черновик заказа.

Укоротить дефолт можно, передав expires_at Unix-таймстампом (минимум – 30 минут от создания):

'expires_at' => time() + 30 * 60, // 30 минут

Короткая жизнь – как раз про инвентарный холд: создали Session, зарезервировали товар, отпустили бронь на checkout.session.expired, если клиент ушёл. Нужен платёжный URL, живущий дольше 24 часов – берите другой примитив: PaymentLink (shareable-ссылка без срока годности) или Invoice (отправляется по email, оплачивается когда удобно).

Закрыть Session досрочно можно программно – удобно, когда пользователь ушёл со страницы, а ждать таймаут нет смысла:

\Stripe\Checkout\Session::expire($session->id);

Частые проблемы

Страница Checkout не грузится / пустой экран

Первое, куда смотреть – Content Security Policy. Stripe загружает скрипты с https://js.stripe.com и открывает фреймы на https://checkout.stripe.com. Если CSP их не пропускает, iframe монтируется пустой, а в консоли – поток CSP violation. Минимальный набор разрешений:

frame-src https://js.stripe.com https://checkout.stripe.com;
script-src https://js.stripe.com;
connect-src https://api.stripe.com;

Для hosted-режима другой частый виновник – AdBlock-правило, которое матчит checkout.stripe.com. На сервере вы это не почините, единственное разумное – мониторить соотношение “создано Session” к “получено checkout.session.completed” и алертить на просадке.

”No such checkout_session”

Вы обращаетесь к Stripe в live-режиме с ID сессии, созданной в test-режиме, или наоборот. Сверьте, какой ключ (sk_test_ или sk_live_) подставили SDK на эндпоинте создания Session и на эндпоинте thank-you-страницы. Частая причина – недонакатанный деплой, когда прод уже на live-ключе, а staging всё ещё в test.

Metadata не приходит в событии

См. раздел про metadata выше – либо metadata положили в line_items[].metadata (не всплывёт в обычном событии), либо webhook слушает payment_intent.succeeded, а на Session не передан payment_intent_data.metadata. Переведите слушателя на checkout.session.completed и проблема уходит.

Не отображаются методы оплаты (Google Pay, Apple Pay)

Набор методов зависит от валюты, страны и конфигурации Stripe-аккаунта. Проверять нужно Payment Methods settings в Dashboard на уровне аккаунта, а не отдельной Session. Google Pay и Apple Pay вдобавок требуют верификации домена – в Dashboard есть self-service UI для этого, и пока домен не верифицирован, кнопки просто не рисуются.

Можно ли встроить Checkout в свой сайт?

Да, это embedded-режим (см. раздел выше): ui_mode: 'embedded', iframe на вашей странице, return_url вместо success_url. Ограничения – iframe нельзя стилизовать произвольно, и форма всё равно редиректит на return_url после оплаты. Если нужна полная власть над вёрсткой, смотрите на custom-режим или на Payment Element поверх PaymentIntent.

Подписки в одну строчку

Для периодических списаний mode меняется с payment на subscription, а line_items указывает на recurring price. Специфика подписок – триалы, пропорциональные списания, отмена, биллинг-портал – будет в отдельном гайде (выйдет позже). Checkout-часть короткая:

$session = \Stripe\Checkout\Session::create([
    'mode' => 'subscription',
    'line_items' => [[ 'price' => 'price_monthly_handle', 'quantity' => 1 ]],
    'success_url' => 'https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url'  => 'https://example.com/pricing',
]);

Что дальше

  • Подпись webhook и идемпотентная обработка: Stripe Webhooks в PHP.
  • Кастомный UI вместо готовой формы: Payment Element поверх PaymentIntent, документация Stripe Elements.
  • Отдельный гайд про подписки с событиями жизненного цикла и customer portal – в работе на этом сайте.
  • Если вы всё ещё на shared-хостинге без composer, init.php-fallback из статьи про интеграцию применим и к Checkout – Checkout\Session::create ведёт себя так же.

Дальнейший код, который стоит написать аккуратно, – webhook-обработчик: статья про webhooks разбирает слушатель на checkout.session.completed, который не дублирует заказ при ретраях.

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

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