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.

Ops Automators
11 min read
Part of the guide:n8n in Production: Readiness, Failure Handling, and Recovery
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.

Download a credential-free error workflow

The error alert workflow JSON includes a manual sample and an Error Trigger path feeding the same message formatter. Run the sample to inspect the output without connecting Slack or sending any messages. Raw error details are excluded from the alert payload.

See the setup and validation notes for the tested version and limits. The manual path verifies formatting; test automatic failure routing separately before using it for monitoring. For hosting, permissions, and production handover, see n8n consulting.

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

Enable Retry On Fail only after deciding whether repeating that operation is safe. Choose the attempt count and wait interval deliberately, and check the controls in your installed n8n version. A fixed wait between attempts is not exponential backoff.

For rate limits, follow the vendor's retry guidance, including Retry-After where provided. Use a durable queue when the delay exceeds your short retry budget. Cap attempts and give exhausted items an owner; an endless retry loop can keep a broken request alive for days.

Classify failures using the endpoint's contract. A 429 or 503 may permit a delayed retry. Validation errors usually need corrected input. A timeout after a write leaves the outcome unknown: the remote system might have committed it. Before retrying, use its idempotency support or reconcile the destination record.

The HTTP Request documentation describes Include Response Headers and Status and Never Error. If you use Never Error to branch explicitly on HTTP status, ensure every unsuccessful status reaches a failure path. Transport failures still need separate handling. Inspect actual node output before writing expressions against it.

Also configure the HTTP Request timeout. n8n documents this as the wait for response headers and the start of the body, so don't treat it as a guaranteed deadline for every subsequent operation. Test a slow response and an ambiguous write in a sandbox before allowing automatic recovery.

Idempotency: prevent duplicate writes without losing work

Suppose an invoice-creation request succeeds, but its response never reaches n8n. Retrying without protection can create another invoice. Creating an invoice and charging a payment are separate operations; each write needs its own recovery policy.

Persist one key per business operation. Derive it from an immutable event ID or generate a random key once and store it with the operation. Reuse that key and the same request parameters for retries of that operation. Don't generate a new key on each attempt or rely on an execution ID surviving a manual rerun.

An illustrative key for one opportunity milestone is:

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

That example assumes the two IDs uniquely identify one intended invoice within your account. Use a separate operation key for a later adjustment or payment. Don't include personal data in keys.

Check the vendor's retention rules. Stripe's idempotency documentation explains that saved results include failures, even a 500 response, and keys may be pruned after at least 24 hours. Reusing a pruned key can start a new request. Don't assume a stored key makes a replay weeks later safe.

Track the outcome as well as the key. A unique database row can coordinate workers, but it doesn't prove the external write completed. PostgreSQL's ON CONFLICT handles conflicts within the database; it cannot commit a remote API request in the same transaction.

If a worker claims a row and then crashes before the API call, blindly skipping every existing key loses unfinished work. If the API succeeds and the worker crashes before recording success, blindly retrying can duplicate the write. Your recovery design must handle both gaps.

Record pending, confirmed-success, and uncertain outcomes, along with the request identity, destination ID when known, and attempt history. Coordinate concurrent workers; reconcile uncertain results using the vendor's lookup or idempotency contract. An external-ID upsert can help when the API supports it, but check whether repeating an update triggers other side effects.

When there is no reliable way to establish the outcome, send the operation for review. A local dedupe table alone cannot guarantee exactly-once effects in another system. Test crashes on both sides of the API call, key expiry, concurrent delivery, and restore recovery before relying on this design.

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()
);

Build a scheduled recovery workflow with exclusive claims so two workers cannot replay the same pending row at once. Check the recorded outcome and the vendor's current idempotency window before sending another write. Mark confirmed results as replayed; send uncertain outcomes for reconciliation and exhausted attempts for review. Carrying an old key in the payload does not, by itself, make replay safe.

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.

When we inherit an instance, the fastest way to find the workflows that are lying to you is to filter the executions list to successes and look for the ones that finish suspiciously fast. A run that "succeeded" in 200ms against an API that normally takes two seconds usually did nothing at all, and plain Continue is why nobody noticed.

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 policy chosen per external operation, with bounded attempts and an owner for exhausted failures
  • Non-retryable status codes branching away from the retry path
  • A persistent operation key, vendor-specific expiry rules, and recovery for uncertain write outcomes
  • 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.

Have a similar operating problem?

Tell us about the process and the decision you need to make.