Интеграции и API

Как проектировать webhook: подпись, replay protection, дубли и порядок событий

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_SECRET

Provider берёт 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
→ защищает от слишком старого replay
event 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.updated

Receiver делает:

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
→ 200

Provider счастлив.

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=abcd

URL может попасть:

в 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

Есть две распространённые модели.

Первая:

snapshot

Event содержит большой снимок объекта.

Вторая:

thin event

Event в основном сообщает:

что и с чем произошло,

а актуальные данные 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 ещё не updated

Worker 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.processing

state 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.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →