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

Stripe Webhooks в PHP: проверка подписи и надёжная обработка

Что такое Stripe webhook? Поддерживает ли Stripe webhooks в PHP? Да. Stripe POST-ит JSON на один из ваших URL-ов каждый раз, когда на аккаунте происходит что-то значимое: успешный платёж, очередное списание по подписке, возврат, диспут по транзакции. Тело запроса подписано общим секретом – сервер может доказать, что запрос действительно от Stripe, а не от случайного человека, угадавшего URL. В PHP проверка подписи – один вызов метода; основное время съедает всё, что вокруг этого вызова.

Работающий эндпоинт для продакшна состоит из трёх частей: HTTP-роут, который читает сырое тело запроса, вызов Stripe\Webhook::constructEvent(), отбрасывающий всё с невалидной подписью, и диспетчер, который быстро отвечает 200 и убирает реальную обработку в фон. Ошибёшься в любой – и Stripe трое суток долбит ретраями, логи забиты signature-ошибками, а узнаёшь о проблеме от пользователя.

Статья разбирает полный рабочий эндпоинт, ловушки с чтением тела в Laravel и Symfony, идемпотентность при ретраях и локальный тест через Stripe CLI. Предполагается, что SDK уже установлен и API-ключи настроены; если нет – начинать со статьи про интеграцию Stripe в PHP.

Минимальный рабочий эндпоинт

Начнём с одного файла: без фреймворка, без очереди. Вот тот минимум, которого Stripe ждёт от вас перед тем, как в дело вступит бизнес-логика:

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

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = getenv('STRIPE_WEBHOOK_SECRET');

try {
    $event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
} catch (\Stripe\Exception\UnexpectedValueException $e) {
    http_response_code(400);
    exit('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400);
    exit('Invalid signature');
}

// Подпись верна, $event – полноценный объект Stripe\Event
error_log("Received {$event->type} ({$event->id})");

http_response_code(200);

php://input – это сырое, нераспарсенное тело запроса. Прочитаете тело любым другим способом – подпись не сойдётся. Это самая частая причина сломанной подписи на Stack Overflow, и значительная часть статьи дальше – про то, какими именно способами фреймворки мешают её соблюдать.

constructEvent кидает два разных исключения, оба в пространстве имён \Stripe\Exception\. UnexpectedValueException (наследуется от базового PHP-класса, так что ловится и через core-имя) означает, что тело вообще не валидный JSON – скорее всего, health-check или кто-то просто ткнул в URL. SignatureVerificationException означает, что JSON распарсился, но подпись не совпала – либо секрет не тот, либо перепутан эндпоинт, либо что-то модифицирует тело по пути. Логировать их стоит по-разному.

200 возвращается до того, как начинается любое длительное действие. Stripe ждёт 2xx раньше, чем вы успеете уйти в таймаут. Если хэндлер висит секунд десять на записи в базу и рассылке писем, Stripe сдаётся, помечает доставку как failed и ретраит – теперь у вас два события, которые надо дедупить.

$event->type и $event->id – те два поля, с которых начинается любой обработчик. Тип маршрутизирует в нужный хэндлер, идентификатор служит ключом идемпотентности (про это ниже).

Откуда взять webhook-секрет

Из дашборда Stripe, раздел Developers > Webhooks. Создаёте эндпоинт, вставляете публичный URL, выбираете события (начинайте узко – payment_intent.succeeded, charge.refunded, то, что отвечает реальному доменному действию; расширить всегда успеете), и Stripe выдаёт подписывающий секрет, начинающийся с whsec_.

У test и live режимов отдельные дашборды и отдельные секреты. Когда меняете STRIPE_SECRET_KEY с тестового на боевой, одновременно меняете и STRIPE_WEBHOOK_SECRET, и каждое окружение получает свою пару. Перепутать эти пары – и вы получите такой же SignatureVerificationException, как при подмене тела, а в тексте ошибки про режим ни слова.

У разных эндпоинтов одного аккаунта разные секреты. Если вы разнесли квитанции и подписки на два URL-а, у каждого свой whsec_. Общую переменную окружения между ними шерить нельзя; конфигурируется отдельно.

Сырое тело: грабли Laravel и Symfony

В обоих фреймворках есть middleware, который парсит входящий JSON в массив ещё до того, как контроллер что-то увидит. Обычно это удобно. Для Stripe webhook-а это катастрофа: подпись считается от последовательности байт, которую Stripe отправил, а json_decode($body) с последующим json_encode($array) побайтно не эквивалентен исходнику – сдвигаются пробелы, порядок ключей, числовая точность.

Laravel. Тело запроса остаётся доступным через $request->getContent() (строка), даже после того как фреймворк уже заполнил $request->all(). Эту строку и надо передавать в constructEvent:

use Illuminate\Http\Request;

public function handle(Request $request)
{
    $payload = $request->getContent();
    $signature = $request->header('Stripe-Signature', '');
    $secret = config('services.stripe.webhook_secret');

    try {
        $event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
    } catch (\Stripe\Exception\SignatureVerificationException) {
        abort(400, 'Invalid signature');
    }

    // диспатч по $event->type
    return response('', 200);
}

Из CSRF-защиты маршрут надо исключить. VerifyCsrfToken вернёт 419 задолго до того, как constructEvent получит шанс отработать. В Laravel 11+ это живёт в bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'stripe/webhook',   // точный путь
        'stripe/*',         // либо wildcard для вложенных роутов
    ]);
})

