Откуда берутся дубли: анатомия проблемы
Дубли в автоматизации платежей возникают не из-за багов в коде. Они возникают из-за того, что распределённые системы по определению не гарантируют "ровно один раз". Гарантируют "хотя бы один раз". Это фундаментальное свойство, и с ним нужно работать явно.
Я выделяю три сценария, каждый с разной природой.
Webhook retry от платёжной системы. Stripe, ЮKassa и любой другой шлюз ждут HTTP 200 в ответ на webhook. Не получили за таймаут (у Stripe это 30 секунд) - отправляют снова. По данным документации Stripe, повторные попытки идут с нарастающим интервалом на протяжении нескольких дней, но конкретное расписание стоит проверять в официальной документации: провайдеры периодически его меняют. ЮKassa ведёт себя похоже. Если ваш n8n притормозил на старте workflow, ответил с задержкой или вообще упал после приёма события, но до записи результата, платёжная система посчитает доставку неудачной и пришлёт тот же payment.succeeded ещё раз.
Повторный запуск workflow после сбоя. n8n в queue mode работает через Redis как брокер задач. Воркер берёт задачу, начинает выполнять, и если падает на середине, Redis через некоторое время возвращает задачу в очередь. Другой воркер её подбирает. Это правильное поведение с точки зрения отказоустойчивости. Но если первый воркер успел выполнить, например, списание через API, а потом упал уже после этого вызова, второй воркер об этом не знает и спишет снова. При сбое брокера ситуация хуже: задача может оказаться у двух воркеров одновременно.
Ручной перезапуск через UI. Самый предсказуемый сценарий, но от этого не менее опасный. Оператор видит в логах execution со статусом "error", жмёт "Retry", и workflow стартует заново. Никакой магии здесь нет: n8n просто запускает его с теми же входными данными. Если проблема была в сети на шаге "отправить письмо", а не на шаге "списать деньги", повторный запуск молча проведёт второе списание.
В n8n Community в 2024 году был показательный тред: пользователь настроил webhook от Stripe на создание подписки в своей базе и списание через Stripe API. Stripe прислал checkout.session.completed дважды (первый раз n8n ответил 200 с задержкой 28 секунд, Stripe не засчитал). Workflow не проверял, существует ли уже запись о платеже. Результат: два списания с карты клиента, два часа разбора полётов, ручной возврат через dashboard. Классика.
Общий корень у всех трёх сценариев один. Workflow не хранит никакого состояния о том, обработал ли он конкретное событие раньше. Каждый запуск с его точки зрения первый и единственный.

Один и тот же вебхук может прийти дважды: сеть упала, сервер не ответил 200, и платёжная система отправила запрос повторно.
Что такое идемпотентность и при чём тут n8n
Идемпотентная операция: запусти её один раз или сто, состояние системы будет одинаковым. Классический пример: DELETE /users/42. Первый вызов удаляет пользователя, второй просто получает 404. Итог тот же: пользователя нет. С GET то же самое: сколько раз ни читай ресурс, ты ничего не меняешь.
POST по спецификации HTTP идемпотентным не считается. Каждый вызов создаёт новую сущность или инициирует новое действие. И вот здесь начинаются проблемы: webhook-триггер n8n принимает именно POST.
Stripe, GitHub, Shopify, любой внешний сервис стреляет в твой webhook POST-запросом. Если что-то пошло не так на стороне отправителя, он повторит запрос. Может через 30 секунд, может через 5 минут, может три раза подряд. Это нормальное поведение: delivery at least once заложен в большинство webhook-систем на уровне архитектуры.
n8n получит каждый из этих запросов и запустит воркфлоу заново. Никакой встроенной дедупликации нет. Инструмент просто не знает, видел ли он это событие раньше.
Значит, идемпотентность надо строить руками. Основа конструкции: ключ идемпотентности. Это уникальный идентификатор конкретного события, который ты используешь как барьер перед выполнением любых действий. Stripe кладёт его прямо в тело: поле id у каждого event-объекта выглядит как evt_1Nxxx. Shopify передаёт X-Shopify-Webhook-Id в заголовке. В самописных системах это может быть order_id, idempotency_key или любое поле, которое гарантированно одинаково для повторных доставок одного и того же события.
Схема простая: пришёл запрос, извлёк ключ, проверил хранилище, уже видел этот ключ, остановился. Не видел, обработал, записал ключ. Сложность в деталях реализации, но принцип именно такой.

