n8n Retry On Fail and Paid APIs: Stop Double Charges with Idempotency Keys

Introduction

If your n8n workflow calls a paid API such as Stripe, a billing endpoint, or a metered AI service, turning on Retry On Fail can quietly cost you real money. The retry exists for a good reason: networks drop responses, and most of those failures are transient. But when the request actually reached the provider and only the response was lost, the retry fires a second real request, and you get billed twice. This tutorial shows you the retry-safe pattern: a stable idempotency key on every mutating call, a Postgres checkpoint table that survives crashes, and a reconciler that settles the “unknown” state where the provider charged you but n8n never got the receipt.

Quick answer

Turn on Retry On Fail, then send an Idempotency-Key header derived from the order id or request body, never from $execution.id. The paid API dedupes retries, and a Postgres checkpoint table records every attempt so no retry can charge twice.

Evidence: why retries double-charge

The mechanism is simple and common. n8n’s Retry On Fail re-executes the whole node operation. If the provider executed your payment and the TCP response was lost, n8n marks the node as failed and fires again. From n8n’s point of view that is a legitimate retry. From Stripe’s point of view it is a second charge. Recent threads on the n8n community forum describe this exact pattern, and the recommended fix is idempotency keys plus a checkpoint table.

The fix is a named, industry-standard mechanism. Stripe’s API documentation states: “The API supports idempotency for safely retrying requests without accidentally performing the same operation twice. When creating or updating an object, use an idempotency key.” Stripe adds two numbers that matter for your design: keys may be up to 255 characters long, and they are automatically removed once at least 24 hours old, after which a reused key is treated as a brand-new request (verified against the Stripe idempotency reference on 2026-10-19). OpenAI, PayPal, and most billing APIs accept a similar header on POST endpoints.

n8n gives you the retry knobs, with hard limits. Every node has a Settings tab with three fields: Retry On Fail (boolean), Max Tries, and Wait Between Tries. The schema behind the editor enforces Max Tries between 2 and 5 attempts and Wait Between Tries between 0 and 5000 milliseconds (verified against n8n 2.41.1’s own source and documentation on 2026-10-19). The 5-second cap is deliberate: n8n’s support team explains that longer in-node waits would block worker threads, and that “for waits longer than 5 seconds, you have to build a custom retry loop using a Wait node instead. it safely pauses the execution in the database without hogging system resources.”

Two n8n settings decide how retries behave across executions. First, the workflow settings page documents “Save execution progress”: “If set to Save, the workflow resumes from where it stopped in case of an error.” With this on, a manual Retry from the executions list resumes at the failed node instead of replaying the charge node. Second, the error handling guide states: “With an error workflow, you can control how n8n responds to a workflow execution failure.” Your error workflow receives execution.retryOf when the failed run was itself a retry, which lets you route retried failures to a different recovery path.

One more measured fact shapes the whole design: the HTTP Request node’s Options tab ships a Timeout of 10,000 ms by default, described in n8n 2.41.1 as “Time in ms to wait for the server to send response headers (and start the response body) before aborting the request” (verified in the installed node source on 2026-10-19). Slow billing endpoints routinely exceed that, which is the single most common trigger for the retry you then pay for twice. The answer is not simply “raise the timeout”: a longer timeout keeps the worker busy longer, and it does nothing for the case where the provider is slow but healthy. The answer is retries that are safe by construction.

Prerequisites

  • An n8n instance (Cloud or self-hosted, v2.x; screenshots and limits verified on 2.41.1)
  • A Postgres database the n8n instance can reach (used as the checkpoint store)
  • An HTTP-based paid API that accepts an idempotency header (Stripe is used as the example; the pattern applies to any Idempotency-Key header)
  • Intermediate n8n skills: node Settings tab, expressions, Postgres node with “Execute Query”

Step-by-step guide

Step 1: Reproduce the danger mentally, once

Picture this workflow: Manual Trigger, a Code node that builds an order, an HTTP Request node that POSTs a charge to the billing API, a Postgres node that records the receipt. The charge POST takes 12 seconds because the provider is slow. n8n’s default 10,000 ms timeout fires, the node errors, and Retry On Fail fires a second POST. The provider received the first POST just fine and charged the customer. Now it receives a second one and charges again. Nothing told the provider these two requests were the same operation. That missing signal is the idempotency key.

Step 2: Create the checkpoint table

The checkpoint table is the source of truth that survives n8n restarts, manual retries, and provider-side key expiry. Create it once in Postgres:

