Обработка вебхуков в PHP: чек-лист перед продакшеном
Вебхук от платёжки пришёл дважды — и вы дважды выдали товар. Разбираем, что проверить в PHP-обработчике до выката: подпись, идемпотентность, таймауты и повторы.

Платёжный провайдер отправил уведомление об успешной оплате, ваш обработчик отдал 200, но по дороге упала запись в базу. Провайдер решил, что доставка не удалась, и повторил запрос через минуту. Обработчик отработал заново — и клиент получил второй заказ, а вы минус на складе. Классическая история для вебхука, написанного «чтобы работало на тесте».
Ниже — чек-лист для PHP-обработчика перед выкатом в прод. Не про то, как устроен HTTP, а про конкретные места, где вебхуки ломаются: проверка подлинности, повторы, порядок событий и время ответа. Примеры на PHP 8.2+, без привязки к фреймворку — логика одинакова и для голого PHP, и для Laravel, и для Symfony.
Проблема, которую недооценивают
Вебхук — это входящий HTTP-запрос от чужой системы, которую вы не контролируете. У вас нет гарантий, что запрос придёт один раз, придёт в правильном порядке, придёт вообще от того, кем представляется, и что отправитель дождётся вашего ответа. Обычный контроллер, который пишет в базу и радостно возвращает JSON, ни одно из этих допущений не проверяет.
Три допущения, которые чаще всего подводят:
- «Запрос придёт один раз» — почти все провайдеры (Stripe, ЮKassa, Telegram, GitHub) повторяют доставку, если не получили быстрый 2xx. Повтор при таймауте — норма, а не сбой.
- «Запрос точно от провайдера» — URL обработчика рано или поздно попадёт в логи, историю браузера или в чужие руки. Без проверки подписи любой может слать вам «успешные оплаты».
- «События придут по порядку» — событие
payment.succeededможет прийти позже, чемpayment.refunded, если сеть перетасовала повторы.
Чек-лист перед продакшеном
- Проверяем подпись до любой бизнес-логики и до парсинга во что-то сложное.
- Обрабатываем идемпотентно: повторный вебхук с тем же ID не должен менять состояние второй раз.
- Отвечаем 2xx быстро, тяжёлую работу выносим в очередь.
- Логируем сырое тело и заголовки до обработки, чтобы было что разбирать при инциденте.
- Возвращаем правильные коды: 2xx — принято, 4xx — не повторяй, 5xx — повтори позже.
1. Проверка подписи
Провайдер подписывает тело запроса секретом, который знаете только вы двое, и кладёт подпись в заголовок. Ваша задача — посчитать HMAC от сырого тела тем же секретом и сравнить. Ключевое слово — сырого: если вы сначала прогоните тело через json_decode и обратно через json_encode, порядок ключей и пробелы изменятся, и подпись не совпадёт.
Читать тело нужно из php://input, а не из $_POST — последний заполняется только для form-urlencoded и multipart, а вебхуки обычно шлют JSON.
<?php
// PHP 8.2+
// Сырое тело — до любого json_decode
$payload = file_get_contents('php://input');
// Заголовок с подписью (имя зависит от провайдера)
$signatureHeader = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET'); // не хардкодьте секрет в коде
$expected = hash_hmac('sha256', $payload, $secret);
// hash_equals защищает от атаки по времени сравнения
if (!hash_equals($expected, $signatureHeader)) {
http_response_code(400);
exit; // не логируем как ошибку сервера — это не наша поломка
}
Точное имя заголовка и формат подписи смотрите в документации конкретного провайдера: у одних это чистый hex-HMAC, у других — строка с версией и таймстампом (как t=...,v1=... у Stripe), которую нужно разбирать перед сравнением. Не угадывайте формат — проверьте в их доках.
Сравнивать подписи через==или===нельзя. Обычное сравнение строк завершается на первом несовпавшем байте, и по времени ответа можно подобрать подпись побайтно.hash_equalsсравнивает за постоянное время.
2. Идемпотентность
У каждого события обычно есть уникальный ID (event.id, x-request-id в заголовке или ID платежа). Заведите таблицу обработанных событий и вставляйте ID до выполнения бизнес-логики. Уникальный индекс на колонке ID превращает повтор в ошибку вставки, которую вы ловите и отвечаете 200, ничего не делая второй раз.
<?php
// $pdo — подключение PDO к PostgreSQL/MySQL
$eventId = $data['id']; // ID события из тела вебхука
try {
$stmt = $pdo->prepare(
'INSERT INTO processed_webhooks (event_id, received_at) VALUES (?, NOW())'
);
$stmt->execute([$eventId]);
} catch (\PDOException $e) {
// Нарушение уникального индекса — событие уже обработано
// SQLSTATE 23505 в PostgreSQL, 23000 в MySQL
if (in_array($e->getCode(), ['23505', '23000'], true)) {
http_response_code(200);
exit; // повтор — молча подтверждаем
}
throw $e; // другая ошибка БД — пусть провайдер повторит
}
// Сюда доходим только для нового события
processPayment($data);
Тонкость: вставка ID и сама бизнес-логика должны быть в одной транзакции, иначе возможна гонка — ID записали, а на выдаче товара упали. Тогда при повторе событие считается обработанным, но товар не выдан. Оборачивайте вставку и обработку в beginTransaction() / commit(), а при исключении делайте rollBack(), чтобы повтор прошёл заново.
3. Быстрый ответ, тяжёлая работа — в очередь
У провайдеров есть таймаут на ответ вебхука — часто в районе нескольких секунд (точное значение смотрите в их документации, оно разное). Если вы внутри обработчика шлёте письмо, дёргаете три внешних API и генерируете PDF, вы в этот таймаут не уложитесь. Провайдер получит таймаут, посчитает доставку неудачной и пришлёт повтор — привет, дубликаты.
Правильный порядок: проверили подпись → записали событие → положили задачу в очередь → вернули 200. Всё тяжёлое делает воркер. Очередь — Redis, RabbitMQ, БД-таблица с задачами или встроенная очередь фреймворка.
4. Правильные коды ответа
Код ответа — это сигнал провайдеру, повторять ли доставку. Перепутанный код превращает единичный сбой в бесконечные повторы или, наоборот, в потерянное событие.
| Код | Когда возвращать | Что сделает провайдер |
|---|---|---|
| 200 / 204 | Событие принято или это повтор уже обработанного | Считает доставленным, не повторяет |
| 400 | Подпись неверна, тело битое | Обычно не повторяет |
| 500 / 503 | Ваша база или очередь недоступны | Повторит позже — это то, что нужно |
Главная ошибка новичка: поймать любое исключение, залогировать и вернуть 200. Тогда при временном сбое базы событие для провайдера «доставлено», повтора не будет, а вы его потеряли. Если не смогли надёжно принять событие — верните 5xx и дайте провайдеру повторить.
Что ломается в проде
- Тело уже прочитано фреймворком. Laravel и Symfony могут распарсить body в свой Request раньше вашего кода, и
php://inputвернёт пустоту. Берите сырое тело из объекта запроса фреймворка ($request->getContent()) — оно там кешируется. - Прокси и балансировщик меняют тело. Nginx с
gzipили модификация charset может изменить байты, и подпись перестанет совпадать. Проверяйте подпись до любых преобразований и не давайте прокси трогать тело вебхука. - Очередь переполнена, воркер отстаёт. Вы отвечаете 200 мгновенно, но задачи копятся, и клиент видит «оплата прошла, а доступа нет» через 20 минут. Мониторьте длину очереди и возраст самой старой необработанной задачи.
- События вне порядка. Не полагайтесь на порядок прихода. Смотрите на состояние объекта в теле события или запрашивайте актуальный статус через API провайдера, прежде чем менять своё состояние.
Что настроить после выката
Обработчик заработал — дальше три вещи, которые спасут при инциденте:
- Логируйте каждый вебхук с ID события, кодом ответа и временем обработки. Когда клиент напишет «оплатил, ничего не пришло», вы найдёте событие по ID за минуту.
- Настройте алерт на всплеск 5xx от обработчика — это первый признак, что база или очередь легли.
- Проверьте в личном кабинете провайдера, есть ли ручной повтор доставки события. Он пригодится, чтобы догнать потерянные события после разбора инцидента, не выдумывая события руками.
Инвалидация кеша статусов и обратная сверка (реконсиляция) остатков с провайдером — отдельная тема; здесь мы разобрали только приём и обработку самого вебхука.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: PHP Manual: hash_hmac, PHP Manual: hash_equals
Частые вопросы
А если тело приходит не JSON, а form-urlencoded?
У меня Laravel, php://input возвращает пустую строку — что делать?
Провайдер прислал одно и то же событие три раза — это баг у них?
Стоит ли проверять IP отправителя вместо подписи?
Какой таймаут на ответ у платёжных провайдеров?
А если моя база упала прямо во время обработки вебхука?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.