Webhooks: server push для интеграций
Классическая модель REST — клиент опрашивает сервер: «есть ли новые заказы?» каждые 5 секунд. Это
Webhook переворачивает направление: сервер сам стучится клиенту, когда событие произошло. Latency — миллисекунды; запросов — ноль, пока ничего не случилось. Это классика интеграций: GitHub шлёт push event-ы, Stripe — payment_intent.succeeded, Slack — message-event.
Как устроен webhook
Поток состоит из трёх шагов:
- Регистрация. Получатель (наш сервис) сообщает источнику свой публичный HTTPS-URL:
https://api.mycompany.com/webhooks/github. Делается через UI или API источника. - Доставка. При наступлении события источник делает POST на этот URL с JSON-payload-ом.
- Обработка. Получатель валидирует подпись, проверяет идемпотентность, обрабатывает событие и возвращает 200 OK.
Polling-клиент
Клиент опрашивает каждые 5 секунд: 'есть новости?'Источник
Источник проверяет БД и почти всегда возвращает []Источник (push)
Источник держит список зарегистрированных URL-ов и при событии шлёт POSTEndpoint получателя
Получатель валидирует подпись и обрабатывает только при наличии событияБезопасность: HMAC-подпись
Главная угроза для webhook-а — подделка: любой, кто узнал URL, может прислать поддельные события. Решение — общий
Источник при отправке вычисляет:
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}
Никогда не сравнивайте подписи через ==. Это позволяет
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 + дедупликация.
- Подпись включает timestamp; запросы старше 5 минут отбрасываем.
- У каждого события есть уникальный
id(например,evt_1234567890у Stripe). Сохраняем обработанныеidв БД с TTL. - Если такой
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 часов.
Что должен делать получатель: чек-лист
- Подтверждать быстро. Возвращайте 200 OK в течение 5-10 секунд (источник имеет таймаут). Тяжёлая обработка — в фоновую очередь (Celery, RQ, Kafka).
- Валидировать подпись. Без HMAC любой может прислать вам поддельный webhook.
- Дедуплицировать по event_id. При at-least-once доставке повторы будут — закладывайтесь.
- Логировать всё. Сохраняйте сырое тело + заголовки. При расследовании incident-а это золото.
- Возвращать 5xx только при реально временных проблемах. 4xx означает «не повторяй» (но Stripe иногда повторяет). 200 OK — обработано.
- Отдельный URL на тип источника. Так проще логировать и менять секреты независимо.
Когда выбрать webhooks
| Сценарий | Лучше выбрать |
|---|---|
| Редкие, асинхронные события (платежи, push commit-ов) | Webhooks |
| Нужна low-latency реакция на чужое событие | Webhooks |
| Источник принципиально внешний (Stripe, GitHub) | Webhooks |
| Высокочастотный поток данных в одну сторону | SSE |
| Двусторонний интерактив (chat) | WebSocket |
| Получатель не имеет публичного URL | Polling или event broker |
| Нужна гарантия order/at-most-once | Polling по 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-ам.
В современных архитектурах вместо самописных webhook-receiver-ов часто используют managed-решения: AWS EventBridge, Zapier, n8n, Pipedream. Они принимают webhook, делают трансформацию и кладут в нужный downstream — без написания серверного кода.