И из auth-middleware маршрут держать в стороне. Stripe не шлёт ни session-cookies, ни bearer-токены, а редирект на логин-страницу ломает сырое тело так же надёжно, как и CSRF.

Symfony. Сырое тело берётся через $request->getContent() (метод называется так же, но класс другой – Symfony\Component\HttpFoundation\Request). getContent() можно вызывать повторно без последствий. Ловушка не в потреблении, а в том, что $request->toArray() с последующим json_encode() не будет побайтно идентичен тому, что Stripe подписал (пробелы и порядок ключей сдвинутся). В constructEvent передавайте именно сырую строку, а не повторно закодированный массив.

Идемпотентность: Stripe ретраит три дня

В live-режиме Stripe пытается доставить каждое событие до трёх суток с экспоненциальной задержкой, в sandbox-аккаунте – три раза за несколько часов. Ретраи случаются на любой ответ не 2xx, на любой таймаут и иногда на успешную доставку, которую инфраструктура Stripe не смогла подтвердить. Вывод неизбежен: ваш обработчик увидит один и тот же event.id не один раз, и “обработать событие” по определению должно значить “обработать максимум один раз, сколько бы раз оно ни пришло”. В любом нетривиальном хэндлере event.id – ключ дедупликации:

function handleEvent(\Stripe\Event $event, \PDO $db): void
{
    // Insert-or-ignore по event.id. Primary key – Stripe event ID.
    // Ниже синтаксис PostgreSQL; на MySQL – INSERT IGNORE INTO ...,
    // на SQLite (3.24+) ON CONFLICT тоже работает, но NOW()
    // придётся заменить на CURRENT_TIMESTAMP.
    $stmt = $db->prepare(
        'INSERT INTO processed_webhooks (event_id, type, processed_at)
         VALUES (:id, :type, NOW())
         ON CONFLICT (event_id) DO NOTHING'
    );
    $stmt->execute(['id' => $event->id, 'type' => $event->type]);

    if (0 === $stmt->rowCount()) {
        return; // уже видели, делать нечего
    }

    match ($event->type) {
        'payment_intent.succeeded' => onPaymentSucceeded($event->data->object),
        'charge.refunded'          => onRefund($event->data->object),
        'customer.subscription.deleted' => onSubscriptionCancelled($event->data->object),
        default => null, // неизвестные типы – нормально, пишем в лог и идём дальше
    };
}

Паттерн простой: сначала insert, бизнес-логика только на реально вставленной строке. Так дедуп атомарен с базой и не ломается в гонке “check-then-act” на Redis.

Для webhook-ов, приходящих на несколько инстансов за балансировщиком, одной общей таблицы с уникальным ограничением хватает. На высоконагруженных аккаунтах имеет смысл партиционировать по месяцам и чистить старые партиции – Stripe ретраит только три дня, уникальность event_id дальше месяца уже мёртвый груз.