CREATE TABLE payment_attempts (
  idempotency_key TEXT PRIMARY KEY,
  order_id TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'processing',
  provider_charge_id TEXT,
  payload_hash TEXT,
  attempts INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The primary key on idempotency_key does the real work: INSERT ... ON CONFLICT DO NOTHING makes claiming an attempt atomic, so two concurrent retries cannot both think they are first. The status column moves through processing, completed, failed, and unknown. unknown means the request was sent but n8n never got the receipt, so the provider must be asked what happened.

Step 3: Generate a stable idempotency key

Add a Code node before the charge call. The key must be stable across retries, restarts, and manual re-runs. That rules out $execution.id, $execution.timestamp, and anything random. Derive it from the business operation:

// Code node: derive a stable idempotency key from the order
const orderId = $input.first().json.order_id;
const payload = {
  order_id: orderId,
  amount: $input.first().json.amount,
  currency: 'usd'
};
const crypto = require('crypto');
const hash = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
return [{
  json: {
    ...payload,
    idempotency_key: `order-${orderId}-${hash.slice(0, 16)}`,
    payload_hash: hash
  }
}];

Two rules: the key is bound to the exact payload (Stripe errors when a reused key arrives with different parameters, so a changed amount must hash differently), and it must stay well under 255 characters. Preferring expressions? n8n ships a .hash() string method: {{ ('charge-' + $json.order_id).hash('sha256') }} (verified in n8n 2.41.1).

Step 4: Claim the attempt before calling the API

Order matters. Insert the checkpoint row before the HTTP Request node, not after. Use a Postgres node with “Execute Query”:

INSERT INTO payment_attempts (idempotency_key, order_id, status, payload_hash)
VALUES ($1, $2, 'processing', $3)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;

If the query returns a row, this execution owns the attempt and may proceed. If it returns nothing, another execution already owns this key: stop and reconcile against the provider rather than fire the call again. Route the Postgres node’s “no data” branch to an IF node that checks the existing row’s status; if it says completed, skip the charge and continue with the stored result.

Step 5: Send the idempotency key on the charge call

In the HTTP Request node, set Method to POST, URL to your charge endpoint, and add the header in the Headers section:

  • Name: Idempotency-Key
  • Value: {{ $json.idempotency_key }}

Then open the node’s Settings tab and configure the retry that is now safe to use:

  • Retry On Fail: on
  • Max Tries: 3 (the editor allows 2 to 5)
  • Wait Between Tries: 1000 (milliseconds; the cap is 5000)

Because every retry now carries the same Idempotency-Key, the provider returns the stored response for a duplicate instead of executing a second charge. Retries stop being dangerous and become what they were supposed to be: safe recovery from transient network failures.

Step 6: Mark the checkpoint on success

After the HTTP Request node, add a Postgres node that records the provider’s charge id and flips the status:

UPDATE payment_attempts
SET status = 'completed',
    provider_charge_id = $1,
    attempts = attempts + 1,
    updated_at = now()
WHERE idempotency_key = $2;

Bind $1 to the charge id from the API response (for Stripe, {{ $json.id }}) and $2 to {{ $json.idempotency_key }}. From this moment on, any retry or manual re-run of this order sees completed at Step 4 and never touches the API again.

Step 7: Make failed executions resume instead of replay

Open the workflow menu, choose Settings, and set Save execution progress to Save. As the docs say, “the workflow resumes from where it stopped in case of an error,” so a manual Retry from the executions list restarts at the failed node instead of replaying the charge node. Also enable Save failed production executions so the failed run exists to be retried at all.

Then handle the error output explicitly. In the charge node’s Settings, set On Error to “Continue (using error output)” and connect the red error output to a Postgres node that marks the row unknown:

UPDATE payment_attempts
SET status = 'unknown',
    attempts = attempts + 1,
    updated_at = now()
WHERE idempotency_key = $1;

unknown is an honest state: n8n sent the request but cannot prove what the provider did. Do not auto-retry unknown rows from the main workflow.

Step 8: Add the reconciler workflow

Create a second workflow on a Schedule Trigger (every 5 minutes) that settles unknown rows. It reads candidates from Postgres, then asks the provider what actually happened using the idempotency key. Three outcomes:

  • Charge found: update the row to completed with the real charge id. The retry that seemed lost actually succeeded, and the system now agrees with the provider.
  • Charge not found: the request never landed. Reset the row to failed (or requeue it for one more guarded attempt with the same key, which is now provably safe).
  • Row stuck in processing older than your HTTP timeout: the worker died mid-call, most likely a crash after the provider charged but before n8n recorded it. Mark it unknown so the reconciler asks the provider.

Keep the lease comfortably longer than the charge node’s timeout, otherwise the reconciler sweeps a row whose request is still legitimately in flight. With a 10-second node timeout, a 5-minute lease is a sane default. Rows that stay unknown beyond your provider’s idempotency window (24 hours for Stripe) get escalated to review for a human.

Troubleshooting

1. The charge runs twice even though I set an Idempotency-Key header

Cause: the key is derived from $execution.id or a timestamp, so every retry, manual re-run, or restarted execution generates a different key and the provider sees unrelated requests. Fix: rebuild the key from stable business data as in Step 3, typically order-<order_id>-<sha256(body).slice(0,16)>. Verify by running the workflow twice against a test key: the second run must return the first response, not a new charge id.

2. The node errors with a timeout before the provider answers

Cause: the provider is slow and the HTTP Request node’s default 10,000 ms timeout fires first. Fix: in the node’s Options, raise Timeout to something realistic (for example 60,000 ms) and keep the idempotency key, because a longer timeout only reduces the odds of the race; it does not remove it. If the provider exposes an asynchronous job endpoint, prefer that pattern instead: submit the job, then use a Wait node set to “Resume on webhook call” and hand the provider your $execution.resumeUrl as the callback. This is also the officially recommended shape for waits beyond the 5,000 ms in-node cap.

3. Retry fires again with a new key after a manual “Retry” from the executions list

Cause: you enabled manual retry without “Save execution progress”, so n8n replays from the start and any expression built on execution metadata changes. Fix: in Workflow Settings set Save execution progress to Save, so the retry resumes from the failed node and reuses earlier node outputs. Combined with the checkpoint claim at Step 4, even a full replay stops at the IF node when the row is already completed.

4. Reusing a key with a different body returns an error from the provider

Cause: idempotency keys are bound to the parameters they were first used with. Stripe compares incoming parameters to the original and errors on mismatch to prevent accidental misuse. Fix: this is the design working as intended. Change your key derivation so a changed amount, currency, or customer produces a different key (hashing the full body does this automatically). Never catch this error and retry with the same key and new parameters; decide which body is correct first.

5. A crash leaves rows in processing forever

Cause: n8n died between the checkpoint INSERT and the status UPDATE, so no error output ever ran. Fix: the reconciler from Step 8 is the designed answer. Make its first statement sweep processing rows whose updated_at is older than your lease into unknown, then reconcile them against the provider.

FAQ

The FAQ answers are in the front matter at the top of this page.

Conclusion

Retry On Fail is a reliability feature, not a billing feature: it re-sends the request, and without an idempotency key the provider cannot tell the second POST is a retry of the first. The safe pattern costs one table and one header: a stable key derived from the order or payload, a checkpoint row claimed before the call and settled after it, execution progress saved so manual retries resume instead of replay, and a reconciler that turns “unknown” into an answer by asking the provider.

If you run n8n against Postgres in production, the n8n Postgres tutorial covers credentials, query modes, and connection tuning for the checkpoint database used here. For the error-side design, the n8n error handling guide walks through error workflows, the Stop and Error node, and execution-data strategies that complement the reconciler in this article.

FAQ

The FAQ answers are in the front matter at the top of this page.

Conclusion

Frequently asked questions

Does n8n Retry On Fail re-send the request, or just re-read the failed response?

It re-sends the whole node operation, including the HTTP request to the paid API. That is exactly why retries can double-charge you when the provider already executed the first attempt.

Can I use $execution.id as the idempotency key?

No. A manual retry from the executions list creates a new execution with a new id, so the key changes and the paid call runs again. Derive the key from stable business data instead, such as the order id or a hash of the request body.

What if the paid API does not support idempotency keys?

Hash the full outgoing payload and keep your own checkpoint table with a unique constraint on the key. Before sending, check whether the same payload was already recorded as completed. It does not make an unsafe API safe, but it turns most duplicate sends into cheap no-ops.

Does Stripe really delete idempotency keys after 24 hours?

Yes. Stripe automatically removes keys once they are at least 24 hours old, and a reused key after pruning is treated as a brand-new request. Your own checkpoint table has no such expiry, so keep it as the long-term record.

Why does Wait Between Tries refuse to go above 5000 ms?

n8n caps it at 5000 ms to keep long retries from blocking worker threads. For longer waits, build a custom retry loop with a Wait node, which pauses the execution safely in the database.

Do I still need Retry On Fail if I already use idempotency keys?

Yes. The key makes a retry safe, but Retry On Fail is what actually performs the retry. Use both: the setting retries the node, the key and checkpoint table make sure the retry never charges twice.

TP
ToolPilot Team

We install, break, and fix automation tools so you don't have to. Every tutorial on ToolPilot is tested hands-on before publishing.

Keep reading