Webhooks и идемпотентность событий
TL;DR
Хороший вебхук — это подписанное (HMAC/mTLS), резюмируемое и идемпотентное событие, доставляемое по модели at-least-once с экспоненциальным backoff и дедупликацией у получателя. Договоритесь о конверте (`event_id`, `type`, `ts`, `version`, `attempt`, `signature`), окне времени (≤5 мин), кодах ответов, ретраях, DLQ и статус-эндпоинте.
1) Роли и модель доставки
Отправитель (вы/провайдер): формирует событие, подписывает, пытается доставить до 2xx, ретраит при 3xx/4xx/5xx (кроме явных «не принимай»), ведет DLQ, дает replay API.
Получатель (партнер/ваш сервис): проверяет подпись/временное окно, делает дедуп и идемпотентную обработку, отвечает корректным кодом, предоставляет /status и /ack replay по `event_id`.
Гарантии: at-least-once. Получатель должен уметь обрабатывать дубликаты и смену порядка.
2) Конверт события (envelope)
json
{
"event_id": "01HF7H9J9Q3E7DYT5Y6K3ZFD6M",
"type": "payout.processed",
"version": "2025-01-01",
"ts": "2025-11-03T12:34:56.789Z",
"attempt": 1,
"producer": "payments",
"tenant": "acme",
"data": {
"payout_id": "p_123",
"status": "processed",
"amount_minor": 10000,
"currency": "EUR"
}
}
Обязательные поля: `event_id`, `type`, `version`, `ts`, `attempt`.
Правила эволюции: добавляем поля; удаление/смена типов — только с новой `version`.
3) Безопасность: подписи и привязка
3.1 HMAC-подпись (рекомендуется по умолчанию)
Заголовки:
X-Signature: v1=base64(hmac_sha256(<secret>, <canonical>))
X-Timestamp: 2025-11-03T12:34:56Z
X-Event-Id: 01HF7...
Каноническая строка:
<timestamp>\n<method>\n<path>\n<sha256(body)>
Проверка у получателя:
- abs(now − `X-Timestamp`) ≤ 300s
- `X-Event-Id` не обработан ранее (дедуп)
- `X-Signature` совпадает (с тайм-безопасным сравнением)
3.2 Доп. меры
mTLS для высокочувствительных вебхуков.
IP/ASN allow-list.
DPoP (опционально) для sender-constrained, если вебхук инициирует обратные вызовы.
4) Идемпотентность и дедупликация
4.1 Идемпотентность события
Событие с одинаковым `event_id` не должно менять состояние повторно. Получатель:- хранит `event_id` в идемпотентном кеше (KV/Redis/БД) на TTL ≥ 24–72 ч;
- сохраняет результат обработки (успех/ошибка, артефакты) для повторной отдачи.
4.2 Идемпотентность команд (обратные вызовы)
Если вебхук заставляет клиента дернуть API (например, «подтверди payout»), используйте `Idempotency-Key` на том REST-вызове, храните результат на стороне сервиса (exactly-once outcome).
KV-модель (минимум):
key: idempotency:event:01HF7...
val: { status: "ok", processed_at: "...", handler_version: "..." }
TTL: 3d
5) Ретраи и backoff
Рекомендуемый график (экспоненциальный с джиттером):- `5s, 15s, 30s, 1m, 2m, 5m, 10m, 30m, 1h, 3h, 6h, 12h, 24h` (дальше ежедневные до N дней)
- 2xx — успех, прекратить ретраи.
- `400/401/403/404/422` — не ретраим, если подпись/формат ок (клиентская ошибка).
- `429` — ретраим по `Retry-After` или backoff.
- 5xx/сетевые — ретраим.
Заголовки отправителя: `User-Agent`, `X-Webhook-Producer`, `X-Attempt`.
6) Обработка на стороне получателя
Псевдопайплайн:pseudo verify_signature()
if abs(now - X-Timestamp) > 300s: return 401
if seen(event_id):
return 200 // идемпотентный ответ
begin transaction if seen(event_id): commit; return 200 handle(data) // доменная логика mark_seen(event_id) // запись в KV/DB commit return 200
Транзакционность: метка «seen» должна ставиться атомарно с эффектом операции (или после фиксации результата), чтобы избежать двойной обработки при сбое.
7) Гарантии порядка и снапшоты
Порядок не гарантируется. Используйте `ts` и доменные `seq`/`version` в `data` для сверки актуальности.
Для длинных лагов/потерь — добавьте /replay у отправителя и /resync у получателя (получить снапшот и дельты по окну времени/ID).
8) Статус, replay и DLQ
8.1 Эндпоинты отправителя
`POST /webhooks/replay` — по списку `event_id` или по окну времени.
`GET /webhooks/events/:id` — показать исходный пакет и историю попыток.
DLQ: «мертвые» события (исчерпан лимит ретраев) → отдельное хранилище, алерты.
8.2 Эндпоинты получателя
`GET /webhooks/status/:event_id` — `seen=true/false`, `processed_at`, `handler_version`.
`POST /webhooks/ack` — (опционально) подтверждение ручной обработки из DLQ.
9) Контракты ошибок (ответ получателя)
http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Retry-After: 120
X-Trace-Id: 4e3f...
{
"error": "invalid_state",
"error_description": "payout not found",
"trace_id": "4e3f..."
}
Рекомендации: всегда возвращайте четкий код и, если можно, `Retry-After`. Не возвращайте подробные детали безопасности.
10) Мониторинг и SLO
Метрики (отправитель):- delivery p50/p95, success rate, ретраи/событие, drop-rate DLQ, share 2xx/4xx/5xx, окно задержки до 2xx.
- verify fail rate (подпись/время), dup-rate, latency handler p95, 5xx.
- Доставка: ≥ 99.9% событий получают 2xx < 3 c p95 (после первой успешной попытки).
- Криптопроверка: валидация подписи ≤ 2–5 мс p95.
- Дедуп: 0 повторных эффектов (exactly-once outcome на уровне домена).
11) Безопасность данных и приватность
Не передавайте PAN/PII в теле вебхука; используйте идентификаторы и последующий pull за деталями по авторизованному API.
Маскируйте чувствительные поля в логах; храните тела событий только по минимуму, с TTL.
Шифруйте хранилища DLQ и реплея.
12) Версионирование и совместимость
Версия в `version` (конверт) и в пути: `/webhooks/v1/payments`.
Новые поля — опциональны; удаление — только после периода `Sunset`.
Документируйте изменения в machine-readable changelog (для автопроверок).
13) Тест-кейсы (UAT чек-лист)
- Повторная доставка того же `event_id` → один эффект и `200` на дубликаты.
- Подпись: верный ключ, неверный ключ, старый ключ (ротация), `X-Timestamp` вне окна.
- Backoff: получатель дает `429` с `Retry-After` → корректная пауза.
- Порядок: события `…processed` приходит раньше `…created` → корректная обработка/ожидание.
- Сбой БД у получателя между эффектом и `mark_seen` → атомарность/повтор.
- DLQ и ручной replay → успешная доставка.
- Массовый «шторм» (провайдер шлет пачки) → без потери, лимиты не душат критичное.
14) Мини-сниппеты
Подпись отправителя (псевдо):pseudo body = json(event)
canonical = ts + "\n" + "POST" + "\n" + path + "\n" + sha256(body)
sig = base64(hmac_sha256(secret, canonical))
headers = {"X-Timestamp": ts, "X-Event-Id": event.event_id, "X-Signature": "v1="+sig}
POST(url, body, headers)
Проверка и дедуп получателя (псевдо):
pseudo assert abs(now - X-Timestamp) <= 300 assert timingSafeEqual(hmac(secret, canonical), sig)
if kv.exists("idemp:"+event_id): return 200
begin tx if kv.exists("idemp:"+event_id): commit; return 200 handle(event.data) // доменная логика kv.set("idemp:"+event_id, "ok", ttl=259200)
commit return 200
15) Частые ошибки
Нет дедупа → повторные эффекты (двойные рефанды/пэйауты).
Подпись без таймштампа/окна → уязвимость к replay.
Хранение одного HMAC-секрета на всех партнеров.
Ответы `200` до фиксации результата → потеря событий при крэше.
«Вымывание» деталей безопасности в ответы/логи.
Отсутствие DLQ/реплея — инциденты нерешаемы.
16) Шпаргалка внедрения
Безопасность: HMAC v1 + `X-Timestamp` + `X-Event-Id`, окно ≤ 5 мин; мTLS/IP allow-list по надобности.
Конверт: `event_id`, `type`, `version`, `ts`, `attempt`, `data`.
Доставка: at-least-once, backoff с джиттером, `Retry-After`, DLQ + replay API.
Идемпотентность: KV-кеш 24–72 ч, атомарная фиксация эффекта + `mark_seen`.
Наблюдаемость: метрики доставки, подписи, дубликатов; трассировка `trace_id`.
Документация: версия, коды ответов, примеры, UAT-чек-лист.
Резюме
Стойкие вебхуки строятся на трех китах: подписанный конверт, at-least-once доставка и идемпотентная обработка. Формализуйте контракт, включите HMAC/mTLS и окно времени, реализуйте ретраи+DLQ и реплей, храните идемпотентные метки и фиксируйте эффекты атомарно. Тогда события остаются надежными даже при сбоях сети, пиках нагрузки и редких «дубликатах судьбы».