Stripe webhooks на PHP: надёжный обработчик для платежей
Webhook Stripe, который работает на ноутбуке, ещё нельзя доверять деньги. В этом гайде разбираем проверку подписи, быстрый ответ, дедупликацию событий и повторные доставки на чистом PHP и Laravel, с кодом, который можно взять в проект.
Коротко
- Источник правды о платеже - webhook, а не редирект браузера после оплаты.
- Читайте сырое тело запроса и проверяйте заголовок
Stripe-Signatureдо любой работы с данными. - Отвечайте 2xx быстро, а основную работу делайте в очереди или после сохранения события.
- Сохраняйте ID события с уникальным индексом: Stripe может прислать одно и то же событие несколько раз и в другом порядке.
- Перед запуском протестируйте всё через Stripe CLI (
stripe listenиstripe trigger).
Если вы принимаете платежи через Stripe, именно Stripe webhooks на PHP решают, будет ли заказ помечен оплаченным, активируется ли подписка и попадёт ли возврат в вашу базу. Страница оплаты - только входная дверь: клиенты закрывают вкладки, банки подтверждают с задержкой, а некоторые способы оплаты проходят через минуты или дни. Webhook - это сообщение от Stripe о том, что произошло на самом деле.
Мы делаем и сопровождаем платёжные интеграции для клиентов и для собственных продуктов (например, Bill принимает оплату через Stripe и PayPal на публичной странице). Ниже - чек-лист, по которому мы пишем endpoint для Stripe webhooks на PHP.
Как работают Stripe webhooks за минуту
Вы регистрируете HTTPS-адрес в Stripe Dashboard (или через API) и выбираете типы событий. Когда что-то происходит - завершилась Checkout Session, оплачен счёт, сделан возврат - Stripe отправляет на этот адрес POST с JSON-объектом Event. Ваш сервер должен ответить статусом 2xx. Если нет, Stripe повторяет доставку с экспоненциальной задержкой до трёх дней в live-режиме.
Из этого вытекают три факта, которые определяют весь дизайн:
- Событие может прийти больше одного раза. Повторы и сетевые сбои - нормальная ситуация.
- События могут прийти не по порядку. Вы можете получить
invoice.paidраньше, чемcustomer.subscription.created. - POST на ваш адрес может отправить кто угодно. Без проверки подписи злоумышленник сам отметит свой заказ оплаченным.
Шаг 1. Установите SDK и создайте endpoint
Используйте официальную библиотеку stripe/stripe-php, а секрет endpoint (он начинается с whsec_) храните в переменной окружения, но не в репозитории.
composer require stripe/stripe-php
# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
У каждого endpoint в Dashboard свой секрет подписи, а у stripe listen в разработке - ещё один. Самая частая причина ошибки «signature verification failed» - просто не тот секрет.
Шаг 2. Проверьте подпись по сырому телу
Stripe подписывает каждый запрос. Заголовок Stripe-Signature содержит метку времени и одну или несколько подписей - HMAC-SHA256 от строки timestamp.payload с секретом endpoint. Библиотека проверяет подпись и по умолчанию отклоняет события старше пяти минут.
Важная деталь: для проверки нужны те же самые байты, которые отправил Stripe. Если фреймворк распарсил JSON, а вы закодировали его обратно, пробелы изменятся и подпись не совпадёт.
<?php
// public/webhooks/stripe.php (plain PHP)
require __DIR__ . '/../../vendor/autoload.php';
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = getenv('STRIPE_WEBHOOK_SECRET');
try {
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\UnexpectedValueException $e) {
http_response_code(400); // invalid JSON
exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400); // bad or missing signature
exit;
}
В Laravel читайте $request->getContent(), а не $request->all(), и исключите маршрут из CSRF-защиты (у webhook нет CSRF-токена, защитой служит подпись). По возможности выносите маршрут из группы web с сессиями.
Шаг 3. Сделайте обработчик идемпотентным
Идемпотентность - это когда повторная обработка события даёт тот же результат, что и первая. Самый простой надёжный способ - таблица обработанных ID событий с уникальным индексом. Сначала вставляем запись, и если вставка падает из-за дубликата, значит, событие уже было.
CREATE TABLE stripe_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
status ENUM('received','processed','failed') NOT NULL DEFAULT 'received',
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
UNIQUE KEY uniq_event (event_id)
);
try {
$pdo->prepare('INSERT INTO stripe_events (event_id, type, payload) VALUES (?, ?, ?)')
->execute([$event->id, $event->type, $payload]);
} catch (PDOException $e) {
if ($e->getCode() === '23000') { // duplicate key: already received
http_response_code(200);
exit;
}
throw $e;
}
Сохранённый payload - это журнал для разбора: когда клиент спрашивает «почему с меня списали дважды?», вы можете точно показать, что сообщил Stripe. А ещё так можно повторно обработать упавшие события после исправления бага.
Шаг 4. Отвечайте быстро, обрабатывайте отдельно
Stripe ждёт ответ в течение нескольких секунд. Если обработчик прямо в запросе отправляет письма, ходит в другие API или строит PDF, он упрётся в таймаут, Stripe повторит доставку, и работа выполнится ещё раз. В запросе делайте минимум - проверить, сохранить, ответить 200, - а логику выполняйте в очереди (очереди Laravel, Symfony Messenger, cron, который читает записи со status = 'received', или worker под supervisor).
Если событие вообще не удалось сохранить (упала база), верните 5xx. Это единственный случай, когда вы хотите, чтобы Stripe повторил позже.
Шаг 5. Обработайте нужные события
Все типы событий не нужны. Подписывайтесь только на те, что использует ваша логика: меньше шума и меньше поверхность атаки. Типичный набор:
checkout.session.completed- клиент завершил Checkout. Найдите заказ поclient_reference_idили metadata, которые вы задали при создании сессии.payment_intent.succeededиpayment_intent.payment_failed- для собственных платёжных форм.invoice.paidиinvoice.payment_failed- биллинг подписок.customer.subscription.updatedиcustomer.subscription.deleted- смена тарифа и отмена.charge.refundedиcharge.dispute.created- деньги уходят обратно.
switch ($event->type) {
case 'checkout.session.completed':
$session = $event->data->object;
$orderId = $session->client_reference_id;
if ($session->payment_status === 'paid') {
markOrderPaid($orderId, $session->payment_intent);
}
break;
case 'invoice.payment_failed':
notifyBillingProblem($event->data->object->customer);
break;
case 'charge.refunded':
markOrderRefunded($event->data->object->payment_intent);
break;
default:
// unknown or unsubscribed type: acknowledge and ignore
break;
}
Две привычки предотвращают большинство ошибок. Во-первых, не доверяйте суммам из браузера: сравнивайте оплаченную сумму и валюту из события с заказом в вашей базе. Во-вторых, поскольку порядок событий не гарантирован, делайте изменения состояния монотонными: не переводите заказ из «оплачен» обратно в «ожидает» только потому, что запоздало старое событие. Если есть сомнения, запросите актуальный объект через API, а не полагайтесь на снимок из события.
Шаг 6. Тестируйте локально через Stripe CLI
Stripe CLI пересылает настоящие тестовые события на вашу машину, публичный туннель не нужен:
stripe login
stripe listen --forward-to http://localhost:8000/webhooks/stripe
# in another terminal:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe listen печатает временный секрет whsec_, его нужно положить в локальный .env. Проверьте и плохие сценарии специально: отправьте запрос с неверной подписью (ожидаем 400), отправьте одно событие дважды (должен быть один эффект в базе) и остановите worker очереди на минуту, чтобы убедиться, что события копятся, а не пропадают.
Частые ошибки, которые мы находим в аудитах
- Редирект считается доказательством оплаты. Страницу
success_urlможет открыть кто угодно; показывайте «подтверждаем оплату», пока не придёт webhook. - JSON парсится до проверки. Перекодированное тело ломает подпись, частый виновник - body parser фреймворка.
- Один секрет для test и live. У тестового и боевого режимов разные ключи и разные webhook endpoint.
- Нет логов. Без сохранённых событий и заметного канала ошибок баги платежей находят клиенты.
- Медленные обработчики. Всё, что дольше пары секунд, вызывает повторные доставки и двойную работу.
- Stripe блокируется на файрволе или WAF. Если вы используете Cloudflare или другой WAF, разрешите путь webhook и проверьте, что правила для ботов не отдают 403 серверам Stripe.
Мониторинг: как понять, что всё продолжает работать
Webhook может сломаться спустя месяцы после запуска: деплой переименовал колонку, ротировали секрет, истёк сертификат. Stripe Dashboard показывает попытки доставки и ошибки по каждому endpoint, а при постоянных сбоях Stripe присылает письмо. Со своей стороны настройте алерты на три вещи: растущее число событий в статусах received и failed, резкое падение числа событий в час, а также долю ответов 4xx и 5xx на маршруте webhook. Небольшая ежедневная сверка оплаченных заказов в базе со списком успешных платежей в Stripe закроет остальное.
Чем мы можем помочь
Мы проектируем и делаем платёжные и webhook-интеграции для продакшена: проверка подписи, идемпотентность, повторные доставки и логи, понятные вашей команде. Если Stripe-поток уже есть и нужен свежий взгляд, быстрее всего найти слабые места на консультации или архитектурном ревью. О том, как выносить критичные события из Zapier и Make, читайте в статье когда no-code ломается, а про другого провайдера - в гайде по интеграции WayForPay на PHP.
Частые вопросы
Почему проверка подписи Stripe webhook не проходит?
Обычно причина в неверном секрете подписи (test и live, CLI и endpoint из Dashboard), в проверке распарсенного или перекодированного тела вместо сырого либо в прокси, который меняет payload. Проверяйте точные сырые байты секретом именно этого endpoint (whsec_).
Как не обработать событие Stripe дважды?
Сохраняйте ID каждого события в таблице с уникальным индексом и вставляйте запись до начала работы. Если вставка падает из-за дубликата, верните 200 и пропустите обработку. Само бизнес-действие тоже должно быть безопасным при повторе.
Какой код ответа должен возвращать webhook Stripe?
Код 2xx (200 или 204) сразу после проверки и сохранения события. 400 - при неверной подписи, 5xx - только если событие не удалось сохранить и вы хотите, чтобы Stripe повторил доставку.
Как долго Stripe повторяет неудачные webhook?
В live-режиме Stripe повторяет доставку с экспоненциальной задержкой до трёх дней. В тестовом режиме окно повторов намного короче, поэтому не судите о поведении продакшена по повторам в test.
Можно ли тестировать Stripe webhooks без публичного адреса?
Да. Запустите stripe listen --forward-to в Stripe CLI, чтобы пересылать тестовые события на localhost, а stripe trigger создаёт примеры событий.
Бесплатный discovery-звонок
Хаотичный стек или зависшая интеграция - Integrono наметит самый умный первый шаг.
Вы получаете senior-взгляд на бюджетные ловушки, зависимости и когда покупать, склеивать или писать - до старта оплаты. CEO остаётся в контуре.