Idempotency и ретраи в webhooks: как сделать синхронизацию…
  • Интеграции
  • Автор: Команда CrmAI
  • Опубликовано:
Idempotency и webhooks: устойчивая интеграция CRM без дублей

Четверг, пять вечера. Менеджер по продажам звонит в IT-отдел: «Почему у меня в CRM три одинаковых заказа от одного клиента? Он же один раз купил!». Начинается расследование. Оказывается, вчера ночью был кратковременный сбой сети, и webhook от интернет-магазина отправился трижды. А система его честно обработала три раза — создала три заказа, три задачи для склада, отправила клиенту три SMS с подтверждением.

Знакомая история? Если вы работаете с интеграциями — наверняка что-то похожее случалось. И это ещё мягкий сценарий. Бывает хуже: тройное списание денег, три отправки товара на один адрес, или три уведомления клиенту о записи на одно и то же время. Клиенты недовольны, бухгалтерия в панике, репутация под угрозой.

Корень проблемы — в одном простом факте: интернет ненадёжен. Запросы теряются, таймауты срабатывают, серверы падают. И когда отправитель не получает ответ на свой webhook, он делает то, что кажется логичным — отправляет запрос ещё раз. А потом ещё. А ваша система каждый раз думает, что это новый запрос, и честно его обрабатывает.

«Мы полгода жили с этой проблемой — дубли в 1С, дубли в CRM, ручная чистка каждую неделю. Когда наконец внедрили нормальную идемпотентность, сэкономили минимум 10 часов работы бухгалтера в месяц. И нервов — не сосчитать.»

Технический директор
Интернет-магазин электроники, Астана
Цитата

Идемпотентность: слово страшное, идея простая

Если вы не математик и не программист, слово «идемпотентность» звучит как заклинание. На самом деле идея элементарная: операция идемпотентна, если её можно выполнить несколько раз с одним и тем же результатом.

Простой пример из жизни. Вы заходите в лифт и нажимаете кнопку «5». Лифт едет на пятый этаж. Если от нетерпения вы нажмёте эту кнопку ещё пять раз — лифт всё равно поедет на пятый этаж, а не на двадцать пятый. Нажатие кнопки лифта — идемпотентная операция.

А вот добавление товара в корзину — не идемпотентная. Нажали «Добавить» три раза — получили три товара. Это логично для пользователя (может, он хочет три штуки), но катастрофа для автоматической синхронизации.

Идемпотентные vs неидемпотентные операции

Идемпотентные (безопасно повторять)
  • Установить статус заказа = «Оплачен»
  • Обновить email клиента на «ivan@example.kz»
  • Удалить товар из корзины по ID
  • Получить информацию о заказе (GET-запрос)
Неидемпотентные (опасно повторять)
  • Создать новый заказ
  • Списать деньги со счёта
  • Отправить SMS/email
  • Добавить запись в историю действий

Главное понимание: большинство webhook'ов, которые приходят в вашу CRM — это как раз неидемпотентные операции. Создание сделки, добавление комментария, регистрация платежа. И если вы не предусмотрели защиту — рано или поздно получите дубли.

Но хорошая новость в том, что почти любую операцию можно сделать идемпотентной. Нужно просто добавить проверку: «А я это уже делал?»

Почему webhooks — это зона повышенного риска

Webhook — это когда одна система сама стучится к вам с новостями, вместо того чтобы вы её постоянно спрашивали «ну что, есть новости?». Звучит удобно. На практике — источник головной боли.

Представьте: интернет-магазин отправляет webhook в вашу CRM с информацией о новом заказе. Что может пойти не так?

Сценарии сбоев при отправке webhook

1
Таймаут на стороне получателя

Ваш сервер получил запрос, начал обрабатывать, но ответ отправил через 35 секунд. Отправитель ждал 30 — решил, что запрос потерялся, отправил повторно.

2
Сетевой сбой «посередине»

Вы обработали запрос и отправили «200 OK», но ответ потерялся по дороге. Отправитель не получил подтверждение — отправил снова.

3
Временная недоступность

Ваш сервер был недоступен 5 минут (деплой, перезагрузка, DDoS). Отправитель накопил очередь и отправил всё разом — возможно, с повторами.

