FlowKit

n8n Webhook Idempotency: Avoiding Double Processing of the Same Request

Published 23 July 2026 · 5 min read

A webhook firing twice for the same event isn't an edge case — it's the normal behavior of nearly every serious sender out there. Stripe, GitHub, Typeform, and most services that send webhooks operate on at-least-once delivery: if your n8n instance doesn't respond fast enough, responds with an error, or the connection drops before the response goes out, the sender retries. That's a reliability guarantee on their end. On the n8n side, without precautions, it means the same Stripe payment can trigger an invoice email twice, or the same GitHub commit can kick off a deployment twice. This guide covers how to make an n8n workflow idempotent — able to absorb a replay without duplicating its effects.

Why a webhook fires twice

Three common scenarios, all legitimate on the sender's side:

  • Response timeout: if the workflow processes the event (an LLM call, a database write, sending an email) before responding to the webhook, and that processing takes longer than the sender's timeout window (often just a few seconds), the sender considers the delivery failed and resends the same request.
  • HTTP 5xx response: a transient error on the n8n side (a restart, a load spike) causes a server error response; the sender automatically retries, typically several times with increasing backoff.
  • Multiple workers in queue mode: on an instance running queue mode with Redis, nothing prevents a sender's replay and the original execution from landing on two different workers, running almost simultaneously.

None of these cases is a bug in n8n or in the sender: they're the direct consequence of a reliable delivery protocol. The Webhook node runs the workflow on every request it receives, with no memory of prior requests — deduplication is entirely the workflow's responsibility.

Idempotency: a more precise concept than "avoid duplicates"

A process is idempotent when running it multiple times produces the same result as running it once. That's not the same thing as simply blocking a request already seen: a well-designed idempotent webhook can respond successfully to a replay (often by returning the response already computed the first time) without re-executing the side effects. Pat Helland, in a landmark paper that remains a classic read in distributed systems (Idempotence Is Not a Medical Condition, ACM Queue, 2012 — see on Google Scholar), formalizes this distinction: a system's reliability doesn't come from the absence of replays, which are unavoidable in any distributed architecture, but from each operation's ability to absorb them without altering the final result. That's exactly the principle to apply to an n8n workflow triggered by a webhook.

Strategy 1: the idempotency key on the caller's side

When you control the caller (your own frontend, an internal backend, another n8n workflow), the most robust pattern — popularized by the Stripe API — is to generate a unique key per business intent on the client side (for example, a UUID generated once per purchase attempt, reused on every client-side retry) and send it in an Idempotency-Key header. The n8n workflow then checks whether that key already exists in a tracking table before processing anything:

  1. Webhook receives the request with the Idempotency-Key header.
  2. A Data Table or Supabase node attempts to insert the key into a dedicated table, with a uniqueness constraint on the key column.
  3. If the insert succeeds: it's a brand-new request, the workflow processes it normally and then stores the result associated with the key.
  4. If the insert fails due to a duplicate: it's a replay, the workflow returns the already-stored result directly, without re-running the business logic.

Strategy 2: deduplication by business identifier

When you don't control the caller (Stripe, GitHub, Typeform), there's usually no idempotency header available to you — but the payload almost always contains a stable identifier for the event itself (the Stripe event's id, GitHub's delivery_id, a Typeform submission ID). That field plays exactly the same role as an explicit idempotency key: just handle it the same way in a deduplication table, following the pattern described in our n8n Data Tables guide. It's the same reflex as for automatically sorting incoming documents or AI-based email triage: never process the same source identifier twice.

The race condition trap

A naive design — an IF node that first checks via a SELECT whether the key exists, then an insert node only if it doesn't — works in testing but breaks in production as soon as two requests arrive nearly simultaneously: both executions can read "key doesn't exist" before either one has had time to insert. The result: processing runs twice despite the check. This scenario isn't theoretical — it's exactly what happens when a replay lands on a different worker than the original execution in queue mode.

The fix isn't in the workflow's logic, it's in the database: a UNIQUE column on Supabase/Postgres, combined with INSERT ... ON CONFLICT DO NOTHING (or the equivalent upsert operation on the Supabase node), makes the insert atomic. Only one of the two concurrent executions actually succeeds at inserting; the other gets a conflict response it interprets as "already processed" and short-circuits its side effects. The atomicity comes from the database's transactional guarantee, not from a check done ahead of time in an IF node.

Cutting the risk at the source: respond before processing

A good share of timeout-triggered replays simply disappears by setting the Webhook node to Respond Immediately (or placing a Respond to Webhook node right after receipt, before any long processing): the sender gets a 200 OK within milliseconds, well before its own timeout window, and the rest of the workflow (LLM call, database write, notification) continues in the background without risking a new retry. This setting doesn't remove the need for deduplication — a replay can still happen for other reasons (a transient 5xx, a manual retry on the sender's side) — but it eliminates the most common cause in practice.

What changes when processing genuinely fails

A well-designed idempotent webhook naturally works together with error handling: if processing fails after the deduplication key has been inserted, a legitimate replay from the sender shouldn't get stuck reading "already processed" when nothing actually succeeded. The tracking table should therefore distinguish at least three states (in progress, succeeded, failed), and only the "succeeded" state should short-circuit further processing — a "failed" state should instead leave the door open for a new attempt, potentially retried by a dedicated Error Workflow rather than relying on the sender's replay alone.

Wrapping up

A replayed webhook isn't an anomaly to fix on the sender's side — it's a structural property of reliable distributed systems, and n8n filters none of it out by default. An explicit idempotency key (or, failing that, the business identifier already present in the payload), a unique database constraint rather than a simple pre-check, and an immediate response to the webhook before any long processing: these three habits cover most of the duplicate cases seen in production. The FlowKit pack workflows that receive external events — the questionnaire in the Compliance & Audit Pack ($149), the Q&A API in the RAG Assistant Pack ($119) — consistently apply this principle: invisible deduplication beats discovering in production that a customer got billed twice.

FAQ

Frequently asked questions

Does n8n's Webhook node handle idempotency automatically?

No. The Webhook node runs the workflow on every request it receives, without checking whether an equivalent request has already been processed. If the sender (Stripe, GitHub, your own backend) resends the same event after a timeout, n8n triggers a second full execution with the same side effects — detecting and blocking that duplicate is the workflow's responsibility.

What's the difference between an idempotency key and a business identifier?

An idempotency key is generated by the caller specifically to distinguish a request from a replay of that same request (the Idempotency-Key header, a pattern popularized by Stripe). A business identifier (an order ID, a GitHub event ID) exists independently of the HTTP request and serves the same purpose when the caller doesn't provide a dedicated key: both enable deduplication, the idempotency key is just more explicit about its intent.

How do you avoid a race condition between two workers processing the same webhook in parallel?

A SELECT that checks whether a row is absent, followed by an INSERT, leaves a window where two concurrent executions can both pass the check before either one inserts. The fix is a unique database constraint (a UNIQUE column on Supabase/Postgres) combined with an upsert that handles conflicts (ON CONFLICT DO NOTHING): atomicity is guaranteed by the database, not by the workflow's logic.

Bundle FlowKit Complet

€269