CRM создала сделку. Ответ до отправителя не дошёл: timeout. Отправитель честно повторил webhook. Вторая сделка тоже создалась.

Ничего экзотического. На happy path всё было красиво. Проблема появилась в тот момент, когда транспорт не смог доказать отправителю, что side effect уже произошёл. Idempotency key нужен здесь как стабильная идентичность бизнес-операции, а не как случайная строка для очередного HTTP-запроса.

Если workflow меняет внешний мир, повтор доставки нужно считать штатным сценарием. Idempotency — не оптимизация. Это условие, при котором один логический event не превращается в две бизнес-операции.

Повтор входного event и повтор side effect — разные вещи

Webhook provider может доставить одно событие больше одного раза. Stripe прямо рекомендует обрабатывать duplicate events и не полагаться на единственную доставку.

Получатель при этом может безопасно принять event повторно. Опасность начинается дальше: второй запуск workflow снова вызывает `createDeal`, `sendPayment` или `issueRefund`.

Я бы проектировал idempotency на границе бизнес-операции, а не только на HTTP handler. Handler может повториться. Side effect — нет.

Проверяемые источники: Stripe — Receive Stripe events in your webhook endpoint · Stripe — Idempotent requests

Нужны event ID и operation key

Event ID отвечает на вопрос: видели ли мы это входное событие. Operation key отвечает на другой: выполняли ли мы конкретную бизнес-команду для этого объекта.

Например, один event может породить создание задачи и отправку письма. Это две операции с разными ключами: `event-184:create-task` и `event-184:send-email`. Повтор workflow сможет вернуть прежний результат каждой команды отдельно.

Stripe использует idempotency keys для POST-запросов и возвращает сохранённый результат повторного запроса с тем же ключом. Сам принцип хорошо переносится на внутренние API.

Проверяемые источники: Stripe — Idempotent requests

MEDIA FRAME · PLACEHOLDERИллюстрация будет добавлена позже
Idempotency key для webhook: как не создать дубль сделки при повторной доставке · временная заглушка

Проверка «SELECT, потом INSERT» ломается при двух workers

Самая популярная реализация: спросить базу, есть ли ключ, и если нет — выполнить операцию. Два workers делают SELECT почти одновременно. Оба видят пусто. Оба создают сделку.

Dedup должен опираться на atomic guarantee: unique constraint, transactional insert, lock или эквивалентный механизм. Сначала один worker получает право на operation key. Остальные видят существующую запись и не выполняют side effect второй раз.

Распределённая система не уважает логическую последовательность в коде. Иногда это даже полезно помнить.

Проверяемые источники: Stripe — Idempotent requests

Храните не только ключ, но и результат операции

Допустим, первый вызов создал CRM deal `D-9021`, затем процесс упал до следующего шага. При повторе нам недостаточно знать «уже было». Нужен сохранённый outcome, чтобы продолжить workflow с тем же deal ID.

Минимальная запись может содержать operation key, status, external object ID, response digest, started_at и completed_at. Для долгой операции промежуточный status помогает отличить завершённый вызов от застрявшего.

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

Проверяемые источники: Stripe — Idempotent requests · Stripe — Receive Stripe events in your webhook endpoint

Контракт можно сделать очень маленьким

Мне достаточно, чтобы caller прислал стабильный ключ операции, а executor гарантировал один логический outcome. Например:

`{ event_id: "evt_184", operation: "create_crm_deal", idempotency_key: "evt_184:create_crm_deal" }`. Сервер атомарно регистрирует ключ, выполняет команду и сохраняет `deal_id`. Повтор с тем же ключом возвращает тот же `deal_id`.

AI в этой схеме может выбрать операцию и параметры. Idempotency key лучше строить детерминированно из process state, а не просить модель придумать уникальную строку.

Проверяемые источники: Stripe — Idempotent requests

Retry разрешён только там, где повтор действительно безопасен

AWS описывает retry with backoff как pattern для transient failures. Но backoff не превращает неидемпотентную команду в безопасную. Он лишь делает повтор менее агрессивным.

Перед автоматическим retry я бы ответил на два вопроса: ошибка действительно временная? операция либо read-only, либо защищена idempotency? Если нет, запрос уходит в отдельный recovery path.

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

Проверяемые источники: AWS — Retry with backoff pattern · Stripe — Receive Stripe events in your webhook endpoint

AI не должен генерировать ключ повторяемости

Idempotency key — часть процессного state. Его лучше строить из стабильного event ID, operation name и бизнес-объекта. Просить модель «придумать уникальный ключ» — прямой путь к уникальному ключу на каждый retry, то есть к отсутствию idempotency.

Если один логический action можно запустить из разных каналов, нужен общий operation identity выше конкретного webhook. Например, повторный email и webhook могут относиться к одному заказу и одной команде возврата.

Чем ближе ключ к семантике операции, тем проще расследовать дубль. UUID сам по себе доказывает только то, что мы умеем генерировать UUID.

Проверяемые источники: Stripe — Idempotent requests · OpenTelemetry — OpenTelemetry Semantic Conventions

Короткие вопросы перед production

Webhook может прийти несколько раз?

Да. Для многих webhook-систем повторная доставка — штатное поведение при retries и проблемах подтверждения. Обработчик должен ожидать duplicate events.

Чем event ID отличается от idempotency key?

Event ID идентифицирует входное событие. Idempotency key идентифицирует конкретную операцию, которую это событие вызывает. Один event может породить несколько idempotent operations.

Можно ли просто сохранить обработанные webhook ID?

Для простого one-event-one-action иногда достаточно. Но при многошаговом workflow полезно хранить отдельные operation keys и результаты side effects, чтобы безопасно продолжать после частичного сбоя.

Проверка у меня одна: возьмите production webhook и отправьте его дважды параллельно. Если появился один deal, один платёж или один refund — контракт похож на рабочий. Если два — AI здесь вообще ни при чём. Сначала чините idempotency.

Смежные контуры, которые стоит прогнать отдельно для «idempotency key»: Как связать CRM, Telegram и AI в один бизнес-процесс · Как контролировать сделки, созданные AI автоматически · Как перенести AI-автоматизацию с прототипа в рабочую систему.