Вебхук — это входящий HTTP-запрос, который внешний сервис сам присылает на ваш URL, когда у него что-то произошло («платёж прошёл»). Вы не опрашиваете его — он стучится к вам. С точки зрения требований это означает четыре обязательных вопроса. Подпись (HMAC): как вы убедитесь, что запрос прислал реально провайдер, а не злоумышленник, нашедший ваш URL. Ретраи: если ваш эндпоинт лежал или ответил не 200, провайдер пришлёт то же событие снова — поэтому обработчик обязан быть идемпотентным. Дедупликация: по уникальному id события вы отсекаете повторы. Порядок не гарантирован: «оплата» может прийти раньше «заказ создан». И отвечать надо быстро — вернуть 200 сразу, а тяжёлую работу унести в фон.
Однажды я принимал вебхуки от платёжного провайдера и неделю не мог понять, почему у части заказов статус «оплачен» прилетал, а у части — нет. Логи показывали: провайдер всё присылал исправно. Оказалось, наш обработчик честно лез в базу, дёргал три сервиса и только потом отвечал 200 — и иногда не укладывался в таймаут провайдера в пять секунд. Провайдер считал доставку неудачной, повторял запрос, обработчик снова не успевал, и так по кругу, пока провайдер не сдавался. Чинили не «приём оплаты», а саму модель приёма: отвечать 200 мгновенно, а обрабатывать потом. Тогда я понял, что вебхук — это не «бэк принял запрос», а отдельный контракт со своими правилами, которые пишет аналитик.
Базовую идею вебхука — «не звони мне, я сам позвоню» — я разбирал в записи про синхронную и асинхронную интеграцию. Здесь не буду повторять, что это и зачем; здесь — про то, как его правильно спроектировать, чтобы он не разваливался в проде.
Как выглядит входящий вебхук
Чтобы было предметно: провайдер присылает обычный POST на ваш URL. В теле — JSON с описанием события, в заголовках — служебные данные, среди которых подпись и идентификатор события.
POST /webhooks/payment HTTP/1.1
Host: api.myshop.ru
Content-Type: application/json
X-Webhook-Id: evt_9f8a7b6c5d
X-Webhook-Signature: sha256=4a7d1ed414474e4033ac29ccb8653d9b...
{
"event": "payment.succeeded",
"id": "evt_9f8a7b6c5d",
"occurred_at": "2025-05-23T10:15:00Z",
"data": { "payment_id": "pay_123", "order_id": 456, "amount": 1500 }
}
Всё, что вам нужно для надёжного приёма, уже здесь: id события для дедупликации, подпись для проверки подлинности, время события для понимания порядка и сам payload. Если провайдер чего-то из этого не присылает — это первый вопрос на интеграционной встрече.
Подпись: как понять, что прислал реально провайдер
Ваш URL приёма вебхуков торчит наружу — иначе провайдер до него не достучится. Это значит, что прислать на него POST может кто угодно, кто узнал адрес. Без проверки злоумышленник пришлёт вам «payment.succeeded» на чужой заказ, и вы отгрузите товар за неоплаченное. Поэтому каждый серьёзный провайдер подписывает запрос.
Механизм называется HMAC — это хеш от тела запроса, посчитанный с секретным ключом, который знают только вы и провайдер. При настройке интеграции провайдер выдаёт вам webhook secret. Дальше на каждый запрос провайдер берёт тело, прогоняет через HMAC с этим секретом и кладёт результат в заголовок (у нас — X-Webhook-Signature). Вы на своей стороне делаете ровно то же самое с полученным телом и сравниваете. Совпало — запрос подлинный и не подменён по дороге. Не совпало — выкидываете, не глядя в содержимое.
// псевдокод проверки подписи вебхука
secret = "whsec_живёт_в_секретах_не_в_коде"
function verify(request):
body = request.raw_body // именно сырое тело, байт в байт
received = request.header("X-Webhook-Signature")
expected = "sha256=" + hmac_sha256(secret, body)
if not constant_time_equals(received, expected):
return 401 // не провайдер — отбой
return ok
Два нюанса, на которых ломаются: сравнивать надо сырое тело до любого парсинга (распарсили и собрали JSON обратно — порядок полей поедет, подпись не сойдётся), и сравнивать надо в постоянное время (обычное сравнение строк подсказывает атакующему по времени ответа, сколько символов угадано). Это уже забота бэка, но знать про грабли полезно, чтобы не написать в спеке «проверить подпись» и считать дело сделанным.
Подпись — это про доверие к данным
HMAC решает две задачи разом: подтверждает, что отправитель знает общий секрет (значит, это провайдер), и что тело не изменили по пути. Это та же логика, что и в защите чувствительных данных вообще — секрет хранится в защищённом хранилище, а не в коде и не в Git. Подробнее про обращение с такими секретами и персональными данными — в записи про безопасность и PII.
Ретраи: провайдер будет слать снова
Вот правило, которое меняет всё проектирование приёма: если вы не ответили провайдеру 200, он считает доставку неудачной и пришлёт то же событие снова. Лежал ваш сервис, ответил 500, не уложился в таймаут — неважно, причина для провайдера одна: «не доставлено, повторю». Обычно повторы идут с нарастающей паузой (через минуту, через пять, через час) несколько раз в течение суток.
Из этого следует прямое требование: обработчик вебхука обязан быть идемпотентным. Одно и то же событие «оплата прошла», обработанное дважды, не должно отгрузить товар дважды или начислить бонусы дважды. Это та же идемпотентность, что и в платежах, — детально про идемпотентные ключи и про то, как их закладывать в требования, я разбирал в записи про идемпотентность платежей. Здесь ключ для дедупликации вам уже выдан — это id события.
Дедупликация по id события
Механика простая. У каждого события есть уникальный id (у нас evt_9f8a7b6c5d). Перед обработкой вы проверяете: видели ли мы уже это событие? Видели — отвечаем 200 и ничего не делаем (провайдер успокоится). Не видели — обрабатываем, запоминаем id как обработанный, отвечаем 200.
sequenceDiagram participant P as Провайдер participant W as Ваш эндпоинт participant Q as Очередь / фон P->>W: POST вебхук (evt_9f8a, подпись) W->>W: проверить подпись W->>W: видел evt_9f8a? нет W->>Q: положить на обработку W-->>P: 200 OK (сразу) Note over P,W: сеть моргнула, P не получил 200 P->>W: POST тот же вебхук (evt_9f8a) W->>W: видел evt_9f8a? да W-->>P: 200 OK (ничего не делаем) Q->>Q: обработка в фоне
Сама эта диаграмма — sequence-диаграмма (обмен сообщениями во времени). На схеме провайдер дважды присылает одно событие evt_9f8a — второй раз потому, что не получил подтверждения на первый. Ваш эндпоинт оба раза сначала проверяет подпись, затем смотрит в журнал обработанных id. На первом запросе события там нет — он кладёт работу в фоновую очередь и сразу отвечает 200. На втором запросе id уже в журнале — эндпоинт просто отвечает 200, не делая работу повторно. Реальная обработка при этом идёт в фоне и от приёма отвязана. В итоге сколько бы раз провайдер ни прислал событие, в системе оно отрабатывает ровно один раз.
Порядок не гарантирован
Ещё одна ловушка: вебхуки приходят не обязательно в том порядке, в каком события случились. Из-за ретраев и сетевых задержек «payment.succeeded» по заказу может прийти раньше, чем «order.created», хотя в реальности заказ создался первым. Если ваш обработчик оплаты предполагает, что заказ в базе уже есть, — он упадёт.
Лечится это так: либо обработчик умеет дождаться недостающего (увидел оплату по неизвестному заказу — отложил, дождался создания), либо вы опираетесь на поле времени события (occurred_at), а не на момент получения. Главное — в требованиях честно написать «порядок прихода вебхуков не гарантирован», иначе кто-то напишет логику «по оплате считаем, что заказ есть» и поймает плавающий баг.
Отвечать быстро: 200 сразу, работа в фоне
Мой неделя-в-аду баг был именно про это. У провайдера на ваш ответ есть таймаут — обычно несколько секунд. Если вы за это время лезете в базу, дёргаете сторонние сервисы и считаете — вы рискуете не уложиться, и провайдер сочтёт доставку неудачной (см. ретраи выше). Поэтому правильная модель: проверить подпись, проверить на дубль, положить событие в очередь и сразу ответить 200. Вся тяжёлая обработка — отдельно, в фоне, в своём темпе. Эндпоинт приёма должен быть тупым и быстрым.
Как тестировать вебхуки
Вебхук неудобен тем, что инициатор — снаружи, и на локальной машине его не поймать без фокусов. Что помогает: туннель наружу (вроде ngrok), пробрасывающий локальный порт в интернет, чтобы провайдер достучался до вашей машины; песочница провайдера, которая шлёт тестовые события по кнопке; и сохранённые «слепки» реальных запросов, которыми вы прогоняете обработчик в тестах. Проверять обязательно нужно не только счастливый путь, но и: пришёл повтор (дедуп сработал?), пришла битая подпись (отбили?), пришло событие в неверном порядке (не упали?).
Как это спрашивают на собесе
Частая связка вопросов: «Сервис принимает вебхуки об оплате. Как защититься от того, что злоумышленник пришлёт фейковый вебхук?» — ждут ответа про подпись HMAC и общий секрет. И добивка: «А что если провайдер прислал один и тот же вебхук дважды?» — тут проверяют, скажете ли вы про ретраи и идемпотентность/дедупликацию по id события. Сильный ответ: «провайдер ретраит при неудаче, поэтому обработчик идемпотентен, дедуп по event id, плюс отвечаем 200 сразу и обрабатываем в фоне, чтобы не словить лишние ретраи по таймауту». Если добавите «и порядок прихода не гарантирован» — это уже уровень senior.
Откуда это взялось
Термин webhook придумал Джефф Линдсей в 2007 году — он назвал так «обратные вызовы (hooks) для веба»: вместо того чтобы клиент дёргал сервер, сервер сам уведомляет клиента о событии по HTTP. Идея выстрелила вместе с ростом веб-API: GitHub, Stripe, платёжные системы и мессенджеры сделали вебхуки стандартным способом «толкать» события наружу. Подпись HMAC и дедупликация по id пришли не сразу — их добавили, когда выяснилось, что открытый эндпоинт без проверки подлинности и без защиты от повторов в проде живёт недолго.
Частые вопросы
Зачем подписывать вебхук, если URL знают только мы и провайдер?
URL приёма торчит в интернет — иначе провайдер до него не достучится, — и рано или поздно может утечь (логи, история браузера, перехват). Без подписи любой, кто узнал адрес, пришлёт фейковое «оплата прошла», и вы отгрузите товар за неоплаченное. Подпись HMAC доказывает, что запрос прислал тот, кто знает общий секрет (то есть провайдер), и что тело не подменили по дороге. Проверка подписи — обязательная часть приёма вебхука, а не опция.
Что делать, если один и тот же вебхук пришёл дважды?
Это нормально и ожидаемо: провайдер повторяет отправку, если не получил от вас 200 (а это бывает из-за таймаутов и сбоев сети). Защита — дедупликация по уникальному id события: перед обработкой проверяете, видели ли вы уже это событие; видели — отвечаете 200 и ничего не делаете, не видели — обрабатываете и запоминаете id. Плюс сам обработчик должен быть идемпотентным, чтобы повтор не задвоил эффект. Подробнее про идемпотентность — в записи про идемпотентность платежей.
Почему на вебхук надо отвечать 200 сразу, а не после обработки?
У провайдера на ваш ответ есть таймаут (обычно несколько секунд). Если вы во время приёма выполняете тяжёлую работу — лезете в базу, зовёте другие сервисы — вы рискуете не уложиться, провайдер сочтёт доставку неудачной и начнёт слать повторы, перегружая вас. Правильно: проверить подпись, отсечь дубль, положить событие в очередь и сразу ответить 200, а саму обработку вести в фоне. Эндпоинт приёма должен быть быстрым и тупым.