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

Типичная картина: платёжная система шлёт вебхук об успешной оплате, ваш обработчик тратит четыре секунды на генерацию PDF и отправку письма, отправитель не дожидается ответа и по таймауту повторяет запрос. В логах — два события payment.succeeded с одним и тем же ID платежа, а у вас в базе два заказа. Ниже — приёмы, которые снимают этот и соседние классы проблем. Примеры на чистом PHP 8.2, без фреймворка, чтобы логика была видна; в Laravel или Symfony это раскладывается на контроллер, middleware и очередь, но принципы те же.
Первое правило: ответить быстро, работать потом
Отправитель вебхука ждёт от вас код ответа за секунды. У Stripe таймаут порядка 20 секунд, у ЮKassa и других — свои значения, но общий смысл один: не уложились — получите ретрай. Если внутри обработчика вы синхронно рендерите документы, ходите во внешние API и шлёте почту, вы напрашиваетесь на дубли.
Разделите приём и обработку. Обработчик делает минимум: проверяет подпись, сохраняет сырое событие, отвечает 200. Тяжёлую работу выносит в очередь или в отдельный воркер.
Вебхук-эндпоинт — это не бизнес-логика. Это приёмник, который обязан сказать «получил» раньше, чем начнёт думать, что с этим делать.
Скелет обработчика
<?php
// PHP 8.2. Приёмник вебхука: принять, проверить, сохранить, ответить.
declare(strict_types=1);
// 1. Читаем сырое тело ДО любого парсинга.
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if ($rawBody === false || $rawBody === '') {
http_response_code(400);
exit;
}
// 2. Проверяем подпись на СЫРОМ теле (см. раздел ниже).
if (!verifySignature($rawBody, $signature, getenv('WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
$eventId = $event['id'] ?? null;
if ($eventId === null) {
http_response_code(400);
exit;
}
// 3. Сохраняем событие идемпотентно и ставим задачу в очередь.
$stored = storeEventOnce($eventId, $rawBody);
if ($stored) {
enqueueProcessing($eventId);
}
// 4. Отвечаем сразу. Дубль тоже получает 200 — иначе будут ретраи.
http_response_code(200);
echo 'ok';
Обратите внимание на порядок: сырое тело читается первым и подпись считается по нему же. Если сначала прогнать json_decode, а потом json_encode обратно, вы измените порядок ключей и пробелы — подпись перестанет сходиться.
Идемпотентность: чтобы дубль ничего не сломал
Ретраи — это не сбой, это штатное поведение. Отправитель гарантирует доставку «хотя бы один раз», а не «ровно один раз». Значит, ваш код должен спокойно переживать повтор.
Опорная точка — идентификатор события от отправителя (id, event_id, у некоторых площадок отдельный заголовок вроде Idempotency-Key). Кладите его в таблицу с уникальным индексом и полагайтесь на базу, а не на проверку «SELECT, потом INSERT» — между этими двумя запросами прилетит второй ретрай и оба пройдут.
<?php
// PHP 8.2 + PDO (MySQL 8 / PostgreSQL 16).
// Таблица:
// CREATE TABLE webhook_events (
// event_id VARCHAR(191) PRIMARY KEY,
// payload JSON NOT NULL,
// status VARCHAR(20) NOT NULL DEFAULT 'received',
// received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
// );
function storeEventOnce(string $eventId, string $rawBody): bool
{
$pdo = getPdo();
try {
$stmt = $pdo->prepare(
'INSERT INTO webhook_events (event_id, payload) VALUES (:id, :payload)'
);
$stmt->execute(['id' => $eventId, 'payload' => $rawBody]);
return true; // Вставили — событие новое.
} catch (\PDOException $e) {
// Нарушение уникальности = дубль. Коды: MySQL 23000, PostgreSQL 23505.
if (in_array($e->getCode(), ['23000', '23505'], true)) {
return false;
}
throw $e; // Любая другая ошибка БД — не глотаем.
}
}
Возврат false означает: событие уже было. Задачу в очередь второй раз не ставим, но всё равно отвечаем 200 — иначе отправитель решит, что доставка не удалась.
Отдельно про саму бизнес-обработку: даже с этим замком делайте операции идемпотентными по смыслу. Не «создать заказ», а «создать заказ, если для этого платежа его ещё нет». Замок по event_id спасает от повторной обработки того же события, но не от двух разных событий об одном заказе.
Проверка подписи без утечки по времени
Почти все отправители подписывают тело секретом через HMAC. Проверять надо две вещи: что подпись совпадает и что сравнение устойчиво к атакам по времени. Обычное == сравнивает строки посимвольно и завершается на первом несовпадении — по времени ответа можно подбирать подпись. Используйте hash_equals.
<?php
// PHP 8.2. HMAC-SHA256, схема близка к Stripe/GitHub.
function verifySignature(string $rawBody, string $signatureHeader, string $secret): bool
{
if ($secret === '' || $signatureHeader === '') {
return false;
}
// Вычисляем ожидаемую подпись по сырому телу.
$expected = hash_hmac('sha256', $rawBody, $secret);
// Сравнение с защитой от timing-атак.
return hash_equals($expected, $signatureHeader);
}
Точный формат заголовка зависит от площадки. У некоторых там префикс вроде sha256=, у Stripe — конструкция с временной меткой t= и одной или несколькими подписями v1=, которую надо разобрать и заодно проверить, что метка не слишком старая (защита от повторной отправки перехваченного запроса). Точную схему смотрите в документации конкретного отправителя — не подгоняйте под пример выше вслепую.
Куда девается тело при неверной подписи
Если подпись не сошлась — отвечайте 401 и не пишите событие в основную таблицу. Но заведите отдельный лог отклонённых запросов с IP и заголовками: всплеск 401 — либо у вас разъехался секрет после ротации, либо кто-то стучится в эндпоинт.
Сравнение способов принять и обработать
| Подход | Ответ отправителю | Риск дублей | Когда брать |
|---|---|---|---|
| Всё синхронно в обработчике | Медленный, ловит таймауты | Высокий | Только для очень лёгкой логики без внешних вызовов |
| Сохранить в БД + очередь (Redis, БД, RabbitMQ) | Быстрый | Низкий при уникальном индексе | Рабочий вариант по умолчанию |
| Сохранить в БД + крон-воркер по расписанию | Быстрый | Низкий | Нет брокера очередей, но есть крон |
Разница между очередью и кроном — в задержке обработки. Очередь берёт задачу почти сразу, крон раз в минуту. Для оплат минута обычно терпима, для отправки одноразовых кодов — нет.
Что ломается в проде чаще всего
- Тело читают дважды.
php://inputв некоторых конфигурациях читается один раз. Прочитали в переменную — дальше работайте с ней, а не с потоком повторно. - Подпись считают по перекодированному JSON. Классика:
json_decode→json_encodeменяет строку, HMAC не сходится. Считайте только по сырому телу. - Проверку идемпотентности делают через SELECT+INSERT. Гонка двух ретраев проходит оба. Только уникальный индекс и обработка ошибки вставки.
- На ошибку бизнес-логики отвечают 500. Отправитель начнёт ретраить событие, которое у вас и так сохранено. Ответьте
200, а обработку с ошибкой пометьтеfailedи разбирайте отдельно. - Веб-сервер режет тело по размеру. Большие payload'ы упираются в
post_max_size. Для вебхуков лимит стоит поднять осознанно.
Что настроить после базовой обработки
Когда приёмник работает, добавьте наблюдаемость и восстановление:
- Статусы у событий:
received→processing→done/failed. По зависшим вprocessingдольше N минут — алерт. - Повторную обработку упавших событий из таблицы, раз payload уже сохранён — восстановление не требует нового вебхука.
- Метрику задержки между
received_atи завершением обработки — она первой покажет, что воркер не справляется.
Ротация секрета и работа с несколькими отправителями на одном эндпоинте — отдельная тема, здесь мы разбирали приём и идемпотентность одного потока.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: PHP Manual: hash_equals, PHP Manual: hash_hmac
Частые вопросы
А если у меня Laravel, а не чистый PHP?
Обязательно ли ставить очередь? Проект маленький.
Что вернуть отправителю, если у меня внутри всё упало?
hash_equals есть во всех версиях PHP?
Подпись не сходится, хотя секрет точно правильный. Где искать?
Как тестировать вебхуки локально?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.