Stripe event ID не стоит использовать как свой order ID или любой другой доменный ключ. Он ваш только для идемпотентности, не для маршрутизации.

Быстрый 2xx, реальная работа в очереди

Двухфазный паттерн:

  1. В HTTP-хэндлере: проверить подпись, записать event.id и полное тело события в базу или очередь, сразу вернуть 200.
  2. В воркере: прочитать строку, диспатчить по event.type, выполнять реальную работу с собственными ретраями.

Разделение нужно потому, что ваш медленный код теперь проблема ваша, а не Stripe. Упала почтовая служба? Ваша очередь ретраит. Миграция залочила таблицу на минуту? Воркер ждёт, Stripe этого не видит. Сам webhook-эндпоинт не делает ничего, что могло бы упасть, кроме проверки подписи.

Laravel с очередью:

public function handle(Request $request)
{
    $payload = $request->getContent();
    $signature = $request->header('Stripe-Signature', '');

    try {
        $event = \Stripe\Webhook::constructEvent(
            $payload, $signature, config('services.stripe.webhook_secret')
        );
    } catch (\Stripe\Exception\SignatureVerificationException) {
        abort(400);
    }

    ProcessStripeEvent::dispatch($event->id, $event->type);

    return response('', 200);
}

Продакшн-грабля в этом паттерне: объект Stripe\Event в Job-payload сериализовать нельзя. Он тянет за собой весь вложенный граф – данные клиента, payment method, биллинговый адрес – и кладёт это в Redis или в таблицу очереди MySQL. В Job передавайте только event.id и type, а в воркере перезапрашивайте через \Stripe\Event::retrieve($id). PII остаётся на стороне Stripe до момента, когда воркеру реально что-то из него нужно.

Работа с payload-ом события

Ядро обёртки у всех событий одинаковое (сверх этого в payload-е ещё object: "event", pending_webhooks, request.idempotency_key и account в Connect – их полный список в API-справочнике Stripe):

{
  "id": "evt_1ABCDEf2ghIJK3LmNOPQr456",
  "type": "payment_intent.succeeded",
  "created": 1715775847,
  "data": {
    "object": { ... }           // сам ресурс, меняется по типу
  },
  "livemode": false,
  "api_version": "2025-03-31.basil"
}

data.object – это тот объект, о котором событие: PaymentIntent, Charge, Customer, Subscription. В SDK до него добираются через $event->data->object, причём он уже типизирован соответствующим Stripe-классом, так что подсказки работают, если IDE индексирует SDK.

Для разовых платежей стоит подписаться на payment_intent.succeeded, payment_intent.payment_failed, charge.refunded и charge.dispute.created – последний вы заметите в нужный момент, если обработали его до первого chargeback-а. Checkout-интеграции добавляют checkout.session.completed и, для асинхронных методов вроде SEPA, checkout.session.async_payment_succeeded. Подписочный биллинг шумнее всех: customer.subscription.created/updated/deleted, плюс invoice.paid и invoice.payment_failed для реального движения денег. Отдельно стоит customer.source.expiring – приходит за неделю до того, как карта на файле перестанет работать, но это событие legacy-слоя Card/Source и не срабатывает на интеграциях через PaymentMethod API (то есть на всём, что основано на PaymentIntent или современном Checkout). Для PaymentMethod-потока эквивалент мониторится сторонне – по payment_method.updated или по отчётам.

В дашборде подписывайтесь только на те события, которые реально обрабатываете в коде. Подписка на всё делает журнал событий нечитаемым ровно в тот момент, когда вам надо дебажить конкретный flow. Полный актуальный список лежит на docs.stripe.com/api/events/types.

Metadata – мост в ваш домен. Когда создаёте PaymentIntent, Checkout Session или Subscription, прикрепляйте массив metadata со своими внутренними ID:

$intent = $stripe->paymentIntents->create([
    'amount' => 2500,
    'currency' => 'usd',
    'metadata' => ['order_id' => (string) $order->id],
]);