4
Баг в логике повторов отправителя

Система-отправитель ошибочно считает, что запрос не доставлен. Или кто-то нажал кнопку «Переотправить» вручную.

Заметьте: в трёх из четырёх сценариев вы всё сделали правильно. Запрос обработали, данные сохранили. Но отправитель об этом не узнал — и отправил снова. Это не баг, это особенность работы распределённых систем.

Проблема усугубляется тем, что многие системы (Kaspi, Ozon, Wildberries, платёжные шлюзы) настроены на агрессивные повторы. Не получили ответ за 10 секунд? Отправим ещё раз. И ещё. И ещё — до победного или до исчерпания лимита попыток.

В итоге вместо одного webhook'а вы можете получить три, пять, а иногда и десять одинаковых. И если ваша система не готова — здравствуй, хаос.

Три способа защитить систему от дублей

Мы перепробовали кучу подходов. Часть выглядела красиво в документации, но на практике не взлетала. Вот три метода, которые реально работают и которые мы внедряем клиентам.

Способ 1: Idempotency Key (ключ идемпотентности)

Самый надёжный и универсальный подход. Суть простая: отправитель вместе с данными передаёт уникальный идентификатор запроса. Получатель сохраняет этот идентификатор и проверяет при каждом новом запросе: «Я уже видел такой ключ?»

Пример webhook с Idempotency Key
{
  "idempotency_key": "ord_2024_12345_v1",
  "event": "order.created",
  "data": {
    "order_id": "12345",
    "customer": "Иван Петров",
    "amount": 45000,
    "currency": "KZT"
  },
  "timestamp": "2025-01-05T14:30:00+06:00"
}

Когда приходит такой запрос, ваша система:

  1. Смотрит в базу: есть ли запись с ключом ord_2024_12345_v1?
  2. Если нет — обрабатывает запрос, сохраняет ключ в базу, возвращает результат.
  3. Если да — не обрабатывает повторно, а возвращает сохранённый результат предыдущей обработки.

Красота этого подхода в том, что он работает даже если запросы приходят параллельно. Главное — использовать транзакции в базе данных или атомарные операции типа «вставить если не существует» (INSERT ... ON CONFLICT DO NOTHING в PostgreSQL).

Способ 2: Проверка по бизнес-идентификатору

Не все системы отправляют idempotency_key — это пока не стандарт. Но почти все отправляют бизнес-идентификатор: номер заказа, ID платежа, номер документа. Используйте его!

Перед созданием сущности проверяйте: «Есть ли у меня уже заказ с таким external_id?» Если есть — это повтор, не создаём новый.

Алгоритм обработки webhook с проверкой по external_id

1. Получаем webhook
order_id = "12345"
2. Ищем в базе
SELECT * FROM orders WHERE external_id = '12345'
Не найдено
→ Создаём заказ
или
Найдено
→ Возвращаем существующий

Этот способ проще в реализации, но есть нюанс: он работает только для операций создания. Для обновлений (например, «изменить статус заказа») нужен более тонкий подход — версионирование или timestamp.

Способ 3: Хэширование содержимого

Иногда ни idempotency_key, ни внятного бизнес-идентификатора нет. Тогда можно вычислять хэш от содержимого запроса и использовать его как ключ дедупликации.

Берём тело запроса, считаем SHA-256, сохраняем. Если приходит запрос с таким же хэшем — это дубль.

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

Idempotency и webhooks: устойчивая интеграция CRM

Если вы отправляете webhooks: как делать ретраи правильно

До сих пор мы говорили о защите получателя. Но если вы сами отправляете webhooks (например, из CRM в 1С или из интернет-магазина в службу доставки) — важно делать это грамотно, чтобы не создавать проблем партнёрам.

Правило 1: Экспоненциальный backoff

Не бомбардируйте получателя повторами каждую секунду. Если первая попытка не удалась — подождите 5 секунд. Вторая не удалась — 15 секунд. Третья — 45 секунд. И так далее, увеличивая интервал.

