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';
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;
If this returns no row, the draft was already handled, superseded or expired, so the workflow stops instead of sending.
Workflow outline (n8n)
- Webhook intake, then a Code node builds
draft_key,event_keyandversion. - Postgres drops duplicate deliveries via
seen_events. - Postgres looks up the pending draft; an IF node skips events that are not newer.
- The LLM writes the draft only for new or newer events (this also saves tokens).
- Upsert one pending draft, bump
revision, notify the reviewer once per key. - 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)