Этот metadata потом едет на всех событиях об этом intent-е. В webhook-е:

$orderId = $event->data->object->metadata['order_id'] ?? null;

Никакого поиска по Stripe ID, никаких хрупких join-ов. Значения metadata – только строки, числовые ID кастуйте явно.

Частый вопрос со Stack Overflow: “В webhook-е payment_intent.succeeded – как получить session_id Checkout-сессии, которая создала этот PaymentIntent?” Ответ не про expand – у PaymentIntent нет обратной ссылки на сессию. Положите ID сессии или ID заказа в metadata самой сессии при создании, и он поедет дальше по цепочке:

$session = $stripe->checkout->sessions->create([
    'mode' => 'payment',
    'metadata' => ['order_id' => (string) $order->id],
    // ...
]);

Хэндлер payment_intent.succeeded читает order ID прямо из $event->data->object->metadata, никаких дополнительных запросов к сессии.

Порядок событий не гарантирован

В best-practices Stripe на этом специально заостряет внимание: порядок доставки не гарантируется. Самый показательный кейс – для одной и той же подписки customer.subscription.updated иногда прилетает раньше, чем customer.subscription.created; то же самое возможно с любой парой *.created и *.updated на одном ресурсе.

Обычно это не проблема – дедуп по event.id плюс вызов retrieve() в момент обработки (чтобы взять текущее состояние объекта) делают хэндлер независимым от порядка. Если порядок реально важен, паттерн такой: на каждое событие перезапрашиваем ресурс у API и действуем по его актуальному состоянию, а не по снапшоту из события:

case 'customer.subscription.updated':
    // Снапшот события ненадёжен; берём текущее состояние.
    $subscription = $stripe->subscriptions->retrieve($event->data->object->id);
    updateLocalStatus($subscription);
    break;

Один лишний API-запрос, и он окупается всякий раз, когда переходы статусов действительно важны.

Локальный тест через Stripe CLI

Зарегистрировать http://localhost:8000/stripe/webhook в дашборде как эндпоинт нельзя – Stripe нужен публично достижимый URL. Старым ответом был ngrok; актуальный ответ – Stripe CLI, который открывает тоннель специально для webhook-ов и не выставляет в интернет весь ваш локальный сервер:

# Одноразовый логин (откроет браузер, чтобы привязать CLI к аккаунту)
stripe login

# Пересылаем события на локальный эндпоинт
stripe listen --forward-to localhost:8000/stripe/webhook

CLI сразу после старта печатает webhook-секрет – он отличается от дашбордных, специфичен для этой forwarding-сессии. Положите его в локальное окружение:

STRIPE_WEBHOOK_SECRET=whsec_xxx_from_cli

Дальше из соседнего терминала триггерите синтетические события:

stripe trigger payment_intent.succeeded
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted

Каждый такой вызов бьёт по вашему эндпоинту полностью оформленным, корректно подписанным payload-ом. Можно прогнать весь хэндлер – включая запись в базу и диспатч в очередь – без реальной карты и без прохождения checkout-а.

Для событий, привязанных ко времени – возобновление подписки, окончание trial, отмена в конце периода – stripe trigger уже не помогает: они срабатывают только когда реально тикнут часы. Используйте test clocks для симуляции времени:

# Создаём test clock с указанной стартовой точкой
stripe test_helpers test_clocks create --frozen-time 1735689600

# Переводим его, скажем, на 35 дней вперёд – перейти через месячный цикл
stripe test_helpers test_clocks advance <clock_id> --frozen-time 1738713600

Привязываете clock к Customer-у на создании, и все billing-события этого клиента едут по симулированному времени. Иначе для теста ежемесячного возобновления придётся либо ждать месяц, либо подкручивать системные часы.

Грабли с reverse-proxy и edge-сетью

Контракт “сырое тело” распространяется за пределы вашего PHP-кода. Всё, что находится между Stripe и хэндлером и модифицирует байты, ломает подпись. Две типовые ситуации.