Идемпотентность означает, что повторный вызов с теми же данными даёт тот же результат без побочных эффектов.
Паттерн 1: дедупликация через Redis SET NX
SET NX делает ровно одно: записывает ключ, только если его ещё нет. Если ключ уже существует, операция возвращает null и ничего не меняет. Атомарность встроена в Redis на уровне протокола, никаких гонок и race condition при параллельных запросах.
Практика такая. Приходит webhook от Stripe с id: evt_1ABC.... Ты берёшь этот ID, пишешь в Redis ключ stripe:evt_1ABC...:processed через SET NX с TTL 86400 секунд (сутки). Если ответ {"result":"OK"}, запрос новый, обрабатываешь. Если {"result":null}, этот ивент уже прошёл, останавливаешь цепочку.
Проблема с нативным Redis-нодом n8n
Встроенный Redis-нод в n8n по состоянию на июнь 2026 не поддерживает атомарный SET NX напрямую. Feature request висит в Community с 2023 года, воз и ныне там. Уточняйте актуальный список поддерживаемых команд в документации: возможности нода обновляются. Обходных путей два. Первый: Upstash Redis через HTTP Request нод. Upstash отдаёт REST API поверх обычного HTTPS, авторизация через Bearer-токен, тело запроса не нужно, всё в URL.
// HTTP Request к Upstash Redis REST API
// POST https://<host>.upstash.io/set/<key>/processed?nx=true&ex=86400
// Headers: Authorization: Bearer <token>
//
// Ответ: {"result":"OK"} — новый запрос, продолжаем
// Ответ: {"result":null} — дубль, прекращаем обработку
Второй путь: Execute Command нод с redis-cli SET stripe:{{ $json.id }}:processed 1 EX 86400 NX. Работает, если n8n развёрнут на своём сервере с доступом к redis-cli. В облачных managed-окружениях обычно недоступно.
TTL обязателен
Ключ без TTL останется в Redis навсегда. Если у тебя тысяча платёжных ивентов в день, через год это несколько миллионов ключей, которые ничего не делают кроме как занимают память. Строка SET key value EX 86400 NX ставит TTL атомарно вместе с записью. Никогда не делай SET NX и EXPIRE двумя отдельными командами: между ними процесс может упасть, и ключ останется без TTL.
Ответ инициатору
Когда SET NX вернул null, IF-нод должен вернуть HTTP 200, а не 409 или 4xx. Stripe при получении любого кода кроме 2xx ставит webhook в очередь повторной отправки. Получишь 409, получишь ретрай. А ретрай снова вернёт null, и ты снова ответишь 409. Классический цикл, который ломает очередь и засоряет логи. 200 говорит Stripe: "получил, всё хорошо", и тот успокаивается.

