Четверг, пять вечера. Менеджер по продажам звонит в IT-отдел, и в его голосе слышна паника: «Ребят, тут какая-то магия. В CRM висит три одинаковых заказа от Ивановой. Она же покупала один раз, я с ней лично разговаривал!». И начинается детективное расследование. Поднимаем логи, смотрим историю – ага, вот оно. Ночью интернет-провайдер немного «икнул», связь на 20 секунд пропала. Webhook от интернет-магазина отправился, не дождался ответа, попробовал ещё раз... и ещё. А наша система, не моргнув глазом, каждый раз создавала новый заказ. Три заказа в CRM, три задания на склад, и – о ужас – три 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'а к вам прилетает три, пять, а бывает и десять совершенно одинаковых запросов. И если ваша система к этому не готова – добро пожаловать в хаос, наслаждайтесь дублями во всех базах данных.
Три способа защитить систему от дублей
За годы работы мы перепробовали кучу подходов. Честно скажу: половина того, что красиво выглядит в документации – со схемами, диаграммами и умными терминами – на практике либо не работает, либо требует такой экспертизы, которой в обычной команде просто нет. Поэтому расскажу только про то, что реально внедряем у клиентов и что не подводит.
Способ первый: ключ идемпотентности
Самый надёжный вариант. Идея простая до неприличия: отправитель вместе с данными передаёт уникальный номер запроса – что-то вроде серийного номера посылки. Получатель этот номер сохраняет. Пришёл новый запрос – первым делом смотрим: «А не видел ли я уже этот номерок?»
Пример 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"
}
Когда приходит такой запрос, ваша система работает по простому алгоритму:
- Смотрит в базу: «Эй, а у меня уже есть запись с ключом
ord_2024_12345_v1?» - Если нет – отлично, обрабатывает запрос как новый, сохраняет ключ в базу, возвращает результат.
- Если да – ага, дубль! Ничего не делает, просто возвращает сохранённый результат от прошлой обработки.
Вся красота этого метода в том, что он работает даже когда запросы прилетают одновременно, параллельно. Главное – правильно использовать возможности базы данных: транзакции или атомарные операции вроде «вставить, только если такого ещё нет» (в PostgreSQL это делается через INSERT ... ON CONFLICT DO NOTHING).
Способ 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, получаем хэш-строку, сохраняем. Приходит новый запрос – считаем хэш, смотрим: «О, такой уже был!» – значит, дубль.
Но тут есть важное ограничение: этот метод сработает только если повторные запросы абсолютно идентичны, байт в байт. Стоит отправителю добавить в JSON новый timestamp или поменять порядок полей – и хэши станут разными. Защита не сработает. Так что этот способ – крайний случай, plan C, когда все остальные варианты недоступны.

Если вы отправляете webhooks: как делать ретраи правильно
До сих пор мы говорили о том, как защититься, если вы принимаете webhook'и. А теперь давайте развернёмся на 180 градусов. Если вы сами отправляете их (скажем, из вашей 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
Даже если вы на 100% уверены, что получатель его всё равно не использует – отправляйте. Почему? Во-первых, может начать использовать завтра. Во-вторых, это признак профессионализма и культуры разработки. А в-третьих, вам самим будет проще отлаживать проблемы: по ключу легко отследить всю историю попыток доставки конкретного события.
Антипаттерн: бесконечные ретраи
Встречал системы, которые настроены на повторы «до победного конца». Это очень плохая идея. Смотрите: если webhook не доставился за 10 попыток в течение суток – вероятность того, что проблема рассосётся сама, стремится к нулю. Что делать? Сохраните это событие в dead letter queue (очередь неразобранных), отправьте алерт администратору, и остановитесь. А то получится, что вы просто бесконечно стучитесь в закрытую дверь.
Практика: как мы реализуем это в проектах
Ладно, хватит теории. Покажу вам, как это всё выглядит в реальной жизни. Вот типовая архитектура, которую мы раскатываем почти на всех проектах, где фигурируют webhook'и.
Шаг 1: Входная точка (endpoint)
Все webhook'и сыплются на один endpoint. Что мы делаем первым делом? Проверяем подпись запроса (если отправитель её вообще добавляет), сохраняем весь сырой запрос целиком в таблицу incoming_webhooks. И сразу же отвечаем «202 Accepted» – мол, ребят, запрос принят, обработаем чуть позже.
Почему именно 202, а не привычный 200? Потому что 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 падает в быстрое хранилище (Redis, база данных), сразу отвечаем «принято», а 1С потом забирает данные оттуда в своём черепашьем темпе.
О том, как синхронизировать товары и цены с 1С без вечного «разъезда» справочников, мы расскажем в следующей статье этого цикла.
Платёжные системы и критичность дедупликации
Когда в игру вступают деньги – дедупликация становится вопросом жизни и смерти. Дублирование платежа может обернуться двойным списанием с клиента (и его праведным гневом), или двойным начислением на ваш счёт (и проблемами с бухгалтерией и налоговой). Оба варианта – полный кошмар.
Платёжные шлюзы (Kaspi Pay, Halyk Bank, ForteBank и другие) обычно шлют payment_id или transaction_id. Используйте их как ключ идемпотентности! И ещё важно: храните историю обработанных транзакций минимум год. Это вам понадобится для сверок, расследований спорных ситуаций и вообще для спокойного сна.
Как понять, что дедупликация работает
Ок, защиту настроили – замечательно. Но как понять, что она реально работает, а не просто создаёт иллюзию безопасности? И как узнать о проблемах раньше, чем узнает клиент?
За чем следить
Первое – количество отклонённых дублей. Сколько запросов система отсеяла как повторные? Если цифра всегда около нуля – странно, может отправители вообще не делают ретраи (или у вас какая-то космическая сеть без сбоев). Если вдруг взлетело в десять раз – тревога, что-то пошло не так, начался шторм повторных отправок.
Второе – время обработки webhook. От момента получения до ответа. Если больше пяти секунд – готовьтесь к повторам. Нетерпеливые отправители решат, что вы умерли, и пошлют ещё раз.
Третье – размер очереди на обработку. Сколько 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. Затем прикрутите мониторинг. Шаг за шагом ваши интеграции станут по-настоящему надёжными, а вы освободитесь от вечной рутинной борьбы с дублями в базах данных.