Webhook часто путают с API. Разница принципиальная: обычный API — это когда вы сами идёте за данными («эй, есть что-то новое?»). Webhook — наоборот, сервис сам стучится к вам, когда что-то происходит («вот тебе событие, обработай»).

Пример: пользователь оплатил заказ в ЮКассе. Вместо того чтобы каждые 5 секунд спрашивать «а платёж прошёл?», вы один раз указываете URL — и ЮКасса сама пришлёт POST-запрос с результатом. Это и есть webhook.

Как устроен webhook изнутри

Схема простая:

  1. Вы регистрируете URL (endpoint) в стороннем сервисе
  2. Происходит событие — оплата, регистрация, изменение статуса
  3. Сервис отправляет HTTP POST на ваш URL с телом запроса в JSON
  4. Ваш сервер обрабатывает данные и возвращает 200 OK

Если вы не ответили 200 — сервис решит, что доставка не удалась, и попробует снова. Stripe, например, делает до 16 повторных попыток в течение нескольких дней. ЮКасса — до 10 попыток с экспоненциальным backoff.

Вот типичное тело webhook от платёжной системы:

{
  "event": "payment.succeeded",
  "object": {
    "id": "pay_abc123",
    "amount": 4900,
    "currency": "RUB",
    "metadata": {
      "order_id": "1042"
    }
  }
}

Главная ошибка: тяжёлая обработка в момент запроса

Самая частая проблема — разработчики пишут обработчик, который делает всё сразу: проверяет подпись, лезет в базу, отправляет письмо, обновляет склад. И всё это синхронно, в одном запросе.

Проблема в том, что webhook-отправитель ждёт ответа секунды три, максимум пять. Если ваш endpoint завис на 10 секунд — сервис посчитает запрос неудачным и пришлёт его снова. Вы получите дублированное списание, двойное письмо или дважды обновлённый статус.

Правильная схема:

  1. Принять запрос
  2. Проверить подпись (быстро)
  3. Записать событие в очередь или базу
  4. Вернуть 200
  5. Обработать асинхронно

Даже если обработчик упадёт — событие не потеряется, потому что оно уже сохранено.

Как проверять подпись

Открытый endpoint без проверки — приглашение для мусорного трафика. Любой может слать POST-запросы на ваш URL и имитировать события.

Большинство сервисов подписывают запросы: вычисляют HMAC-SHA256 от тела запроса с помощью секретного ключа и кладут результат в заголовок. Stripe использует Stripe-Signature, GitHub — X-Hub-Signature-256, Telegram — отдельный механизм с секретным токеном.

Проверка на Python выглядит так:

import hmac
import hashlib

def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

Важные детали:

  • Считайте HMAC от сырого тела запроса, не от распарсенного JSON
  • Используйте hmac.compare_digest, а не обычное == — это защита от timing-атак
  • Секретный ключ храните в переменных окружения, не в коде

Stipe ещё добавляет временную метку в подпись и рекомендует отклонять запросы старше 5 минут — защита от replay-атак.

Идемпотентность: как не обработать одно событие дважды

Daже при правильной архитектуре событие может прийти дважды. Сеть нестабильна, сервис не получил 200 и повторил запрос, ваш сервер лёг в процессе — вариантов масса.

Решение — идемпотентная обработка. Каждое событие имеет уникальный идентификатор. Перед обработкой проверяете, не видели ли вы его раньше:

def handle_webhook(event_id: str, payload: dict):
    if redis.exists(f"webhook:{event_id}"):
        return  # уже обработали
    
    # обрабатываем
    process_payment(payload)
    
    # отмечаем как обработанное (TTL 24 часа достаточно)
    redis.setex(f"webhook:{event_id}", 86400, 1)

Если Redis нет — подойдёт таблица в базе данных с уникальным индексом по event_id. Попытка вставить дубль выбросит ошибку, которую можно поймать и игнорировать.

Отправка webhook: что важно на стороне отправителя

Если вы сами строите систему, которая шлёт webhook другим сервисам или клиентам, правила те же — только зеркально.

