коротко

Вебхук — это входящий 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, а саму обработку вести в фоне. Эндпоинт приёма должен быть быстрым и тупым.