Попытка Интервал ожидания Суммарное время Комментарий
1 Сразу 0 сек Первая попытка
2 5 сек 5 сек Возможно, кратковременный сбой
3 15 сек 20 сек Сервер перегружен?
4 45 сек ~1 мин Серьёзная проблема
5 2 мин ~3 мин Возможно, деплой у получателя
6-10 5-15 мин до 1 часа Крупный сбой, алерт администратору

Зачем это нужно? Если у получателя проблемы — ваши повторы каждую секунду только усугубят ситуацию. А если проблема временная (перезагрузка, пик нагрузки) — экспоненциальный backoff даст системе время восстановиться.

Правило 2: Добавляйте jitter (случайный разброс)

Представьте: у вас в очереди 1000 недоставленных webhook'ов. Все они ждут 5 секунд и отправляются одновременно. Сервер получателя, который только что восстановился, снова падает под нагрузкой. Классика.

Решение — добавлять случайный разброс к интервалу. Не ровно 5 секунд, а от 3 до 7. Не ровно 15, а от 10 до 20. Так запросы распределяются во времени и не создают пиковой нагрузки.

Правило 3: Всегда отправляйте idempotency_key

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

Антипаттерн: бесконечные ретраи

Некоторые системы настроены на ретраи «до победного». Это плохо. Если webhook не доставился за 10 попыток в течение суток — скорее всего, проблема не временная. Сохраните событие в dead letter queue, отправьте алерт администратору и прекратите попытки. Иначе вы будете бесконечно долбить в закрытую дверь.

Практика: как мы реализуем это в проектах

Хватит теории — покажу, как это работает в реальных проектах. Вот архитектура, которую мы используем почти везде, где есть webhooks.

Шаг 1: Входная точка (endpoint)

Все webhook'и приходят на один endpoint. Первое, что мы делаем — валидируем подпись (если отправитель её поддерживает) и сохраняем сырой запрос в таблицу incoming_webhooks. Сразу отвечаем «202 Accepted» — запрос принят, обработаем позже.

Почему 202, а не 200? Потому что мы ещё не обработали — только приняли. Это честно и соответствует HTTP-семантике. Но главное — мы отвечаем быстро (меньше 100мс), и отправитель не уходит в таймаут.

Шаг 2: Проверка на дубль

Перед сохранением проверяем: есть ли уже запись с таким idempotency_key (или external_id, если ключа нет)? Если есть — не создаём новую запись, просто возвращаем 202. Дубль отсечён на самом раннем этапе.

Шаг 3: Асинхронная обработка

Фоновый процесс (worker) забирает новые записи из incoming_webhooks и обрабатывает их: создаёт сделки в CRM, обновляет статусы, отправляет уведомления. Если обработка упала с ошибкой — запись остаётся в очереди для повторной попытки.

Шаг 4: Идемпотентность бизнес-операций

Даже если дубль проскочил на первом шаге (например, idempotency_key не было), бизнес-логика тоже защищена. Перед созданием заказа проверяем: «Есть ли заказ с external_id = X?». Перед созданием контакта: «Есть ли контакт с таким email/телефоном?».

Двойная защита — на уровне входной точки и на уровне бизнес-логики — практически исключает дубли.

Архитектура обработки webhook с защитой от дублей

Отправитель
Kaspi, 1C, сайт...
Входная точка
Валидация + дедупликация
incoming_webhooks
Worker
Бизнес-логика + повторы

Особенности интеграций в Казахстане

Поработав с казахстанскими компаниями, мы набили несколько специфических шишек. Делюсь.

Kaspi и его webhook'и

Kaspi Магазин — одна из самых популярных площадок для e-commerce в Казахстане. Их API и webhook'и работают стабильно, но есть нюансы. Webhook'и приходят с order_id, который можно использовать как ключ идемпотентности. Но при смене статуса заказа приходит новый webhook с тем же order_id — это не дубль, это обновление. Важно различать.

Подробнее об интеграции с Kaspi мы писали в статье про маппинг статусов и SLA.

1С и проблема «долгих» операций

