Workflow automation

Idempotency: the concept that saves your data

Retries are not optional, so duplicates are not optional either unless you design them out. Idempotency is the property that makes a retry safe, and it is cheaper to build on day one than to repair.

On this page
  1. The short answer: a retry is only safe if the write is keyed
  2. The words, used precisely
  3. The seven moments a duplicate is born
  4. The Key Ladder, from a key that holds to a key that lies
  5. The claim and commit pattern
  6. Owning the dedupe store or borrowing the provider's
  7. Where a key is not enough
  8. Prove it with a drill, not with an argument
  9. The design checklist

The short answer: a retry is only safe if the write is keyed

The short answer

An operation is idempotent when running it twice leaves the system in the same state as running it once. You get that property by attaching a stable key to every write, claiming that key in a database with a unique constraint before doing the work, and committing the key and the business change in one transaction. Networks make retries unavoidable, so every automation is already running operations more than once, whether or not anyone designed for it. The only choice you have is whether the second run is harmless or produces a second invoice.

  • At-least-onceThe default delivery guarantee of every queue, webhook and HTTP retry you use. Exactly-once delivery across a network does not exist.
  • The keyMust be minted before the first attempt and reused by every retry. A key generated at send time is not a key, it is a random number.
  • One transactionThe dedupe record and the business write commit together, or a crash between them recreates the duplicate you were preventing.
  • 409A conflict response usually means your earlier write landed. Verify the record rather than retrying it.

Idempotency has a reputation as distributed systems theory. In practice it is one table, one unique constraint and a discipline about where the key comes from. The teams who skip it are not the ones who never heard of it. They are the ones who assumed their retry count was low enough for it not to matter, and duplicates do not care about your retry count. They care about whether the unlucky run happened at all.

The words, used precisely

Six terms that decide the design
Idempotency
The property that performing an operation two or more times produces the same result and the same system state as performing it once. It is a property of the write, not of the network transport that carried it.
Idempotency key
A stable identifier attached to one logical operation and reused by every retry of that operation. It must be created before the first attempt and stored, because a key regenerated on retry cannot match the earlier attempt.
At-least-once delivery
The guarantee that a message will arrive one or more times, with duplicates possible. Every practical queue, webhook and HTTP retry uses it, because guaranteeing a single delivery over an unreliable network is not achievable.
Exactly-once effect
The achievable version of exactly-once. Messages still arrive repeatedly, and the receiver makes duplicate arrivals harmless by keying the write. Systems marketed as exactly-once are almost always doing this internally.
Fencing token
A number that increases with every lease handover, sent with each write and rejected by the store if it is lower than the last one seen. It stops a worker that froze and resumed from overwriting the work of the worker that replaced it.
Dedupe window
How long you retain a key before forgetting it. A retry arriving after the window expires is treated as new work, which makes the window the real limit on your protection.

The distinction that matters most in that list is delivery against effect. Nobody can promise your webhook fires once. Anybody can make a second firing do nothing, and that is the whole game.

The seven moments a duplicate is born

Duplicates are not caused by bad code. They are caused by a network that loses responses, and every one of these seven moments happens in ordinary operation on a healthy system.

MomentWhat the caller seesWhat the server actually didWhat defuses it
Gateway timeout at thirty secondsA timeout, so it retriesCommitted the write at thirty one secondsAn idempotency key claimed before the work starts
Connection reset while the response is in flightA network errorCompleted the operation normallyThe same key, plus reading back the outcome before retrying
Two retry layers stackedOne failure, five attemptsFive writes, because the HTTP client retried inside your retry loopTurn off library-level retries when you own the loop
Provider redelivers a webhookA second identical eventNothing new. The provider never saw your 200Dedupe on the provider event id, not on your own receipt time
Queue visibility timeout expiresNothing at allHanded the same message to a second workerA claim row plus a lease longer than the slowest realistic run
A human replays a dead letter itemOne deliberate replayReprocesses work that partly succeeded beforeStore the key with the dead letter payload and reuse it on replay
A backfill overlaps the live windowA tidy one-off scriptRewrites records the live workflow already handledRun the backfill through the same keyed path as production
Every row is a real production event, not an edge case.
Exactly-once delivery is a marketing term

No transport can guarantee a message is delivered exactly once, because the sender cannot distinguish a lost request from a lost response. Products that advertise exactly-once are giving you exactly-once effects, achieved with keys and dedupe stores on the receiving side. Read their documentation for the dedupe window, because that window is the real guarantee and it is always finite.

The Key Ladder, from a key that holds to a key that lies

Framework

The Key Ladder

Almost every idempotency bug in production is a key problem rather than a logic problem. The code claims a key correctly and the key itself changes between attempts, so the claim always succeeds and the protection is decorative. These five rungs run from the key you want to the key that quietly does nothing. Take the highest rung available for each operation.

01
Rung one: the provider's own event id

