Reliability

n8n Webhook Idempotency: Stop Double Charges and Duplicate Emails

2026-09-28 · 7 min read

Your workflow is not buggy. The provider is behaving exactly as designed — it retried because your server was too slow to answer, and now the customer has been charged twice.

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.

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:

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:

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:

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:

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

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:

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.

Related templates

Want the workflow, not the write-up?

Every Matemplates workflow imports into your own n8n instance — you own it, run it free, and edit it. Grab one and ship today.

Browse the store →