Redis SET NX атомарно записывает ключ только если он отсутствует, что делает его удобным инструментом для дедупликации на лету.
Паттерн 2: дедупликация через PostgreSQL
Если n8n развёрнут в production по-человечески, Postgres уже есть в инфраструктуре. n8n сам хранит там свои данные об исполнениях, credentials, workflows. Подключение настроено. Так что добавить одну таблицу для дедупликации буквально бесплатно с точки зрения операционных расходов.
Создаём таблицу:
CREATE TABLE processed_events (
event_id VARCHAR PRIMARY KEY,
received_at TIMESTAMP NOT NULL DEFAULT NOW(),
workflow VARCHAR NOT NULL
);
PRIMARY KEY на event_id делает всю работу. Никакой логики поверх не нужно.
Запрос в Postgres-ноде n8n:
-- Если результат пустой массив -> IF-нод -> Stop
INSERT INTO processed_events (event_id, received_at, workflow)
VALUES ('{{ $json.id }}', NOW(), 'stripe-payment')
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
Логика простая: RETURNING event_id вернёт строку, если запись была вставлена. Если event_id уже существует, ON CONFLICT DO NOTHING тихо проглотит конфликт, и результат будет пустым массивом. После этого IF-нод проверяет длину результата: {{ $json.length === 0 }} ведёт в Stop and Branch.
Транзакция здесь не нужна. PRIMARY KEY constraint в Postgres атомарен сам по себе. Два параллельных INSERT с одинаковым event_id не дадут обоим пройти: один получит строку в RETURNING, второй получит пустой результат. Гонка исключена на уровне движка.
Про скорость честно: Postgres медленнее Redis на этой операции. Один INSERT с проверкой занимает порядка 1-5 мс против 50-200 мкс у Redis. Конкретная разница зависит от железа, нагрузки и конфигурации базы. Но для типичной webhook-нагрузки, скажем, Stripe или GitHub, где потолок обычно в районе десятков событий в секунду, эта разница не ощущается вообще. Redis имеет смысл, когда счёт идёт на тысячи событий в секунду или когда latency критична для SLA.
Есть и побочный плюс: таблица processed_events становится аудит-логом. Можно в любой момент посмотреть, какой workflow обработал какой event_id и когда. С Redis такого из коробки нет.

Конструкция ON CONFLICT DO NOTHING позволяет PostgreSQL молча игнорировать повторную вставку строки с уже существующим уникальным ключом.
Паттерн 3: идемпотентный ключ на стороне платёжной системы
Stripe и ЮKassa оба поддерживают идемпотентность на уровне API, и это закрывает один из самых неприятных сценариев: n8n отправил запрос на создание PaymentIntent, получил таймаут или 502, и теперь вы не знаете, списались ли деньги или нет.
Механика в обоих случаях одинакова. Stripe принимает заголовок Idempotency-Key при POST /v1/payment_intents. Если в течение 24 часов прийти с тем же ключом, Stripe вернёт ответ первого запроса без создания новой транзакции. ЮKassa работает через поле idempotence_key в теле запроса к POST /payments, срок жизни ключа там тоже 24 часа.
Ключ нужно генерировать до первого запроса и тут же сохранять в контексте заказа, а не вычислять на лету при каждом вызове. Формула uuid4() от order_id плюс фиксированный суффикс вроде -pay работает хорошо: она детерминирована для конкретного заказа и не создаёт коллизий между разными операциями по одному и тому же заказу (возврат, частичный захват и т.д. получат свои суффиксы).
В n8n это решается Code-нодом перед HTTP Request:
// Code-нод: генерация idempotency key
const orderId = $json.order_id;
const key = `order-${orderId}-pay`;
// Далее передаём idempotencyKey в HTTP Request -> Headers
return [{ json: { idempotencyKey: key } }];
В самом HTTP Request node добавляем заголовок Idempotency-Key со значением {{ $json.idempotencyKey }}. Для ЮKassa то же значение идёт в тело запроса.
Этот паттерн страхует от двойного создания транзакции при сетевых сбоях между n8n и платёжным API. Он не заменяет дедупликацию входящих webhook-ов со стороны вашего сервиса. Stripe может дважды прислать payment_intent.succeeded даже для одного PaymentIntent, и с этим надо разбираться отдельно через хранение обработанных event_id. Два механизма решают разные проблемы и должны работать вместе.