Интеграция с 1С — отдельная песня. 1С любит отвечать медленно, особенно если в базе много данных или сервер не очень мощный. Запрос может обрабатываться 30-60 секунд. Если ваш webhook-отправитель настроен на таймаут 10 секунд — он решит, что запрос потерялся, и отправит снова. А 1С создаст два документа.

Решение — асинхронная обработка на стороне 1С или промежуточный буфер. Мы обычно используем второй вариант: webhook сохраняется в быстрое хранилище, а 1С забирает данные оттуда в своём темпе.

О синхронизации товаров и цен с 1С без «разъезда» справочников мы расскажем в следующей статье этого цикла.

Платёжные системы и критичность дедупликации

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

Платёжные шлюзы (Kaspi, Halyk, ForteBank) обычно отправляют payment_id или transaction_id. Используйте их как ключ идемпотентности и храните историю обработанных транзакций минимум год (для возможных сверок и расследований).

Как понять, что дедупликация работает

Настроили защиту — отлично. Но как убедиться, что она реально работает? И как узнать, если что-то пошло не так?

Метрики, которые стоит отслеживать

  • Количество отклонённых дублей — сколько запросов отсеяно как повторные. Если это число стабильно около 0 — возможно, отправители не используют ретраи (или у вас очень надёжная сеть). Если внезапно выросло в 10 раз — что-то сломалось, идёт массовая переотправка.
  • Время обработки webhook — сколько времени от получения до ответа. Если больше 5 секунд — риск получить повторы от нетерпеливых отправителей.
  • Размер очереди на обработку — сколько webhook'ов ждут своей очереди. Если растёт — worker'ы не справляются, нужно масштабировать.
  • Ошибки обработки — сколько webhook'ов упало с ошибкой. Если выросло — проблема в бизнес-логике или в данных от отправителя.

Алерты, которые спасут от хаоса

Настройте уведомления на:

  • Всплеск дублей — больше 10% запросов отклонено как дубли за последний час. Что-то идёт не так на стороне отправителя.
  • Рост очереди — больше 1000 необработанных webhook'ов. Система не справляется.
  • Критические ошибки — webhook'и связанные с платежами падают с ошибкой.

О построении системы мониторинга и дашбордов мы подробно писали в статье про наблюдаемость LLM-систем — принципы те же, только контекст другой.

Нужна надёжная интеграция?

Поможем спроектировать и реализовать интеграцию CRM с вашими системами — с правильной дедупликацией, ретраями и мониторингом. Без дублей, без потерь данных, без головной боли.

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

Чек-лист: webhook-интеграция без дублей

Используйте этот список при проектировании новой интеграции или аудите существующей.

Если вы получаете webhooks

  • Отвечаете быстро (< 5 сек), даже если обработка занимает больше времени
  • Проверяете idempotency_key или external_id перед обработкой
  • Сохраняете сырые webhook'и для отладки и аудита
  • Бизнес-логика также защищена от дублей (проверка по external_id)
  • Настроен мониторинг: количество дублей, ошибки, время обработки

Если вы отправляете webhooks

  • Генерируете уникальный idempotency_key для каждого события
  • Используете экспоненциальный backoff с jitter для ретраев
  • Ограничиваете количество попыток (не бесконечные ретраи)
  • Недоставленные webhook'и попадают в dead letter queue
  • Есть алерты на массовые неудачи доставки

Заключение: инвестиция в спокойный сон

Идемпотентность и правильная работа с ретраями — это не «продвинутая техника для senior-разработчиков». Это базовая гигиена для любой интеграции, которая работает через ненадёжную среду (а интернет — это ненадёжная среда по определению).

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

  • Нет дублей в CRM, 1С и других системах
  • Нет ручной работы по «чистке» данных
  • Нет паники при сбоях — система восстанавливается сама
  • Нет разгневанных клиентов, получивших три SMS или три списания

И главное — спокойный сон. Вы знаете, что даже если ночью что-то упадёт и начнёт массово ретраить webhook'и — ваша система справится. Потому что вы её к этому подготовили.

Начните с малого: добавьте проверку external_id перед созданием сущностей. Потом добавьте таблицу для idempotency_key. Потом — мониторинг. Шаг за шагом ваши интеграции станут надёжными, а вы — свободнее от рутинной борьбы с дублями.