DEV Community

Viet Automation
Viet Automation

Posted on

Human-in-the-Loop AI Drafts in n8n Without the Mess: One Pending Draft per Customer, Stale Expiry and Out-of-Order Webhooks (2026)

Short answer: give every draft a deterministic draft_key (hash of the customer or record plus the action type), keep at most one pending draft per key with a partial unique index, ignore webhook events that are older than the draft you already hold, expire drafts nobody reviewed after 48 hours, and only send from an approval webhook that flips pending to approved atomically.

Why this keeps happening

A common pattern in automation communities: teams add a human review step for safety, then the next run stacks a second and third draft on top of the first, a retried webhook creates a duplicate, an older event arrives late and overwrites a newer draft, and a reviewer approves something stale.

The four failure modes and the fix

Failure What the reviewer sees Fix
Rerun stacks drafts 3 drafts for the same customer draft_key + partial unique index WHERE status='pending', upsert instead of insert
Webhook retried Duplicate draft and duplicate Slack ping seen_events table keyed by a hash of the raw payload, ON CONFLICT DO NOTHING
Events out of order Old data overwrites new draft Store source_version (event updated_at), skip events with a lower or equal version
Nobody reviews Draft approved days later with stale facts expires_at, hourly sweep sets expired, never auto-sends

Schema

CREATE TABLE seen_events (
  event_key CHAR(64) PRIMARY KEY,
  seen_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE draft_queue (
  id BIGSERIAL PRIMARY KEY,
  draft_key CHAR(64) NOT NULL,
  entity TEXT, kind TEXT, body TEXT,
  source_version BIGINT,
  revision INT DEFAULT 1,
  status TEXT NOT NULL,           -- pending | approved | rejected | expired | sent
  expires_at TIMESTAMPTZ,
  decided_by TEXT, decided_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX one_pending ON draft_queue(draft_key) WHERE status = 'pending';
Enter fullscreen mode Exit fullscreen mode

The approval step that can't double-send

UPDATE draft_queue
SET status = 'approved', decided_by = $1, decided_at = NOW()
WHERE draft_key = $2 AND status = 'pending' AND expires_at > NOW()
RETURNING id, body;
Enter fullscreen mode Exit fullscreen mode

If this returns no row, the draft was already handled, superseded or expired, so the workflow stops instead of sending.

Workflow outline (n8n)

  1. Webhook intake, then a Code node builds draft_key, event_key and version.
  2. Postgres drops duplicate deliveries via seen_events.
  3. Postgres looks up the pending draft; an IF node skips events that are not newer.
  4. The LLM writes the draft only for new or newer events (this also saves tokens).
  5. Upsert one pending draft, bump revision, notify the reviewer once per key.
  6. A schedule trigger expires stale drafts every hour.

Where to run it for $0 to $5

  • Self-host n8n plus Postgres on Railway or start on n8n Cloud.
  • In a visual builder, the same rules work in Make.com using a Data Store keyed by draft_key.

Pair it with a safety guard

This queue pairs well with the budget caps, idempotency keys and kill switch from my previous post: How to Stop an AI Agent From Emailing 400 Customers at 3AM.

FAQ

Why not just let the agent send when confidence is high? You can, but put the same draft_key and status check in front of the send, so retries still can't double-send.
Does the version check need updated_at? Any monotonic field works: a sequence number, a CRM modified timestamp, or the provider's event id if it is ordered.
What about Slack spam during reruns? Notify only when the upsert inserts a new key or raises revision, and edit the original Slack message instead of posting a new one.

If you prefer a visual builder for this pattern, you can try Make.com.


Disclosure: Some links are affiliate links — I may earn a commission at no extra cost to you.

Top comments (0)