Learning Platform
Глоссарий Troubleshooting
Урок 12.01 · 35 мин
Начальный
WebhooksHMACRetryIdempotencySecurityGitHubStripe

Webhooks: server push для интеграций

Классическая модель REST — клиент опрашивает сервер: «есть ли новые заказы?» каждые 5 секунд. Это

polling
: сотни лишних запросов за час, latency равна интервалу опроса, нагрузка на сервер растёт линейно с количеством клиентов.

Webhook переворачивает направление: сервер сам стучится клиенту, когда событие произошло. Latency — миллисекунды; запросов — ноль, пока ничего не случилось. Это классика интеграций: GitHub шлёт push event-ы, Stripe — payment_intent.succeeded, Slack — message-event.

Как устроен webhook

Поток состоит из трёх шагов:

  1. Регистрация. Получатель (наш сервис) сообщает источнику свой публичный HTTPS-URL: https://api.mycompany.com/webhooks/github. Делается через UI или API источника.
  2. Доставка. При наступлении события источник делает POST на этот URL с JSON-payload-ом.
  3. Обработка. Получатель валидирует подпись, проверяет идемпотентность, обрабатывает событие и возвращает 200 OK.
Polling против Webhook

Polling-клиент

Клиент опрашивает каждые 5 секунд: 'есть новости?'
GET /events?since=...

Источник

Источник проверяет БД и почти всегда возвращает []
HTTP 200 -- пусто
99 пустых ответовПри интервале 5 сек за 5 минут = 60 запросов; интересные события -- 1-2

Источник (push)

Источник держит список зарегистрированных URL-ов и при событии шлёт POST
POST /webhooks/... payload

Endpoint получателя

Получатель валидирует подпись и обрабатывает только при наличии события
HTTP 200
0 запросов в idleLatency = время доставки сообщения, нет фонового шума
Event-driven architecture: webhooks как pub/sub через HTTP

Безопасность: HMAC-подпись

Главная угроза для webhook-а — подделка: любой, кто узнал URL, может прислать поддельные события. Решение — общий

HMAC-подпись
в заголовке запроса.

Источник при отправке вычисляет:

signature = HMAC-SHA256(secret, request_body)
header = X-Hub-Signature-256: sha256=<hex(signature)>

Получатель повторяет ту же операцию над полученным телом и сравнивает результат. У GitHub заголовок называется X-Hub-Signature-256, у Stripe — Stripe-Signature (более сложный формат с timestamp), у Slack — X-Slack-Signature (тоже с timestamp). Семантика одна.

import hashlib
import hmac
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
WEBHOOK_SECRET = b"super-secret-shared-with-github"

@app.post("/webhooks/github")
async def github_webhook(request: Request):
    body = await request.body()
    signature_header = request.headers.get("X-Hub-Signature-256", "")

    if not signature_header.startswith("sha256="):
        raise HTTPException(status_code=400, detail="missing signature")

    expected = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
    received = signature_header[len("sha256="):]

    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="invalid signature")

    payload = await request.json()
    event_type = request.headers.get("X-GitHub-Event")
    await process_event(event_type, payload)
    return {"ok": True}
WARNING

Никогда не сравнивайте подписи через ==. Это позволяет

timing attack
: атакующий замеряет время ответа и побайтно восстанавливает подпись. Используйте hmac.compare_digest, который выполняется за константное время.

Дополнительные уровни защиты:

  • Timestamp в подписи (Stripe, Slack): t=1715798400, v1=<hmac>. Получатель считает HMAC от строки "<t>.<body>". Если timestamp старше 5 минут — отклоняем.
  • IP allowlist для известных источников (GitHub публикует список своих IP).
  • Mutual TLS (mTLS) — обе стороны предъявляют сертификаты.

Защита от replay-атак

Replay-атака — атакующий перехватил легитимный webhook (или скачал из логов) и переотправляет его, чтобы повторно списать деньги или повторить действие. HMAC-подпись валидна — она ведь настоящая.

Защита: timestamp + nonce + дедупликация.

  1. Подпись включает timestamp; запросы старше 5 минут отбрасываем.
  2. У каждого события есть уникальный id (например, evt_1234567890 у Stripe). Сохраняем обработанные id в БД с TTL.
  3. Если такой id уже видели — возвращаем 200 OK без повторной обработки.
async def process_stripe_event(event: dict):
    event_id = event["id"]
    # SETNX в Redis: ставит ключ, только если его нет
    is_new = await redis.set(
        f"stripe:event:{event_id}",
        "processed",
        ex=86400 * 7,  # неделя жизни
        nx=True,
    )
    if not is_new:
        return  # уже обрабатывали -- игнорируем
    await handle_event(event)

Это и есть идемпотентность: повторный вызов даёт тот же результат. Принцип уже знаком из урока про REST design — здесь он применяется к webhook-обработчикам.

Retry policy на стороне отправителя

Сеть нестабильна, получатель может временно лежать. Поэтому источники гарантируют at-least-once доставку: если ответ не пришёл в разумное время, повторят.

Типовая стратегия — exponential backoff с jitter:

ПопыткаЗадержка
1сразу при событии
2через 1 минуту
3через 5 минут
4через 30 минут
5через 2 часа
6через 6 часов
7через 24 часа

