Четверг, пять вечера. Менеджер по продажам звонит в IT-отдел: «Почему у меня в CRM три одинаковых заказа от одного клиента? Он же один раз купил!». Начинается расследование. Оказывается, вчера ночью был кратковременный сбой сети, и webhook от интернет-магазина отправился трижды. А система его честно обработала три раза — создала три заказа, три задачи для склада, отправила клиенту три SMS с подтверждением.
Знакомая история? Если вы работаете с интеграциями — наверняка что-то похожее случалось. И это ещё мягкий сценарий. Бывает хуже: тройное списание денег, три отправки товара на один адрес, или три уведомления клиенту о записи на одно и то же время. Клиенты недовольны, бухгалтерия в панике, репутация под угрозой.
Корень проблемы — в одном простом факте: интернет ненадёжен. Запросы теряются, таймауты срабатывают, серверы падают. И когда отправитель не получает ответ на свой webhook, он делает то, что кажется логичным — отправляет запрос ещё раз. А потом ещё. А ваша система каждый раз думает, что это новый запрос, и честно его обрабатывает.
«Мы полгода жили с этой проблемой — дубли в 1С, дубли в CRM, ручная чистка каждую неделю. Когда наконец внедрили нормальную идемпотентность, сэкономили минимум 10 часов работы бухгалтера в месяц. И нервов — не сосчитать.»
Если вы не математик и не программист, слово «идемпотентность» звучит как заклинание. На самом деле идея элементарная: операция идемпотентна, если её можно выполнить несколько раз с одним и тем же результатом.
Простой пример из жизни. Вы заходите в лифт и нажимаете кнопку «5». Лифт едет на пятый этаж. Если от нетерпения вы нажмёте эту кнопку ещё пять раз — лифт всё равно поедет на пятый этаж, а не на двадцать пятый. Нажатие кнопки лифта — идемпотентная операция.
А вот добавление товара в корзину — не идемпотентная. Нажали «Добавить» три раза — получили три товара. Это логично для пользователя (может, он хочет три штуки), но катастрофа для автоматической синхронизации.
Главное понимание: большинство webhook'ов, которые приходят в вашу CRM — это как раз неидемпотентные операции. Создание сделки, добавление комментария, регистрация платежа. И если вы не предусмотрели защиту — рано или поздно получите дубли.
Но хорошая новость в том, что почти любую операцию можно сделать идемпотентной. Нужно просто добавить проверку: «А я это уже делал?»
Webhook — это когда одна система сама стучится к вам с новостями, вместо того чтобы вы её постоянно спрашивали «ну что, есть новости?». Звучит удобно. На практике — источник головной боли.
Представьте: интернет-магазин отправляет webhook в вашу CRM с информацией о новом заказе. Что может пойти не так?
Ваш сервер получил запрос, начал обрабатывать, но ответ отправил через 35 секунд. Отправитель ждал 30 — решил, что запрос потерялся, отправил повторно.
Вы обработали запрос и отправили «200 OK», но ответ потерялся по дороге. Отправитель не получил подтверждение — отправил снова.
Ваш сервер был недоступен 5 минут (деплой, перезагрузка, DDoS). Отправитель накопил очередь и отправил всё разом — возможно, с повторами.
Система-отправитель ошибочно считает, что запрос не доставлен. Или кто-то нажал кнопку «Переотправить» вручную.
Заметьте: в трёх из четырёх сценариев вы всё сделали правильно. Запрос обработали, данные сохранили. Но отправитель об этом не узнал — и отправил снова. Это не баг, это особенность работы распределённых систем.
Проблема усугубляется тем, что многие системы (Kaspi, Ozon, Wildberries, платёжные шлюзы) настроены на агрессивные повторы. Не получили ответ за 10 секунд? Отправим ещё раз. И ещё. И ещё — до победного или до исчерпания лимита попыток.
В итоге вместо одного webhook'а вы можете получить три, пять, а иногда и десять одинаковых. И если ваша система не готова — здравствуй, хаос.
Мы перепробовали кучу подходов. Часть выглядела красиво в документации, но на практике не взлетала. Вот три метода, которые реально работают и которые мы внедряем клиентам.
Самый надёжный и универсальный подход. Суть простая: отправитель вместе с данными передаёт уникальный идентификатор запроса. Получатель сохраняет этот идентификатор и проверяет при каждом новом запросе: «Я уже видел такой ключ?»
{
"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"
}
Когда приходит такой запрос, ваша система:
ord_2024_12345_v1?Красота этого подхода в том, что он работает даже если запросы приходят параллельно. Главное — использовать транзакции в базе данных или атомарные операции типа «вставить если не существует» (INSERT ... ON CONFLICT DO NOTHING в PostgreSQL).
Не все системы отправляют idempotency_key — это пока не стандарт. Но почти все отправляют бизнес-идентификатор: номер заказа, ID платежа, номер документа. Используйте его!
Перед созданием сущности проверяйте: «Есть ли у меня уже заказ с таким external_id?» Если есть — это повтор, не создаём новый.
Этот способ проще в реализации, но есть нюанс: он работает только для операций создания. Для обновлений (например, «изменить статус заказа») нужен более тонкий подход — версионирование или timestamp.
Иногда ни idempotency_key, ни внятного бизнес-идентификатора нет. Тогда можно вычислять хэш от содержимого запроса и использовать его как ключ дедупликации.
Берём тело запроса, считаем SHA-256, сохраняем. Если приходит запрос с таким же хэшем — это дубль.
Важное ограничение: этот метод работает только если повторные запросы идентичны байт в байт. Если отправитель добавляет timestamp или меняет порядок полей — хэши будут разные, и защита не сработает. Поэтому используйте этот способ как последний резерв, когда другие невозможны.
До сих пор мы говорили о защите получателя. Но если вы сами отправляете webhooks (например, из CRM в 1С или из интернет-магазина в службу доставки) — важно делать это грамотно, чтобы не создавать проблем партнёрам.
Не бомбардируйте получателя повторами каждую секунду. Если первая попытка не удалась — подождите 5 секунд. Вторая не удалась — 15 секунд. Третья — 45 секунд. И так далее, увеличивая интервал.
| Попытка | Интервал ожидания | Суммарное время | Комментарий |
|---|---|---|---|
| 1 | Сразу | 0 сек | Первая попытка |
| 2 | 5 сек | 5 сек | Возможно, кратковременный сбой |
| 3 | 15 сек | 20 сек | Сервер перегружен? |
| 4 | 45 сек | ~1 мин | Серьёзная проблема |
| 5 | 2 мин | ~3 мин | Возможно, деплой у получателя |
| 6-10 | 5-15 мин | до 1 часа | Крупный сбой, алерт администратору |
Зачем это нужно? Если у получателя проблемы — ваши повторы каждую секунду только усугубят ситуацию. А если проблема временная (перезагрузка, пик нагрузки) — экспоненциальный backoff даст системе время восстановиться.
Представьте: у вас в очереди 1000 недоставленных webhook'ов. Все они ждут 5 секунд и отправляются одновременно. Сервер получателя, который только что восстановился, снова падает под нагрузкой. Классика.
Решение — добавлять случайный разброс к интервалу. Не ровно 5 секунд, а от 3 до 7. Не ровно 15, а от 10 до 20. Так запросы распределяются во времени и не создают пиковой нагрузки.
Даже если вы уверены, что получатель его не использует — отправляйте. Во-первых, он может начать использовать в будущем. Во-вторых, это хороший тон и признак зрелой интеграции. В-третьих, вам самим будет проще дебажить: по ключу можно отследить все попытки доставки конкретного события.
Некоторые системы настроены на ретраи «до победного». Это плохо. Если webhook не доставился за 10 попыток в течение суток — скорее всего, проблема не временная. Сохраните событие в dead letter queue, отправьте алерт администратору и прекратите попытки. Иначе вы будете бесконечно долбить в закрытую дверь.
Хватит теории — покажу, как это работает в реальных проектах. Вот архитектура, которую мы используем почти везде, где есть webhooks.
Все webhook'и приходят на один endpoint. Первое, что мы делаем — валидируем подпись (если отправитель её поддерживает) и сохраняем сырой запрос в таблицу incoming_webhooks. Сразу отвечаем «202 Accepted» — запрос принят, обработаем позже.
Почему 202, а не 200? Потому что мы ещё не обработали — только приняли. Это честно и соответствует HTTP-семантике. Но главное — мы отвечаем быстро (меньше 100мс), и отправитель не уходит в таймаут.
Перед сохранением проверяем: есть ли уже запись с таким idempotency_key (или external_id, если ключа нет)? Если есть — не создаём новую запись, просто возвращаем 202. Дубль отсечён на самом раннем этапе.
Фоновый процесс (worker) забирает новые записи из incoming_webhooks и обрабатывает их: создаёт сделки в CRM, обновляет статусы, отправляет уведомления. Если обработка упала с ошибкой — запись остаётся в очереди для повторной попытки.
Даже если дубль проскочил на первом шаге (например, idempotency_key не было), бизнес-логика тоже защищена. Перед созданием заказа проверяем: «Есть ли заказ с external_id = X?». Перед созданием контакта: «Есть ли контакт с таким email/телефоном?».
Двойная защита — на уровне входной точки и на уровне бизнес-логики — практически исключает дубли.
Поработав с казахстанскими компаниями, мы набили несколько специфических шишек. Делюсь.
Kaspi Магазин — одна из самых популярных площадок для e-commerce в Казахстане. Их API и webhook'и работают стабильно, но есть нюансы. Webhook'и приходят с order_id, который можно использовать как ключ идемпотентности. Но при смене статуса заказа приходит новый webhook с тем же order_id — это не дубль, это обновление. Важно различать.
Подробнее об интеграции с Kaspi мы писали в статье про маппинг статусов и SLA.
Интеграция с 1С — отдельная песня. 1С любит отвечать медленно, особенно если в базе много данных или сервер не очень мощный. Запрос может обрабатываться 30-60 секунд. Если ваш webhook-отправитель настроен на таймаут 10 секунд — он решит, что запрос потерялся, и отправит снова. А 1С создаст два документа.
Решение — асинхронная обработка на стороне 1С или промежуточный буфер. Мы обычно используем второй вариант: webhook сохраняется в быстрое хранилище, а 1С забирает данные оттуда в своём темпе.
О синхронизации товаров и цен с 1С без «разъезда» справочников мы расскажем в следующей статье этого цикла.
Когда речь идёт о деньгах — дедупликация критична вдвойне. Дублирование платежа может привести к двойному списанию с клиента или двойному начислению на ваш счёт. И то, и другое — серьёзные проблемы.
Платёжные шлюзы (Kaspi, Halyk, ForteBank) обычно отправляют payment_id или transaction_id. Используйте их как ключ идемпотентности и храните историю обработанных транзакций минимум год (для возможных сверок и расследований).
Настроили защиту — отлично. Но как убедиться, что она реально работает? И как узнать, если что-то пошло не так?
Настройте уведомления на:
О построении системы мониторинга и дашбордов мы подробно писали в статье про наблюдаемость LLM-систем — принципы те же, только контекст другой.
Поможем спроектировать и реализовать интеграцию CRM с вашими системами — с правильной дедупликацией, ретраями и мониторингом. Без дублей, без потерь данных, без головной боли.
Обсудить проектИспользуйте этот список при проектировании новой интеграции или аудите существующей.
Идемпотентность и правильная работа с ретраями — это не «продвинутая техника для senior-разработчиков». Это базовая гигиена для любой интеграции, которая работает через ненадёжную среду (а интернет — это ненадёжная среда по определению).
Да, реализация требует времени. Нужно добавить таблицу для хранения ключей, написать логику проверки, настроить мониторинг. Но это инвестиция, которая окупается многократно:
И главное — спокойный сон. Вы знаете, что даже если ночью что-то упадёт и начнёт массово ретраить webhook'и — ваша система справится. Потому что вы её к этому подготовили.
Начните с малого: добавьте проверку external_id перед созданием сущностей. Потом добавьте таблицу для idempotency_key. Потом — мониторинг. Шаг за шагом ваши интеграции станут надёжными, а вы — свободнее от рутинной борьбы с дублями.