Learned this the expensive way. Retry On Fail on the HTTP Request node can’t tell whether the first request got through and only the response was lost. With a paid API (render, TTS, LLM, whatever) that means the job runs twice and you pay twice.
If the API takes an Idempotency-Key header, set it to
{{ $execution.id }}-{{ $itemIndex }}
both parts, not just the execution id. The id is the same for every item in a run, so with the id alone item 2 gets item 1’s result back (yes, found out that way too).
Also turn on Options > Response > Never Error, otherwise a 4xx throws before the JSON body with the reason reaches your If node.
No idempotency key on your API? Then Retry On Fail off, and poll a status endpoint instead.
Curious which APIs people here use that support the header. Stripe does, most render APIs I tried don’t.
Good write-up. One more trap with {{ $execution.id }}-{{ $itemIndex }}: it only protects the automatic Retry On Fail inside the same run. If the execution fails and you hit Retry in the executions list, n8n starts a new execution with a new id, so the key changes and the paid call goes through a second time.
If a manual retry is possible, build the key from the data instead: an order id, a row id, or a hash of the payload you send. Then the same item always gets the same key, whichever execution sends it.
On your question, the ones I know take it: Stripe and Adyen (Idempotency-Key header), PayPal (PayPal-Request-Id header), Square (idempotency_key in the body, not a header).
@catchleak’s data-derived key is the right fix for manual retries, and it points at the remaining gap: it only works where the API accepts an idempotency key at all. On @Biboo’s question — for LLM/TTS/render specifically, none of the majors I know accept one (OpenAI and Anthropic don’t; the payments world — Stripe, Adyen, PayPal, Square — is the exception, not the rule). So for exactly the APIs where a duplicate is expensive, the header route is mostly closed.
What closes it: dedupe on the payload at your own boundary. Hash the full request body (model, messages, params — everything that determines the output), store the response keyed by that hash, and on an exact match replay the stored response instead of calling again. Because the key derives from the payload rather than the execution id, it survives automatic retries, manual retries from the executions list, and webhook refires — the three ways the same paid call actually happens twice. Scope stated honestly: it only catches byte-identical repeats, which is exactly what a retry is; a deliberate re-run with a changed prompt is a different hash and bills again, as it should.
Cheap version without new infrastructure: a Redis check inside the workflow before the paid node — look up the hash, short-circuit to the stored result on hit, call and store on miss. One hole worth knowing: the check-and-call isn’t atomic, so two items racing the same hash can both miss. Setting the key with NX before the call, and having the loser wait and re-check, closes most of it.
Side note on the lint numbers — 2 of 67 webhooks authenticated is its own duplicate source: most webhook providers redeliver until they get a 2xx, so a slow or unauthenticated endpoint often processes the same event two or three times. Deduping on the provider’s delivery id (same hash-and-store pattern, different key) fixes that class too.
Did you publish the linter anywhere? “3% retry / 1% timeout across the 400 most-viewed templates” is a stat I’d cite.
@catchleak is right, and it caught me too: I use the execution-id key in a few workflows and will change it. The data-derived key is the fix. If the API binds the key to the request body, which the good ones do, a key built from the input (source URL plus output format, or an order id) survives the manual retry and still cannot hand you the wrong job back.
On the payload hash: the two ideas combine. Where the API does take a key, send that hash as the key value. Then the server does the dedupe, the key is stable across automatic retry, manual retry and webhook refire, and the Redis store is not needed. Where it does not take one, your store is the only option. I checked the OpenAI and Anthropic Python SDKs, neither sends an idempotency header for chat or messages, so agreed on that part.
see @catchleak. The execution-id key does not survive a manual Retry from the executions list, because that is a new execution with a new id. Build the key from the request instead, for example a sha256 of the body you send: {{ JSON.stringify(body).hash('sha256') }}. It also stays under the length limits most APIs put on the header.
The NX marker before the call is the right primitive, and it still leaves the expensive case open. If the workflow dies between the marker and the call, fine, the TTL clears it. If it dies between the call and the outcome write, the provider may already have run the job, and once the TTL lapses the store can’t tell did-not-send from sent-but-not-recorded. The naive next retry can pay twice for one result. An expired marker with no outcome belongs on hold for reconciliation, not straight back into retry.
That’s why I stopped treating my own store as the source of truth. The provider’s billing is the ground truth, and most of them only hand you aggregated cost reports, so I reconcile totals: money spent against attempts I can account for, and anything unexplained gets dug up. Orphans are leaked money, whatever the dedupe layer believes.
The other thing I’d question is who writes the store. If the workflow writes both the intent and the outcome, a crash or a code change can skip either write, and the dedupe is only as honest as the process it protects. A small proxy on the wire that logs every call outlives the workflow dying, though nothing outlives its own crash. One n8n trap on top: Retry On Fail re-runs the paid node itself, not whatever guard you put in front of it, so the guard has to ride on the call.
The test I trust: kill -9 the node on purpose. Once between the pending write and the call, once between the call and the outcome write. Check what the provider charged next morning against what you recorded. The gap is the bug, and this has caught more double-billing for me than any header ever did.
There’s also a multi-item retry boundary worth checking alongside the lost-response case.
I ran two items, 42 and 43, through HTTP Request v4.2 on n8n 2.41.7, with Max Tries = 2. The local test endpoint returns one 503 for 42, then 200; every request for 43 gets 200. Recorded requests:
Workflow shape
Request IDs in order
Total
HTTP Request + Retry On Fail
42, 43, 42, 43
4
Same node, HTTP batching = 1
42, 43, 42, 43
4
Loop Over Items, batch size 1 → HTTP Request → loop back
42, 42, 43
3
All three finish with the two expected output items. Checking the request trace exposes the repeated successful call for 43.
For a multi-item paid-node input, the one-item loop narrows each automatic retry to that item. Keep the stable business-derived idempotency key as well: the provider’s treatment of a repeated attempt still governs the accepted-request/lost-response case discussed above.
The runnable fixture and traces are in my public n8n-check repo under examples/retry-isolation; the table uses synthetic items and a local HTTP service.
@Biboo, were your paid calls receiving several items in one node execution? That would make the successful item’s request count a useful extra assertion in your regression case.
@blucca the table is the bit that matters. All three shapes finish with two output items. Only the request trace shows 43 went out twice under Retry On Fail (and again with HTTP batching = 1). The execution log will not tell @Biboo they paid twice for a success.
That is the same hole as the lost-response case, just quieter: Retry On Fail retries the node, not the failing item, so the sibling that already got 200 is a second paid call. Loop Over Items, batch 1, isolates it. The data-derived Idempotency-Key is still what stops the provider accepting the duplicate — if they take a key at all. OpenAI and Anthropic still do not, which is why the store I mentioned earlier is the only option on those APIs.
One consequence for that store: if you write the outcome from the node’s output items, this fixture looks clean (two items) and you miss the extra 200 on 43. You have to count requests, not outputs.
If you want a record a stranger can check without trusting the n8n log, I can run your 42/43 fixture through the pipe and send back a session receipt link: four signed hops against two outputs. Session receipt is $0. It does not stop the retry and it does not replace the key.
@Biboo — were the paid calls several items on one HTTP node? That’s the question I’d want answered too.