Cloudflare и аналогичные CDN по умолчанию могут gzip-жать или минифицировать ответы – это нормально, это исходящий трафик. Проверять нужно, не применяется ли на вашем тарифе какая-нибудь трансформация к входящим запросам. На путь webhook-а стоит повесить Cloudflare Configuration Rule (или старую Page Rule на legacy-тарифах), которая отключает optimization-фичи и оставляет тело нетронутым.

nginx proxy_pass в типовой конфигурации прозрачен, но настройки буферизации в комбинации с модулями вроде sub_filter или WAF-слоем иногда трогают байты по пути. Если подпись проходит локально, но падает за прокси, проверьте proxy_buffering, proxy_request_buffering и все директивы, которые умеют что-то делать с телом запроса на webhook-пути.

Когда диагностируете production-сбой подписи, быстрее всего вот так: залогируйте первые 50 байт $payload вместе с заголовком подписи и сравните с payload-ом, который виден в дашборде Stripe для этого события. Если байты отличаются – где-то в инфраструктуре переписывают тело.

Скос версий API

Тонкая штука, кусается на долгоживущих интеграциях. Схема payload-а события привязана к версии API, закреплённой за эндпоинтом в дашборде, а не к той, которую вы установили в коде SDK. Если в дашборде на эндпоинте запинено 2020-08-27, а в SDK-вызовах stripe_version равно 2025-03-31.basil, события приходят в формате 2020-го года, хотя исходящие запросы идут в формате 2025-го.

Практическое следствие: имена полей и вложенность, которые вы видите в актуальной документации Stripe, могут не совпадать с тем, что реально приходит на ваш эндпоинт. Проверить версию эндпоинта: Developers > Webhooks > [ваш эндпоинт] > API version. Поднять её можно там же; Stripe не переиграет старые события, но новые пойдут уже в свежем формате.

На свежесоздаваемом эндпоинте пините ту же версию, что и для исходящих вызовов, и держите их синхронными. Кошмарный сценарий – унаследовать эндпоинт, запиненный в 2019, и через три года обнаружить, что половины полей из вашего кода хэндлера в реальных payload-ах нет.

Типичные грабли

VerifyCsrfToken возвращает 419. Специфично для Laravel. Исключайте webhook-маршрут из CSRF – см. секцию про Laravel выше.

SignatureVerificationException на каждом запросе. Три типовые причины в порядке убывания частоты: эндпоинт использует не тот whsec_ (перепутаны test и live либо скопирован секрет от другого эндпоинта), middleware переупаковывает тело, прокси модифицирует байты. Логируйте первые байты $payload и сверяйте с payload-ом в логе Stripe по этому событию.

Сервер отвечает 200, но события помечены в дашборде как Failed. Эндпоинт отвечает слишком медленно. Возвращайте 200 до того, как хэндлер начинает реальную работу.

События приходят не по порядку. См. раздел выше – снапшоту из события не доверяйте, актуальное состояние берите через retrieve.

Ngrok-тоннель постоянно рвётся. Используйте Stripe CLI – он сделан именно для этого и не отваливается посреди сессии.

Следующие шаги

Эндпоинт – единственный способ узнать о результатах, которые происходят вне вашего request-response цикла: отложенное SEPA-списание, возобновление подписки, оспаривание старого платежа. Как только эндпоинт на месте, добавление нового типа события – изменение в одну строку в диспатч-мапе.

Если держать свой диспатчер желания нет, spatie/laravel-stripe-webhooks оборачивает весь этот паттерн – проверку подписи, диспатч Job-ов по типу события – в Laravel-пакет. Цена вопроса – ожидание релизов Spatie после мажорных апдейтов stripe-php; для большинства проектов компромисс нормальный, для тех, кому нужны свежие фичи SDK сразу после релиза, – нет.

Смежные материалы: интеграция Stripe в PHP настраивает PaymentIntent-ы, о которых и сообщают эти webhook-и. Stripe-подписки разбирают типы событий, специфичные для рекуррентного биллинга.

Каталог исключений SDK и их значений – в частых ошибках Stripe PHP. А в staging-окружении подключайте PSR-3-логгер к SDK: \Stripe\Stripe::setLogger($psr3Logger). Дебаг webhook-ов идёт заметно быстрее, когда полный сырой запрос виден рядом с исключением.

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

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