Preventing duplicate webhook executions in n8n (idempotency gate workflow)

Webhook providers use at-least-once delivery.

If a request times out or fails, they retry the webhook — which can cause the same workflow to execute twice.

That means duplicated side effects like:

• duplicate Stripe charges
• duplicate emails
• duplicate database writes

I ran into this problem when building production workflows, so I created a small idempotency gate template for n8n.

The workflow checks an event key before any side effect runs:

first event → ALLOW
retry → BLOCK

This ensures retries are stopped before any mutation happens.

The template was recently approved and published in the official n8n template library:

GitHub repo:

Curious how others handle webhook retries in n8n workflows.

solid approach! idempotency is one of those things most people skip until they’ve sent duplicate emails or charged someone twice lol. we do something similar with redis as the dedup store — are you using a database or in-memory for the event key lookup?

Yeah, same conclusion here - once the side effect is real, skipping idempotency stops being optional pretty quickly.

In our case the lookup is external as well. The important part for us was making sure the check happens before the mutation, not after it.

Redis works well for that because the workflow itself usually has no memory across executions, and retries often come from more than one layer.

@neshkito makes sense — check-before-mutation is the only safe order when the side effect can’t be undone. redis fits well here because the atomic SET NX gives you the check and the lock in one operation, so you don’t have a race window even across concurrent executions. sharing dedup state across multiple workflow instances is another benefit of keeping it external — scaling horizontally doesn’t break the idempotency layer.

One thing we also noticed is that retries often don’t come from a single source.

You might get a retry from the webhook provider, then another from an HTTP client, and sometimes from queue workers as well.

That’s where the external store becomes important, because each execution looks completely independent from the workflow’s perspective.


Quick update — made this a community node (@aari/n8n-nodes-aari).

Replaces the HTTP + IF setup with:
AARI Gate → action → AARI Complete

Has a blocked output + handles retries.

Works on self-hosted. Template still needed for n8n cloud.

How do you usually deal with duplicates?

nice, community node is a much cleaner install than wiring up HTTP + IF yourself. still on redis here (SET NX + TTL), thats been working well for the use cases we have. does it expose the event key so you can plug in your own dedup logic, or is the key format fixed?

The key isn’t fixed - run_id is a free-form field you can set to
anything ({{ $execution.id }}, your own webhook event ID, etc.).
AARI uses it to group related actions in the audit trail, not for
dedup itself.

If you need dedup at the gate level (block duplicate calls with
the same key), that’s idempotency_key - currently not exposed in
the node UI, but it’s in the underlying API. Worth adding as an
optional field?

What’s your dedup use case - webhook retries or something else?

mostly webhook retries from providers with at-least-once delivery — stripe, shopify etc. we use the event id from the payload as the key since thats stable across retries. exposing idempotency_key as optional would be useful, expression binding makes it flexible enough to handle different key formats per integration.

Yep, that’s the ideal setup - stable event ID → idempotency key.

That’s exactly where duplicate execution issues show up (at-least-once delivery + retries).

Exposing idempotency_key as an optional, expression-based field in the node UI makes sense - keeps it flexible across providers without adding complexity for simpler flows.

Will add it :+1:

nice, will test it once its out. expression binding makes it drop-in across different providers without having to rewire the dedup key per integration.

Yeah exactly. Event_id as key is the cleanest way to handle retries.

Redis SET NX + TTL works well there, especially when everything is coming from the same source.

Where it got messy for us was when retries came from multiple layers (provider + client + queue) and the same logical action ended up with slightly different payload shapes.

Then dedup becomes less about “same key” and more about “same intent”.

Curious if you’ve run into that or if your flows are mostly single-source?

mostly single-source for us — stripe and shopify where the event id stays stable. did hit the multi-layer thing once with a custom batch pipeline where the same event came from the provider retry and our dead-letter queue with slightly different wrapping. ended up normalizing before the gate: strip the envelope fields, hash only the stable business identifiers. once you treat it as schema normalization instead of key matching its much cleaner, but only worth the complexity if you’re running multiple concurrent retry paths

yeah that makes sense - schema normalization is a clean way to handle that case

we hit something similar with multi-layer retries, especially when DLQs got involved

where it got a bit tricky for us was when everything deduped correctly, but the action still wasn’t the right thing to run in that situation

so technically correct event → still wrong outcome

not super common, but annoying when it happens

have you mostly stayed within dedup so far, or did you ever need to add checks around when something should actually run?

yeah, we hit that too — mostly with state drift. the gate handles “has this event been processed”, but by the time a retry arrives the underlying resource might have moved on. cancelled order, already-refunded payment, user already off-boarded. we added a state check after the gate as a separate step: pull current state, validate preconditions, then run. cleaner to keep the two concerns separate rather than baking both into the dedup logic.

Yeah that separation makes sense - we saw the same pattern early on

that’s actually part of why we built the AARI gate the way it is - not just dedup, but deciding before execution based on context + prior actions.

The node you’re testing is basically a lightweight version of that flow.

Curious how it feels once you try it in your setup.

that’s a more complete solution than what I have — adding a context-aware decision layer on top of dedup is where things actually get interesting. curious what you use as the “context” signal there — state from prior runs, or something external like a status field? will report back once this has run in prod for a bit.

Yeah, we try to keep it pretty simple in practice

usually a mix of:

  • what happened earlier in the same run (sequence of actions)
  • basic resource state (like status / flags)
  • and some lightweight constraints (amounts, limits, etc.)

nothing super heavy like full graph queries . More like “given what just happened, does this next step still make sense”

Trying to stay fast enough to sit in front of the call without adding noticeable latency

curious how it behaves in your setup once you run it - that’s usually where the interesting edge cases show up

makes sense to keep it lightweight — if the gate adds latency, the whole premise breaks. the “given what just happened” framing is basically a minimal FSM check, which is usually enough for the common failure cases anyway. will report back, our setup has some interesting edge cases around partial retries so should be a decent stress test.

Exactly: FSM is a good way to think about it.

partial retries are where it usually gets interesting, especially when one step succeeds and the next one replays out of context

curious to see what breaks in your setup - those edge cases are usually what shaped most of the current behavior on our side

let me know what you hit once it’s running…