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.

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.

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?

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?

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:

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?

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 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.

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

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…