Stripe, Shopify, a Postgres logical replication slot, any decent webhook source gives each event a stable id that survives redelivery. This is the best key available because the upstream system guarantees its stability and you do not have to store anything before the first attempt.

02
Rung two: a business natural key

Something like order 8814 transitioning to refunded, or invoice 22 for period 2026-07. It is stable across retries, across process restarts and across two different systems doing the same job, and it stays readable in a support conversation. Include the state transition, not just the record id, or two legitimate operations on one record collide.

03
Rung three: a client key minted before the first attempt

Generate a UUID, persist it with the pending work, then send it with every attempt. The discipline is the mint-and-store step. If the key is created in the same expression that makes the call, a retry after a process restart mints a fresh one and the whole mechanism turns into decoration.

04
Rung four: a canonical hash of the semantic fields

Hash only the fields that define the operation, after sorting keys and normalising number and date formats. Workable for stateless transforms. Fragile everywhere else, because the day someone adds a request timestamp to the payload, every retry hashes differently and you lose the protection with no error to tell you.

05
Rung five: a hash of the whole payload

Do not do this. Whitespace, key ordering, a new optional field or a float rendered with one more decimal place all produce a different key. It looks like protection in code review and provides none in production, which makes it worse than having nothing, because nobody goes looking for a duplicate problem they believe is already solved.

Three things must never appear in a key: the current timestamp, a random value generated at call time, and a row number or array index. Each changes between attempts, and each turns the unique constraint into an expensive way of inserting every request.

The claim and commit pattern

Reserve the key before doing any work, then finish the bookkeeping inside the same transaction as the business write. Checking whether a key exists and then inserting it as two separate statements loses the race, because two workers can both read absent before either writes. Let the database decide the winner with a unique constraint.

processed_operations.sqlsql
-- One table. Every workflow that writes anything shares it.

create table processed_operations (
  idempotency_key text primary key,
  workflow        text        not null,
  status          text        not null check (status in ('in_flight','done','failed')),
  request_hash    text        not null,
  response        jsonb,
  attempt         int         not null default 1,
  claimed_at      timestamptz not null default now(),
  completed_at    timestamptz,
  expires_at      timestamptz not null
);
create index on processed_operations (expires_at);

-- STEP 1  Claim the key before doing any work.
-- Exactly one caller wins the insert. Everyone else gets zero rows.

insert into processed_operations (idempotency_key, workflow, status, request_hash, expires_at)
values ($1, $2, 'in_flight', $3, now() + interval '30 days')
on conflict (idempotency_key) do nothing
returning idempotency_key;

-- STEP 2  Zero rows means somebody already claimed it. Read the outcome, then decide.

select status, request_hash, response, now() - claimed_at as age
from processed_operations
where idempotency_key = $1;

--  done                        -> return the stored response, perform no write
--  failed                      -> safe to retry, set status back to in_flight and proceed
--  in_flight and age < lease   -> another worker owns it, back off and try later
--  in_flight and age > lease   -> the owner died mid-flight, take the lease over
--  request_hash differs        -> same key, different body: reject 422, never overwrite

-- STEP 3  Commit the bookkeeping in the SAME transaction as the business write.
-- Two transactions means a crash between them, and a crash between them is the bug
-- this whole table exists to prevent.

begin;
  update orders
     set status = 'refunded', refunded_at = now()
   where id = $4 and status = 'paid';

  update processed_operations
     set status = 'done', response = $5, completed_at = now()
   where idempotency_key = $1;
commit;

The request hash column earns its place the first time somebody reuses a key with a different body, usually a script looping over a list with a key computed outside the loop. Without the hash you silently return the first response for every subsequent item. With it you reject the second call and someone finds the bug in minutes.

If the write goes to a system you do not control, the same shape still applies. Claim locally, call the remote API with its own idempotency header if it offers one, store the response, and mark the row done. Your table is then the record of what you attempted, which is the artifact you need when the provider and your ledger disagree during systems integration work.

Owning the dedupe store or borrowing the provider's

Many APIs accept an idempotency header and deduplicate for you. That is genuinely useful and it is not a substitute for your own table, because it protects one hop of a workflow that has several.

Provider idempotency headerA dedupe table you own
What it protectsOne API call to that vendorThe whole logical operation, every hop of it
RetentionWhatever the vendor decided, often shortWhatever your business needs, and you can prove it
Evidence during a disputeA vendor dashboard, if you still have accessA row you can query, with the payload and the attempt count
Works across vendorsNo, each has its own scheme or noneYes, one key covers a multi-step operation
EffortOne headerOne table and a claim step
Failure modeSilently expires and stops protecting youFills up if you never expire old rows

Use both. The provider header protects the hop and your table protects the operation. The pattern to avoid is trusting the header alone for a workflow that writes to three systems, because a crash after the second write leaves a replay with no memory of the first two.

Where a key is not enough

Idempotency prevents the same operation applying twice. It says nothing about two different operations arriving in the wrong order, and it does not protect against a worker that came back from the dead.

Out of order updates