Stripe принимает заголовок Idempotency-Key и гарантирует, что повторный запрос с тем же ключом не создаст второй платёж.
Как правильно ответить на webhook, чтобы не спровоцировать retry
Stripe ждёт HTTP 200 в течение 30 секунд. Не получил, считает доставку неудачной и ставит событие в очередь на повтор. Звучит разумно, но на практике это создаёт проблему: n8n по умолчанию держит соединение открытым до конца всего workflow. Если у тебя там запросы к внешним API, запись в базу и отправка письма, эти 30 секунд улетают быстро.
Решение прямолинейное: поставить нод "Respond to Webhook" сразу после входа, до любой тяжёлой логики. Тело ответа минимальное, статус 200, и соединение закрыто. Stripe доволен. Остаток workflow выполняется асинхронно, и тебе уже всё равно, сколько он займёт.
Вот конкретная последовательность нодов:
Webhook → Respond to Webhook (200 OK) → проверка подписи → идемпотентность → бизнес-логика
Второй момент с дублями. Когда идемпотентный ключ уже есть в Redis и ты понимаешь, что это повтор, не возвращай 409 или 422. Stripe трактует любой 4xx как сбой на своей стороне или на твоей, и ретраит снова. Правильный ответ на дубль: тот же 200, просто с другим телом, например {"status": "already_processed"}. Событие будет помечено как доставленное, ретраев не будет.
Дубли при этом стоит фиксировать. Я пишу их в отдельную таблицу в Postgres: event_id, received_at, source_ip, тип события. Это не для отладки в моменте. Через неделю смотришь на эту таблицу и видишь, например, что конкретный тип события (customer.subscription.updated) прилетает в два раза чаще, чем должен, и всегда с интервалом ровно 30 секунд. Это точный признак того, что где-то в цепочке ответ уходит с задержкой, и Stripe успевает запустить retry до закрытия соединения.
Redis SET NX здесь работает как первый фильтр: быстро, без обращения к базе. Postgres-лог работает как аудит: медленнее, но с историей. Оба нужны по разным причинам.
Похожая ситуация возникает, когда несколько сообщений от пользователя прилетают быстро подряд: если интересует буферизация очереди сообщений перед обращением к AI, принцип там схожий.

Ответить 200 сразу и обработать вебхук асинхронно, это способ избежать повторных попыток из-за таймаута на стороне отправителя.
Дедупликация заказов из WooCommerce и других CMS
WooCommerce умеет отправить одно и то же событие order.created дважды. Это происходит при обрыве соединения, когда плагин не получил ответ 200 и повторил запрос, или когда кто-то в панели администратора нажал "Отправить повторно" на вкладке Webhooks. Итог: в CRM два одинаковых заказа, задвоенная сумма в отчёте, злой менеджер.
Простейший ключ дедупликации: поле id из тела заказа. WooCommerce гарантирует его уникальность внутри одного магазина. Сохраняете id в Redis или в отдельной таблице, перед записью проверяете. Если запись есть, возвращаете 200 (не 409, иначе WooCommerce посчитает это ошибкой и повторит снова) и ничего не делаете.
Если вы интегрируете несколько магазинов в одну систему, id = 1047 могут прислать три разных магазина. Переходите на составной ключ: shop_domain + order_id. Например, "myshop.com:1047". Хэшировать смысла нет, строка и так короткая, хранится нормально.
Но иногда Redis или отдельной БД под дедупликацию просто нет. Тогда работает более медленный, зато самодостаточный вариант: перед созданием заказа в CRM делаете GET /orders?external_id=1047 и смотрите на ответ. Если заказ уже есть, пропускаете. Это добавляет один HTTP-запрос на каждое событие и немного увеличивает latency, но убирает инфраструктурную зависимость. Для магазинов с нагрузкой до нескольких сотен заказов в сутки разницы вы не почувствуете.
Отдельный момент с Shopify, Tilda и самописными CMS: у них нет такого же гарантированного целочисленного id. Shopify присылает id как gid://shopify/Order/1234567890, Tilda генерирует свои строковые идентификаторы, а самописный сайт может прислать вообще что угодно. Заранее договоритесь, какое поле считается внешним идентификатором, и закрепите это в документации интеграции, а не в голове разработчика.
Кстати, реальная авария с потерей данных из-за неожиданного перезапуска воркера подробно разобрана в постмортеме синхронизации остатков Ozon: там хорошо видно, как отсутствие проверки состояния до записи превращается в шестичасовую аварию.

