Skip to main content
All postsIntegrations

n8n Error Handling: The Patterns That Separate a Demo From Production

A demo workflow and a production one look identical on the canvas. The difference is entirely error handling: error workflows, retries, idempotency keys, signed webhooks, dead-letter replay, and alerting on the failure nobody notices.

Ross Devins
August 25, 2026 11 min read
Cover image for n8n Error Handling: The Patterns That Separate a Demo From Production

A demo workflow and a production workflow can look identical in the n8n canvas. Same nodes, same connections, same satisfying green checkmarks when you hit Test Workflow.

The difference shows up on a bad day. The API that times out, the webhook that arrives twice, the vendor that renames a field on a Tuesday without telling anyone, the OAuth token that expires at 2am. Error handling is most of the actual job. Everything else is wiring.

These are the patterns we put on every n8n build we ship, roughly in the order we add them.

Every production workflow needs an error workflow

n8n gives you a global catch and almost nobody uses it. Build one workflow whose only trigger is the Error Trigger node, then point every production workflow at it under Workflow Settings → Error Workflow. When a production execution fails, n8n calls that workflow with a payload describing what died:

{
  "execution": {
    "id": "231",
    "url": "https://n8n.example.com/workflow/42/executions/231",
    "error": { "message": "connect ETIMEDOUT", "stack": "..." },
    "lastNodeExecuted": "Create Stripe Invoice",
    "mode": "trigger",
    "retryOf": "230"
  },
  "workflow": { "id": "42", "name": "Closed Won → Invoice" }
}

Most teams point this at a Slack channel and call it done. That's a fraction of the value. The error workflow is also where you write a durable row to a failures table, attach the execution URL so whoever picks it up is one click from the evidence, and route by severity. A failed CRM activity log and a failed payment write shouldn't wake the same person.

Two things people get wrong here. First, don't set the error workflow as its own error workflow. Second, test it. Drop a Stop and Error node into a copy of a real workflow, run it in production mode, and confirm the alert actually lands. An untested error path is a decorative one.

One limit worth knowing up front: the error workflow fires when an execution fails. It cannot fire for an execution that never started. That gap is the expensive one, and it gets its own section below.

Retry on fail, and which node settings matter

Open any node's Settings tab and you get Retry On Fail, Max Tries, and Wait Between Tries. Turn the first one on for every call that crosses the network. The defaults are three tries with a one second gap, and the wait is capped low (five seconds in the UI at the time of writing), so what you get is a short flat retry, not exponential backoff.

That's fine for a blip. It's useless against a rate limit, where three attempts inside fifteen seconds just burn the rest of your quota. For a 429 you want the failure to leave the workflow: catch it on the error output, drop the item into a queue table, and let a scheduled run pick it up minutes later.

The bigger problem is that n8n's retry doesn't know which errors are worth retrying. A 503, a 429, a socket timeout: retry those. A 400 or a 422: never. The payload is wrong and the third attempt will be wrong in exactly the same way, while your logs fill with noise that looks like an outage. On the HTTP Request node, switch on the option to include the response status, set the node's On Error to use the error output, and put a Switch node behind it that branches on status code. Retryable goes back around. Everything else goes to the dead letter store.

Also set a timeout on outbound requests. A hung connection with no timeout holds a worker slot until something else gives up, and on a queue-mode instance that's how one slow vendor takes down unrelated workflows.

Here's the part that matters for the next section: when n8n retries a node, it re-runs it with the same input items. Which is exactly how a retry charges a customer twice.

Idempotency, or how a retry double-charges someone

Picture three tries against Stripe where the first call actually succeeded and the response got lost on the way back. n8n saw a timeout, so it tried again. Twice. You now have three invoices and one very confused client.

There are two layers to fixing this and you want both.

Use the vendor's idempotency key wherever one exists. Stripe takes an Idempotency-Key header and will return the original response instead of creating a second object. The key has to be deterministic and derived from the business event, not from the execution. A retried execution gets a new $execution.id, so keying off that gives you a fresh key on the retry, which is the same as having no key at all.

Idempotency-Key: invoice-{{ $json.opportunity_id }}-{{ $json.milestone_id }}

Where the vendor has no such header, own the dedupe yourself. Most CRMs and ticketing systems don't offer one. A single Postgres table covers it:

create table automation_writes (
  idempotency_key text primary key,
  workflow_id     text not null,
  external_id     text,
  created_at      timestamptz not null default now()
);

