Partial Failures Between n8n and PostgreSQL

Hello I have an n8n workflow that updates PostgreSQL and then calls an external API. The difficult part is when one step succeeds and the next one fails.
If PostgreSQL updates successfully but the API call times out, the workflow can be left in an uncertain state. Retrying the entire workflow could also create duplicate side effects.
I’m thinking about using statuses such as pending, processing, completed, and failed, along with an idempotency key.
My questions are:
How would you design the workflow so it can safely resume after a failure?Would you use a PostgreSQL transaction, an outbox table, or a separate recovery workflow?
What should happen when the API succeeds but n8n crashes before saving the result?
At what point would you move a job to a dead-letter state?
I’m looking for a practical pattern for making multi-step n8n workflows recoverable without relying on manual intervention.

Describe the problem/error/question

What is the error message (if any)?

Please share your workflow

(Select the nodes on your canvas and use the keyboard shortcuts CMD+C/CTRL+C and CMD+V/CTRL+V to copy and paste the workflow.)

Share the output returned by the last node

Information on your n8n setup

  • n8n version:
  • Database (default: SQLite):
  • n8n EXECUTIONS_PROCESS setting (default: own, main):
  • Running n8n via (Docker, npm, n8n cloud, desktop app):
  • Operating system:

Hello @Peter_Gavin I would make the workflow state driven and idempotent, rather than trying to wrap the whole process in one large transaction.

For example:pending

processing

API Call

completed

Store an idempotency key for each operation so a retry can safely determine whether the API call was already made.

For PostgreSQL, I’d use a unique constraint:

INSERT INTO orders (order_id, status)
VALUES ($1, ‘processing’)
ON CONFLICT (order_id) DO NOTHING;

If the API times out, the recovery workflow can check the API using that same idempotency key before sending the request again.

For more complex workflows, I’d also consider an outbox table so database changes and pending external actions are recorded reliably.

The pattern that survives production here is: make the side effect claimable before it happens, and never let “unknown” collapse into “failed”.

Order of writes matters more than the transaction. A PostgreSQL transaction can’t cover the external API, so don’t try to make it. Instead write the intent before the call and the outcome after:

-- before the API call
UPDATE jobs SET status='processing', attempt=attempt+1, claimed_at=NOW()
WHERE id=$1 AND status IN ('pending','failed')
RETURNING id;

If that returns no row, someone else owns it — stop. That single conditional update is your idempotency key and your lock in one statement, and it’s atomic without an explicit transaction.

“API succeeded but n8n crashed before saving” is the case everything hinges on. The rule I’d hold to: because you wrote processing before the call, a row still sitting in processing means the API may have run. That is not the same as failed, and it must never be auto-retried. Give it its own terminal state:

UPDATE jobs SET status='unknown'
WHERE status='processing' AND claimed_at < NOW() - INTERVAL '15 minutes';

unknown is resolved by reading the provider, not by retrying — query their API for your idempotency key and settle to completed or failed from their answer. If you collapse unknown into failed and retry, you will double-charge someone eventually. This is the single most expensive mistake in this design, and it’s silent.

Outbox vs recovery workflow: outbox if the API call can be derived entirely from DB state (then a separate worker drains it and crashes are free). A recovery workflow is simpler and fine if the job payload is self-contained — sweep processing older than your lease, move to unknown, resolve against the provider.

Dead-letter: on attempt N (3 is reasonable) with a definite failure. Never dead-letter from unknown — that’s how a successful payment gets marked dead. And make sure the DLQ is visible; a dead-letter nobody looks at is the same as a silent branch.

One practical note: send the idempotency key to the external API too, if it supports one. Then even a retry you didn’t intend is safe at their end rather than only at yours.

@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.

Three gaps in the design above, all of them the quiet kind.

The Retry button will not rescue a crashed run of workflow 1. Retry re-runs the claim, and the claim only matches pending and failed, so it picks up a fresh batch while the rows the crash stranded sit in processing untouched. The execution goes green and nothing was recovered. Only the sweep recovers those rows. Worth saying out loud, because a crashed execution in the list is exactly what makes someone reach for Retry.

Never Error on the reconcile GET only covers response codes. The docs describe it as returning success regardless of the code returned, so a 404 becomes data as intended, but a timeout or a dropped connection produces no code at all and still throws. Workflow 2 has no error output, so one unreachable provider ends that tick and abandons the rest of the batch. Give that node Continue (using error output) as well and leave the row in unknown on the error branch.

unknown has no way out. Workflow 2 resolves it from the provider’s answer, which is right, but if the provider cannot answer, because the key predates their retention or their lookup is down for a day, the row circles every five minutes forever and nobody is told. Give reconciliation its own counter and an age cap:
UPDATE jobs SET status = 'review' WHERE status = 'unknown' AND updated_at < now() - interval '24 hours';
review is the one state that means a human. dead is the machine saying it definitely failed, review is the machine saying it cannot find out. Those need different queues and different alerts, and collapsing them puts an unresolved possible charge in the same bucket as a declined card.