Четверг, пять вечера. Менеджер по продажам звонит в IT-отдел, и в его голосе слышна паника: «Ребят, тут какая-то магия. В CRM висит три одинаковых заказа от Ивановой. Она же покупала один раз, я с ней лично разговаривал!». И начинается детективное расследование. Поднимаем логи, смотрим историю — ага, вот оно. Ночью интернет-провайдер немного «икнул», связь на 20 секунд пропала. Webhook от интернет-магазина отправился, не дождался ответа, попробовал ещё раз... и ещё. А наша система, не моргнув глазом, каждый раз создавала новый заказ. Три заказа в CRM, три задания на склад, и — о ужас — три 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?»Вся красота этого метода в том, что он работает даже когда запросы прилетают одновременно, параллельно. Главное — правильно использовать возможности базы данных: транзакции или атомарные операции вроде «вставить, только если такого ещё нет» (в PostgreSQL это делается через INSERT ... ON CONFLICT DO NOTHING).
Жизнь не всегда идеальна, и не все системы отправляют этот самый idempotency_key — он пока не стал общепринятым стандартом. Но знаете что? Почти все системы отправляют какой-нибудь бизнес-идентификатор: номер заказа, ID платежа, номер документа. Вот его-то мы и можем использовать!
Принцип такой: перед тем как создавать новую сущность, проверяем базу: «А нет ли у меня уже заказа с таким external_id?». Нашли? Значит, это повторная отправка, и создавать ничего не надо.
Этот способ попроще в реализации, но есть важный нюанс: он отлично работает для операций создания, а вот для обновлений (скажем, «изменить статус заказа на "Доставлен"») нужен более тонкий подход — версионирование или проверка по timestamp.
Бывают совсем грустные случаи: ни idempotency_key нет, ни нормального бизнес-идентификатора не прилетает. Что делать? Можно пойти на хитрость — вычислить хэш от всего содержимого запроса и использовать его как ключ для проверки на дубли.
Схема такая: берём всё тело запроса целиком, прогоняем через SHA-256, получаем хэш-строку, сохраняем. Приходит новый запрос — считаем хэш, смотрим: «О, такой уже был!» — значит, дубль.
Но тут есть важное ограничение: этот метод сработает только если повторные запросы абсолютно идентичны, байт в байт. Стоит отправителю добавить в JSON новый timestamp или поменять порядок полей — и хэши станут разными. Защита не сработает. Так что этот способ — крайний случай, plan C, когда все остальные варианты недоступны.
До сих пор мы говорили о том, как защититься, если вы принимаете webhook'и. А теперь давайте развернёмся на 180 градусов. Если вы сами отправляете их (скажем, из вашей 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. Так запросы размазываются во времени, и вы не создаёте искусственные пики нагрузки.
Даже если вы на 100% уверены, что получатель его всё равно не использует — отправляйте. Почему? Во-первых, может начать использовать завтра. Во-вторых, это признак профессионализма и культуры разработки. А в-третьих, вам самим будет проще отлаживать проблемы: по ключу легко отследить всю историю попыток доставки конкретного события.
Встречал системы, которые настроены на повторы «до победного конца». Это очень плохая идея. Смотрите: если webhook не доставился за 10 попыток в течение суток — вероятность того, что проблема рассосётся сама, стремится к нулю. Что делать? Сохраните это событие в dead letter queue (очередь неразобранных), отправьте алерт администратору, и остановитесь. А то получится, что вы просто бесконечно стучитесь в закрытую дверь.
Ладно, хватит теории. Покажу вам, как это всё выглядит в реальной жизни. Вот типовая архитектура, которую мы раскатываем почти на всех проектах, где фигурируют webhook'и.
Все webhook'и сыплются на один endpoint. Что мы делаем первым делом? Проверяем подпись запроса (если отправитель её вообще добавляет), сохраняем весь сырой запрос целиком в таблицу incoming_webhooks. И сразу же отвечаем «202 Accepted» — мол, ребят, запрос принят, обработаем чуть позже.
Почему именно 202, а не привычный 200? Потому что 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 падает в быстрое хранилище (Redis, база данных), сразу отвечаем «принято», а 1С потом забирает данные оттуда в своём черепашьем темпе.
О том, как синхронизировать товары и цены с 1С без вечного «разъезда» справочников, мы расскажем в следующей статье этого цикла.
Когда в игру вступают деньги — дедупликация становится вопросом жизни и смерти. Дублирование платежа может обернуться двойным списанием с клиента (и его праведным гневом), или двойным начислением на ваш счёт (и проблемами с бухгалтерией и налоговой). Оба варианта — полный кошмар.
Платёжные шлюзы (Kaspi Pay, Halyk Bank, ForteBank и другие) обычно шлют payment_id или transaction_id. Используйте их как ключ идемпотентности! И ещё важно: храните историю обработанных транзакций минимум год. Это вам понадобится для сверок, расследований спорных ситуаций и вообще для спокойного сна.
Ок, защиту настроили — замечательно. Но как понять, что она реально работает, а не просто создаёт иллюзию безопасности? И как узнать о проблемах раньше, чем узнает клиент?
Первое — количество отклонённых дублей. Сколько запросов система отсеяла как повторные? Если цифра всегда около нуля — странно, может отправители вообще не делают ретраи (или у вас какая-то космическая сеть без сбоев). Если вдруг взлетело в десять раз — тревога, что-то пошло не так, начался шторм повторных отправок.
Второе — время обработки webhook. От момента получения до ответа. Если больше пяти секунд — готовьтесь к повторам. Нетерпеливые отправители решат, что вы умерли, и пошлют ещё раз.
Третье — размер очереди на обработку. Сколько webhook'ов томятся в ожидании своей участи? Если число растёт — worker'ы не справляются, пора добавлять мощности или оптимизировать код.
Четвёртое — ошибки обработки. Сколько webhook'ов упало с ошибкой? Если внезапно выросло — либо баг в вашей бизнес-логике, либо отправитель начал слать что-то странное и неожиданное.
Обязательно настройте уведомления на такие события:
О том, как построить нормальную систему мониторинга и дашбордов, мы подробно писали в статье про наблюдаемость LLM-систем — принципы там те же, просто контекст другой.
Поможем спроектировать и реализовать интеграцию CRM с вашими системами — с правильной дедупликацией, ретраями и мониторингом. Без дублей, без потерь данных, без головной боли.
Обсудить проектИспользуйте этот список при проектировании новой интеграции или аудите существующей.
Идемпотентность и правильная работа с ретраями — это не какая-то продвинутая техника для senior-разработчиков с десятью годами опыта. Это базовая гигиена. Как мыть руки перед едой, только для интеграций. Любая система, которая общается через интернет (а интернет — штука крайне ненадёжная, напомню), должна уметь переваривать повторы.
Да, реализация потребует времени. Таблицу для хранения ключей завести, логику проверки написать, мониторинг прикрутить. Но это инвестиция, которая окупится многократно. Никаких дублей в CRM и 1С — только чистые данные. Никакой еженедельной «чистки» мусора из базы вручную. Никакой паники при сбоях — система сама восстановится и переварит очередь. И никаких злых клиентов, которые получили три SMS подряд или обнаружили тройное списание с карты.
И самое главное — спокойный сон. Вы будете знать, что даже если ночью что-то упадёт и начнётся шторм из повторных webhook'ов — ваша система спокойно их переварит. Потому что вы её к этому заранее подготовили.
Не пытайтесь сделать всё сразу. Начните с малого: добавьте проверку external_id перед созданием сущностей. Потом заведите таблицу для idempotency_key. Затем прикрутите мониторинг. Шаг за шагом ваши интеграции станут по-настоящему надёжными, а вы освободитесь от вечной рутинной борьбы с дублями в базах данных.