Webhook часто выглядит как одна из самых простых интеграций.
Внешний сервис отправляет:
POST /api/webhooks/payment
Content-Type: application/jsonа внутри:
{
"event": "payment.succeeded",
"paymentId": "pay_1842"
}Backend принимает JSON и делает:
payment → PAIDКажется, что задача решена.
Пока однажды тот же webhook не приходит второй раз.
И система повторно:
начисляет бонус;
создаёт документ;
отправляет письмо;
выдаёт доступ.Или сначала приходит:
subscription.cancelledа через несколько секунд:
subscription.updatedхотя фактически updated произошло раньше.
Или злоумышленник отправляет на публичный endpoint:
{
"event": "payment.succeeded",
"paymentId": "pay_9999"
}и приложение принимает его так же, как настоящее событие платёжной системы.
Или настоящий webhook перехватывается, а затем повторяется через час:
replay.Или endpoint выполняет тяжёлую бизнес-логику 20 секунд, provider считает запрос неуспешным и отправляет событие повторно.
После этого становится понятно:
webhook — это не обычный POST.
Это отдельный входной протокол, который должен выдерживать:
подделку запроса;
повторную доставку;
replay;
потерю ответа;
нарушение порядка;
параллельную обработку;
старые события;
новые неизвестные типы событий;
временный отказ базы или worker.Именно поэтому production webhook лучше проектировать примерно так:
Internet
↓
Webhook endpoint
↓
Signature verification
↓
Replay / timestamp checks
↓
Durable inbox
↓
2xx response
↓
Background processing
↓
Idempotent business handler
↓
State reconciliationА не так:
POST
↓
JSON.parse()
↓
изменить бизнес-данные
↓
200Разберём эту архитектуру по частям.
Webhook endpoint — публичный вход в систему
Обычно webhook endpoint доступен из Интернета:
https://example.ru/api/webhooks/providerТо есть отправить:
POSTна него потенциально способен кто угодно.
Следовательно, первое правило:
сам факт получения HTTP-запроса ничего не доказывает.
Нельзя рассуждать так:
URL webhook никто не знает, значит запрос настоящий.
URL может попасть:
в логи;
в monitoring;
в proxy configuration;
в документацию;
в историю браузера;
в source code.И даже если адрес невозможно угадать, security by obscurity всё равно недостаточен.
Webhook должен иметь криптографическое подтверждение происхождения.
Подпись webhook
Один из распространённых вариантов — HMAC.
У provider и receiver есть общий secret:
WEBHOOK_SECRETProvider берёт payload:
{
"event": "payment.succeeded",
"id": "evt_123"
}и вычисляет, например:
HMAC-SHA256(secret, payload)Полученную подпись передаёт в HTTP header.
Receiver получает:
payload
+
signatureи самостоятельно вычисляет ожидаемую подпись.
Если значения совпали:
payload был подписан
стороной, которая знает secret.Если нет:
request rejected.GitHub, например, подписывает webhook payload секретом и рекомендует проверять X-Hub-Signature-256, используя HMAC-SHA256. GitHub отдельно рекомендует сравнивать подписи constant-time функцией, а не обычным ==.
Почему нельзя доверять IP вместо подписи
Можно дополнительно разрешить запросы только с IP-адресов provider.
Это полезный дополнительный слой.
Но IP allowlist не должен обязательно заменять подпись.
Причины:
адреса provider могут меняться;
proxy-инфраструктура усложняет источник;
ошибка firewall может открыть endpoint;
IP не подтверждает целостность payload.Подпись отвечает сразу на два вопроса:
Запрос подписал владелец секрета?
и:
Payload не был изменён после подписания?
GitHub, например, рекомендует и webhook secret, и HTTPS; IP allowlist рассматривается как дополнительная защита, а не как единственный механизм доверия.
Подписывать нужно именно те байты, которые пришли
Это очень важная деталь.
Представим provider подписал:
{"id":"evt_1","amount":500}Framework получил JSON и после parsing сериализовал его как:
{
"id": "evt_1",
"amount": 500
}Смысл тот же.
Байты — другие.
HMAC станет другим.
Поэтому webhook signature обычно нужно проверять по:
raw request bodyдо изменения payload framework'ом.
Stripe прямо предупреждает: для проверки webhook signature необходим исходный raw body; изменение тела запроса до проверки ломает signature verification.
Правильная последовательность
Не:
JSON.parse
↓
нормализация
↓
signature verificationА:
read raw bytes
↓
verify signature
↓
parse JSON
↓
validate event structureПочему signature не решает replay attack
Представим настоящий webhook:
event = payment.succeededправильно подписан.
Злоумышленник каким-то образом получил:
payload
+
signatureи через час отправил точно тот же запрос снова.
Signature остаётся валидной.
Payload никто не менял.
Следовательно:
HMAC verification = PASS.Но запрос всё равно не новый.
Это и есть replay attack.
Подпись отвечает на вопрос
Этот payload был действительно подписан?
Но не обязательно:
Этот payload пришёл сейчас впервые?
Для второго вопроса нужна replay protection.
Первый механизм replay protection — timestamp
Provider может подписывать не только body, но:
timestamp + body.Например концептуально:
timestamp = 1791270000
signed_payload =
1791270000.{"event":"payment.succeeded"}Receiver проверяет:
signature valid?и:
timestamp достаточно свежий?Если запрос подписан час назад, а окно допуска:
5 минут,его можно отклонить как слишком старый.
Stripe именно так строит защиту: timestamp входит в подписываемые данные, а официальные библиотеки по умолчанию используют окно допустимой разницы времени в пять минут. При повторной доставке Stripe создаёт новую подпись и новый timestamp.
Но timestamp не заменяет дедупликацию
Допустим provider законно retry webhook.
Это новый delivery attempt.
Timestamp свежий.
Signature правильная.
Но business event всё ещё тот же:
evt_123.Значит security check пройдёт:
PASS.И это правильно.
Теперь другой слой должен решить:
Мы уже обработали этот event?
Timestamp и event ID решают разные задачи
timestamp
→ защищает от слишком старого replayevent ID / delivery ID
→ защищает от повторной обработкиНе нужно пытаться одним механизмом решить обе проблемы.
Что использовать как уникальный идентификатор
Хороший webhook provider обычно передаёт:
event_idили:
delivery_id.Например:
{
"id": "evt_01J...",
"type": "payment.succeeded"
}Receiver сохраняет:
provider + event_idс уникальным database constraint.
Например inbox table
CREATE TABLE webhook_events (
id bigserial PRIMARY KEY,
provider text NOT NULL,
event_id text NOT NULL,
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
provider_time timestamptz,
status text NOT NULL DEFAULT 'received',
payload jsonb NOT NULL,
attempts integer NOT NULL DEFAULT 0,
last_error text,
processed_at timestamptz,
UNIQUE(provider, event_id)
);Теперь первый webhook:
INSERT → success.Повторный:
UNIQUE violation.Можно просто вернуть:
2xxбез повторного business effect.
Почему duplicate webhook — нормальное явление
Receiver иногда отвечает слишком поздно.
Provider не получил подтверждение.
Network connection оборвалось.
Endpoint вернул:
500.Provider пытается доставить событие ещё раз.
Это не баг webhook-системы.
Это механизм надёжности.
Stripe прямо предупреждает, что webhook endpoint может получить одно событие больше одного раза, и рекомендует хранить обработанные event IDs. В live mode Stripe также выполняет автоматические повторы доставки в течение нескольких дней при неуспехе endpoint.
GitHub тоже предоставляет уникальный X-GitHub-Delivery; при ручной redelivery идентификатор доставки остаётся тем же, что удобно для определения повторов.
Поэтому webhook delivery обычно имеет семантику at-least-once
Не стоит строить архитектуру на предположении:
каждый event придёт ровно один раз.Практически безопаснее считать:
event может:
не прийти вовремя;
прийти несколько раз;
прийти после более нового события.Значит handler обязан быть:
идемпотентным.Дедупликация event и идемпотентность business effect — не одно и то же
Это очень важное различие.
Представим событие:
evt_123
payment.succeededпришло дважды.
UNIQUE по:
event_idзащитит нас.
Но иногда provider способен создать два разных event объекта, описывающих один и тот же бизнес-факт.
Stripe отдельно предупреждает о таком случае: кроме одинакового event ID, иногда два самостоятельных Event могут описывать одно и то же событие, и тогда для дополнительной дедупликации рекомендует использовать object ID вместе с event.type.
Значит хорошо иметь два уровня защиты
Первый:
delivery/event deduplicationВторой:
business idempotency.Например выдача доступа после оплаты
Плохой handler:
if (event.type === 'payment.succeeded') {
await insertSubscription({
userId,
plan: 'PRO'
});
}Каждый вызов:
создаёт новую subscription row.Безопаснее выразить бизнес-инвариант
Например:
UNIQUE(user_id, order_id, entitlement_type)И операция:
ensure access existsа не:
always create access.Теперь даже если webhook прошёл разные уровни несколько раз, итоговый business effect остаётся один.
Webhook должен менять состояние, а не просто «выполнять действие»
Например:
payment.succeededне обязательно означает:
INSERT order as paid.Лучше:
PaymentAttempt
PENDING
↓
SUCCEEDEDПереход можно сделать условным:
UPDATE payment_attempts
SET status = 'succeeded'
WHERE id = $1
AND status <> 'succeeded';Если уже:
SUCCEEDED,повтор становится no-op.
State machine естественно защищает от дублей
Например:
PENDING
↓
PROCESSING
↓
SUCCEEDEDСобытие payment.succeeded может прийти:
1 раз;
2 раза;
5 раз.Итог остаётся:
SUCCEEDED.Но нельзя просто верить последовательности событий
Представим provider генерирует:
12:00:01 subscription.created
12:00:02 subscription.updated
12:00:03 subscription.cancelledИнтернет не является FIFO-очередью.
До нас события могут дойти:
12:00:04 cancelled
12:00:05 created
12:00:06 updatedЕсли handler бездумно применяет события по arrival order, финальное локальное состояние станет:
updatedхотя у provider subscription уже отменена.
Порядок создания и порядок доставки — разные вещи
Stripe прямо указывает, что не гарантирует доставку событий в порядке их генерации и рекомендует не строить webhook handler на предположении об ordering. Если более позднее событие пришло первым, Stripe предлагает при необходимости получить актуальный объект через API.
Это фундаментальный принцип, который полезно считать универсальным для внешних webhook-интеграций, если provider явно не гарантирует обратное.
Как бороться с out-of-order events
Есть несколько стратегий.
Главное — выбрать её для конкретного domain.
Стратегия 1. Event содержит version
Например:
{
"type": "subscription.updated",
"subscriptionId": "sub_1",
"version": 17
}Локально:
last_version = 18.Приходит:
version = 17.Значит событие старое.
Его можно не применять.
Условное обновление
UPDATE subscriptions
SET
status = $status,
version = $incomingVersion
WHERE external_id = $id
AND version < $incomingVersion;Если:
affected rows = 0,мы получили старое или повторное состояние.
Это намного надёжнее времени доставки
Не:
кто пришёл последним,
тот и прав.А:
у кого domain version новее,
тот и прав.Стратегия 2. Provider sequence number
Иногда событие содержит:
sequence = 18427.Это ещё лучше, если provider гарантирует monotonic ordering для конкретной сущности.
Локально храним:
last_sequence.Но sequence может быть глобальным или локальным
Нужно читать contract provider.
Например sequence:
глобальный для всего accountи:
sequence для одной subscription— совершенно разные модели.
Не нужно угадывать semantics.
Стратегия 3. Fetch latest state
Иногда event лучше воспринимать не как:
Вот окончательное новое состояние.
А как:
Что-то изменилось — сходи и получи актуальный объект.
Например приходит:
subscription.updatedReceiver делает:
GET /subscriptions/sub_1и получает:
{
"status": "cancelled"
}Даже если сам webhook был старее, API возвращает текущее состояние.
Это особенно полезно при out-of-order delivery
Представим:
cancelledпришло первым.
Мы fetched:
cancelled.Через секунду приходит более старое:
updated.Снова fetch:
cancelled.Локальное состояние не откатывается назад.
Но fetch-latest имеет цену
Каждый webhook превращается в дополнительный API call.
При:
100 events/secэто уже существенная нагрузка.
Поэтому нужно смотреть на:
rate limits;
стоимость API;
частоту событий;
надежность payload.Стратегия 4. Хранить события и упорядочивать самостоятельно
Иногда event содержит:
entity_version;
sequence;
event_number.Можно сначала сохранить события:
17
19
18а затем обработать:
17
18
19.Но это требует понимать:
А что если 18 вообще никогда не придёт?
Нужны:
timeouts;
gap handling;
reconciliation.Это уже маленькая event processing system.
Не используйте timestamp как универсальный sequence
Например:
created_at = 12:00:01.Кажется:
Просто отсортируем события по времени.
Но timestamps могут:
иметь низкую точность;
совпадать;
генерироваться разными системами;
не гарантировать строгий порядок.Stripe прямо предупреждает не использовать created для определения порядка или дедупликации: несколько событий могут иметь одинаковый timestamp.
Нужно различать event time и arrival time
Например:
provider_created_at:
12:00:01
received_at:
12:03:17Оба значения полезны.
Но означают разные вещи.
Received_at нужен для эксплуатации
Например:
delivery delay =
received_at - provider_created_at.Можно заметить:
обычно 2 sec
сегодня 15 min.Это признак проблем provider или нашей интеграции.
Provider time нужен для domain/reconciliation
Но только если provider документирует его semantics.
Самая безопасная архитектура — webhook inbox
Очень плохой handler:
POST /webhook
↓
verify
↓
update order
↓
send email
↓
generate PDF
↓
sync CRM
↓
return 200Чем больше действий внутри HTTP request, тем больше шанс:
timeout;
partial success;
retry;
duplicate.Лучше разделить приём и обработку
Webhook request
↓
Verify signature
↓
Validate envelope
↓
INSERT inbox event
↓
COMMIT
↓
Return 2xx
↓
Worker
↓
Business processingПочему это лучше
HTTP endpoint делает только то, что критично для надёжного приёма:
проверяет;
фиксирует;
подтверждает.Тяжёлая работа происходит отдельно.
GitHub рекомендует отвечать на webhook успешным 2xx в течение 10 секунд и при необходимости передавать работу в очередь. Stripe также рекомендует быстро вернуть 2xx до выполнения сложной бизнес-логики и обрабатывать webhook асинхронно.
Очень важное слово — COMMIT
Представим:
получили webhook
↓
положили event только в память
↓
вернули 200
↓
process погибProvider считает:
delivery successful.А событие потеряно.
Значит до 2xx нужна durable фиксация
Например:
PostgreSQL INSERT
↓
COMMIT
↓
200 OK.Теперь process может упасть через микросекунду.
После restart worker найдёт:
status = receivedи продолжит работу.
Что именно сохранять в inbox
Практично хранить:
provider;
event_id;
event_type;
raw payload;
parsed payload;
received_at;
provider timestamp;
signature metadata при необходимости;
processing status;
attempts;
last_error.Raw payload полезен для:
аудита;
повторного разбора;
расследования signature issues;
миграции parser.Но нужно учитывать:
персональные данные;
secrets;
retention.Не хранить всё бесконечно только потому, что удобно debug.
Можно хранить raw body отдельно
Например:
webhook_deliveryи:
webhook_processing.Первая сущность отвечает:
Что реально пришло?
Вторая:
Что мы с этим сделали?
Это очень полезное разделение
Потому что:
delivery acceptedне означает:
business processing succeeded.HTTP 2xx не должен означать «весь бизнес-процесс завершён»
Он может означать:
Событие корректно принято и durable сохранено.
Worker обработает его позже.
Так webhook endpoint становится быстрым и предсказуемым.
Что вернуть duplicate event
Допустим:
evt_123уже есть в inbox.
Provider присылает снова.
Не нужно отвечать:
409 Conflict.Provider может решить:
Delivery failed.
И повторять ещё.
Обычно duplicate — это успешная доставка
Receiver уже знает событие.
Значит можно:
return 200/204.Не выполняя business logic повторно.
Получается
first delivery
→ persisted
→ 200
duplicate delivery
→ already persisted
→ 200Provider счастлив.
Business effect один.
Когда возвращать non-2xx
Например:
signature invalid;
payload structurally invalid;
event cannot be durably accepted;
database unavailable.Но и здесь contract provider имеет значение.
Если БД недоступна
Нельзя:
catch(error)
return 200только чтобы provider «не ругался».
Тогда событие потеряно.
Лучше:
5xxчтобы provider, если его contract поддерживает retries, попытался доставить снова.
Но provider retry policy нужно знать заранее
У разных систем она отличается.
Stripe выполняет автоматические повторы при проблемах доставки.
GitHub, напротив, указывает, что failed deliveries автоматически не redeliver и восстановление пропущенных доставок нужно организовывать отдельно или вручную.
Поэтому нельзя проектировать receiver с предположением:
Любой provider потом обязательно повторит.
Webhook contract нужно читать как API contract
До интеграции полезно выяснить:
Как подписывается request?
Есть ли timestamp?
Есть ли event/delivery ID?
Как долго возможны retries?
Повторяется ли event ID?
Гарантирован ли порядок?
Можно ли запросить событие повторно?
Можно ли получить актуальное состояние объекта через API?Эти ответы напрямую определяют архитектуру receiver.
Подпись должна проверяться до доверия payload
Плохо:
const event = JSON.parse(body);
if (event.type === 'payment.succeeded') {
// ...
}
verifySignature();Мы уже начали действовать на основании неподтверждённых данных.
Лучше
raw body
↓
signature validation
↓
timestamp/replay validation
↓
parse
↓
schema validation
↓
inboxНужно проверять event type
Даже корректно подписанный provider может прислать событие, которое endpoint не ожидает.
Например вы подписались на:
payment.succeeded
payment.failedа со временем provider добавил:
payment.review_opened.Handler:
switch (event.type) {
case 'payment.succeeded':
...
}должен иметь safe default:
unknown event
→ acknowledge / logа не:
throw fatal exception.GitHub отдельно советует проверять тип и action события; платформа продолжает добавлять новые типы и действия.
Unknown event — не обязательно ошибка
Provider расширил protocol.
Наш client пока не использует новый event.
Это нормальный additive evolution.
Но schema version тоже важна
Webhook event — такой же API contract, как обычный REST response.
Сегодня:
{
"type": "project.updated",
"projectId": 1842
}Через год:
{
"type": "project.updated",
"data": {
"project": {
"id": 1842
}
}
}Если consumer не обновился:
projectId = undefined.Поэтому webhook schema должна иметь lifecycle
Например:
{
"version": 2,
"type": "project.updated",
"data": {}
}или version привязан к webhook subscription.
Event после создания лучше считать immutable
Это особенно важно для retries.
Событие:
evt_123создано вчера в schema v1.
Сегодня provider обновил систему до v2.
Если старый event redeliver сегодня, он должен сохранять свой исходный contract.
Не превращаться внезапно в:
evt_123,
но уже другого JSON.Stripe, например, фиксирует API-version semantics события на момент его создания; последующее обновление API version не переписывает уже созданный Event.
Порядок событий и state machine
Представим local order:
PENDINGПриходят webhook:
order.paid
order.cancelledЕсли оба являются валидными независимыми переходами, нужно определить:
может ли paid → cancelled?и:
может ли cancelled → paid?Не применять любое событие к любому состоянию.
Например
PENDING
├── paid → PAID
└── cancelled → CANCELLED
PAID
├── refunded → REFUNDED
└── cancelled → invalidТеперь старое:
cancelledне сможет случайно откатить:
PAIDесли domain этого не допускает.
State machine — мощная защита от out-of-order
Не обязательно знать абсолютный порядок всех событий.
Иногда достаточно проверять:
Допустим ли этот переход из текущего состояния?
Но state machine должна отражать provider domain
Нельзя просто придумать локальные правила, противоречащие внешней системе.
Например provider может разрешать:
PAID → CANCELLEDкак cancellation after authorization.
Тогда это нужно моделировать корректно.
Событие и текущее состояние provider могут расходиться
Например webhook говорит:
subscription.activeно после его создания subscription уже стала:
cancelled.Если событие пришло поздно и мы blindly применили payload:
локально ACTIVE.Для критичных объектов полезен reconciliation
Например после значимого события:
fetch current provider objectили периодический background reconciliation:
локальная БД
↕
provider API.Webhook — ускоритель синхронизации, а не всегда единственный источник истины
Особенно для:
платежей;
подписок;
выплат;
доставки.Можно построить модель:
Webhook
→ быстро сообщает об изменении
Provider API
→ позволяет проверить актуальное состояниеЭто спасает и от пропущенного webhook
Если endpoint был недоступен, а provider:
не повторил;
исчерпал retries;периодический reconciliation всё равно способен обнаружить:
локально PENDING
provider SUCCEEDED.Пример payment reconciliation
Каждые несколько минут:
SELECT payment_attempts
WHERE status IN (
'pending',
'pending_confirmation'
)Для каждого:
GET provider status.Если:
provider = succeededлокально:
SUCCEEDED.Webhook и reconciliation должны использовать один transition service
Не:
webhook logicи отдельный:
reconciliation logic,которые обновляют таблицы по-разному.
Лучше:
applyProviderPaymentState(...)используется обоими каналами.
Тогда независимо от источника
HTTP response;
webhook;
reconciliationбизнес-состояние меняется по одним правилам.
Очень важная проблема — race webhook с обычным API response
Представим backend создаёт payment.
Provider работает очень быстро.
Событие:
payment.succeededприходит webhook'ом раньше, чем обычный HTTP request от provider вернул ответ нашей системе.
Получаем параллельно:
Process A:
create payment response
Process B:
webhook payment.succeededЕсли оба пытаются:
INSERT paymentполучаем дубль.
Поэтому provider object ID должен иметь UNIQUE
Например:
UNIQUE(provider, provider_payment_id)Теперь два параллельных процесса не смогут создать две локальные сущности одного внешнего платежа.
Database constraint сильнее
чем:
if (!(await paymentExists(id))) {
await createPayment();
}Потому что два процесса могут одновременно сделать:
exists? → false
exists? → falseи оба выполнить INSERT.
Правильный invariant живёт в БД
one provider payment
→ one local payment.Replay protection через event ID тоже должен быть атомарным
Плохо:
SELECT event_id
↓
not found
↓
INSERTДва параллельных request могут оба увидеть:
not found.Лучше UNIQUE + INSERT
Например:
INSERT INTO webhook_events (...)
VALUES (...)
ON CONFLICT (provider, event_id)
DO NOTHING;Проверяем:
inserted row?Если нет:
duplicate.Secret rotation
Webhook secret нельзя считать вечным.
Он может:
утечь;
попасть в старый backup;
оказаться у бывшего сотрудника;Нужна возможность rotation.
Но мгновенная замена может сломать delivery
Например provider всё ещё подписывает:
old_secretа receiver уже принимает только:
new_secret.Получаем массовые:
signature invalid.Хорошая rotation допускает переходное окно
Receiver временно проверяет:
new secret
OR
old secret.После завершения grace period:
old secret removed.Stripe, например, позволяет при rotation временно держать несколько signing secrets активными, чтобы переход не был мгновенным.
Secret нельзя логировать
Особенно так:
signature verification failed
secret=...
payload=...Это превращает security mechanism в credential leak.
Нельзя хранить secret в webhook URL
Плохо:
/api/webhook?secret=abcdURL может попасть:
в access logs;
proxy logs;
analytics.GitHub прямо рекомендует не размещать чувствительные credential в webhook payload URL и использовать webhook secret для signature validation.
HTTPS обязателен
Подпись защищает целостность и происхождение payload, но transport encryption всё равно нужна.
Без HTTPS внешний наблюдатель может видеть:
payload;
metadata;
event IDs.Даже если изменить payload без нарушения подписи он не сможет.
Webhook endpoint не должен принимать огромный body
Публичный POST endpoint — потенциальная точка DoS.
Нужны:
body size limit;
request timeout;
connection limits;
rate limits.Но rate limit нужно проектировать осторожно:
Не заблокируем ли легитимный всплеск provider?
Например массовое продление подписок может создать настоящий пик.
Полезно подписываться только на нужные события
Если integration использует:
3 event types,нет смысла принимать:
150.Это уменьшает:
трафик;
нагрузку;
логирование;
площадь ошибок.И GitHub, и Stripe рекомендуют подписываться только на реально необходимые webhook events.
Monitoring webhook infrastructure
Просто metric:
POST /webhook = 200недостаточна.
Endpoint может всё принимать, но worker уже час ничего не обрабатывает.
Нужно различать delivery и processing
Например:
webhooks_received_total
webhooks_signature_failed_total
webhooks_duplicate_total
webhooks_processed_total
webhooks_failed_totalИ отдельно:
oldest_unprocessed_webhook_age.Oldest age особенно полезен
Например:
pending events = 20не выглядит страшно.
Но если самый старый:
3 hours,очередь явно застряла.
Processing latency
processed_at - received_atпоказывает:
Через сколько после webhook наше бизнес-состояние стало актуальным?
Delivery lag
received_at - provider_created_atесли provider timestamp надёжен.
Показывает задержку между внешним событием и доставкой.
Duplicate rate
Обычно дубли допустимы.
Но резкий рост:
0.1%
→
30%может означать:
наш endpoint отвечает медленно;
provider считает доставку неуспешной;
сеть нестабильна.Signature failures
Один случай:
ошибка конфигурации;
тестовый request.Тысяча за минуту:
атака;
неверный secret после rotation;
сломанный proxy.Unknown event rate
Если после обновления provider:
unknown events = 5000,возможно integration contract расширился, а receiver не обновлён.
Failed processing нельзя скрывать
Плохо:
catch(error)
log(error)
mark processed.Событие исчезает из active queue.
Но business effect не произошёл.
Нужен state lifecycle
Например:
RECEIVED
↓
PROCESSING
↓
PROCESSEDили:
PROCESSING
↓
RETRY
↓
FAILEDЭто тот же жизненный цикл background jobs.
Retry business processing отдельно от delivery
Provider уже успешно доставил event.
Не нужно заставлять его присылать webhook снова только потому, что:
CRM temporarily unavailable.Мы можем:
durably accept event
↓
200
↓
сами retry worker.Это очень сильное разделение ответственности
Provider отвечает:
Доставить событие до нашего inbox.
Наша queue отвечает:
Довести business processing до результата.
Не связывайте внешний retry с внутренней зависимостью
Если во время webhook:
SMTP down,не нужно возвращать provider:
500только потому, что email не отправился.
Событие можно принять.
А email retry выполнять внутренней job.
Иначе webhook provider начинает играть роль нашей job queue
Это неудобно и непредсказуемо.
Dead-letter/failed state нужен и webhook processing
После:
10 retriesне нужно повторять событие вечно.
Состояние:
FAILEDс:
event_id;
type;
error;
attempts;
payload reference.позволяет расследовать проблему.
Manual retry
Администратор нажимает:
Повторить обработку.Но это не новый event.
Event ID тот же.
Значит dedupe logic не должна блокировать internal retry
Полезно разделить:
delivery accepted?и:
processing attempts.То есть inbox row одна.
Worker attempts много.
Иначе возникает ошибка
Webhook:
evt_123сохранён.
Processing упал.
Администратор делает retry.
Код проверяет:
event already exists
→ skip.И никогда больше его не обрабатывает.
Dedupe должна происходить на входе
external delivery
→ one inbox row.Internal processing этой строки может повторяться сколько необходимо.
Event ordering и concurrency
Представим два события одной сущности:
version 10
version 11Два workers забирают их одновременно.
Даже если queue получила события правильно:
10
11,processing может завершиться:
11 first
10 second.Снова локальное состояние рискует откатиться.
Значит ordering нельзя решать только порядком очереди
Нужны:
version checks;
entity locks;
serial processing by key;
state machine.Например advisory lock по entity ID
Worker получает:
subscription sub_123и берёт lock:
hash(sub_123).Другой event этой же subscription ждёт.
Но события разных subscriptions обрабатываются параллельно.
Или queue partitioning
partition key =
subscription_id.Внутри одного key:
serial.Между разными:
parallel.Но даже serialization не лечит out-of-order delivery
Если version 11 реально пришла раньше 10, worker честно обработает:
11
↓
10.Поэтому version/state checks всё равно нужны.
Lock решает concurrency
Version решает chronology.
Это разные проблемы.
Что если событие зависит от другого, которого ещё нет
Например первым приходит:
invoice.paidНо локальная:
subscriptionещё не создана, потому что subscription.created задержался.
Плохой вариант:
throw EntityNotFound
↓
FAILED forever.Возможные стратегии
Можно:
получить subscription через provider API;или:
retry later;или:
создать локальную сущность по данным текущего event.Выбор зависит от contract provider.
Stripe прямо приводит аналогичный пример: если invoice.paid пришёл раньше связанных событий, интеграция может получить invoice, charge и subscription через API, а не ждать жёсткого порядка доставки.
Нельзя строить цепочку событий как единственный способ собрать состояние
Если:
event Aобязательно должен предшествовать:
event B,но provider такого порядка не гарантирует, система хрупкая.
Хороший webhook payload содержит достаточно идентификаторов
Например:
{
"id": "evt_123",
"type": "invoice.paid",
"data": {
"invoiceId": "inv_55",
"subscriptionId": "sub_77",
"customerId": "cus_21"
}
}Теперь receiver способен восстановить недостающее состояние через API.
Snapshot events и thin events
Есть две распространённые модели.
Первая:
snapshotEvent содержит большой снимок объекта.
Вторая:
thin eventEvent в основном сообщает:
что и с чем произошло,а актуальные данные receiver получает отдельно.
Snapshot удобен
Меньше API requests.
Но snapshot уже может быть устаревшим к моменту обработки.
Thin event требует fetch
Зато receiver может получить более актуальное состояние.
Stripe сейчас прямо поддерживает обе модели event destinations: snapshot events и thin events; для thin events документация ориентирует consumer на получение связанного актуального объекта через API.
Не существует универсально лучшего варианта
Для audit:
snapshotочень полезен.
Для синхронизации current state:
thin + fetch latestчасто безопаснее.
Webhook не должен доверять business fields без валидации
Даже после signature verification provider мог:
обновить schema;
прислать null;
добавить новое enum value.Нужна schema validation.
Но schema validation должна быть forward-compatible
Плохо:
unknown field
→ reject entire webhook.Provider добавил optional field.
Все deliveries начинают:
400.Лучше обычно разрешать неизвестные additive fields
Но строго проверять то, от чего зависит бизнес:
event_id;
type;
object ID;
amount;
currency.Unknown enum требует отдельной политики
Например:
payment.status =
under_reviewа наш код знает:
pending
succeeded
failed.Безопаснее:
UNKNOWN / unhandledчем автоматически:
succeeded.Не доверяйте сумме webhook, если её можно проверить
Для платежа provider сообщает:
{
"amount": 100000
}Но если бизнес-критично убедиться в платеже, можно дополнительно сопоставить:
provider payment ID;
order ID;
expected amount;
currency.Webhook «платёж успешен» не должен означать
найти order из payload
и отметить paid без условий.Нужно проверить связь.
Например:
payment belongs to order?
amount expected?
currency expected?
operation not already finalized?Это защищает даже при корректной подписи
Ошибки configuration тоже существуют.
Например webhook от test account случайно направлен в production endpoint.
Signature будет абсолютно правильной.
Но:
environment/account IDне тот.
Полезно проверять provider account/environment
Например:
mode = liveили:
provider account id = ожидаемый.Test event не должен менять production order.
Разные environments — разные secrets
Не нужно:
staging
productionподписывать одним webhook secret.
Лучше:
PROD_WEBHOOK_SECRET
STAGING_WEBHOOK_SECRETи разные endpoints.
Webhook URL тоже лучше разделять
Например:
https://example.ru/api/webhooks/providerи:
https://staging.example.ru/api/webhooks/providerНе один endpoint с:
?environment=test.Logging
Для каждого event полезно иметь correlation:
provider
event_id
event_type
object_id
received_at
processing_statusНо не обязательно логировать весь payload.
Особенно осторожно с webhook финансовых систем
Payload может содержать:
email;
имена;
billing data;
metadata.Full-body logging увеличивает privacy risk.
Хороший лог
webhook_received
provider=stripe
event_id=evt_123
type=payment_intent.succeededНе:
body={огромный JSON со всеми данными клиента}.Audit storage и application logs — разные вещи
Если raw payload нужен для аудита, хранить его лучше:
контролируемо;
с retention;
с ограниченным доступом.Не случайно в:
stdout container logs.Сколько хранить event IDs
Если provider может redeliver событие:
несколько дней,dedupe запись должна жить как минимум дольше этого окна.
Но для финансовых операций можно хранить business identifiers значительно дольше.
Не удаляйте dedupe history раньше retry window
Иначе:
event пришёл;
обработан;
dedupe record удалён;
provider делает поздний retry;
event выполняется снова.Business idempotency может жить вообще постоянно
Например:
provider_payment_idявляется частью payment row.
UNIQUE остаётся навсегда.
И это сильнее временной inbox dedupe.
Replay attack и legitimate retry нужно различать
Replay attacker:
переиспользует старую delivery.Legitimate provider retry:
повторно доставляет тот же event
по правилам provider.Система должна:
отклонить неподходящий старый signature timestamp,но при этом:
принять легитимно переподписанный retry
и дедуплицировать event ID.Поэтому нельзя просто говорить
Если event ID уже был — возвращаем 401.
Это не security failure.
Это нормальный duplicate.
Лучше:
verify signature
↓
event already known
↓
2xx
↓
no reprocessing.Что тестировать
Webhook без failure tests практически не протестирован.
Happy path:
valid request
→ 200
→ state updatedпроверяет самую простую ситуацию.
Production bugs живут в других.
Тест подписи
Отправляем правильный payload.
Меняем один символ.
Ожидаем:
signature invalid.Тест raw body
Проверяем, что middleware не меняет тело до signature verification.
Тест старого timestamp
Подпись математически правильная.
Но event слишком старый.
Ожидаем replay rejection, если protocol provider поддерживает такую схему.
Тест duplicate
Один и тот же:
event_idприходит два раза.
Ожидаем:
два 2xx;
один business effect.Тест параллельного duplicate
Два одинаковых webhook одновременно.
Оба успевают дойти до:
INSERT.Database UNIQUE должен оставить одну inbox row.
Тест crash после inbox commit
INSERT event
↓
COMMIT
↓
process killПосле restart:
worker должен обработать event.Тест crash после business effect
update payment
↓
crash
↓
processing status ещё не updatedWorker retry должен не создать второй effect.
То есть business operation тоже идемпотентна.
Тест out-of-order
Отправляем:
version 11затем:
version 10.Финальное состояние должно соответствовать:
11.Тест same timestamp
Два разных event имеют одинаковый:
created_at.System не должна считать один duplicate другого только по времени.
Тест missing predecessor
Отправляем:
invoice.paidдо:
subscription.created.Проверяем recovery/fetch/retry strategy.
Тест unknown event type
future.eventне должен:
ронять endpoint;
ломать queue.Тест webhook spike
Например:
10 000 eventsза короткое время.
Проверяем:
endpoint latency;
DB inserts;
queue backlog;
worker throughput.Тест DB outage
Webhook приходит.
Signature валидна.
Но inbox не может быть durable сохранён.
Endpoint не должен говорить:
200, всё хорошо.если событие реально потеряется.
Тест secret rotation
Переходное окно:
old secret valid
new secret valid.После окончания:
only new.Production checklist
Перед запуском webhook-интеграции стоит проверить:
- endpoint работает только по HTTPS;
- provider secret хранится вне source code;
- signature проверяется по raw request body;
- используется constant-time comparison там, где verification реализуется самостоятельно;
- если protocol поддерживает timestamp, проверяется допустимая свежесть;
- clock сервера синхронизирован;
- существует стабильный event/delivery ID;
(provider, event_id)защищён UNIQUE constraint;- duplicate event получает успешный HTTP response, но не повторный business effect;
- критичные business effects дополнительно идемпотентны;
- endpoint durable сохраняет event до
2xx; - тяжёлая обработка вынесена в worker;
- failed processing имеет retry и terminal failed state;
- out-of-order events предусмотрены архитектурой;
- timestamp не используется как единственная гарантия порядка;
- при наличии provider version/sequence она проверяется;
- для критичных состояний существует reconciliation с API provider;
- неизвестные additive event types не ломают весь endpoint;
- provider account/environment проверяется;
- raw payload и логи имеют разумный retention;
- secret можно безопасно ротировать;
- мониторятся signature failures, duplicates, backlog, failures и processing latency.
Как выглядит production-ready receiver
Концептуально:
POST /webhooks/provider
│
▼
Body size check
│
▼
Read raw body
│
▼
Verify signature
│
▼
Replay checks
│
▼
Parse + validate
│
▼
INSERT webhook inbox
UNIQUE(provider,event_id)
│
┌────┴────┐
│ │
duplicate new
│ │
└────┬────┘
▼
COMMIT
│
▼
2xx
│
▼
Worker
│
▼
Idempotent handler
│
▼
State/version checks
│
▼
PROCESSEDВ этой схеме каждый слой отвечает за отдельную проблему.
Signature
Отвечает:
Можно ли доверять происхождению и целостности запроса?
Timestamp / replay window
Отвечает:
Не пытается ли кто-то повторно использовать слишком старую подписанную delivery?
Event ID
Отвечает:
Видели ли мы уже это конкретное событие?
Business idempotency
Отвечает:
Даже если обработка повторится, создастся ли второй бизнес-эффект?
Version/sequence/state machine
Отвечает:
Не является ли событие старее уже обработанного состояния?
Inbox
Отвечает:
Не потеряем ли мы event после того, как сообщили provider об успешном приёме?
Worker retry
Отвечает:
Что делать, если наша внутренняя бизнес-обработка временно не удалась?
Reconciliation
Отвечает:
Как восстановить истину, если webhook пропущен, запоздал или пришёл в неожиданном порядке?
Именно поэтому одной «проверки подписи» недостаточно
Подписанный webhook всё ещё может быть:
дублем;
replay;
старым event;
out-of-order;
легитимным retry.Криптография не решает delivery semantics.
И одной дедупликации тоже недостаточно
UNIQUE event ID не спасёт, если:
два разных events
описывают один и тот же business effect.Нужна idempotency domain layer.
И очередь не решает порядок автоматически
Она может принять:
A;
Bи обработать:
B;
Aиз-за concurrency.
Нужна domain versioning/state machine.
И provider retries не заменяют нашу собственную надёжность
Webhook provider отвечает за:
доставку.Но после:
2xxвесь дальнейший recovery уже наша ответственность.
Пример: платёжный webhook целиком
Приходит:
{
"id": "evt_123",
"type": "payment.succeeded",
"created": 1791270000,
"data": {
"paymentId": "pay_1842",
"orderId": "ord_52",
"amount": 500000,
"currency": "RUB"
}
}Endpoint сначала:
1. проверяет signature;
2. проверяет replay timestamp;
3. валидирует envelope;
4. INSERT evt_123;
5. COMMIT;
6. возвращает 200.Worker получает:
evt_123.Находит:
pay_1842.Проверяет:
этот provider payment
действительно относится к ord_52?Затем:
expected amount = 500000?
currency = RUB?Дальше state transition:
PENDING
→
PAID.Database constraint гарантирует:
ord_52
не получит второй payment effect.Если event приходит снова:
evt_123 already exists
→ 200
→ no processing.Если приходит другое событие:
evt_999
payment.succeeded
для того же pay_1842,business uniqueness всё равно не позволяет создать второй платёж.
Если позднее приходит старое:
payment.processingstate machine не откатывает:
PAID
→ PROCESSING.Если возникает сомнение:
fetch pay_1842
from provider API.Получаем актуальную истину.
Именно так webhook становится частью надёжной integration architecture, а не триггером случайных side effects.
Webhook должен быть скучным
Это хороший признак.
Хороший endpoint делает очень мало:
authenticate;
validate;
persist;
acknowledge.В нём не должно быть:
генерации PDF;
SMTP;
CRM sync;
сложной аналитики;
длинных transactions.Этим занимаются worker и domain services.
Чем меньше делает HTTP handler, тем легче гарантировать его надёжность
И тем меньше вероятность:
provider timeout
→ retry
→ duplicate.Вместо вывода
Webhook часто воспринимают как удобный callback:
Когда что-нибудь произойдёт, пришлите нам POST.
Но production webhook — это значительно более серьёзный контракт.
Он работает между двумя независимыми системами.
А значит между ними существует:
ненадёжная сеть;
разные retry policies;
разные часы;
разные версии API;
разная скорость обработки.Поэтому надёжный webhook нужно проектировать с самого начала с несколькими предположениями:
Любой публичный запрос недоверенный, пока не проверена подпись.
Корректно подписанный запрос ещё может быть replay.
Одно событие может быть доставлено повторно.
Разные события одного объекта могут прийти не по порядку.
Два worker могут обрабатывать события параллельно.
HTTP 2xx означает приём delivery, а не обязательно завершение всей бизнес-операции.
Webhook вообще может быть пропущен, поэтому для критичных интеграций нужен способ reconciliation.
В результате хорошая архитектура выглядит не так:
POST
↓
if payment.succeeded
↓
give accessа так:
POST
↓
signature
↓
replay protection
↓
deduplication
↓
durable inbox
↓
2xx
↓
worker
↓
idempotency
↓
ordering/state protection
↓
reconciliationКаждый слой здесь нужен не ради архитектурной красоты.
Он закрывает конкретный класс production-сбоев.
Именно поэтому хороший webhook receiver определяется не тем, насколько быстро разработчик научился принимать JSON.
А тем, что произойдёт, когда:
один и тот же JSON придёт дважды;старое событие придёт после нового;worker погибнет после изменения данных;provider повторит delivery;кто-то отправит поддельный POST;или настоящий подписанный request попытаются воспроизвести повторно.Если после каждого такого сценария бизнес-состояние остаётся корректным, webhook действительно готов к production.