После N неудач источник либо «отключает» webhook, либо отправляет уведомление администратору. У Stripe — 3 дня попыток, GitHub — 8 попыток за 8 часов.

Retry-цикл с exponential backoff
Источник (Stripe)
Retry queue
Endpoint клиента
POST event evt_001503 Service Unavailableschedule retry +1mPOST event evt_001 (retry 1)200 OK

Что должен делать получатель: чек-лист

  1. Подтверждать быстро. Возвращайте 200 OK в течение 5-10 секунд (источник имеет таймаут). Тяжёлая обработка — в фоновую очередь (Celery, RQ, Kafka).
  2. Валидировать подпись. Без HMAC любой может прислать вам поддельный webhook.
  3. Дедуплицировать по event_id. При at-least-once доставке повторы будут — закладывайтесь.
  4. Логировать всё. Сохраняйте сырое тело + заголовки. При расследовании incident-а это золото.
  5. Возвращать 5xx только при реально временных проблемах. 4xx означает «не повторяй» (но Stripe иногда повторяет). 200 OK — обработано.
  6. Отдельный URL на тип источника. Так проще логировать и менять секреты независимо.

Когда выбрать webhooks

СценарийЛучше выбрать
Редкие, асинхронные события (платежи, push commit-ов)Webhooks
Нужна low-latency реакция на чужое событиеWebhooks
Источник принципиально внешний (Stripe, GitHub)Webhooks
Высокочастотный поток данных в одну сторонуSSE
Двусторонний интерактив (chat)WebSocket
Получатель не имеет публичного URLPolling или event broker
Нужна гарантия order/at-most-oncePolling по cursor

Главный недостаток webhook-а — получатель должен иметь публичный HTTPS-endpoint. Если ваш сервис за корпоративным firewall-ом — webhook не доедет. Решения: tunnel (ngrok для dev), VPN, прокси, переход на polling.

Тестирование webhook-ов

В dev-окружении доходить до публичного сервера сложно. Помогают:

  • ngrok или cloudflared tunnel — публикуют localhost через временный URL.
  • Webhook test tools — webhook.site (создаёт URL и показывает входящие запросы).
  • Stripe CLI: stripe listen --forward-to localhost:8000/webhooks/stripe — пробрасывает webhook-и Stripe в локальный dev.
  • Mock-источник в тестах — pytest-фикстура, которая делает POST с правильно подписанным телом.

Пример теста:

import hmac, hashlib

def test_github_webhook_valid_signature(client):
    body = '{"action":"opened","number":42}'.encode()
    sig = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
    response = client.post(
        "/webhooks/github",
        content=body,
        headers={
            "X-Hub-Signature-256": f"sha256={sig}",
            "X-GitHub-Event": "pull_request",
            "Content-Type": "application/json",
        },
    )
    assert response.status_code == 200

Webhooks в DE-сценариях

  • Stripe -> DWH. Получаем payment_intent.succeeded, кладём в Kafka, дальше Spark/Flink в Snowflake.
  • GitHub -> metric pipeline. Webhook на push -> подсчёт DORA-метрик.
  • Salesforce / HubSpot CDC. Outbound message при изменении записи -> лог в S3 -> инкрементальная загрузка.
  • Airflow -> notifier. DAG-completion -> webhook в Slack.
  • dbt Cloud -> metadata catalog. Webhook о завершении прогона -> обновление DataHub.

Junior DE регулярно пишет webhook-receiver-ы на FastAPI и подключает их к существующим event-bus-ам.

TIP

В современных архитектурах вместо самописных webhook-receiver-ов часто используют managed-решения: AWS EventBridge, Zapier, n8n, Pipedream. Они принимают webhook, делают трансформацию и кладут в нужный downstream — без написания серверного кода.

Проверка знанийKnowledge check
Stripe прислал webhook о завершённой оплате. Ваш сервис обработал, начислил кредиты пользователю и вернул 200 OK. Через 30 минут Stripe прислал тот же webhook повторно (с тем же event id) -- потому что 200 OK потерялся в сети из-за временного network glitch. Что произойдёт у вас и как защититься?
ОтветAnswer
Без защиты -- кредиты начислятся повторно: задвоенный платёж в системе, недовольный пользователь, репутационный ущерб. Это классическая проблема at-least-once доставки webhook-ов. Защита -- идемпотентность по event_id. Перед обработкой делаем атомарную проверку 'уже видели?': SELECT/INSERT в БД с UNIQUE-constraint на event_id, либо SETNX в Redis с длинным TTL (неделя+, потому что Stripe может ретраить до 3 дней). Если запись о event_id уже существует -- возвращаем 200 OK без повторной бизнес-логики. Дополнительно: сама бизнес-операция начисления кредитов должна быть атомарной (одна транзакция: запись о webhook + начисление), чтобы не возникло гонки между параллельными retry-ами. Альтернатива на уровне БД -- делать INSERT начисления с уникальным ключом (user_id, source_event_id), при дубликате срабатывает UNIQUE constraint и операция отменяется. В обоих подходах ключевой принцип: повторный вызов с тем же event_id не должен изменять состояние.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В чём принципиальное отличие webhook-а от polling-а?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3