Fix · n8n
Stop n8n retries from repeating a create, send or charge
The problem
“Multi-item node that fails on item 3: are items 1 and 2 repeated on retry?”
Short answer: Yes. Retry runs the failed node again with all of its input items, and a timeout after the remote side accepted the call looks the same as a failure. The guard is a ledger keyed on the business item (action plus record id, never the execution id). Claim the key with INSERT ... ON CONFLICT DO NOTHING before the write, mark it done after, skip done keys on retry, and send keys stuck in started to a person.
Most answers say "use an idempotency key", which is right, but many chat and email APIs don't accept one. A ledger in your own database works for any API. The key detail is the third state: if a row says started and the response was lost, nobody knows whether the send went through, so the safe move is a human check, not another send.
Download
- schema.sql: ledger table plus the claim, mark done, mark failed, lookup and reclaim statements
- guard.n8n.json: importable n8n template: one item per record, claim, write, mark done, skip or review
- simulate.py: offline simulation of a 5 item batch failing on item 3, a retry, and a lost response
- README.md: full setup notes and limits
- n8n-retry-duplicate-guard.zip: everything, including the test
Step by step
1. Create the ledger table
Run the CREATE TABLE from schema.sql in any Postgres your n8n can reach.
CREATE TABLE IF NOT EXISTS side_effect_ledger (
idem_key text PRIMARY KEY, -- '<action>:<business id>'
action text NOT NULL,
status text NOT NULL CHECK (status IN ('started','done','failed')),
execution_id text,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
response jsonb
);2. Key on the business item
Build idem_key as action name plus a stable record id, for example create_invoice:INV-1042. Do not use the execution id: a retry creates a new execution, so the lookup would pass and the write would run again.
3. Split to one item per record
Retry re-runs a node for all its input items. If each record goes through the guard on its own, items 1 and 2 are already marked done when item 3 fails.
4. Claim before the write
This statement is atomic. It returns claimed = true for exactly one caller.
WITH c AS (
INSERT INTO side_effect_ledger (idem_key, action, status, execution_id)
VALUES ($1, $2, 'started', $3)
ON CONFLICT (idem_key) DO NOTHING
RETURNING idem_key
)
SELECT $1::text AS idem_key, EXISTS (SELECT 1 FROM c) AS claimed;5. Write, then mark done
If claimed, call the external API (the template also sends an Idempotency-Key header with the same key for APIs that support it, such as Stripe), then UPDATE the row to done with the response.
6. Handle the not claimed path
Look the key up. done: skip. failed (a definite rejection): reclaim and send again. started: the outcome is unknown, so alert a person to check the remote system.
7. Import and test
Import guard.n8n.json, create a Postgres credential named "Ledger Postgres", and run it twice against a throwaway endpoint. The second run should skip every item. Then run the offline simulation.
python3 simulate.py --mock
python3 test_fix.pyWhat this does not cover
- The SQL and the n8n JSON were checked structurally and simulated with sqlite. They were not run against a live Postgres or n8n.
- Keep Retry On Fail off on the write node unless the remote API enforces the idempotency key, because node-level retries happen inside one claim.
- Treating a 4xx (other than 408 and 409) as a definite rejection is a default. Adjust it for your API.
Want this done for you?
Kingdom of Kumar runs it for $500/month, first automation live in 48 hours. Claude + Grok Bot + $500/month (my fee). Email me the task you hate doing every week.