Что такое 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 |
| Embedded | iframe на вашей странице | return_url с {CHECKOUT_SESSION_ID} |
| Custom | Ваш HTML, примитивы Checkout | return_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_url – ui_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 не видна в событии”. Два места, где обычно спотыкаются:
-
Metadata на Session, а не на line_items. API примет
line_items[].metadata, но она останется на самой line_item и не всплывёт в event payload, который слушают интеграторы, и не пропишется на PaymentIntent. Кладитеorder_id,user_idи всё прочее для сопоставления в top-levelmetadataу Session:'metadata' => [ 'order_id' => $orderId, 'user_id' => $userId, ], -
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, который не дублирует заказ при ретраях.
Нашли неточность на этой странице?
Сообщить об ошибке