Retry-логика. Если endpoint ответил не 200 — пробуйте снова. Экспоненциальный backoff: через 1 минуту, 5 минут, 30 минут, 2 часа, 8 часов. После 5-10 неудачных попыток — помечайте как failed и уведомляйте пользователя.

Таймаут. Устанавливайте явный таймаут на запрос — обычно 5-10 секунд. Иначе один зависший endpoint заблокирует поток и задержит остальные события.

Подпись. Подписывайте каждый запрос HMAC-SHA256. Генерируйте уникальный секрет для каждого клиента, чтобы можно было отозвать его без влияния на остальных.

Очередь. Никогда не отправляйте webhook синхронно в ходе бизнес-операции. Запись в базу + воркер, который берёт события из очереди — стандартная схема. Celery, BullMQ, Sidekiq — выбирайте по стеку.

Логи. Сохраняйте каждую попытку отправки: URL, тело запроса, код ответа, время. Когда клиент говорит «я ничего не получал», это единственный способ разобраться.

Как тестировать webhook локально

Главная боль при разработке — сторонний сервис не может достучаться до localhost. Для этого есть несколько инструментов.

ngrok — самый популярный. Поднимаете туннель, получаете публичный URL, указываете его в настройках webhook:

ngrok http 3000
# Получаете: https://abc123.ngrok.io

Webhook.site — онлайн-инструмент для инспекции входящих запросов. Полезен, чтобы посмотреть, что именно шлёт сервис, прежде чем писать обработчик.

Локальные CLI от провайдеров. Stripe CLI, GitHub CLI умеют форвардить webhook-события прямо из продакшн-окружения на локальный сервер:

stripe listen --forward-to localhost:3000/webhooks/stripe

Это удобнее ngrok, потому что не нужно перерегистрировать URL при каждом запуске — CLI использует постоянный токен.

Мониторинг и дашборд

Производственная система обработки webhook должна давать ответ на вопрос: «всё ли дошло?». Минимальный набор метрик:

  • Количество полученных событий за период
  • Доля успешно обработанных
  • Количество failed (исчерпали retry)
  • Медианное время обработки
  • Очередь на обработку (если растёт — проблема)

Если у вас нет отдельного мониторинга, хотя бы настройте алерт на рост таблицы с failed-событиями в базе.

Частые грабли

Парсить тело до проверки подписи. Некоторые фреймворки автоматически парсят JSON в middleware. Для проверки HMAC нужны сырые байты — убедитесь, что получаете их до любой обработки.

Блокировать IP отправителя. Некоторые сервисы меняют диапазоны IP без предупреждения. Лучше полагаться на проверку подписи, а не на whitelist адресов.

Игнорировать неизвестные типы событий. Сервисы добавляют новые типы событий. Обработчик должен корректно игнорировать незнакомые типы и возвращать 200, а не падать с ошибкой.

Синхронные HTTP-запросы из обработчика. Внутри обработчика webhook не делайте синхронных запросов к внешним API — только асинхронно через очередь.

Когда это выходит за рамки простого endpoint

Простой webhook-обработчик — это несколько строк кода. Но когда речь идёт о высоконагруженной системе, где события приходят тысячами в минуту, архитектура усложняется: нужны очереди с гарантией доставки, дедупликация на уровне базы, партиционирование по типам событий.

При разработке интеграций для клиентов в REEXY мы часто сталкиваемся с задачей «связать» несколько сервисов через webhook — CRM, платёжку, складскую систему. Стоимость такой интеграции начинается от 1 500 ₽, но конкретная цена зависит от количества систем и логики обработки событий.

Резюме

Webhook — надёжный инструмент, если не пренебрегать несколькими правилами:

  • Проверяйте подпись всегда, без исключений
  • Отвечайте быстро (200 OK), обрабатывайте асинхронно
  • Обеспечивайте идемпотентность через хранение event_id
  • Логируйте каждое событие и каждую попытку доставки
  • Тестируйте retry-поведение намеренно — убивайте обработчик в процессе и смотрите, что происходит

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