Webhook idempotency в n8n: защита от дублей, повторных доставок и двойных оплат
Обновлено: 2026-05-29
Webhook почти никогда не гарантирует “ровно один раз”. Внешний сервис может отправить одно событие повторно: из-за таймаута, 5xx-ответа, сетевого сбоя, ручного retry или собственной политики доставки. Если workflow не готов к повторам, вы получите дубли лидов, повторные уведомления, лишние задачи и, в худшем случае, двойные действия с оплатами.
Idempotency означает, что повторная обработка одного и того же события не меняет результат. В n8n это нужно проектировать явно: выбрать ключ события, сохранить факт обработки, проверять его до записи в CRM или платежную систему и возвращать корректный ответ отправителю.
Где чаще всего появляются дубли
| Источник | Пример дубля | Ключ для защиты |
|---|---|---|
| Tilda forms | пользователь нажал кнопку дважды | form_id + submitted_at + phone/email |
| ЮKassa | повторное уведомление по payment event | event + object.id |
| amoCRM/Bitrix24 | повторный webhook по изменению сделки | entity_type + entity_id + updated_at |
| Telegram bot | повторный update после сбоя | update_id |
| Custom API | retry клиента после timeout | Idempotency-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 | началась бизнес-обработка | помогает ловить зависшие события |
| processed | CRM/таблица/оплата обновлены | можно строить отчёт |
| failed | ошибка после принятия события | можно безопасно переобработать вручную |
Redis lock или Postgres?
Для большинства webhook-сценариев достаточно PostgreSQL unique constraint. Redis lock полезен, если нужно защитить короткое окно конкурентной обработки, но он не заменяет постоянный журнал событий. Если Redis перезапустится, lock исчезнет; запись в Postgres останется.
Особый случай: платежи
Для платежей idempotency обязателен. Никогда не делайте бизнес-действие только по факту входящего webhook без проверки статуса у платёжного API. Безопасная схема:
- Принять webhook.
- Собрать ключ
payment_event:payment_id. - Записать ключ в dedupe table.
- Если событие новое — запросить платёж у провайдера.
- Проверить статус, сумму, валюту, order_id.
- Только потом обновить CRM или отправить товар/доступ.
Типовые ошибки
- ключ строится из нестабильного timestamp доставки;
- дедупликация сделана после создания сделки в CRM;
- дубли получают 500 и провоцируют новые retry;
- нет unique constraint в базе, только IF в workflow;
- ошибки обработки не отделены от ошибок приёма webhook.