Дизайн эндпоинтов для вебхуков: подписи, ретраи и защита от повторной доставки
Разберём, как проектировать webhook-контракты так, чтобы интеграции переживали таймауты, дубликаты и перестановки событий. Поговорим про валидацию подписи, идемпотентные ключи, порядок обработки и безопасные коды ошибок для партнёра.
Содержание
Дизайн эндпоинтов для вебхуков: подписи, ретраи и защита от повторной доставки
Вебхук — это, по сути, инверсия управления: ваш сервис не «пулит» события, а получает их снаружи. На первый взгляд задача проста: принять HTTP-запрос, распарсить JSON и обработать. Но на практике вебхуки — одна из самых частых точек отказов в интеграциях. Причины банальны: сети теряют пакеты, партнёры ретраят доставку, события приходят не в том порядке, что ожидалось, а ретраи могут приводить к повторной обработке.
Хороший webhook-контракт — это набор правил: как подписывать запросы, как обрабатывать дубликаты, как справляться с перестановками событий, как корректно отвечать коды ошибок, чтобы партнёр понимал, можно ли ретраить. Ниже разберём, как проектировать endpoint так, чтобы интеграция выдерживала таймауты, повторную доставку и «хаотичный» порядок.
1) Базовая модель: что именно ломается
Прежде чем говорить про механики, полезно зафиксировать, какие свойства вам обещает HTTP и какие — нет.
1.1 Нет гарантии «ровно один раз»
HTTP не гарантирует, что запрос будет доставлен один раз. Между клиентом (партнёром) и сервером (вашим webhook-эндпоинтом) могут происходить:
- таймаут ответа (партнёр не получил 2xx и ретраит);
- временные сбои (партнёр ретраит с экспоненциальной задержкой);
- сетевые задержки, из-за которых два запроса приходят в другом порядке.
В результате один и тот же «логический» event может быть доставлен несколько раз. Иногда даже с частично отличающимися payload (например, партнёр мог пере-сформировать данные).
1.2 Порядок событий не гарантирован
Даже если события отправляются «последовательно» по логике партнёра, в реальности при ретраях и параллелизме порядок может нарушаться. Особенно часто это проявляется при:
- параллельной доставке для разных типов событий;
- масштабировании партнёра или вашего endpoint;
- долгой обработке (пока вы не успели сохранить/зафиксировать событие, следующее уже прилетело).
1.3 Ошибка «в обработке» vs ошибка «в доставке»
Партнёр обычно интерпретирует ваши HTTP-коды так:
- 2xx — «доставлено, можно прекращать ретраи»;
- 4xx — «невалидно/неразрешимо, ретраить не стоит»;
- 5xx — «временно не работает, ретраить можно».
Если вы вернёте 5xx при детерминированной проблеме (например, подпись неправильная), вы сами спровоцируете бесконечные ретраи и лавину логов. Если вы вернёте 2xx, но реально не обработали событие из‑за внутренней ошибки — вы потеряете консистентность.
2) Контракт webhook: минимальный набор полей
Чтобы гарантировать устойчивость, webhook должен не просто «принять JSON», а иметь идентификаторы и метаданные, которые позволяют построить идемпотентность.
2.1 Рекомендуемые идентификаторы
В идеале в payload или в заголовках партнёра должны быть:
event_id(уникальный идентификатор события);delivery_id(идентификатор попытки доставки — полезен, но не обязателен);created_at/timestamp(время формирования события);event_type(тип события);signature-контекст (см. ниже про подпись);webhook_id/subscription_id(если у партнёра несколько подписок).
Если партнёр не даёт event_id, вам придётся строить идемпотентность по комбинации полей (например, type + order_id + status + updated_at). Это хуже, но часто возможно.
2.2 Контроль версии формата
Добавьте возможность пережить смену схемы: храните schema_version или хотя бы логируйте event_type + структуру payload. Для хранения удобно записывать raw_body (или хэш raw body) — это помогает разбирать инциденты.
3) Подписи запросов: как валидировать без дыр
Подпись — это ваша защита от поддельных запросов и важный элемент для корректного кода ошибок.
3.1 Типичный сценарий: HMAC
Наиболее распространённая модель — HMAC (например, sha256) по телу запроса и ключу из настроек webhook.
Пример заголовков, которые часто встречаются (названия зависят от партнёра):
X-Signature— подпись;X-Signature-Timestamp— время (для защиты от replay-атак через старые запросы).
Даже если партнёр использует HMAC без timestamp, вы всё равно должны валидировать подпись перед любыми действиями.
3.2 Важно: подпись следует считать по каноническому телу
Подводный камень: если вы посчитаете подпись по «распарсенному» JSON (например, re-serialize с другим порядком ключей), подпись может не совпасть. Поэтому:
- берите «сырой»
raw_bodyкак получил сервер (bytes); - подпись считайте по bytes в том виде, как получен запрос.
Ниже пример (Node.js/Express) с использованием raw body. В production убедитесь, что middleware настроен на сбор raw bytes.
import crypto from "crypto";
import express from "express";
const app = express();
// Важно: rawBody должен быть доступен как buffer
app.post("/webhooks", express.raw({ type: "*/*" }), async (req, res) => {
const rawBody = req.body; // Buffer
const signature = req.get("X-Signature");
const ts = req.get("X-Signature-Timestamp"); // если есть
const secret = process.env.WEBHOOK_SECRET;
if (!signature) {
// Если нет подписи — детерминированная проблема контракта
return res.status(400).json({ error: "Missing signature" });
}
// (опционально) проверка допустимого дрейфа времени
if (ts) {
const ageMs = Date.now() - Number(ts) * 1000;
if (ageMs < 0 || ageMs > 5 * 60 * 1000) {
return res.status(401).json({ error: "Stale signature" });
}
}
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// timing-safe compare
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) {
// Подпись невалидна: не ретраим
return res.status(401).json({ error: "Invalid signature" });
}
// Только после успешной валидации подписи
const body = JSON.parse(rawBody.toString("utf-8"));
// TODO: идемпотентная обработка
res.status(200).json({ received: true });
});
3.3 Replay-атаки: где timestamp действительно нужен
Если у партнёра есть timestamp в сигнатурном контексте — используйте. В противном случае теоретически атакующий может повторить старый payload (хотя на практике это сложно, но защита лишней не бывает).
Рекомендация: задайте допустимое окно (например, 5 минут), учитывая погрешности времени и ретраи.
3.4 Логи: осторожно с секретами
Никогда не логируйте secret и не логируйте подпись целиком в публичных системах. Для отладки можно логировать первые/последние символы хэша.
4) Идемпотентность: как «пережить» повторную доставку
Идемпотентность — это ключ к стабильной интеграции. Идея: при получении одного и того же event повторное выполнение не меняет итог.
4.1 Идемпотентность по event_id
Если партнёр присылает event_id, ваша схема самая простая:
- перед обработкой попытайтесь «зарезервировать»
event_idв БД; - если запись уже существует — верните 2xx и прекратите обработку.
Критически важно: резервирование должно быть атомарным (в транзакции), чтобы два параллельных запроса не обработали одно и то же.
Ниже пример с PostgreSQL (псевдо-ORM и чистым SQL по смыслу).
Таблица
create table webhook_events (
event_id text primary key,
event_type text not null,
received_at timestamptz not null default now(),
status text not null default 'processing',
payload_hash text not null,
processed_at timestamptz,
error_code text
);
Логика (концептуально)
-- 1) Пытаемся создать запись
insert into webhook_events(event_id, event_type, payload_hash, status)
values ($1, $2, $3, 'processing')
on conflict (event_id) do nothing
returning event_id;
-- 2) Если вернули строку — вы первый обработчик
-- иначе — событие уже было обработано/обрабатывается, возвращайте 2xx
4.2 Что делать с параллельными ретраями: «processing» vs «processed»
Если вы сразу ставите status='processing', а обработка занимает время, возможен сценарий:
- первый запрос начал обработку, записал
processing; - он таймаутнулся/упал до изменения статуса;
- партнёр ретраит тот же event;
- второй запрос увидел
processingи решил, что всё ок — и не обработал повторно.
Чтобы не попасть в «вечный processing», используйте один из подходов:
- либо храните
processed_atи возвращайте 2xx только если естьprocessed_at; - либо храните версию состояния и делайте восстановление по таймеру (например, если
processingстарше N минут — можно повторить).
Практичный вариант: после успешной обработки обновлять строку на processed. Повторный запрос:
- если
processed_atзаполнено — идемпотентный дубль → 2xx; - если
processed_atпусто → смотрим, не «зависло» ли processing по времени, и решаем: либо повторяем, либо ставим в очередь.
4.3 Идемпотентность при отсутствии event_id
Если партнёр не даёт уникальный идентификатор события, сделайте хэш от «канонического ключа»:
type + subject_id + status + timestamp(илиupdated_at);- или хэш от отсортированного JSON subset (но осторожно: сложно сделать канонизацию без ошибок).
Вы всё равно должны помнить, что такая схема не гарантирует 100%: если партнёр изменит поле или формат timestamps, хэш станет другим.
5) Порядок событий: как не сломать бизнес-логику
Идемпотентность отвечает за «один и тот же event». Но порядок — это «какой event важнее», если пришло несколько.
5.1 Версионирование состояния: монотонные поля
Частый паттерн: для сущности (например, заказ) храните монотонный version или updated_at. Тогда:
- при получении события сравните
event_timestamp/sequenceс текущим состоянием; - если событие «старее» — не применяйте изменения, но верните 2xx.
Например, заказ уже в статусе paid, а пришёл created с более ранним timestamp — игнорируем.
5.2 “Out-of-order” при распределённой обработке
Если обработка асинхронная (вы принимаете HTTP быстро и отправляете в очередь), события могут прийти в очередь по порядку, но обработаться не так, как доставлены. Поэтому важно:
- либо обрабатывать события последовательно для одного
subject_id(например, partition key в очереди); - либо внутри обработки проверять версию/временную метку.
Второй вариант чаще проще, потому что он не зависит от очереди.
5.3 Нельзя полагаться на «первое пришло — первое обработалось»
Это одна из самых типичных ошибок. Даже если партнёр обещает порядок, ретраи и повторная доставка разрушат гарантию. Поэтому порядок нужно явно моделировать данными (sequence/version/timestamp) или бизнес-правилами.
6) Быстрая и надёжная архитектура endpoint: “accept fast, process later”
Цель webhook endpoint — отвечать быстро, после проверки подписи и записи идемпотентного следа. Долгие операции оставляйте на асинхронную обработку.
6.1 Почему 2xx должен приходить быстро
Если вы делаете синхронно:
- вызовы к другим сервисам,
- расчёты,
- запись больших объёмов,
- доступ к внешним API,
то вы повышаете шанс таймаута. Таймаут превращается в ретраи → дубль → рост нагрузки.
6.2 Подход: 2 фазы
- Синхронная фаза: валидация подписи, идемпотентная запись, enqueue job.
- Асинхронная фаза: обработка, обновление статуса, запись эффектов (изменение доменной модели).
Пример псевдокода:
def webhook_handler(request):
raw = request.raw_body
verify_signature(raw, headers)
event = json.loads(raw)
event_id = event["event_id"]
created = idempotency_reserve(event_id, event)
if not created:
# уже обработано или обрабатывается — 2xx
return 200
enqueue_processing_job(event_id)
# фиксируем, что событие принято в обработку
return 200
7) Коды ошибок и договорённость с партнёром: что реально важно
Самая недооценённая часть webhook-дизайна — корректная сигнализация партнёру, можно ли ретраить.
7.1 Практическая матрица
Ориентир (может отличаться у конкретного провайдера, но логика универсальна):
- 200–299: событие принято и обработка/фиксация выполнена. Ретраить не нужно.
- 400: запрос не соответствует формату контракта (например, отсутствуют обязательные поля). Ретраить бессмысленно.
- 401/403: подпись неверная или нет прав. Ретраить тоже бессмысленно.
- 409: конфликт идемпотентности/версии (в некоторых схемах). Но осторожно: многие провайдеры трактуют 409 как ретраить или нет — уточняйте. Часто лучше возвращать 2xx при дубле и решать в БД.
- 429: вы ограничили скорость. Некоторые партнёры ретраят.
- 500–503: временная ошибка сервера. Ретраить можно.
7.2 Ошибка в обработке vs ошибка в принятии
Если вы приняли событие и отдали 2xx, но затем в обработке упали и вы не смогли завершить эффект — партнер не узнает и не ретраит. Поэтому вам нужно:
- либо делать enqueue в той же логике идемпотентности (чтобы не потерять job);
- либо держать обработку так, чтобы «успех HTTP» означал «событие зафиксировано как принятый к обработке».
Если у вас есть очереди, важно уметь ретраить job внутри себя.
7.3 Ошибки валидации подписи: всегда 4xx
Подпись — детерминированная проверка. Если она не совпала, повторная доставка того же payload не поможет. Поэтому корректнее отвечать 401/403, чтобы партнёр не зацикливался на ретраях.
7.4 Ошибки бизнес-валидации: чаще 2xx + внутренний статус
Допустим, событие содержит корректную подпись, но «не соответствует текущему состоянию» (например, заказ в отменённом статусе, а прилетело событие оплаты). В зависимости от контрактов вы можете:
- игнорировать событие (но вы должны записать, что это было);
- или вернуть 4xx (но это может спровоцировать бесконечные ретраи, если партнер будет продолжать отправлять корректно подписанные payload).
Чаще в интеграциях лучше возвращать 2xx и корректно фиксировать outcome у себя, потому что ретраи не исправят логическую несовместимость.
8) Типовые сценарии и как на них отвечать
Разберём несколько частых кейсов.
8.1 Дубль event после таймаута
Схема:
- endpoint успешно зарезервировал
event_id; - затем операция ушла в асинхронную обработку;
- но HTTP ответ не дошёл (или ваш сервер упал после резерва, но до 2xx);
- партнёр ретраит.
Правильное поведение:
- на ретрае вы увидите
event_idи состояние (processing/processed); - если уже обработано — вернуть 2xx;
- если processing завис — либо повторить, либо продолжить (в зависимости от механизма).
8.2 Перестановка: “cancel” пришёл раньше “paid”
Если у вас есть версия/временная метка, вы можете:
- применить только событие с наиболее свежей версией;
- «старые» события игнорировать.
Если бизнес требует строгого порядка (например, нельзя отменить до оплаты), используйте state machine и храните последовательность. В любом случае важно не «обрабатывать в порядке доставки».
8.3 Несовместимая схема payload
Если вы меняете контракт, а партнёр присылает другой формат, подпись может быть верной, но JSON структура не соответствует.
Лучшее:
- валидация схемы (JSON Schema / Pydantic / Zod и т.п.);
- при детерминированной несовместимости —
400, чтобы партнёр не ретраил бесконечно.
9) Паттерн “raw body + хэш”: аудит и защита от спорных данных
Даже при правильной идемпотентности иногда возникают разногласия: «мы отправили event A, но у вас в системе другое». Чтобы разрулить инциденты:
- храните
payload_hashот raw_body; - храните
event_id,event_type,received_at; - при повторном event_id сравнивайте текущий hash с сохранённым (это помогает обнаружить случаи, когда партнёр переотправляет с изменённым содержимым).
Если изменилось содержимое при том же event_id — это ошибка партнёра или нарушение контракта. Вы должны зафиксировать это и принять решение по правилам: либо обновить данные, либо оставить как было и пометить инцидент.
10) Пример целостного решения (контур)
Ниже пример на Python (FastAPI + PostgreSQL-идея). Он не привязан к конкретной очереди, но показывает ключевые шаги: подпись → идемпотентная запись → быстрое 2xx → асинхронная обработка.
from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib
import json
from datetime import datetime, timezone
app = FastAPI()
SECRET = b"your_webhook_secret"
def verify_signature(raw_body: bytes, signature_hex: str) -> bool:
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
a = bytes.fromhex(signature_hex)
b = bytes.fromhex(expected)
# timing-safe compare
return len(a) == len(b) and hmac.compare_digest(a, b)
def idempotency_reserve(conn, event_id: str, event_type: str, payload_hash: str):
# Должно быть атомарно
# Возвращает True, если мы "первые", иначе False
with conn.cursor() as cur:
cur.execute("""
insert into webhook_events(event_id, event_type, payload_hash, status)
values (%s, %s, %s, 'processing')
on conflict (event_id) do nothing
returning event_id;
""", (event_id, event_type, payload_hash))
row = cur.fetchone()
return row is not None
def payload_hash(raw_body: bytes) -> str:
return hashlib.sha256(raw_body).hexdigest()
@app.post("/webhooks")
async def webhook(request: Request):
raw_body = await request.body()
sig = request.headers.get("X-Signature")
if not sig:
raise HTTPException(status_code=400, detail="Missing signature")
if not verify_signature(raw_body, sig):
raise HTTPException(status_code=401, detail="Invalid signature")
body = json.loads(raw_body.decode("utf-8"))
event_id = body["event_id"]
event_type = body["event_type"]
ph = payload_hash(raw_body)
# conn = get_db_connection()
# created = idempotency_reserve(conn, event_id, event_type, ph)
created = True # заглушка для примера
if not created:
# Дубликат: корректно возвращаем 2xx
return {"received": True, "duplicate": True}
# enqueue(event_id) # асинхронная обработка
# mark accepted -> обработчик потом обновит status/processed_at
return {"received": True, "duplicate": False}
Ключевая мысль: HTTP обработчик не должен решать бизнес-конфликты «по месту», если это затягивает ответ. Он должен гарантировать: событие не потеряно и не обработано дважды в критической секции.
11) Как тестировать webhook-контракт: чеклист
Устойчивость не появляется «по умолчанию». Стоит заранее протестировать:
11.1 Тесты подписи
- неправильный секрет → 401;
- изменение одного байта body → 401;
- отсутствие подписи → 400;
- проверка replay (если есть timestamp).
11.2 Идемпотентность
- отправьте один
event_idдважды параллельно; - имитируйте сценарий, где первая попытка «зависла» после резервирования;
- проверьте, что итоговый эффект не дублируется.
11.3 Порядок
- отправьте sequence A затем B, но обработайте их в обратном порядке;
- убедитесь, что применяется правильная версия состояния.
11.4 Ошибки ретраев
- симулируйте временную ошибку очереди/БД в асинхронной части и убедитесь, что ретраи происходят там, где нужно;
- проверьте, что на ретраи партнёр не получает 4xx/5xx «не по делу».
12) Что в итоге должно быть в вашем webhook-контракте
Если собрать всё в краткий список требований к endpoint:
- Валидация подписи по raw_body до любых действий.
- Идемпотентность по
event_id(или устойчивому ключу), атомарная запись в БД. - Обработка дублей: при подтверждённой фиксации — всегда 2xx.
- Учет out-of-order через версию/временную метку/sequence в доменной модели.
- Быстрый HTTP: accept → enqueue → обработка отдельно.
- Корректные коды ошибок: 4xx для детерминированных ошибок контракта, 5xx только для временных.
- Аудит: payload_hash и хранение метаданных для расследований.
- План на очереди/ретраи внутри системы: не потерять job после 2xx.
Вывод
Webhook-эндпоинт — это не «ручка для POST», а часть распределённой системы с реальными ограничениями: нет гарантий доставки ровно один раз и нет гарантии порядка событий. Чтобы интеграция переживала таймауты, ретраи и перестановки, проектируйте контракт вокруг идемпотентности (event_id), версионирования состояния и безопасной валидации подписи. Отдельно продумайте коды ошибок: ваш HTTP ответ — это сигнал партнёру, ретраить или прекращать.
Если вы хотите разобрать это глубже на практике (с разбором кейсов, схемами идемпотентности и примерами тестирования интеграций), полезно пройти системный материал по теме, например курс по вебхукам и дизайну API [на /course/] — но в любом случае главная работа начинается с правильного контракта и дисциплины обработки событий на вашей стороне.
Комментарии
Пока нет комментариев