Обработка вебхуков в PHP: сравниваем четыре подхода
Синхронная обработка вебхука в контроллере кажется очевидной, пока отправитель не начинает ретраить по таймауту. Разбираем четыре способа принять вебхук в PHP и считаем цену каждого.

Вы принимаете вебхук от платёжной системы прямо в контроллере: проверяете подпись, обновляете заказ, шлёте письмо, дёргаете внешний API. На тестовом стенде всё летает. В проде отправитель начинает слать один и тот же вебхук по три раза — потому что ваш обработчик отвечает за 8 секунд, а его таймаут 5. В итоге заказ обрабатывается трижды, клиент получает три письма, а вы ищете, где утекают деньги.
Корень проблемы в том, что вебхук — это не обычный запрос от вашего фронта. У него другой контракт: отправитель ждёт быстрый 200, а при его отсутствии повторяет доставку. Ниже — четыре способа принять вебхук в PHP, с ценой каждого. Проверка подписи и идемпотентность упоминаются, но подробно инвалидацию дублей здесь не разбираем — это отдельная тема.
Что вообще ждёт от вас отправитель
Прежде чем выбирать архитектуру, посмотрите на контракт. У большинства провайдеров (Stripe, платёжные шлюзы, GitHub, Telegram) он одинаковый по сути:
- ответ
2xxв пределах короткого таймаута — обычно от 3 до 30 секунд, зависит от провайдера; - при таймауте или
5xx— повтор доставки, часто с экспоненциальной задержкой, иногда до нескольких суток; - тот же вебхук может прийти дважды даже при успешном ответе — сеть теряет ACK. Значит, обработка обязана быть идемпотентной независимо от выбранного подхода.
Вывод, который определяет выбор: тяжёлую работу нельзя делать в цикле ответа на вебхук. Вопрос лишь в том, куда вы её вынесете и чем за это заплатите.
Подход 1. Синхронно в контроллере
Всё делается в момент запроса: приняли, проверили подпись, обработали, ответили.
<?php
// Псевдо-роут,框架-агностично. Проверка подписи схематична.
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!hash_equals(computeSignature($payload), $signature)) {
http_response_code(403);
exit;
}
$event = json_decode($payload, true);
// Тяжёлая работа прямо здесь — вот источник таймаутов
updateOrder($event['order_id'], $event['status']);
sendEmail($event['order_id']);
callExternalApi($event);
http_response_code(200);Что выигрываете: простота. Ноль инфраструктуры, вся логика в одном месте, легко отлаживать.
Чем платите: время ответа складывается из всех операций. Внешний API затупил — отправитель получил таймаут и повторил. Под нагрузкой php-fpm воркеры заняты ожиданием сети, а не работой. Подходит только когда обработка гарантированно укладывается в 100–200 мс и не ходит во внешние сервисы.
Подход 2. Принять, сохранить, ответить — обработать позже по крону
Контроллер делает минимум: проверяет подпись, кладёт сырой payload в таблицу со статусом pending, отвечает 200. Отдельный процесс по крону разбирает очередь.
<?php
// Приём: только валидация и запись
$payload = file_get_contents('php://input');
if (!hash_equals(computeSignature($payload), $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
http_response_code(403);
exit;
}
$eventId = json_decode($payload, true)['id'] ?? null;
// INSERT ... ON CONFLICT DO NOTHING — защита от дублей на уровне БД
$stmt = $pdo->prepare(
'INSERT INTO webhook_events (event_id, payload, status)
VALUES (:id, :payload, :status)
ON CONFLICT (event_id) DO NOTHING'
);
$stmt->execute([
'id' => $eventId,
'payload' => $payload,
'status' => 'pending',
]);
http_response_code(200);Уникальный индекс по event_id плюс ON CONFLICT DO NOTHING (синтаксис PostgreSQL 9.5+; в MySQL — INSERT IGNORE или ON DUPLICATE KEY) закрывают повторную доставку сразу на приёме.
Что выигрываете: ответ отправителю за единицы миллисекунд, никакой внешней инфраструктуры кроме БД, которая у вас и так есть. Идемпотентность встроена.
Чем платите: задержка обработки — до интервала крона. Крон раз в минуту означает, что заказ обновится через минуту. Нужно самому следить, чтобы два запуска крона не разобрали одну строку — блокировка через SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL 9.5+, MySQL 8.0+).
Правило, которое экономит нервы: контроллер вебхука обязан завершаться максимально быстро и делать ровно две вещи — проверить, что запрос настоящий, и надёжно его сохранить. Всё остальное — не его забота.
Подход 3. Очередь задач: Redis + воркер
Контроллер валидирует и кладёт задачу в очередь (Redis, RabbitMQ). Воркер-демон разбирает её сразу, а не по расписанию. В Laravel это штатные Queues, в Symfony — Messenger, но механику можно собрать и вручную.
<?php
// Приём: кладём в очередь Redis (predis)
$payload = file_get_contents('php://input');
if (!hash_equals(computeSignature($payload), $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
http_response_code(403);
exit;
}
$redis->rpush('webhooks', $payload);
http_response_code(200);Что выигрываете: обработка почти мгновенная после приёма, воркеры масштабируются горизонтально, встроенные механизмы ретраев и dead-letter в готовых библиотеках.
Чем платите: появляется демон, который надо держать живым — supervisor или systemd, мониторинг, перезапуск после деплоя. Redis становится звеном, чья недоступность роняет приём (если только не падать обратно на запись в БД). Идемпотентность по-прежнему на вас: очередь может доставить сообщение дважды.
Подход 4. Прокси-приём на уровне веб-сервера
Крайний случай для высокого потока: Nginx принимает и логирует тело запроса, а PHP разбирает лог. На практике почти всегда избыточно — упоминаю, чтобы вы знали, что такой вариант существует, и не тянулись к нему без причины. Порог, за которым он оправдан, — тысячи вебхуков в секунду, когда даже поднять php-fpm на каждый приём дорого.
Сравнение
| Подход | Время ответа | Задержка обработки | Инфраструктура | Когда брать |
|---|---|---|---|---|
| Синхронно | = вся работа | нет | ничего | обработка <200 мс, без внешних API |
| БД + крон | единицы мс | до интервала крона | таблица в БД | задержка в минуту приемлема |
| Redis + воркер | единицы мс | секунды | Redis, демон, мониторинг | нужна быстрая обработка и масштаб |
| Прокси на Nginx | минимальное | зависит | сложная | тысячи rps, экзотика |
Когда что выбирать
- Обработка укладывается в 100–200 мс и не ходит во внешние сети — синхронно. Не усложняйте на пустом месте.
- Работа тяжёлая, но задержка в минуту не критична — БД плюс крон. Минимум движущихся частей, идемпотентность бесплатно через уникальный индекс.
- Нужна обработка за секунды и есть кто следить за демоном — очередь. Если проект на Laravel или Symfony, готовый компонент избавит от ручного велосипеда.
- Тысячи вебхуков в секунду — только тогда думайте про приём на уровне веб-сервера.
Что ломается на практике
- Проверка подписи после парсинга JSON. Подпись почти всегда считается по сырому телу.
json_decodeи обратныйjson_encodeдадут другую строку (пробелы, порядок ключей) — подпись не сойдётся. Читайтеphp://inputдо любых преобразований. - Забыли про повторную доставку. Даже при быстром ответе вебхук придёт дважды. Без идемпотентности это двойное списание. Уникальный ключ события в БД — минимальная защита.
- Тело обрезано. При некоторых конфигурациях
$_POSTпуст, а тело доступно только черезphp://input. Для вебхуков всегда читайте поток напрямую. - Воркер держит старый код после деплоя. Демон-процесс не перечитывает код автоматически. После релиза его надо перезапустить, иначе он крутит прошлую версию обработчика.
Что настроить дальше
Заведите таблицу или лог всех входящих вебхуков с их статусом — это спасёт при разборе «а был ли вообще запрос». Добавьте эндпоинт или скрипт для ручного переотправления зависших событий из pending. Мерьте два показателя в проде: время ответа на вебхук (должно стабильно держаться в десятках миллисекунд) и долю событий, застрявших в pending дольше нормы. Инвалидацию и повторную обработку уже разобранных событий вынесите в отдельную процедуру — это следующий шаг после того, как приём стал надёжным.
Агент-продажник: Скрипты B2B-продаж, обработка возражений по BANT и SPIN.
Спросить за 20 ₽Источники: Stripe Docs — Receive Stripe events in your webhook endpoint, PostgreSQL Documentation — INSERT ON CONFLICT
Частые вопросы
Почему нельзя просто проверить подпись после json_decode?
А если у меня MySQL, а не PostgreSQL — ON CONFLICT не работает?
Что делать, если воркер упал и часть вебхуков не обработалась?
Отправитель прислал один и тот же вебхук дважды — это ошибка на моей стороне?
Стоит ли сразу брать Redis и очередь, чтобы потом не переделывать?
Мой обработчик ходит во внешний API и иногда отвечает медленно — что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.