Insert the key with on conflict do nothing before the write, check whether the insert actually happened, and skip the write if it didn't. The same guard covers a node-level retry, a manual re-run three days later, and a webhook the vendor delivered twice. Where the API supports upsert on an external ID (Salesforce does this well), use that instead and the problem disappears entirely.

n8n's Remove Duplicates node has a mode for items seen in previous executions, which is genuinely useful for light cases. Know what it is, though: state stored in the n8n database. Restore that database from a backup or migrate instances and your dedupe memory resets, quietly, at the worst moment. For anything touching money, keep the keys in a table you own.

Verify webhook signatures before you parse anything

An n8n webhook URL is a public, unauthenticated write endpoint into whatever it's wired to. If it creates deals, someone who learns the URL can create deals.

Turn on the Raw Body option in the Webhook node first. HMAC signatures are computed over the exact bytes the sender transmitted, so if you let n8n parse the JSON and then re-serialize it to check the hash, key ordering and whitespace will break the comparison in ways that take an afternoon to diagnose.

const crypto = require('crypto');

const item = $input.first();
const raw = Buffer.from(item.binary.data.data, 'base64');
const sent = item.json.headers['x-signature'] ?? '';
const secret = $env.WEBHOOK_SIGNING_SECRET;

const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(sent, 'utf8');

if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
  throw new Error('Invalid webhook signature');
}

return [{ json: JSON.parse(raw.toString('utf8')) }];

Use timingSafeEqual, not ===. Read the secret from the environment or the credential store, never a string typed into the node. And check the timestamp header the sender gives you: reject anything older than about five minutes, so a request captured once can't be replayed next week.

When you reject, return a 401 and log that it happened, with the source IP and the path. Don't log the body of a request you just decided you don't trust.

Small operational trap while you're in there: /webhook-test/ only listens while the editor tab is open. /webhook/ is the production path. Configuring a vendor with the test URL is probably the single most common reason a webhook "worked yesterday."

The dead-letter pattern: store the payload, replay the payload

When a downstream system has a bad afternoon, the usual recovery is someone reconstructing what happened from Slack scrollback and a partial CSV export. That's not recovery. That's archaeology, and it's wrong about a couple of records every time.

Store the failure instead. On the error output branch, or inside the error workflow, write the original input plus enough context to re-run it:

create table dead_letter (
  id              bigserial primary key,
  workflow_id     text not null,
  execution_id    text not null,
  node_name       text,
  idempotency_key text,
  payload         jsonb not null,
  error_message   text,
  status          text not null default 'pending',
  attempts        int  not null default 0,
  created_at      timestamptz not null default now()
);

Then build a second, deliberately boring workflow on a schedule: pick up status = 'pending', re-run the same write path, and flip the row to replayed or to failed_permanent after a few attempts. Because the idempotency key rides along in the payload, replaying something that actually landed the first time is a no-op. That's the whole point. Replay stops being scary.

Store the raw payload, not your parsed version of it. The parse is frequently the thing that broke.

"Why not just use the Retry button in the executions list?" We do, for one-offs. It depends on n8n still holding the execution data, and you're about to prune that (see below). A dead letter table has its own schema and its own retention, controlled by you.

One design note decides whether this survives contact with a real team: pending rows need to be visible to someone without database access. A daily count posted to the channel that owns the process is enough. If the only way to see the queue is a SQL client, the queue grows forever. This is the same monitoring-and-replay plumbing sitting behind most of the RevOps and data builds we ship.

Alert on silence, because that's the one that costs money

Here's the failure that actually shows up on a P&L. A sync stopped three weeks ago. Nobody noticed, because the job only ever spoke up when it failed, and it wasn't failing. It wasn't running. No news read as good news, and it was just no news.

The error workflow can't catch two whole classes of this:

  • The run that never happened. The schedule trigger didn't fire after a restart, a refresh token expired and the polling trigger gave up, a queue worker died, or somebody deactivated the workflow "just for today" back in March.
  • The run that happened and did nothing. Zero items came back from a query that should never return zero, because an upstream filter stopped matching after a field rename. Green checkmark, empty result, no error anywhere.

The fix is heartbeats, and it's cheap. Every scheduled workflow's final node writes a success signal with a row count:

create table job_heartbeat (
  job_name               text primary key,
  last_success           timestamptz not null,
  last_count             int,
  expected_every_minutes int not null,
  min_expected_count     int not null default 0
);

