Webhook часто путают с API. Разница принципиальная: обычный API — это когда вы сами идёте за данными («эй, есть что-то новое?»). Webhook — наоборот, сервис сам стучится к вам, когда что-то происходит («вот тебе событие, обработай»).
Пример: пользователь оплатил заказ в ЮКассе. Вместо того чтобы каждые 5 секунд спрашивать «а платёж прошёл?», вы один раз указываете URL — и ЮКасса сама пришлёт POST-запрос с результатом. Это и есть webhook.
Как устроен webhook изнутри
Схема простая:
- Вы регистрируете URL (endpoint) в стороннем сервисе
- Происходит событие — оплата, регистрация, изменение статуса
- Сервис отправляет HTTP POST на ваш URL с телом запроса в JSON
- Ваш сервер обрабатывает данные и возвращает 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 секунд — сервис посчитает запрос неудачным и пришлёт его снова. Вы получите дублированное списание, двойное письмо или дважды обновлённый статус.
Правильная схема:
- Принять запрос
- Проверить подпись (быстро)
- Записать событие в очередь или базу
- Вернуть 200
- Обработать асинхронно
Даже если обработчик упадёт — событие не потеряется, потому что оно уже сохранено.
Как проверять подпись
Открытый 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 перестаёт быть источником головной боли и становится предсказуемым способом получать события от любых внешних систем.