Back to blog
Adal journal

Почему вебхуки нужно обрабатывать идемпотентно

Вебхук — это не сообщение, которое гарантированно придёт ровно один раз.

Published
Почему вебхуки нужно обрабатывать идемпотентно

Даже если отправляющая система работает корректно, один и тот же запрос может быть доставлен повторно: из-за сетевого сбоя, таймаута, повторной попытки со стороны отправителя, перезапуска обработчика или ситуации, когда ваш сервис успел выполнить действие, но не успел вернуть успешный HTTP-ответ.

Для системы, принимающей вебхуки, это означает простой, но важный вопрос:

Что произойдёт, если один и тот же вебхук будет обработан дважды?

Иногда ничего критичного. Например, если вебхук только обновляет статус объекта на уже известное значение.

Но очень часто повторная обработка приводит к реальным последствиям:

  • клиенту дважды отправляется письмо или уведомление;

  • заказ создаётся повторно;

  • баланс пополняется дважды;

  • в CRM появляется несколько одинаковых сущностей;

  • внешний API получает несколько одинаковых запросов;

  • один и тот же webhook-trigger запускает несколько фоновых задач.

Поэтому обработчик вебхуков стоит проектировать так, чтобы повторная доставка одного и того же события не меняла результат.

Это и называется идемпотентностью.

Что такое идемпотентность

Идемпотентная операция может быть выполнена несколько раз, но итоговое состояние останется таким же, как после первого выполнения.

Например, запрос:

PUT /users/42/status { "status": "active" }

можно выполнить дважды — пользователь всё равно останется активным.

Но запрос:

POST /payments { "amount": 100 }

может создать две разные операции списания, если система не умеет распознавать повтор.

Для вебхуков это особенно важно: получатель не всегда может понять, является ли входящий запрос новым событием или повторной доставкой уже обработанного.

Почему нельзя определять дубликаты по содержимому запроса

На первый взгляд может показаться, что достаточно сравнивать тело запроса.

Например, сохранить JSON первого вебхука, а затем при следующем запросе проверить: не приходил ли такой же payload раньше.

На практике это ненадёжно.

Два независимых события могут иметь одинаковое содержимое:

{ "event": "invoice.created", "amount": 1000, "currency": "USD" }

Это могут быть два разных счёта на одинаковую сумму. Или два одинаковых сообщения от разных пользователей. Или два отдельных события, произошедших в разное время, но имеющих одинаковый набор полей.

Попытка сравнивать все поля запроса тоже быстро становится сложной:

  • какие поля учитывать;

  • какие игнорировать;

  • считать ли различием порядок ключей в JSON;

  • учитывать ли заголовки;

  • учитывать ли query-параметры;

  • как обрабатывать бинарные тела;

  • что делать с полями времени, случайными идентификаторами и подписями.

Вычисление хеша от body не делает подход принципиально лучше. Хеш помогает компактно хранить значение и быстро сравнивать его, но не отвечает на главный вопрос: являются ли два одинаковых payload одним и тем же событием или двумя разными событиями.

Кроме того, хеш всегда остаётся вероятностным идентификатором. Риск коллизии может быть крайне мал, но он не равен нулю.

Для чего нужен idempotency key

Надёжный способ отличить повторную доставку от нового события — использовать отдельный идентификатор, который не зависит от содержимого запроса.

Такой идентификатор обычно называют idempotency key.

Отправитель создаёт уникальный ключ для конкретной логической операции и передаёт его вместе с запросом. Получатель сохраняет этот ключ в своей базе данных и перед выполнением действия проверяет, был ли он уже обработан.

Упрощённо это выглядит так:

1. Получить вебхук. 2. Прочитать idempotency key из заголовка. 3. Попытаться сохранить ключ как обработанный. 4. Если ключ уже существует — не выполнять действие повторно. 5. Если ключ новый — выполнить действие и зафиксировать результат.

Например:

POST /webhooks/payment X-Idempotency-Key: 01989d55-20df-7a72-87f9-6e50f3d78315 Content-Type: application/json

На стороне получателя это может выглядеть примерно так:

INSERT INTO processed_webhooks (idempotency_key, processed_at) VALUES (:idempotency_key, NOW()) ON CONFLICT (idempotency_key) DO NOTHING;

Если запись успешно создана, обработчик может продолжить работу.

Если ключ уже существует, значит этот запрос уже был обработан ранее. В таком случае сервис может вернуть успешный ответ без повторного создания заказа, списания средств или отправки уведомления.

Важно, чтобы проверка и резервирование ключа выполнялись атомарно. Простая последовательность вида «сначала проверить, потом создать запись» может дать сбой при параллельной обработке двух одинаковых запросов.

Idempotency key — это не просто UUID в заголовке

