Webhook idempotency в n8n и Postgres: защита от повторных событий ¶
Обновлено: 2026-05-30
Импортируйте workflow, замените credentials и прогоните тестовый payload до включения production.
Проблема: webhook-провайдеры повторяют события при timeout, 5xx или сетевых сбоях. Если n8n каждый раз выполняет бизнес-действие, один платеж, лид или статус может обработаться дважды.
Решение: выносим идемпотентность в Postgres: один `event_id` или HMAC-ключ получает unique index, повторные webhook-события не запускают бизнес-ветку, а возвращают уже известный результат.
Проблема: почему webhook-события приходят повторно ¶
Повторный webhook — это нормальное поведение, а не баг. Платежные сервисы, маркетплейсы, CRM и формы повторяют POST, если не получили быстрый 200 OK. Иногда первый запрос успел изменить CRM, но ответ потерялся на сети, и провайдер присылает событие снова.
Идемпотентность нужна там, где повтор опасен: создание сделки, начисление доступа, отправка письма, смена статуса, списание бонусов. Цель workflow — не “поймать дубль в конце”, а не пустить повтор в бизнес-ветку с самого начала.
Архитектура workflow idempotency на Postgres ¶
| Нода | Роль | Что проверить |
|---|---|---|
| Webhook input | принимает событие от провайдера | event_id, timestamp, signature, raw body |
| Validate signature | проверяет HMAC или secret | защита от подделки и replay |
| Build idempotency key | собирает стабильный ключ | provider:event_type:event_id |
| Insert key in Postgres | делает INSERT ON CONFLICT | unique index и статус processing |
| Run business action | выполняет только новое событие | CRM, склад, письмо, доступ |
| Finalize event | ставит processed или failed | duration, target_id, error message |
В Postgres лучше хранить не только ключ, но и состояние: `processing`, `processed`, `failed`. Тогда можно отличить честный повтор от зависшего события и сделать ручное восстановление без повторного бизнес-действия.
Контракт входных данных webhook-события ¶
{
"event_id": "evt_2f3c6a99",
"event_type": "lead.created",
"provider": "tilda",
"occurred_at": "2026-05-30T10:00:00Z",
"signature": "sha256=7b5f...",
"data": {
"phone": "+7 (916) 555-12-34",
"name": "Анна",
"source": "landing"
}
}
Лучший ключ — ID события от провайдера. Если его нет, используйте комбинацию provider, event_type, external_id и timestamp bucket, но обязательно документируйте компромисс: такой ключ может быть менее точным.
Code Node: idempotency key и SQL ON CONFLICT ¶
const crypto = require('crypto');
const src = $json.body ?? $json;
const provider = String(src.provider ?? 'unknown').trim();
const eventType = String(src.event_type ?? src.type ?? '').trim();
const eventId = String(src.event_id ?? src.id ?? '').trim();
if (!eventType) throw new Error('Missing event_type for idempotency');
if (!eventId) throw new Error('Missing event_id for idempotency');
const idempotencyKey = `${provider}:${eventType}:${eventId}`;
const payloadHash = crypto
.createHash('sha256')
.update(JSON.stringify(src.data ?? src))
.digest('hex');
const sql = `
INSERT INTO webhook_idempotency (idempotency_key, provider, event_type, event_id, payload_hash, status, created_at)
VALUES ($1, $2, $3, $4, $5, 'processing', now())
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;
`;
return [{
json: {
idempotency_key: idempotencyKey,
provider,
event_type: eventType,
event_id: eventId,
payload_hash: payloadHash,
sql,
sql_params: [idempotencyKey, provider, eventType, eventId, payloadHash],
original_event: src
}
}];
Минимальная схема таблицы Postgres
Создайте таблицу webhook_idempotency с unique index по idempotency_key и полями provider, event_type, event_id, payload_hash, status, target_id, error, created_at, processed_at. Это позволит отличать новый event от повтора.
Готовый workflow JSON: скачать и импортировать ¶
Скачать готовый workflow JSON Скачать тестовый payload
{
"name": "Nodbot - Webhook idempotency with Postgres",
"nodes": [
{
"name": "Webhook input",
"type": "n8n-nodes-base.webhook",
"purpose": "Принять событие и заголовки"
},
{
"name": "Validate signature",
"type": "n8n-nodes-base.code",
"purpose": "Проверить HMAC/secret и timestamp"
},
{
"name": "Build idempotency key",
"type": "n8n-nodes-base.code",
"purpose": "Собрать provider:event_type:event_id"
},
{
"name": "Insert key in Postgres",
"type": "n8n-nodes-base.postgres",
"purpose": "INSERT ON CONFLICT DO NOTHING"
},
{
"name": "New event gate",
"type": "n8n-nodes-base.if",
"purpose": "Пустить только новую запись"
},
{
"name": "Business action",
"type": "n8n-nodes-base.httpRequest",
"purpose": "Создать/обновить объект во внешней системе"
},
{
"name": "Finalize status",
"type": "n8n-nodes-base.postgres",
"purpose": "Поставить processed или failed"
},
{
"name": "Respond",
"type": "n8n-nodes-base.respondToWebhook",
"purpose": "Вернуть 200 повтору и новому событию"
}
],
"connections": "Webhook input → Validate signature → Build idempotency key → Insert key in Postgres → New event gate → Business action → Finalize status → Respond"
}
Пошаговая настройка Postgres unique key и n8n ¶
- Создайте таблицу `webhook_idempotency` и unique index по `idempotency_key`.
- Импортируйте workflow и настройте Webhook URL с секретом.
- Добавьте проверку подписи или timestamp, если провайдер это поддерживает.
- Соберите ключ из provider, event_type и event_id до бизнес-действия.
- После успешного действия обновляйте status, target_id и processed_at.
Тесты перед production и проверка повторных webhook ¶
curl -X POST "https://YOUR-N8N-DOMAIN/webhook/webhook-idempotency-to-postgres" \
-H "Content-Type: application/json" \
--data @webhook-idempotency-to-postgres-payload.json
- Отправьте один и тот же event_id дважды: бизнес-действие должно выполниться один раз.
- Измените payload при том же event_id и проверьте реакцию по payload_hash.
- Сымитируйте падение бизнес-API после insert и проверьте статус processing/failed.
- Отправьте webhook без event_id и убедитесь, что workflow не делает бизнес-действие.
- Проверьте ручной replay из Postgres по failed-событию.
Production-риски webhook idempotency ¶
- Ключ строится из всего payload. Незначительный порядок полей создаёт новый hash и дубль.
- Нет unique index. Две одновременные доставки проходят проверку одновременно.
- 200 возвращается до записи ключа. Провайдер считает событие обработанным, хотя n8n мог упасть.
- Повтор считается ошибкой. Провайдер продолжает ретраи, хотя правильный ответ повтору — безопасный 200.
- Нет статусов. Нельзя отличить обработанное событие от зависшего processing.
Полезные ссылки и смежные workflow ¶
См. также webhook signature validation, retry/DLQ для HTTP Request, idempotency keys и ЮKassa webhook с платежами. Официальные документы: PostgreSQL unique constraints, PostgreSQL INSERT ON CONFLICT и n8n Webhook node.
Критерии готовности ¶
- В Postgres есть unique index по idempotency_key.
- Повторный event_id не запускает бизнес-действие.
- События имеют статусы processing, processed и failed.
- Подпись или секрет webhook проверяется до idempotency.
- Есть процедура безопасного replay для failed-событий.
Nodbot настроит idempotency-слой на Postgres: unique key, подписи, статусы, replay, DLQ и мониторинг повторных событий.
Внедрить idempotency