Как избежать дублей заказов и заявок при интеграции API и webhook
Клиент оплатил один раз, а в CRM появились две сделки. Два менеджера звонят ему с разницей в десять минут. Склад дважды списывает остаток, и вечером один и тот же товар уже числится проданным в минус. Заявка с сайта приходит в Telegram три раза, и никто не понимает, какая из них настоящая.
Обычно в таких случаях винят «глючный API». Но почти всегда причина в другом: интеграцию сделали так, будто каждое событие приходит ровно один раз. В реальности события приходят повторно, и это нормально. Вопрос только в том, умеет ли ваша система спокойно это переварить.
Клиент оплатил один раз, а в CRM появились две сделки. Два менеджера звонят ему с разницей в десять минут. Склад дважды списывает остаток, и вечером один и тот же товар уже числится проданным в минус. Заявка с сайта приходит в Telegram три раза, и никто не понимает, какая из них настоящая.
Обычно в таких случаях винят «глючный API». Но почти всегда причина в другом: интеграцию сделали так, будто каждое событие приходит ровно один раз. В реальности события приходят повторно, и это нормально. Вопрос только в том, умеет ли ваша система спокойно это переварить.
Ниже разберём, откуда берутся дубли, как устроена защита от них и как проверить свою интеграцию за один вечер.
Коротко
- Webhook и API почти всегда работают по принципу «доставим хотя бы один раз», а не «ровно один раз». Повторы будут, поэтому обработка должна быть идемпотентной: повторное событие не должно ничего менять.
- У каждого события и заказа должен быть стабильный внешний ключ:
event_id, ID заказа на маркетплейсе, ID платежа. Связка «источник + внешний ID» должна быть уникальной на уровне базы, а не проверяться черезif existsв коде. - На webhook нужно быстро отвечать
200 OK, а тяжёлую работу переносить в очередь. Медленный ответ провоцирует повторную отправку. - Для исходящих запросов, где это поддерживается (платежи, создание сущностей), передавайте
Idempotency-Key. - Ретраи нужны, но с паузой, лимитом и записью в журнал.
- Похожие заявки (тот же телефон, разница в 5 минут) не удаляйте автоматически. Склеивайте их с подтверждением человека.
- Журнал синхронизации — главный инструмент: по нему видно, что пришло, что обработано, что упало и почему.
Откуда берутся дубли
Источников немного, и почти все сводятся к одному: система не узнаёт событие, которое уже видела.
Повторная отправка webhook. Сервис отправил вам событие, не дождался ответа за отведённое время (часто это несколько секунд) и отправил его снова. При этом первый запрос мог дойти и обработаться. Так ведут себя платёжные системы, CRM, Telegram Bot API и большинство SaaS. Это не баг, а их гарантия доставки.
Таймаут на вашей стороне. Вы сами вызываете API CRM, чтобы создать сделку. Сеть подвисла, ответа нет, код повторяет запрос. Первая сделка уже создана, вторая создаётся рядом.
Пересекающийся опрос (polling). Заказы WB или Ozon забираются по расписанию «за последние 30 минут» каждые 10 минут. Окна пересекаются, и один заказ попадает в три выгрузки подряд. Если записывать всё, что пришло, получится три заказа.
Параллельные обработчики. Два воркера одновременно получили одно событие. Оба проверили «такого заказа нет», оба создали. Проверка в коде без ограничения в базе от этого не спасает.
Двойной клик и повторная отправка формы. Клиент нажал «Отправить» дважды или обновил страницу. Технически это две разные заявки, хотя по смыслу одна.
Два канала на одно действие. Заявка пришла с сайта, а клиент на всякий случай написал ещё и в Telegram. Или менеджер руками завёл заказ, а потом его же подтянула автоматика.
Первые четыре причины технические и закрываются надёжно. Последние две связаны с поведением людей, и полностью убрать их автоматикой нельзя, их можно только аккуратно склеивать.
Главный принцип: повтор должен быть безопасным
Гарантировать, что событие придёт ровно один раз, нельзя. Зато можно сделать так, чтобы второе, третье и десятое получение не меняли результат. Это и называется идемпотентностью: операция «создать заказ 12345 из WB» даёт одинаковый результат, сколько раз её ни выполни.
Отсюда простое правило проектирования: любое входящее событие и любой исходящий запрос на создание чего-либо должны иметь ключ, по которому можно понять, что это уже было.
Как устроена защита от дублей: рабочая схема
1. Выберите стабильный ключ для каждого источника
Ключ должен приходить от источника и не меняться при повторе. Время получения, порядковый номер в вашей базе или хеш всего тела запроса не подходят: тело может отличаться полем sent_at.
Что обычно используют:
- Платежи — ID платежа или события из платёжной системы.
- Маркетплейсы — ID заказа или отправления (у Ozon это
posting_number, у WB — ID сборочного задания). - Telegram —
update_idдля апдейтов иmessage_idв рамках чата. - CRM — ID сущности плюс тип события.
- Формы сайта — ключ, который генерируется на клиенте при открытии формы и отправляется вместе с заявкой. Тогда двойной клик даёт один ключ.
Ключ всегда хранится в паре с источником: source + external_id. Номер 12345 в WB и номер 12345 в Ozon — это разные заказы.
2. Уникальность обеспечивает база, а не код
Проверка «если такого заказа нет — создать» ломается при параллельной обработке. Надёжнее поручить это базе данных:
CREATE TABLE processed_events (
source text NOT NULL,
event_id text NOT NULL,
status text NOT NULL DEFAULT 'received',
attempts int NOT NULL DEFAULT 0,
last_error text,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (source, event_id)
);
INSERT INTO processed_events (source, event_id)
VALUES ('ozon', :posting_number)
ON CONFLICT (source, event_id) DO NOTHING;
Если вставка не добавила строку, событие уже видели: отвечаем 200 OK и ничего не делаем. Такой же уникальный индекс стоит поставить на таблицу заказов или сделок по (source, external_id). Это последний рубеж, который сработает, даже если в коде ошибка.
Важная деталь: событие, которое приняли, но не смогли обработать, не должно навсегда считаться обработанным. Для этого нужен статус (received → processing → done / failed), а не просто факт записи.
3. Отвечайте на webhook быстро, работайте в очереди
Webhook-обработчик должен сделать три вещи: проверить подпись или секрет, записать событие и ответить 200 OK. Создание сделки в CRM, запрос к AI-модели, отправка в Telegram выполняются отдельно, через очередь.
Если обработчик 15 секунд ждёт ответа от CRM, отправитель решит, что вы не ответили, и пришлёт событие снова. Это самая частая причина «внезапных» дублей после запуска.
4. Передавайте Idempotency-Key в исходящих запросах
Многие API принимают ключ идемпотентности в заголовке. Например, ЮKassa требует заголовок Idempotence-Key при создании платежа, Stripe принимает Idempotency-Key. Если повторить запрос с тем же ключом, сервис вернёт результат первого вызова, а не создаст второй платёж.
Если API такого не поддерживает (многие CRM), действуйте так: перед повтором после таймаута ищите сущность по внешнему ID, записанному в кастомное поле. Сделка в amoCRM или Битрикс24 с полем «ID заказа WB = 12345» уже существует? Тогда обновляем её, а не создаём новую.
5. Ретраи с паузой и лимитом
Повторять упавшие операции нужно, иначе заказы будут теряться. Но ретрай без правил тоже порождает хаос:
- пауза между попытками растёт (например, 10 секунд, минута, 5 минут, 30 минут);
- попыток не больше 5–7, после этого событие уходит в статус
failedи попадает человеку; - ошибки валидации (неверный телефон, нет SKU в справочнике) не ретраятся вовсе: от повтора они не исправятся.
6. Журнал синхронизации
Это таблица или отдельный лог, где по каждому событию видно: откуда пришло, когда, какой внешний ID, что с ним сделали, сколько было попыток, какая ошибка. Без журнала разбор любого инцидента превращается в гадание по скриншотам из CRM.
Минимум, который должен показывать журнал: сколько событий пришло за сутки, сколько отброшено как повторы, сколько упало и висит без обработки. Если повторов внезапно становится 40% вместо обычных 2–3%, где-то сломался ответ на webhook.
7. Похожие заявки склеивайте с человеком
Клиент оставил заявку на сайте и через три минуты написал в Telegram. Внешние ID разные, технически это два события. Здесь работает нечёткая дедупликация: тот же нормализованный телефон или email в окне, например, 24 часа.
Удалять вторую заявку автоматически не стоит: это может быть второй заказ или другой человек с общего рабочего номера. Правильнее привязать её к существующей сделке с пометкой «возможный дубль» и дать менеджеру кнопку «склеить». AI-агент может подсказать, что это одна и та же потребность, но решение о слиянии остаётся за человеком.
Отдельный случай: AI-агенты
Если к CRM или Telegram подключён AI-агент, у него появляется своя причина для дублей. Агент вызвал инструмент «создать сделку», ответ не пришёл из-за таймаута, и он решил попробовать ещё раз. Модель не знает, что первый вызов сработал.
Защита та же: у каждого действия агента должен быть ключ идемпотентности, который генерирует оркестратор, а не модель. Действия с деньгами и остатками (возвраты, списания, изменение цены) должны требовать подтверждения человеком. Подробнее о том, где агенту можно доверять, мы писали в статье «AI-агент для бизнеса».
Сколько это стоит бизнесу и что даёт исправление
Посчитаем на условном примере. Интернет-магазин с потоком 200 заказов и заявок в день, доля дублей 4%. Получается 8 дублей в день.
На каждый менеджер тратит 5–10 минут: найти, сверить, понять, какой настоящий, закрыть лишний, иногда извиниться перед клиентом за повторный звонок. Это 40–80 минут в день, или 15–30 часов в месяц только на разбор.
Но время — не главная потеря. Хуже всего обходятся редкие, но дорогие случаи: дважды отгруженный заказ, двойное списание остатка, из-за которого карточка на маркетплейсе ушла в ноль, повторный платёж и возврат, искажённая конверсия в отчётах. Один такой эпизод в неделю обычно стоит дороже всей доработки.
Сама доработка в типичной интеграции занимает от нескольких дней до двух недель, в зависимости от числа источников. Конкретную экономию считайте на своих цифрах: если вы ещё не уверены, стоит ли браться, начните с разбора по статье «Как понять, что процесс пора автоматизировать».
Риски и ошибки при исправлении
Слишком широкий ключ. Если склеивать заказы по телефону без окна времени, постоянный клиент, который заказал дважды за месяц, потеряет второй заказ. Это хуже, чем дубль.
Тихое отбрасывание. Повтор отброшен, но об этом нигде не записано. Когда что-то пропадёт, найти причину будет невозможно. Всё отброшенное должно попадать в журнал.
«Обработано» до обработки. Событие помечено как done сразу при получении, а потом обработка упала. Повтор придёт, но будет отброшен как дубль, и заказ потеряется.
Чистка задним числом без бэкапа. Массовое удаление старых дублей скриптом без выгрузки и ручной выборочной проверки легко уничтожит живые сделки. Сначала разметьте их, потом проверьте выборку, и только после этого архивируйте.
Широкие права у интеграции. Сервисному аккаунту, который создаёт сделки, не нужны права на удаление. Тогда ошибка в коде не превратится в массовое удаление данных.
Как внедрить: порядок работ
- Инвентаризация. Перечислите все входящие потоки: формы, webhook платёжки, заказы маркетплейсов, Telegram, ручной импорт. Для каждого запишите, есть ли у события стабильный внешний ID.
- Замер. Выгрузите заказы и заявки за месяц и найдите дубли по внешнему ID и по телефону в окне 24 часа. Так появится базовая цифра, с которой потом будете сравнивать.
- Таблица событий и уникальные индексы. Добавьте
processed_eventsи уникальность(source, external_id)в таблицах заказов и сделок. - Быстрый ответ и очередь. Перестройте webhook-обработчики: принять, записать, ответить, обработать асинхронно.
- Ключи в исходящих запросах.
Idempotency-Keyтам, где API его поддерживает, и поиск по внешнему ID перед созданием там, где нет. - Ретраи и статус failed. Добавьте паузы, лимит попыток и уведомление ответственному в Telegram, когда событие застряло.
- Склейка похожих заявок. Сделайте пометку «возможный дубль» и ручное подтверждение.
- Повторный замер через 2–4 недели. Сравните с базовой цифрой и посмотрите журнал: что отбрасывается и что падает.
Чек-лист: проверьте свою интеграцию
- [ ] У каждого источника есть стабильный внешний ID, и он сохраняется в базе или CRM.
- [ ] Уникальность
источник + внешний IDзадана индексом в базе. - [ ] Webhook отвечает
200 OKза доли секунды, тяжёлая работа идёт в очереди. - [ ] Повторно присланное событие не создаёт новых сущностей. Это проверено руками: отправьте тот же webhook дважды.
- [ ] В исходящих запросах на создание используется ключ идемпотентности или поиск по внешнему ID.
- [ ] У ретраев есть пауза, лимит и статус
failed. - [ ] Журнал показывает пришедшие, отброшенные и упавшие события.
- [ ] Похожие заявки склеиваются с подтверждением менеджера, а не удаляются.
- [ ] Интеграция работает под отдельным аккаунтом без прав на удаление.
FAQ
Почему один и тот же webhook приходит несколько раз?
Отправитель не получил от вас успешного ответа вовремя: ваш сервер ответил ошибкой, ответил слишком медленно или ответ потерялся в сети. В таких случаях сервис повторяет отправку. Это стандартное поведение, и полагаться на то, что повторов не будет, нельзя. Решение — быстрый ответ и проверка event_id перед обработкой.
Чем idempotency key отличается от event ID?
Event ID присваивает источник своему событию, и вы используете его, чтобы распознать повтор входящего webhook. Idempotency key вы генерируете сами и передаёте в исходящем запросе, чтобы внешний сервис не выполнил операцию дважды. Первый защищает вашу систему от повторов извне, второй защищает чужую систему от ваших повторов.
Можно ли убрать дубли только на стороне CRM, без доработки интеграции?
Частично. Встроенные инструменты поиска дублей в amoCRM или Битрикс24 помогают находить похожие контакты, но срабатывают уже после того, как дубль создан и, возможно, ушёл в работу. Надёжнее не создавать дубль вовсе, а CRM-поиск оставить как дополнительную страховку для случаев «клиент написал в два канала».
Как быть, если у источника нет уникального ID?
Соберите ключ из полей, которые не меняются при повторе: например, ID формы, телефон и время отправки, округлённое до минуты, или ключ, сгенерированный в браузере при открытии формы. Это менее надёжно, чем настоящий ID, поэтому такие случаи особенно важно логировать и периодически проверять вручную.
Сколько хранить записи об обработанных событиях?
Не меньше, чем источник может присылать повторы, плюс запас. Для большинства webhook достаточно 30–90 дней. Для заказов маркетплейсов внешний ID лучше хранить постоянно в самой карточке заказа, потому что статусы по нему продолжают приходить неделями: отгрузка, доставка, возврат.
Нужен ли для этого отдельный сервис или хватит no-code?
Для одного-двух источников с небольшим потоком иногда хватает no-code-сценария с проверкой по внешнему ID в таблице или CRM. Но при параллельной обработке no-code-проверка ненадёжна: два сценария могут одновременно увидеть «такого нет». Если заказов сотни в день или в процесс вовлечены деньги и остатки, нужна своя прослойка с базой данных и уникальными индексами.
Как быстро проверить, есть ли проблема прямо сейчас?
Выгрузите заказы или сделки за последний месяц и сгруппируйте их по внешнему ID. Если такого поля нет, группируйте по телефону и дате. Затем отправьте на тестовый стенд один и тот же webhook дважды и посмотрите, что получится. Обычно этих двух проверок достаточно, чтобы понять масштаб проблемы.
Что делать дальше
Начните не с переписывания интеграции, а с замера: сколько дублей у вас за месяц, из каких источников они приходят и сколько стоит каждый. Потом закройте самые частые причины: быстрый ответ на webhook, уникальный внешний ID в базе и журнал событий. Обычно уже это убирает основную часть проблемы. Склейку похожих заявок и автоматизацию на AI стоит добавлять поверх этого фундамента, а не вместо него.
Если хочешь понять, что автоматизировать у себя, напиши Дмитрию в Telegram @dmkosik или опиши процесс для диагностики Paramiko.