Сам по себе случайный идентификатор не делает систему идемпотентной.

Идемпотентность появляется только тогда, когда получатель:

  1. сохраняет ключ;

  2. гарантирует уникальность ключа на уровне базы данных;

  3. не выполняет побочные действия повторно;

  4. понимает, какой результат вернуть при повторном запросе.

Например, если первый запрос уже создал заказ, повторный запрос с тем же ключом не должен создавать второй заказ. Он может вернуть ранее созданный идентификатор заказа или просто ответить 200 OK, в зависимости от логики системы.

Также важно определить срок хранения ключей. Если вебхуки могут повторяться в течение нескольких дней, удалять ключи через несколько минут будет недостаточно. Срок зависит от вашего сценария, политики повторных доставок и того, насколько критичны повторные операции.

Что делает Adal

Не все источники вебхуков передают собственный idempotency key. Некоторые сервисы присылают идентификатор события, некоторые — нет. Иногда идентификатор есть только внутри payload, иногда он вообще отсутствует.

Поэтому в Adal можно включить добавление idempotency key при пересылке вебхуков.

Настройка включается отдельно для каждого destination. Это важно, потому что разные получатели могут иметь разные требования:

  • один destination может требовать полностью неизменённый исходный запрос;

  • другой может использовать idempotency key для защиты от повторной обработки;

  • третий может быть вашим внутренним сервисом, где дополнительный заголовок становится частью стандартного контракта доставки.

Когда настройка включена, Adal добавляет в пересылаемый запрос заголовок:

X-Adal-Idempotency: <unique-key>

Получатель может использовать его как ключ идемпотентности:

POST /incoming-webhook X-Adal-Idempotency: 01989d55-20df-7a72-87f9-6e50f3d78315

Этот ключ относится к конкретному request в Adal.

При автоматических повторных попытках доставки одного и того же request ключ остаётся прежним. То же относится к ручной повторной доставке: если вы повторяете уже существующую доставку, получатель увидит тот же X-Adal-Idempotency и сможет безопасно распознать повтор.

Но действие Replay работает иначе.

Replay означает осмысленное создание нового request на основе ранее полученного вебхука. Хотя тело, заголовки и другие данные могут быть похожи на исходный запрос, это уже новая операция в рамках Adal. Поэтому replay получает новый X-Adal-Idempotency.

Это позволяет различать два сценария:

Автоматический retry исходного request → тот же idempotency key Ручной retry исходной доставки → тот же idempotency key Replay старого webhook request → новый idempotency key

Такое поведение помогает не смешивать техническое повторение одной доставки с намеренным повторным запуском ранее полученного вебхука.

Практический пример

Представим сервис, который получает webhook о новом заказе и создаёт запись в CRM.

Без идемпотентности сценарий может выглядеть так:

1. Adal доставляет webhook. 2. Ваш сервис создаёт клиента и заказ в CRM. 3. Ответ 200 OK не успевает дойти из-за сетевого сбоя. 4. Adal выполняет повторную попытку. 5. Ваш сервис создаёт второго клиента и второй заказ.

С X-Adal-Idempotency обработка становится безопаснее:

1. Adal доставляет webhook с X-Adal-Idempotency. 2. Ваш сервис сохраняет ключ в таблицу обработанных запросов. 3. Ваш сервис создаёт клиента и заказ в CRM. 4. Ответ 200 OK не успевает дойти. 5. Adal повторяет доставку с тем же X-Adal-Idempotency. 6. Ваш сервис видит, что ключ уже существует. 7. Повторное создание клиента и заказа не выполняется.

В результате доставка может быть повторена столько раз, сколько потребуется для подтверждения успешного получения, но бизнес-действие будет выполнено только один раз.

Вывод

Повторные вебхуки — не редкая аномалия и не обязательно признак ошибки. Это нормальное следствие работы распределённых систем, сетей и механизмов retry.

Поэтому обработчик вебхуков не должен полагаться на то, что каждый запрос придёт ровно один раз. Надёжнее исходить из обратного: один и тот же webhook может быть доставлен повторно, и система должна быть к этому готова.

Не стоит определять дубликаты по body запроса, полям JSON или хешу содержимого. У одинаковых payload может быть разный смысл, а у одного и того же события могут быть разные технические доставки.

Для этого и существуют idempotency keys: явные идентификаторы, которые позволяют получателю безопасно отличить повтор одной операции от нового события.

В Adal можно включить добавление X-Adal-Idempotency для каждого destination отдельно. Это позволяет сохранить исходные запросы неизменёнными там, где это требуется, и добавить предсказуемую защиту от повторной обработки там, где она действительно нужна.

More from the Adal blog

Explore product updates, webhook guides, and engineering notes.

Back to blog