Then a watchdog workflow reads that table on its own schedule and alerts on lateness, not on failure:

select job_name, last_success, last_count
from job_heartbeat
where now() - last_success > (expected_every_minutes || ' minutes')::interval
   or last_count < min_expected_count;

That min_expected_count column earns its keep. "Ran successfully, processed zero invoices, on the third of the month" should page a human just as loudly as a stack trace.

Put at least one layer of this outside n8n. If the instance is down, the workflow that tells you the instance is down is also down. An external cron ping to a service like Healthchecks.io or Better Stack costs nothing and covers the case where your whole monitoring story shares a fate with the thing it monitors.

Why "Continue On Fail" is usually the wrong default

The per-node On Error dropdown gives you Stop Workflow, Continue, and Continue (using error output). The plain Continue option is the dangerous one, and it's the one people reach for when a workflow keeps stopping and they want it to stop stopping.

What it actually does is push an item carrying an error property straight down the happy path. Every node after it treats that item as normal data. So you write a CRM record with the literal string "undefined" in the amount field, or send a client an email with a blank merge tag, and the execution reports success. You've converted a loud failure into a quiet wrong answer, which is a strictly worse trade.

Default to Stop Workflow. Reach for the error output branch when you have somewhere real for a failure to go, which by this point in the post you do. Reach for plain Continue almost never, and when you do, put a Filter node immediately behind it that separates errored items from clean ones before anything writes.

There's one honest argument for continuing, and it's batches. When a node processes 500 items and item 217 fails, stopping loses the other 499. Correct. The answer still isn't plain Continue: it's the error output into the dead letter table, so 499 land and one gets replayed. You get both halves.

What to log, and what not to

Log enough to answer "did invoice 4471 get created, and by which run?" without storing the invoice.

Worth logging every time: workflow ID and name, execution ID, trigger type, start and end timestamps, duration, the external record IDs you touched, HTTP status codes, retry counts, the error class and message, and the idempotency key.

Never log: full request and response bodies from systems holding personal data, auth headers, tokens, cookies, API keys, anything resembling card or bank details, and free-text fields like ticket bodies, email bodies, and CRM notes, where people paste things you would genuinely rather not be holding. Redact at the point of writing the log, not in a cleanup job later. The cleanup job is always six months later than the breach.

Now the part almost everyone misses. n8n is itself a logging system. By default it saves the full input and output of every node, for every execution, into its own database. If your workflow moves personal data, that database is now a copy of your customers' personal data with a retention policy of "forever" that nobody chose on purpose.

EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=168          # hours
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_SAVE_ON_ERROR=all
EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=false

Keep errors, drop successes. You retain what you need to debug and stop accumulating a shadow copy of the CRM. The tradeoff is real and you should know it going in: with success data gone, the Retry button in the executions list won't save you. Which is the third reason for the dead letter table.

This is also why we self-host n8n rather than putting client data through a hosted tenant. Execution data stays inside the client's own infrastructure, under their retention policy, revocable without us. More on how we work that way on the n8n consulting page.

The pre-production checklist

Before a workflow gets pointed at live data:

  • Error workflow assigned, and proven by forcing a real failure
  • Retry On Fail set on every external call, with a max tries you actually chose
  • Non-retryable status codes branching away from the retry path
  • A deterministic idempotency key on every write that creates money or a record
  • Signature verification plus a timestamp window on every inbound webhook
  • Dead letter storage on the failure branch, and a replay job that reads it
  • A heartbeat row with an expected interval and a minimum expected volume
  • On Error left at Stop Workflow unless there's a specific, written reason
  • Execution data retention configured, and personal data kept out of your own logs
  • Credentials scoped and stored in the credential store, not pasted into a node

Ten items, most of them twenty minutes of work. Skipping them is why a workflow that was correct in October is quietly producing wrong numbers by February, long after whoever built it has moved on.

Where this fits

None of this is clever engineering. It's the unglamorous slice of build time that decides whether a workflow still works in six months, which is why we include retries, idempotency, signature-verified webhooks, silent-failure alerting, and audit logging in every build instead of quoting them as an upgrade. The full standard is written out on the reliability page.

If you'd rather read about the specific ways workflows break before reading about how to prevent it, the 12 most common automation bugs is the companion piece. And if you've inherited an n8n instance where somebody built the happy path and moved on, that's a normal place to start: usually an audit of what's actually running, and how much of it is still running.

Want us to automate this for you?

Request a call: no pressure, no commitment.