@Peter_Gavin Emmas and Jarvis1 have already covered the important part (write the intent to the DB be4 the call, keep “unknown” as its own state, resolve it by asking the provider). What was missing for me was how you actually wire that up in n8n, which node settings matter & what happens when n8n itself dies mid call. So I built it and tried to break it. have n8n 2.38.7 in docker, Postgres 16, plus a tiny fake payment API where I can make a request timeout, get declined, or kill n8n while the call is in flight.
The short answer is that u don’t need n8n to “resume” anything. 2 scheduled workflows and 1 table. The table is your outbox and every tick jst asks the DB what is still owed.
The table
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
order_id text NOT NULL UNIQUE, -- business key = idempotency key
amount integer NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','processing','completed','failed','unknown','dead')),
attempt integer NOT NULL DEFAULT 0,
claimed_at timestamptz,
charge_id text,
last_error text,
updated_at timestamptz NOT NULL DEFAULT now()
);
Whatever triggers ur flow today (webhook, form, another workflow) should only INSERT a pending row and stop. It should not call the API. Once the side effect is driven by a row in Postgres instead of by an execution that happens to be running, recovery becomes boring, which is what you want.
Workflow 1: charge jobs (Schedule Trigger, every minute)
Schedule Trigger - Postgres (claim) - Filter - HTTP Request (charge) - Postgres (record outcome)
Postgres, Execute Query, “Claim up to 10 jobs”. Same idea as Jarvis1’s conditional update, just batched:
UPDATE jobs j
SET status = 'processing', attempt = attempt + 1,
claimed_at = now(), updated_at = now()
FROM (SELECT id FROM jobs
WHERE status IN ('pending', 'failed')
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10) c
WHERE j.id = c.id
RETURNING j.id, j.order_id, j.amount, j.attempt;
FOR UPDATE SKIP LOCKED means 2 overlapping executions (or 2 workers in queue mode) can’t grab the same row. No explicit transaction needed, a single UPDATE is atomic anyway.
Filter, “Skip if nothing was claimed”, condition {{ $json.order_id }} exists. Easy to miss: when the query above matches no rows the Postgres node does not output zero items, it outputs one item { "success": true }. Without the Filter the HTTP Request runs with an undefined order_id on every idle tick. I only noticed because my first version did exactly that and my fake API got a charge with order_id null. With the Filter in place an idle tick looks like this, claim outputs the success item, Filter keeps nothing, HTTP node never runs:
HTTP Request, “POST /charge”. Three settings do the real work here:
- Header
Idempotency-Key: {{ $json.order_id }}, so a retry is also safe on their side if they support it.
- Options > Timeout = 5000 ms. Set it explicitly so “uncertain” has a known upper bound you can size the recovery lease against. (HTTP Request node docs, Timeout)
- Settings tab > On Error = Continue (using error output), and Retry On Fail = off.
“Continue (using error output)” gives the node a second output. Successful items come out of Success, failed items come out of Error with the error attached to the item, so both outcomes get recorded in the same execution. (node settings docs)
About Retry On Fail being off: it retries on a timeout as well, and a timeout is exactly the case where the provider may already have done the work. I checked this with a separate little workflow (Retry On Fail on, 3 s timeout, provider that answers after 20 s). The provider counted two requests for one item. The second one was only harmless because my fake provider honours the idempotency key. Without that it’s a second charge. And even when it is safe, the retry happens inside n8n where your table can’t see it. I’d rather keep retries in the table (failed rows get re-claimed on the next tick) so they are counted and visible.
Postgres, “Mark completed” on the Success branch:
UPDATE jobs
SET status = 'completed', charge_id = $2, updated_at = now()
WHERE order_id = $1 AND status = 'processing'
RETURNING order_id, status, attempt, charge_id;
Query Parameters: {{ [ $('Claim up to 10 jobs').item.json.order_id, $json.charge_id ] }}
Postgres, “Record failure” on the Error branch. This is where you decide what kind of failure it was:
UPDATE jobs
SET status = CASE
WHEN $2::boolean THEN 'unknown' -- timeout / connection error, provider MAY have done it
WHEN attempt >= 3 THEN 'dead' -- definite failure, out of attempts
ELSE 'failed' -- definite failure, re-claimed next tick
END,
last_error = $3, updated_at = now()
WHERE order_id = $1 AND status = 'processing'
RETURNING order_id, status, attempt, last_error;
Query Parameters:
{{ [ $('Claim up to 10 jobs').item.json.order_id,
/timed out|timeout|ECONNRESET|ECONNABORTED|ETIMEDOUT|socket hang up/i.test(String($json.error?.message ?? $json.error ?? '')),
String($json.error?.message ?? $json.error ?? 'unknown error') ] }}
That regex is the one bit you have to tune to your provider. Timeouts and dropped connections are unknown. A 4xx validation error or an explicit “declined” is definite. I would also treat a 502/504 from a gateway as unknown because you don’t know if the upstream ran. As long as anything you’re not sure about lands in unknown and never in failed, you’re fine.
(Docs for the $1, $2 placeholders: Postgres node, query parameters. Passing them as a single array expression worked for me on Postgres node v2.6 and avoids the problem of an error message that contains commas.)
This is what the first tick produced with one good order, one that gets declined and one where the provider takes 20 s to answer:
id | order_id | status | attempt | charge_id | last_error
----+---------------+-----------+---------+-------------+--------------------------------
1 | ord-1001 | completed | 1 | ch_69285131 |
2 | ord-1002-fail | failed | 1 | | 500 - "{\"error\": \"card declined\"}"
3 | ord-1003-slow | unknown | 1 | | timeout of 5000ms exceeded
And on the provider side for the unknown one, the charge exists, n8n just never got the response:
GET /charges?idempotency_key=ord-1003-slow
{"found": true, "charge_id": "ch_2836483", "order_id": "ord-1003-slow", "amount": 999}
If that row had been marked failed and retried, that’s your double charge.
Workflow 2: reconcile unknown jobs (Schedule Trigger, every 5 minutes)
Schedule Trigger → Postgres (sweep + list) → Filter → HTTP Request (GET by idempotency key) → IF → Postgres (settle)
Postgres, “Sweep stale processing to unknown”:
WITH swept AS (
UPDATE jobs SET status = 'unknown', updated_at = now()
WHERE status = 'processing' AND claimed_at < now() - interval '5 minutes'
RETURNING id
)
SELECT id, order_id, amount, attempt
FROM jobs
WHERE status = 'unknown' OR id IN (SELECT id FROM swept)
ORDER BY id;
The 5 minute lease has to be comfortably longer than the HTTP timeout in workflow 1, otherwise you sweep a row that is still genuinely in flight. Same Filter on order_id after it, same reason as before.
HTTP Request, “GET /charges?idempotency_key=” with Options > Response > Never Error turned on, so a 404 (“we have nothing for that key”) is data and not an exception. Otherwise “not found” and “network blip” would end up on the same error branch, which is the exact mix-up Jarvis1 warned about. (HTTP Request node docs, Response options)
IF, “Provider has it?” on {{ $json.found }} (or whatever your provider returns), then:
-- true: it succeeded, we just never heard
UPDATE jobs SET status = 'completed', charge_id = $2, last_error = NULL, updated_at = now()
WHERE order_id = $1 AND status = 'unknown';
-- false: that attempt definitely did not happen. Now it is safe to count it.
UPDATE jobs SET status = CASE WHEN attempt >= 3 THEN 'dead' ELSE 'failed' END, updated_at = now()
WHERE order_id = $1 AND status = 'unknown';
The crash test (API succeeds but n8n dies before saving)
With workflow 1 published I added a slow order and ran docker kill on the n8n container about 1.5 s after the row went to processing, so while the request was in flight.
After the restart n8n shows this:
The executions list shows it as an error (status crashed if you look at it through the API) and the node panel literally says the data was not saved. So n8n has no idea whether the call returned. Meanwhile the provider had this:
GET /charges?idempotency_key=ord-1004-slow
{"found": true, "charge_id": "ch_69032535", ...}
and both rows from that execution were stuck in processing:
order_id | status | attempt | claimed_at
---------------+------------+---------+-------------------------------
ord-1002-fail | processing | 2 | 2026-09-13 13:55:38
ord-1004-slow | processing | 1 | 2026-09-13 13:55:38
Workflow 1 kept running every minute after that (you can see the ticks in the sidebar of the screenshots) and never touched those two rows, because processing is not claimable. So a crash can’t cause a double charge no matter how long recovery takes. Once the lease expired, workflow 2 settled both based on what the provider said, not on a guess:
order_id | status | attempt | charge_id
---------------+-----------+---------+-------------
ord-1002-fail | failed | 2 | (provider had nothing, re-claimed next tick)
ord-1004-slow | completed | 1 | ch_69032535 (provider had it, n8n just never saw the response)
Two ticks of workflow 1 later the declined order hit attempt 3 and went to dead:
ord-1002-fail | dead | 3 | | 500 - "{\"error\": \"card declined\"}"
So, to your questions
How to resume safely: don’t resume the execution, re-derive the work from the table. A schedule that claims pending/failed rows is a “resume” that works after a node error, a timeout, a crash or a redeploy and needs nothing from n8n’s execution history.
Transaction vs outbox vs recovery workflow: the table above is the outbox, workflow 1 drains it, workflow 2 is the recovery workflow. A Postgres transaction only helps for the pure DB part. The Postgres node does have a Query Batching > Transaction option if you have several statements that must succeed together (docs), but it can’t cover the HTTP call, so don’t try to make it.
API succeeded but n8n crashed before saving: the row is still processing with a claimed_at. Nothing retries it. After the lease it becomes unknown and only the provider’s answer moves it to completed or failed. That is the test above.
Dead letter: on a definite failure at attempt N (I used 3), decided in SQL where the attempt counter lives. Never from unknown, in the crash test that would have dead-lettered a charge that actually went through. And make dead visible. A third tiny workflow doing SELECT count(*) FROM jobs WHERE status = 'dead' and posting to Slack is enough. For real node errors (bad credentials, Postgres down) set an Error Workflow on both workflows so you at least hear about it (docs).
Happy to paste the two workflow JSONs if that helps, they are 6 and 7 nodes and you’d only need to swap the Postgres credential and the two URLs.