Составной ключ из идентификатора события и идентификатора заказа защищает от коллизий, когда одна система генерирует похожие события для разных объектов.
Тестирование идемпотентности: как убедиться, что защита работает
Написать проверку на дубли и не протестировать её, это примерно как застегнуть ремень безопасности и не проверить, защёлкнулся ли замок. Вот как я это делаю.
ЮKassa Sandbox. В личном кабинете sandbox есть кнопка повторной отправки webhook для конкретного события. Берёшь реальный payment.succeeded, жмёшь "Отправить повторно" дважды с интервалом в секунду. Если во второй раз транзакция не создалась, а в логах появился duplicate_skipped, защита работает. Никакого прода, никакого риска.
Stripe CLI даёт больше контроля. Команда:
stripe trigger payment_intent.succeeded --override id=evt_test_123
Запускаешь её три раза подряд с одним и тем же id. Первый запуск обрабатывается, второй и третий должны вернуть 200 без сайд-эффектов. Именно 200, а не 409. Stripe ожидает, что ты уже видел это событие и подтверждаешь получение.
В n8n схема чуть другая. Берёшь Exec ID первого успешного запуска из интерфейса, потом запускаешь workflow вручную через "Test workflow" с тем же payload, подставив этот ID как event_id. Смотришь в историю выполнений: второй прогон должен пройти тот же путь до узла проверки идемпотентности и там остановиться, не дойдя до записи в БД.
Хороший признак корректной работы: второй запрос с уже виденным event_id завершается заметно быстрее первого. Это означает, что система дошла только до Redis или до SELECT в Postgres и вернулась, не выполняя бизнес-логику. Если время выполнения второго запроса сопоставимо с первым, стоит разобраться: возможно, дубль всё-таки обрабатывается полностью.
Отдельно добавляю в мониторинг счётчик отбитых дублей. В Prometheus это выглядит как инкремент webhook_duplicates_total с лейблом источника (yookassa, stripe, internal). В норме это единицы в день, сетевые ретраи. Если счётчик вдруг вырастает до сотен в час, это уже сигнал: либо у платёжного провайдера проблемы с их стороной (retry loop), либо что-то сломалось в обработке и система сама себя переотправляет. Без этого счётчика такую ситуацию можно не заметить неделями.

Stripe CLI позволяет вручную повторить любое событие командой stripe events resend, что удобно для отладки логики дедупликации.
Типичные ошибки при реализации и как их избежать
Разберу пять граблей, на которые наступает почти каждый, кто впервые делает идемпотентность в n8n.
Timestamp как ключ идемпотентности. Выглядит логично: берёшь Date.now() или new Date().toISOString(), кладёшь в Redis. Проблема в том, что два события от той же платёжной системы, пришедших с интервалом меньше миллисекунды, получат одинаковый timestamp и пройдут как один. Или наоборот: одно событие придёт дважды с разными таймстампами, и защита вообще не сработает. Всегда берём event_id из тела запроса. Stripe кладёт его в evt_xxxxxxxx, PayPal в id, большинство провайдеров делают аналогично.
Redis без TTL. Установил ключ, не поставил время жизни, забыл. Через месяц в Redis сотни тысяч ключей событий, которые уже никогда не придут повторно. Память растёт, деньги на инфраструктуру тоже. Ставь TTL сразу при записи. Для платёжных событий обычно хватает 72 часов: большинство систем перестают ретраить раньше, чем через три дня.
Проверка дубля в середине флоу. Вижу это постоянно: разработчик ставит Code-нод с проверкой Redis где-то после HTTP Request к внешнему API или после тяжёлого SQL-запроса. Логика понятна, ему казалось, что сначала нужно "подготовить данные". Но если событие пришло дважды, оба раза выполнится дорогой запрос, и только потом воркфлоу поймёт, что это дубль. Проверка идёт первым шагом сразу после Webhook-нода, до любой логики.
**






