Перейти к содержимому

Приём вебхука от внешнего сервиса - точка входа, подпись, повторы

Принимаем уведомление от платёжной системы или службы доставки: своя точка входа, проверка подписи, защита от повторов и быстрый ответ отправителю.

Что нужно знать заранее

Вебхук приходит от чужого сервера, а не от посетителя сайта. Здесь нет ни сессии, ни авторизованного пользователя, ни привычной проверки признака сеанса, и защита строится на подписи запроса.

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

Отправитель ждёт ответа считаные секунды. Тяжёлую работу внутри обработчика он воспринимает как отказ и присылает уведомление снова, а на сайте появляются двойные заказы и двойные начисления.

Шаги

  1. Завести отдельный адрес для приёма уведомлений и закрыть его от лишней логики.
  2. Проверить подпись запроса до любого разбора его содержимого.
  3. Отсечь повторное событие по идентификатору из тела уведомления.
  4. Ответить отправителю успехом сразу, а работу выполнить в фоне.
  5. Писать сырые запросы в журнал: без них разбор спорных случаев невозможен.

Решение

Заводим точку входа без лишней обвязки:

// /local/webhook/payment.php - отдельный адрес только для уведомлений
define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
$body = file_get_contents('php://input'); // сырое тело нужно для проверки подписи
// разбирать тело в массив можно только после сверки подписи

Точку входа держат тонкой и отдельной от витрины. Никакого шаблона сайта, никакой авторизации: задача файла - проверить подпись, положить событие в обработку и ответить отправителю.

Проверяем подпись запроса:

$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit; // сравнение обычным равенством здесь опасно
}

Подпись проверяют до разбора содержимого, а не после. Сравнение выполняют функцией постоянного времени: обычное сравнение строк подсказывает подбирающему, насколько он близок к верной подписи.

Отсекаем повторное событие:

$data = json_decode($body, true);
$key = 'vendor.webhook.' . $data['event_id'];
$storage = \Bitrix\Main\DI\ServiceLocator::getInstance()
->get(\Bitrix\Main\Data\Storage\PersistentStorageInterface::class);
if ($storage->get($key, false)) { http_response_code(200); exit; } // повтор
$storage->set($key, 1, 86400); // сутки помним обработанные события

Ключ повтора берут из тела уведомления, а не придумывают сами. Идентификатор события у отправителя стабилен, и по нему повтор отличается от нового события за одну проверку.

Отвечаем быстро, а работу выносим в фон:

\Bitrix\Main\Application::getInstance()->addBackgroundJob(
static fn() => (new \Vendor\Module\PaymentHandler())->apply($data) // тяжёлая часть
);
http_response_code(200);
echo 'OK'; // отправителю хватает короткого ответа

Фоновое выполнение снимает главную причину повторов. Отправитель получает ответ за миллисекунды, а разбор платежа, запись в заказ и отправка письма происходят уже после закрытия соединения.

Пишем сырые запросы в журнал:

\Bitrix\Main\Diag\Debug::writeToFile(
['ip' => $_SERVER['REMOTE_ADDR'], 'body' => mb_substr($body, 0, 2000)],
date('H:i:s'), 'webhook.log');
// без журнала спор «мы отправляли» против «мы не получали» не разрешить

Типичные проблемы

Заказ оплачен дважды или начисление прошло дважды.

Обработчик не отличает повторное уведомление от нового события. Повторы отсекают по идентификатору события из тела запроса.

Отправитель шлёт одно и то же уведомление снова и снова.

Ответ приходит дольше его ожидания, и он считает доставку неудачной. Тяжёлую работу выносят в фон, а отправителю отвечают сразу.

Через точку входа проходят чужие запросы.

Подпись не проверяется или сравнивается обычным равенством строк. Подпись проверяют функцией постоянного времени до разбора тела.

Тело запроса приходит пустым.

Содержимое читается из массива полей формы, а отправитель шлёт данные телом запроса. Сырое тело читают потоком, до любых преобразований.

Уведомления отсекаются защитой сайта.

Проактивный фильтр видит в теле запроса подозрительные конструкции. Для адреса вебхука настраивают исключение и проверяют журнал защиты.

Частые вопросы

Нужен ли отдельный файл или хватит контроллера?

Подходит и контроллер, но проверки сеанса на нём отключают явно. Отдельный файл нагляднее: сразу видно, что адрес живёт по своим правилам.

Как проверить обработчик до подключения сервиса?

Отправить себе тот же запрос обычным консольным клиентом с верной подписью. Так проверяется весь путь без ожидания реального события.

Что отвечать при ошибке обработки?

Код ошибки сервера, чтобы отправитель повторил уведомление позже. Успешный ответ при неудаче означает потерянное событие навсегда.

Сколько хранить идентификаторы обработанных событий?

Сутки-двое: дольше отправители обычно не повторяют. Хранят их во временном хранилище со сроком жизни, а не в постоянной таблице.

Нужно ли ограничивать адреса отправителя?

Если сервис публикует свои адреса - да, это дешёвый дополнительный барьер. Подпись при этом всё равно обязательна: адреса меняются и подделываются.

Смежное

Первоисточник