Most duplicate-workflow bugs in n8n are not n8n bugs. A webhook sender — Stripe, Shopify, GitHub, Gumroad — delivers events with at-least-once semantics. If the HTTP request times out or returns a 5xx, the provider assumes you never got it and resends the identical payload. n8n sees that resend as a brand-new trigger, runs the whole workflow again, and the second run charges the card, sends the email, or writes the row a second time.
The fix is not to stop retries. It is to make your workflow idempotent: running it twice with the same logical event must produce the same result as running it once. This post is the pattern — a fast acknowledgement at the trigger, a stable key derived from the business event, a lookup that short-circuits the duplicate, and a log so you can see what got blocked.
Why the duplicate happens: one timeout, two side effects
The failure sequence is short and it is almost always the same. Your workflow starts, the trigger fires, n8n begins running nodes, and somewhere in the middle an external call takes too long. The provider gave up waiting and marked the delivery failed. It retries thirty seconds later.
- First delivery — the run starts, the charge succeeds, the workflow finishes its work, but the response arrives after the provider's window. The provider records a failure it cannot distinguish from "server never ran".
- Retry — the identical payload arrives again. n8n has no memory of the first run, so it starts a second execution from scratch and the side effect fires again.
Notice that your workflow behaved correctly on both runs. The duplication came entirely from the retry. That is why the fix belongs at the boundary of the workflow, not in the middle of it.
Step 1: Acknowledge the webhook immediately
The single highest-leverage setting in this whole pattern is the Webhook node's response mode. In the node's Response Mode setting, choose Using 'Respond to Webhook' node as the mode, then place a Respond to Webhook node as the first node after the trigger that returns 200 with an empty or minimal body.
Everything else in the workflow then runs in the same execution, but the provider has already been told "received" and stops retrying. This one setting eliminates the majority of duplicate scenarios before you write a single line of guard logic, because most duplicates are timeouts, not genuine re-sends.
One caveat worth knowing before you rely on {{ $execution.id }} in the response body: there is a long-standing n8n issue where the expression evaluates to empty when the Webhook trigger itself answers immediately, because the execution ID is not yet assigned at that point. If you need the execution ID in the response, answer from the Webhook node's own response data, or accept the separate Respond to Webhook node workaround.
Step 2: Build a key from the business event, not the run
Now handle the retries that survive step 1 — the provider that re-sends a genuine duplicate, or the one that re-sent while your instance was down. You need a value that means "I have already handled this specific thing".
The most common mistake is using the execution ID or a random UUID as that key. It is generated per run, so a retry from a second run produces a different key and sails straight through the guard. Your key has to be deterministic and derived from the payload:
- Best — the provider's own event ID. Stripe sends
event.id, Shopify sendsX-Shopify-Event-Id, GitHub sendsX-GitHub-Delivery. These are stable across retries of the same event and are exactly what the idempotency key is for. - Good — a composite of business fields. For an invoice dunning workflow, something like
invoice:{{ $json.invoice_number }}:{{ $json.dunning_stage }}means "this nudge for this invoice" only ever fires once, no matter how many times the source retries. - Fallback — a hash of the payload. When the sender gives you nothing stable, add a Code node right after the trigger and hash the canonicalised JSON to produce a collision-resistant fingerprint.
Write the key as a prefixed string rather than a bare value. A prefix like gumroad-sale: or charge: means one shared table can hold keys for several workflows without two unrelated events colliding on the same value.
Step 3: Look up the key before the first side-effect node
This is the guard. It must sit before the first node that touches the outside world — the charge, the email send, the database insert. A guard placed one node too late has already duplicated the thing it was meant to prevent.
The n8n-native way to hold the key set is the Data Table node, which stores structured data inside the instance:
- Create a data table named
webhook_idempotencywith columnskey,status,execution_id, andprocessed_at. You can also create and inspect tables from the Data Tables tab in your project overview. - Add a Data Table node in Row → Select mode, filtering on
keyequals the expression you built in step 2. - Add an If node: if a row came back, this event was already handled.
- On the "already handled" branch, stop the execution — no-op, and optionally write a line to a dead-letter sheet for your own visibility.
- On the "new event" branch, run the real work, then use Row → Upsert to record the key with its final status.
Using Upsert rather than Insert matters: if two copies of the same event arrive close together, the upsert updates the existing row instead of failing on a duplicate-key error and turning a duplicate into a hard failure.
Step 4: Log what you blocked
A guard you never inspect is a guard you cannot trust. Two minutes of logging pays for itself the first time it blocks something real:
- Append blocked duplicates to a Google Sheet with the key, the execution ID, and a timestamp. Volume here is your duplicate rate and it should trend to zero.
- Send a count to Telegram on a schedule so a spike is visible without opening a spreadsheet.
- Prune old rows. A 24-hour window is what the published AARI idempotency gate template uses; anything older than your longest provider retry window is dead weight in the table.
If you would rather have the table handle it, the AARI template on the n8n template library implements exactly this shape — record the key on first arrival, return an immediate 200, and return a BLOCK decision that stops the workflow before any side effect runs when the same key reappears inside 24 hours.
Where this fits alongside error handling
Idempotency is the half of reliability people skip. The other half is what happens when a node genuinely fails, and the two are complements rather than alternatives:
- Node-level retry — a transient 503 on an API call is retried two or three times with a wait. Fine, as long as the node being retried is either read-only or guarded by an idempotency key of its own.
- Error Trigger workflow — the instance-wide handler that pings you when an execution dies. Worth wiring regardless, but note that it fires on failure, not on duplication: a duplicated charge succeeds cleanly and never triggers it.
- Timeouts —
EXECUTIONS_TIMEOUTsets a default per-workflow execution limit in seconds andEXECUTIONS_TIMEOUT_MAXsets the ceiling you can raise it to for an individual workflow. Sizing these to the workflow rather than globally matters, because one value cannot fit both a three-node alert and a long enrichment run.
If you have not set up the error side yet, n8n Error Handling: Stop Losing Runs (and Leads) Silently covers the three-tier pattern. The short version: retry the transient, continue on the optional, halt loudly on the invalid — and add the idempotency gate above it so the retries never double-charge anyone.
The mistakes that cost the most time
- Guarding after the side effect. A lookup placed after the send or the insert protects nothing. The check has to be the first thing after the response.
- Keying on the run, not the event. Execution IDs and
{{ $uuid }}are unique by construction, so they can never detect a duplicate. This is the most common reason a hand-rolled guard "does not work". - Leaving the response mode on "Using 'Respond to Webhook' node" but placing the node at the end. The provider is still waiting the whole time, and the timeout is still there. The Respond to Webhook node has to come first.
- Never expiring keys. An unpruned table grows forever and a key that never expires will eventually block a legitimate repeat — a customer paying the same invoice twice six months apart is two real events, not one duplicate.
- Assuming retries are always the provider's fault. Your own node retry policy can re-fire a side effect too. If a node's request succeeded but the response was lost, Retry on Fail will send it again. The idempotency key has to be applied per side-effecting action, not once per workflow.
Where this matters most in a one-person business
The workflows where a duplicate is expensive rather than annoying are the ones with money or a promise attached:
- Payment webhooks — the customer is charged twice, and you are the one fielding the refund request.
- Digital delivery — a Gumroad sale fires twice, the buyer gets two confirmation emails, and one of them is a second access email for a product they already own.
- Invoice follow-up — a retried dunning run sends the "final notice" email two days in a row, which is the fastest way to lose a client you were trying to keep paid.
- Lead and CRM writes — duplicate rows quietly corrupt the pipeline, and the skew is invisible until someone counts the numbers for a forecast.
Each of these has the same shape: an inbound event, a key that identifies it, one irreversible action. The gate is roughly six nodes, and it is the cheapest insurance in the stack.
Start with a real workflow
If you want the guard pattern in a runnable form, the Gumroad Sales Notifier is the cleanest test case: a sale webhook arrives, n8n alerts you on Telegram and emails the buyer, and a retried delivery means a duplicate alert and a duplicate email to someone who just paid. Import it, add the guard described above, and you have a production-safe notification pipeline in under half an hour.