Перейти к содержанию

Webhook idempotency в n8n: защита от дублей, повторных доставок и двойных оплат

Обновлено: 2026-05-29

Webhook почти никогда не гарантирует “ровно один раз”. Внешний сервис может отправить одно событие повторно: из-за таймаута, 5xx-ответа, сетевого сбоя, ручного retry или собственной политики доставки. Если workflow не готов к повторам, вы получите дубли лидов, повторные уведомления, лишние задачи и, в худшем случае, двойные действия с оплатами.

Idempotency означает, что повторная обработка одного и того же события не меняет результат. В n8n это нужно проектировать явно: выбрать ключ события, сохранить факт обработки, проверять его до записи в CRM или платежную систему и возвращать корректный ответ отправителю.

Где чаще всего появляются дубли

ИсточникПример дубляКлюч для защиты
Tilda formsпользователь нажал кнопку дваждыform_id + submitted_at + phone/email
ЮKassaповторное уведомление по payment eventevent + object.id
amoCRM/Bitrix24повторный webhook по изменению сделкиentity_type + entity_id + updated_at
Telegram botповторный update после сбояupdate_id
Custom APIretry клиента после timeoutIdempotency-Key header

Минимальная архитектура

Webhook → Normalize event → Build idempotency_key → INSERT key into Postgres →
IF inserted → process business action → mark success → Respond 200
IF duplicate → Respond 200 with duplicate=true

Ключевой момент: проверка дубля должна идти до побочных эффектов. Нельзя сначала создать сделку, а потом понять, что событие уже приходило.

PostgreSQL-таблица для dedupe

CREATE TABLE IF NOT EXISTS webhook_events (
  id bigserial PRIMARY KEY,
  idempotency_key text NOT NULL UNIQUE,
  source text NOT NULL,
  event_type text NOT NULL,
  external_id text,
  payload jsonb NOT NULL,
  status text NOT NULL DEFAULT 'received',
  first_seen_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  error text
);

Уникальный индекс по idempotency_key — основа защиты. Даже если два одинаковых webhook придут почти одновременно, база не позволит вставить один и тот же ключ дважды.

Вставка без гонки

INSERT INTO webhook_events (
  idempotency_key, source, event_type, external_id, payload
) VALUES (
  $1, $2, $3, $4, $5::jsonb
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;

В n8n следующая IF-нода смотрит: если Postgres вернул строку, событие новое. Если строк нет, это дубль, и workflow должен быстро вернуть 200 без повторной записи в CRM.

Как собрать ключ события

Не используйте весь payload как ключ: одно и то же событие может прийти с другим timestamp доставки. Лучше собрать стабильные поля:

const body = $json.body ?? $json;
const source = body.source ?? 'custom';
const event = body.event ?? body.event_type ?? 'unknown';
const externalId = body.object?.id ?? body.payment_id ?? body.update_id ?? body.id;

if (!externalId) {
  throw new Error('No stable external id for idempotency key');
}

return [{
  json: {
    ...body,
    idempotency_key: `${source}:${event}:${externalId}`,
    source,
    event_type: event,
    external_id: String(externalId)
  }
}];

Что возвращать отправителю

Если событие уже было обработано, чаще всего нужно вернуть HTTP 200, а не ошибку. Иначе отправитель будет продолжать retry и создавать ещё больше шума.

{
  "ok": true,
  "duplicate": true,
  "message": "event already accepted"
}

Ошибка 409 выглядит логично для API, но для webhook-доставки часто хуже: многие сервисы воспринимают не-2xx как повод повторить запрос.

Статусы обработки

СтатусКогда ставитьЗачем
receivedсобытие принято и ключ записанвидно, что webhook дошёл
processingначалась бизнес-обработкапомогает ловить зависшие события
processedCRM/таблица/оплата обновленыможно строить отчёт
failedошибка после принятия событияможно безопасно переобработать вручную

Redis lock или Postgres?

Для большинства webhook-сценариев достаточно PostgreSQL unique constraint. Redis lock полезен, если нужно защитить короткое окно конкурентной обработки, но он не заменяет постоянный журнал событий. Если Redis перезапустится, lock исчезнет; запись в Postgres останется.

Особый случай: платежи

Для платежей idempotency обязателен. Никогда не делайте бизнес-действие только по факту входящего webhook без проверки статуса у платёжного API. Безопасная схема:

  1. Принять webhook.
  2. Собрать ключ payment_event:payment_id.
  3. Записать ключ в dedupe table.
  4. Если событие новое — запросить платёж у провайдера.
  5. Проверить статус, сумму, валюту, order_id.
  6. Только потом обновить CRM или отправить товар/доступ.

Типовые ошибки

  • ключ строится из нестабильного timestamp доставки;
  • дедупликация сделана после создания сделки в CRM;
  • дубли получают 500 и провоцируют новые retry;
  • нет unique constraint в базе, только IF в workflow;
  • ошибки обработки не отделены от ошибок приёма webhook.

Связанные материалы