Two webhooks describing the same record arrive in the wrong sequence and the older payload lands last. Both are unique operations with valid keys, so dedupe does nothing. The fix is a version on the record: store the source version or event timestamp and refuse any write whose version is not greater than the one already stored. This is a last-write-wins rule made explicit rather than accidental.

Zombie workers

A worker takes a lease, its process freezes long enough for the lease to expire, a second worker takes over and finishes the job, then the first one wakes up and writes. Its key is already marked done, so it either overwrites or reports a false conflict. A fencing token fixes this: every lease handover increments a counter, every write carries the current value, and the store rejects any write carrying a lower one.

Operations that cannot be undone

Sending an email, posting to a public channel or dispatching a physical package cannot be rolled back by a later transaction. For these, claim the key first, perform the side effect, then mark done, and accept that a crash in the middle leaves one ambiguous case rather than a silent duplicate. Log that case loudly enough for a human to resolve it, which is exactly the dead letter path from error handling that stops silent failures.

Prove it with a drill, not with an argument

Idempotency is only real if you have watched it work. The drill takes an afternoon in staging and it finds the broken key in nearly every workflow that has never been tested this way.

  1. Replay the same event twice, back to backThe baseline

    Send an identical event twice with no delay. One record should change and the second call should return the stored response without writing. If two records appear, the key is wrong or the claim happens after the work.

  2. Replay with a delay longer than the leaseThe lease test

    Send it again after the in-flight lease expires. This exercises the takeover path, which is the branch most implementations get wrong, because it is the branch nobody triggers by accident during development.

  3. Kill the process between the write and the bookkeepingThe crash test

    Stop the worker after the business write commits and before the dedupe row is updated, then replay. If both statements share one transaction, nothing is duplicated. If they do not, you have just reproduced your next incident on purpose.

  4. Run two workers on one messageThe race test

    Deliver the same message to two workers at once. Exactly one should win the claim and the loser should read the winner's outcome rather than waiting on a lock or timing out.

  5. Replay the same key with a different bodyThe misuse test

    Send a changed payload under a key already used. The correct answer is a rejection, not a silent success. This is the test that catches keys computed outside a loop, which is the most common way a real system reuses keys by accident.

Keep these five as a test file that runs in CI against a staging environment. They are cheap, they run in seconds, and they fail loudly the day someone moves the key generation one line lower.

The design checklist

Idempotency review, one workflow at a time
0 of 10 done

One habit is worth more than the whole list: decide the key at design time and write it into the workflow contract before anyone builds. Choosing a key after the fact means retrofitting it into code that already assumed retries were harmless, which is a rewrite rather than a change. The contract template in choosing between n8n, Make and custom code has a field for exactly this, and rate limits, retries and backoff covers the retry policy that sits on top of it.

Cite this

ChatGPTalker, Idempotency: The Concept That Saves Your Data: mint a stable key before the first attempt, claim it with a unique constraint, and commit the key and the business write in one transaction.

Questions readers ask next

What does idempotent mean in workflow automation?
An operation is idempotent when running it twice leaves the system in the same state as running it once. In automation this matters because networks lose responses and every queue and webhook delivers at least once, so operations get repeated in normal running. Idempotency is what makes that repetition harmless rather than a source of duplicate records.
What should I use as an idempotency key?
Take the highest option available. First choice is the upstream provider's event id, second is a business natural key such as a record id plus the state transition, third is a UUID your client mints and stores before the first attempt. Never use a timestamp, a random value generated at call time, or a hash of the entire raw payload.
How long should I keep idempotency keys?
Longer than your longest possible replay path, which usually means longer than the retention of your dead letter queue. Thirty days suits most workflows and finance flows often want longer. The retention period is your real guarantee, because a retry arriving after the window expires is treated as brand new work and will duplicate.
Do I still need idempotency if the API supports an idempotency header?
Yes, because the header protects one call to one vendor and your workflow is usually several writes across several systems. Use the header for the hop and your own dedupe table for the whole operation, so a crash after the second of three writes replays safely instead of repeating the first two.
Is a database unique constraint enough on its own?
It is the mechanism you want, and it is only enough when the natural key of the row happens to match the logical operation. Where the same record legitimately changes many times, a constraint on the record id blocks valid work. Key on the operation, meaning the record plus the transition, and keep that key in a dedicated table.
How do I make sending an email idempotent?
You cannot recall a message, so aim for claim, send, then mark done. Reserve the key first, so a duplicate attempt finds the claim and stops. If the process dies between sending and recording, you are left with one ambiguous case per crash instead of a silent duplicate. Send that case to a human queue rather than retrying it automatically.
Cite this

ChatGPTalker. "Idempotency in Automation: The Concept That Saves Your Data." chatgptalker.com, 2026-08-26. https://chatgptalker.com/guides/idempotency-in-automation/

Rather have it built than read about it?

Send the process you want automated. You get a scoped plan back, with the build shape, the stack and a realistic timeline.

Start a project