Generated banner for N8n Error Handling
Generated logo for N8n Error Handling

N8n Error Handling

Romuald CzlonkowskiCommunityCreated by Romuald Czlonkowski

  • Skill
  • Guidelines
  • Automation and integrations
  • MIT
  • No issues found

From the author

Wire n8n error handling so failures are loud, structured, and recoverable. Use when building any webhook/API workflow, a scheduled or unattended workflow, or any path where a silent failure would drop user-visible work — and whenever the user mentions error handling, onError, continueErrorOutput, error branches/outputs, retries, retryOnFail, Respond to Webhook status codes, 4xx/5xx, Error Trigger, or "my workflow fails silently". Covers per-node error outputs and wiring, retry/self-healing, error-trigger workflows, and 4xx/5xx response shapes.
— the description by Romuald Czlonkowski

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

By default, n8n workflows halt on error, which can lead to silent failures in unattended processes like webhooks or cron jobs. This skill provides a systematic approach to making failures loud, structured, and recoverable. It covers the two-step requirement for per-node error outputs, the implementation of workflow-level error triggers, and best practices for mapping failure causes to appropriate HTTP status codes to ensure reliable communication between systems.

What it does

  • Configures per-node error outputs to prevent silent workflow failures.
  • Implements self-healing patterns using retry-on-fail configurations for network-dependent nodes.
  • Establishes workflow-level error triggers for centralized monitoring and alerting.
  • Defines structured response shapes for API workflows to distinguish between client-side and server-side errors.
  • Provides validation patterns to catch input errors before they trigger node-level failures.
  • Offers strategies to avoid common traps like half-wired error outputs or leaking sensitive internal data.

When to use it

  • When building webhook or API workflows that must return meaningful status codes.
  • When deploying scheduled, cron, or queue-based unattended workflows.
  • When you need to prevent silent failures that result in dropped work or missing logs.
  • When you need to differentiate between transient network blips and persistent system errors.
  • When you need to provide clear, actionable error feedback to downstream systems or users.

Advantages

  • Reduces downtime by automating recovery for transient network failures.
  • Improves observability by ensuring all failures are logged and alerted.
  • Enhances API reliability by returning accurate HTTP status codes.
  • Prevents the 'silent failure' trap where workflows appear successful despite errors.
  • Standardizes error reporting across complex automation architectures.

Usage examples

“How do I make sure my HTTP Request node doesn't just stop the workflow when it fails?”
The agent will guide you to set the node's onError property to continueErrorOutput and wire the second output (sourceIndex: 1) to a handler node.
“My webhook workflow is failing silently, what should I do?”
The agent will help you implement a structured error-handling branch that returns a proper 4xx or 5xx status code instead of a generic timeout.
“How can I get an alert when my scheduled workflow fails?”
The agent will instruct you to create a separate workflow using the Error Trigger node to capture and notify you of failures in your main workflow.
“How do I handle transient network errors in n8n?”
The agent will show you how to configure the retryOnFail, maxTries, and waitBetweenTries settings on your network-dependent nodes.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/czlonkowski/n8n-error-handling/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip n8n-error-handling.zip -d .agents/skills

Documentation by Romuald Czlonkowski

n8n Error Handling

By default, when an n8n node throws, the whole workflow halts. For an interactive run you're watching, that's fine — you see the red node and fix it. For anything unattended (a webhook API, a cron job, a queue worker, an agent tool), it's the wrong default: the caller gets a timeout or an empty 500, the operator gets no alert, and the symptom is "the integration just stopped working" with no log and no clue.

This skill is about making failures loud, structured, and recoverable — and, best case, self-healing so transient blips never reach a human at all.

The two ideas that prevent most silent failures:

  • Per-node error outputs — a node's failure routes down a second output you control, instead of killing the run.

  • A workflow-level error workflow — a catch-all that fires for anything that escapes per-node handling (timeouts, crashes between nodes, unwired failures).


When you actually need this

Workflow shapeError handling posture
Webhook / API (anything with Respond to Webhook)Required. Every fallible node's error output wired; status code matches cause.
Scheduled / cron / queue worker / agent tool (unattended)Required. A workflow-level error workflow, plus retryOnFail on network nodes.
Internal one-off you run and watch yourselfOptional. Default onError: "stopWorkflow" is fine — you'll see the red node and re-run.

The dividing line: if anyone other than you sees the output — a downstream system, an end user, an on-call engineer — the failure has to be handled, not swallowed. If you're the only watcher and the cost of failure is "I notice and re-run", looser is fine.


The #1 silent trap: per-node error output is a TWO-step setup

This is the single most common way an n8n workflow "handles" errors while actually swallowing them. Routing a node's failure to a handler takes two changes, and doing only one looks complete but misbehaves:

  1. Set onError: "continueErrorOutput" on the node. This is what creates the second output. Without it, main[1] doesn't exist no matter what you wire.

  2. Wire that error output (connections.<node>.main[1], i.e. sourceIndex: 1) to a real handler. Without a target, the error data is emitted into the void.

Get one without the other and you hit a failure mode:

What you didWhat happens at runtime
onError set, error output not wiredError data is silently discarded. Downstream doesn't fire. The dashboard shows the run as succeeded. Worst case — no error logged anywhere.
Error output wired, onError not setThe slot never fires; the handler is unreachable. On failure the workflow just halts (default stopWorkflow).
Both doneFailure routes down main[1] to your handler. ✅
Doing both with n8n_update_partial_workflow
// 1) Turn on the error output (creates main[1])
{ type: "updateNode", nodeName: "HTTP Request",
  changes: { onError: "continueErrorOutput" } }

// 2) Wire the error output to a handler. sourceIndex: 1 = the error output.
{ type: "addConnection",
  source: "HTTP Request",
  target: "Handle Error",
  sourceIndex: 1 }

sourceIndex: 0 is the success path, sourceIndex: 1 is the error path. (For IF nodes the aliases branch: "true"/"false" map to index 0/1; for a generic fallible node, use the explicit sourceIndex: 1.)

Then verify. This trap doesn't surface in validate_workflow — a half-wired error output validates clean. Pull the workflow with n8n_get_workflow and confirm both halves:

  • The node's onError is "continueErrorOutput".

  • connections["HTTP Request"].main[1] contains your handler.

Valid onError values:

ValueEffect
"stopWorkflow" (default)Error halts the whole workflow.
"continueRegularOutput"Error item flows out the normal output. Rare, usually wrong — downstream gets error-shaped data and keeps going.
"continueErrorOutput"Error item flows out the separate error output (main[1]). The one you wire.

Full failure-mode catalog, fan-in/fan-out shapes, and verification: NODE_ERROR_OUTPUTS.md.

Failures the error output never sees

A correctly wired error output still misses whole classes of failure. Verified on n8n 2.38.5:

FailureWith continueErrorOutputWith continueRegularOutput
JS error inside {{ }} (TypeError on a missing path, JSON.parse on bad input, a thrown Error, a JMESPath syntax error)nothing fires; the field is null and items take the success pathsame
Python Code node rejected before running (blocked import, dunder access: Security violations detected) or bad return shape (list in each-item mode)node marked failed, but the unchanged input items leave through the success output; main[1] stays empty and the execution shows successunchanged input items pass through
Exception while code runs (raise/throw, KeyError, NameError)routed to main[1] as { error } ✅{ error } item on the main output

So an error branch alone can't guard these:

  • Guard the data, not just the node. After a transform whose output matters, check that the field you produced exists (IF/Filter on {{ $json.total !== undefined && $json.total !== null }}) and send the miss down the error path yourself.

  • Test-run and read node statuses and output values. The Python cases show a red node inside a green execution. The expression cases show nothing but nulls.

  • Root causes and fixes: n8n-expression-syntax (Debugging) and n8n-code-python (Errors and onError).


Self-healing first: retryOnFail before you wire error paths

Before you build error branches, absorb the transient failures so they never reach those branches. On any node that calls a network service — HTTP Request, comms (Gmail/Slack/Discord), databases, AI nodes, third-party integrations — set node-level retry:

{ type: "updateNode", nodeName: "HTTP Request",
  changes: {
    retryOnFail: true,
    maxTries: 3,
    waitBetweenTries: 5000   // ms
  } }

Why this comes first: a 429 or a brief upstream hiccup will retry and usually succeed on its own. The error output then fires only on real, persistent failures — so your 5xx responses and on-call alerts reflect actual problems instead of noise.

Engine limits to know: retry fires on any error (there's no per-status-code filter), maxTries caps at 5, and waitBetweenTries caps at 5000ms — so 5000 is both the max and a sensible default. See n8n-node-configuration (NODE_FAMILY_GOTCHAS.md) for node-specific notes.


API workflows: the canonical shape

A webhook-triggered workflow that responds to its caller has one rule that overrides everything else: no hanging branches. Every path — success and every error — must end at a Respond to Webhook, or the caller sits there until it times out.

Webhook (responseMode: "responseNode")
  ├── validate input → process → Respond (200, body)
  └── (any fallible node's error output → sourceIndex 1)
            → Respond (4xx/5xx, structured error body)
            → optional: log full error privately / notify

Three things make this work:

  1. Fan-in to one error responder. Many fallible nodes can route their main[1] to a single Respond node. Keeps the graph readable.

  2. Validation failures (4xx) are checked upstream, not via error outputs. A missing field isn't a node crashing — it's an expected outcome with a known response. Branch on it with IF/Switch (or the per-field validator below) and return 400/401/403/404 directly. Error outputs are for unexpected failures (5xx).

  3. options.responseCode defaults to 200 — even on error branches. This is its own silent trap (see RESPONSE_SHAPES.md and n8n-node-configuration NODE_FAMILY_GOTCHAS.md): an error branch that returns 200 with an error body looks like success to the caller's HTTP client, so their error handling never fires. Set responseCode explicitly on every Respond node.

Input validation: conditions on the canvas

Put the checks in one IF node: one boolean condition per rule (is true), combinator and. True → your logic (200); false → a 400 Respond with a static validation_error body that echoes the expected schema, so the caller can self-correct. When the caller needs per-field messages (an LLM client, a public API), replace the IF with one Code node that loops over a schema list and returns { valid, validationError, details, requiredSchema }, with the note Why Code: IF considered — per-field messages need a loop over the schema. Both patterns, the constraint cookbook and the escaping rules are in API_WORKFLOWS.md.


Response shapes: map cause → status code

A 5xx with text/plain "Internal Server Error" is technically an error response and practically useless. And not every failure is a 5xx. Match the status code to why the request failed, because the caller branches on it: their monitoring alerts on 5xx (your fault) but not 4xx (their fault), and 5xx suggests "retry" while 4xx suggests "don't".

The common mistake: wiring everything — including bad input — to one Respond that returns 500 internal_error. Now the caller can't tell their bug from your outage, and your error rates can't separate real incidents from client noise.

CauseStatuserror codeWhere it's handled
Required field missing / wrong type400validation_errorUpstream check (IF conditions / per-field validator), not error output
Auth missing or invalid401unauthorizedUpstream check
Authenticated but not allowed403forbiddenUpstream check
Resource ID valid in request, absent in your data404not_foundBranch on the lookup result, not its error
Conflicts with current state (duplicate, race)409conflictDetect with logic
Caller exceeded rate limit429rate_limit_exceededSet Retry-After header
Node threw, cause unknown500internal_errorError output path
Third-party API returned an error502upstream_errorError output of the HTTP node
Can't process right now (downstream down)503service_unavailableDetect specific error, hint retry
Third-party API timed out504upstream_timeoutError output filtered by message

So there are two distinct flows: 4xx is decided before the work (IF/Switch + dedicated Respond), 5xx comes out of error outputs ("we tried, it broke").

Route by cause with a Switch; one static Respond per status. Picking a status code from the error is a routing job. A Switch on the error message classifies it, and each output goes to a Respond with a fixed options.responseCode and a fixed body:

error outputs (main[1]) ─→ Switch "Classify error" (on $json.error.message)
                             ├─ contains "INVALID_ID"     → Respond 400 validation_error
                             ├─ contains "429"            → Respond 429 rate_limit_exceeded
                             ├─ contains "timeout"        → Respond 504 upstream_timeout
                             └─ fallback (extra output)   → Respond 500 internal_error

Every status is visible on the canvas, and adding one is adding a rule. Set the Switch's options.fallbackOutput to extra, or an unmatched error is dropped and the caller hangs. A single Respond with a computed code is fine for exactly two outcomes, as one short ternary in its Response Code field. The Switch JSON is in API_WORKFLOWS.md.

The default envelope is { "error": "<code>", "message": "<human text>" } — the HTTP status already says success-vs-failure, so no ok: false flag. Never leak internals (stack traces, SQL, upstream bodies, tokens) into the response — log those privately, return a sanitized message. Correlation IDs, retry_after, validation details, and the full do-not-leak list are in RESPONSE_SHAPES.md.


Workflow-level error workflow (the catch-all)

Per-node outputs handle the failures you anticipated on the nodes you remembered to wire. An error workflow catches everything else: a node you forgot to wire, a crash between nodes, a whole-workflow timeout, a trigger failure. For unattended workflows this is the safety net that turns "it silently stopped" into "an alert arrived".

Build it as a separate workflow starting with an Error Trigger node. n8n invokes it with the failure context:

{
  "execution": { "id": "...", "url": "...", "lastNodeExecuted": "Fetch order",
    "error": { "name": "NodeApiError", "message": "...", "timestamp": 1715000000000 } },
  "workflow": { "id": "...", "name": "Sync Stripe customers" }
}

Minimal version — capture → notify:

Error Trigger → Slack/email (post to #incidents; the alert text is built with expressions in the message field)

A good alert includes the workflow name, a link to the editor and a link to the failed execution, the failed node name, and the real error message (not "Workflow failed"). Field expressions and the optional "fetch the failing input via the n8n node" upgrade are in ERROR_WORKFLOWS.md.

Two traps worth flagging up front:

  • The recursion trap. If the error workflow notifies Slack and Slack is what's down, the error workflow fails too — and the original error vanishes. Notify on a different channel than your monitored workflows use (most workflows alert Slack → error workflow uses email), and add a fallback (write to a Data Table) so a failed notification still leaves a trace.

  • A "handled" error won't bubble up. If a node's error output is wired to a no-op that drops the data, n8n considers the error handled and the error workflow does not fire. Only catch per-node when you're actually doing something with the error.

What the community MCP can't do: assigning the error workflow (instance default or per-workflow override) is an n8n UI setting — Workflow Settings → Error Workflow. There is no MCP tool to set it. Build the error workflow with the MCP, then tell the user the exact UI step to wire it up, and to repeat it (or set the instance default) for every unattended workflow.


What's NOT available via the community MCP

Want to doReality
Set a workflow's Error Workflow settingUI only (Workflow Settings → Error Workflow). No MCP tool. Build the workflow, then hand the user the UI step.
Toggle other workflow settings (Save Execution Data, timezone, timeout, caller policy)UI only. n8n_update_partial_workflow has updateSettings, but the error-workflow assignment is not reliably exposed — confirm in the UI.
Enable instance-wide error logging (Sentry, server logs)Instance config, outside n8n workflows entirely.

What the MCP can do: build the error workflow, set onError/retryOnFail on nodes (updateNode/patchNodeField), wire error outputs (addConnection with sourceIndex: 1), validate (validate_workflow, n8n_validate_workflow), auto-fix common issues (n8n_autofix_workflow), test (n8n_test_workflow), and inspect failures (n8n_executions).


Anti-patterns

Anti-patternWhat goes wrongFix
onError set but error output unwiredError silently discarded; run shows as succeededWire sourceIndex: 1 to a real handler, or revert onError to stopWorkflow so it's loud
Error output wired but onError not setSlot never fires; handler unreachable; workflow halts on failureSet onError: "continueErrorOutput"
Webhook → process → respond, no error branchCaller gets a timeout or n8n's generic 500Wire every fallible node's error output to a Respond
Error branch returns 200 with an {error} bodyCaller's client reads success; their error handling never firesSet options.responseCode to 4xx/5xx explicitly on error Responds
One 500 internal_error for everythingCaller can't tell their bad input from your outageMap cause → status (4xx caller, 5xx you)
Catching errors in a Code node and returning them as dataDownstream processes error-shaped data and continuesLet it throw; use onError: "continueErrorOutput" + wired path
Network node with no retryOnFailEvery transient 429/blip surfaces as a 5xx; alerts fire on noiseretryOnFail: true, maxTries: 3, waitBetweenTries: 5000
A function body in a Respond's Response Code or bodythe routing is hidden in a field, and a runtime error turns into a silent nullSwitch on the cause → one static Respond per status
Unattended workflow with no error workflowA genuine failure goes nowhereBuild an Error Trigger workflow + assign it in the UI
Error workflow notifies the same channel the workflows monitorChannel down → error workflow also fails → error vanishesUse a different channel + a Data Table fallback
Leaking $json.error (stack/SQL/tokens) into the responseExposes internals to callers/attackersLog privately, return a sanitized message

Reference files

FileRead when
NODE_ERROR_OUTPUTS.mdWiring a per-node error output on individual fallible nodes
API_WORKFLOWS.mdBuilding/reviewing a webhook → Respond workflow, including input validation (IF conditions, per-field Code validator)
RESPONSE_SHAPES.mdDefining response body conventions, status codes, and what not to leak
ERROR_WORKFLOWS.mdSetting up the workflow-level catch-all for unattended workflows

Integration with other skills

  • n8n-workflow-patterns — the webhook/API and scheduled patterns are where error handling lives. Use it for the overall shape; use this skill to harden it.

  • n8n-node-configuration — onError/retryOnFail are node config; NODE_FAMILY_GOTCHAS.md covers the Webhook/Respond response-code traps in depth.

  • n8n-validation-expert — the half-wired error output (one of the two steps missing) is a connection/config audit item, not a validation error. This skill is the fix.

  • n8n-expression-syntax — the Switch conditions on $json.error.message and the alert-message expressions rely on correct {{ }} syntax and $json.error access.

  • n8n-code-javascript / n8n-code-python — if you catch errors inside a Code node, decide deliberately: re-throw to use the error output, or handle and continue. Don't return error-shaped data and pretend it succeeded.

  • n8n-code-tool — an agent's Code Tool surfaces thrown errors back to the LLM, which then retries; that's a different error contract from workflow nodes.

  • n8n-binary-and-data — file/binary operations are fallible too; wire their error outputs like any network node.


Quick reference checklist

For an API / webhook workflow:

  • [ ] Webhook trigger uses responseMode: "responseNode"

  • [ ] Input validated upstream → 4xx Respond (IF conditions, or the per-field Code validator)

  • [ ] Every fallible node has onError: "continueErrorOutput" and main[1] wired

  • [ ] Network nodes have retryOnFail: true, maxTries: 3, waitBetweenTries: 5000

  • [ ] Error path ends at a Respond with an explicit 4xx/5xx options.responseCode (a top-level responseCode is ignored)

  • [ ] Status code matches cause (4xx caller, 5xx you)

  • [ ] Error body is { error, message } — no stack traces, SQL, or tokens

  • [ ] Verified with n8n_get_workflow: both onError and main[1] present on each fallible node

For an unattended (scheduled/cron/queue) workflow:

  • [ ] Network nodes have retryOnFail configured

  • [ ] An Error Trigger workflow exists (capture → notify, optional retry)

  • [ ] The error workflow notifies on a different channel + has a fallback (recursion trap)

  • [ ] The error-workflow setting is assigned in the n8n UI (MCP can't do it — remind the user)


Remember: the default is silence. Error handling is two moves — make the failure route (per-node onError + wired output, or a catch-all error workflow) and make it speak (a status code and body that tell the truth). Half a move is worse than none, because it looks done.

Files

API_WORKFLOWS.md

API Workflows

When a workflow is an HTTP API — a Webhook trigger that ends at a Respond to Webhook — error handling stops being optional. The caller is a machine waiting on a response, and the failure modes are unforgiving: a hanging branch becomes a timeout, a wrong status code breaks the caller's error handling, a leaked stack trace becomes a security finding.

This file covers wiring that pattern so it behaves under failure, not just on the happy path. For the per-node mechanics, see NODE_ERROR_OUTPUTS.md; for body conventions and status codes, RESPONSE_SHAPES.md.


The shape

Webhook (responseMode: "responseNode")
  → validate input ──valid──→ process ──→ Respond (200, success body)
  │                └─invalid─→ Respond (400, validation_error body)
  └── (any fallible node's error output, sourceIndex 1)
            → Respond (5xx, structured error body)
            → optional: Log full error privately / notify

The non-negotiable: every path ends at a Respond node. Success, validation failure, execution failure — all of them. A path that doesn't reach a Respond is a hanging branch, and a hanging branch is a caller timeout.

Set responseMode: "responseNode" on the Webhook trigger — without it the trigger acknowledges immediately (onReceived) and the caller never sees your computed response. (See n8n-node-configuration NODE_FAMILY_GOTCHAS.md for the Webhook/Respond traps.)


Wiring every fallible node

For each fallible node (HTTP, DB, third-party, file op), the two-step setup from NODE_ERROR_OUTPUTS.md:

  1. onError: "continueErrorOutput" on the node.

  2. addConnection from its sourceIndex: 1 to your error Respond (directly, or via a logger).

A two-node processing chain, both fallible, both routing to one responder:

// Turn on error outputs
{ type: "updateNode", nodeName: "Fetch User",    changes: { onError: "continueErrorOutput" } }
{ type: "updateNode", nodeName: "Call External", changes: { onError: "continueErrorOutput" } }

// Success path
{ type: "addConnection", source: "Webhook",      target: "Fetch User",      sourceIndex: 0 }
{ type: "addConnection", source: "Fetch User",   target: "Call External",   sourceIndex: 0 }
{ type: "addConnection", source: "Call External",target: "Respond Success", sourceIndex: 0 }

// Error paths — both fan in to one responder
{ type: "addConnection", source: "Fetch User",   target: "Respond Error",   sourceIndex: 1 }
{ type: "addConnection", source: "Call External",target: "Respond Error",   sourceIndex: 1 }

Three things to notice:

  1. One Respond Error for many sources. Fan-in keeps it readable.

  2. Both nodes have onError set. Miss it on either and that node's failure halts the workflow instead of routing — and the caller times out.

  3. If you surface the error message in the body, sanitize it. See "Don't leak internals" below.

The error Respond node, in JSON:

{
  "type": "n8n-nodes-base.respondToWebhook",
  "name": "Respond Error",
  "parameters": {
    "respondWith": "json",
    "responseBody": "={{ { error: 'upstream_error', message: 'External service failed' } }}",
    "options": {
      "responseCode": 502,
      "responseHeaders": { "entries": [{ "name": "Content-Type", "value": "application/json" }] }
    }
  }
}

Always set Content-Type: application/json explicitly — the default depends on the body shape and isn't reliable.


4xx lives upstream, 5xx comes out of error outputs

This is the structural rule that keeps an API honest:

  • Validation / auth / not-found failures are expected outcomes with a known response. They aren't nodes crashing. Check them before the work, with IF/Switch + a dedicated Respond, and return the right 4xx directly. Do not route them through error outputs.

  • Execution failures (a node actually throwing) are unexpected. Those come out of error outputs as 5xx.

A real API usually needs several upstream checks, each its own IF/Switch + Respond, before the processing stage:

Webhook
  → Auth present & valid?      ── no ──→ Respond 401 unauthorized
  → Input valid?               ── no ──→ Respond 400 validation_error (with details)
  → Caller allowed this op?    ── no ──→ Respond 403 forbidden
  → Target resource exists?    ── no ──→ Respond 404 not_found
  → Processing stage (HTTP / DB / etc.) ←── this is where 5xx errors originate

That's not over-engineering — it's the difference between the caller getting an actionable validation_error and getting a generic 500 they can't act on.


Input validation: conditions on the canvas

Put the checks in an IF node: one condition per rule, combinator and. True → your logic; false → a 400 Respond. Every rule is visible on the canvas, and adding one is adding a condition.

{
  "type": "n8n-nodes-base.if",
  "typeVersion": 2.3,
  "name": "Input valid?",
  "parameters": {
    "conditions": {
      "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 },
      "conditions": [
        { "leftValue": "={{ typeof $json.body.name === 'string' && $json.body.name.length > 0 }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } },
        { "leftValue": "={{ /^\\S+@\\S+\\.\\S+$/.test($json.body.email ?? '') }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } },
        { "leftValue": "={{ ['starter', 'pro', 'enterprise'].includes($json.body.plan) }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } },
        { "leftValue": "={{ Number.isInteger($json.body.seat_count) && $json.body.seat_count >= 1 && $json.body.seat_count <= 500 }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } }
      ],
      "combinator": "and"
    },
    "options": {}
  }
}

Write each rule as a boolean expression tested with is true, not as a typed comparison (number ≥ 1). The input is untrusted: with strict type validation, a typed comparison on a value of the wrong type ("seat_count": "five") makes the node throw instead of returning false, and the caller gets a 500 instead of a 400.

The false branch responds with a static body that echoes the expected schema, so the caller (or an LLM driving the call) can fix the request:

{
  "type": "n8n-nodes-base.respondToWebhook",
  "name": "Respond 400",
  "parameters": {
    "respondWith": "json",
    "options": { "responseCode": 400 },
    "responseBody": "={{ { error: 'validation_error', message: 'The request body does not match request_schema.', request_schema: { type: 'object', additionalProperties: false, required: ['name', 'email', 'plan', 'seat_count'], properties: { name: { type: 'string', minLength: 1 }, email: { type: 'string', format: 'email' }, plan: { type: 'string', enum: ['starter', 'pro', 'enterprise'] }, seat_count: { type: 'integer', minimum: 1, maximum: 500 } } } } }}"
  }
}

The status code lives in options.responseCode; a top-level responseCode is ignored and the node returns 200. Pass the body as an object; a string body is parsed as JSON first, so JSON.stringify(...) only adds a step (see n8n-node-configuration NODE_FAMILY_GOTCHAS.md).

Exception: per-field messages (a Code node)

When the caller needs to know which field failed and why, the IF can't say it. Replace the IF with one Code node that loops over a schema list — the loop is the closed-list reason — and branch on its result with an IF on ={{ $json.valid }}. Set the node's note: Why Code: IF considered — per-field messages need a loop over the schema.

// Run Once for All Items. Edit FIELDS for your endpoint; keep the output keys.
const body = $input.first().json.body ?? {};
const FIELDS = [
  { name: 'name', type: 'string', required: true, minLength: 1, description: 'Customer full name' },
  { name: 'email', type: 'string', required: true, pattern: '^\\S+@\\S+\\.\\S+$', description: 'Contact email address' },
  { name: 'plan', type: 'string', required: true, enum: ['starter', 'pro', 'enterprise'], description: 'Subscription plan' },
  { name: 'seat_count', type: 'integer', required: true, minimum: 1, maximum: 500, description: 'Number of licensed seats' },
];
const details = {};
for (const f of FIELDS) {
  const v = body[f.name];
  if (v === undefined) { if (f.required) details[f.name] = `Missing required field "${f.name}"`; continue; }
  const typeOk = f.type === 'integer' ? Number.isInteger(v) : typeof v === f.type;
  if (!typeOk) details[f.name] = `Expected type "${f.type}"`;
  else if (f.minLength !== undefined && v.length < f.minLength) details[f.name] = `Must be at least ${f.minLength} character(s)`;
  else if (f.pattern && !new RegExp(f.pattern).test(v)) details[f.name] = `"${v}" is not valid`;
  else if (f.enum && !f.enum.includes(v)) details[f.name] = `"${v}" is not allowed. Must be one of: ${f.enum.join(', ')}`;
  else if ((f.minimum !== undefined && v < f.minimum) || (f.maximum !== undefined && v > f.maximum)) details[f.name] = `Must be between ${f.minimum} and ${f.maximum}`;
}
for (const key of Object.keys(body)) if (!FIELDS.some(f => f.name === key)) details[key] = 'Unknown field';

const requiredSchema = {
  type: 'object', additionalProperties: false,
  required: FIELDS.filter(f => f.required).map(f => f.name),
  properties: Object.fromEntries(FIELDS.map(({ name, required, ...rule }) => [name, rule])),
};
const issues = Object.entries(details).map(([field, msg]) => `• ${field}: ${msg}`);
return [{ json: issues.length === 0
  ? { valid: true, validationError: null }
  : { valid: false, validationError: `Validation failed (${issues.length} issue${issues.length > 1 ? 's' : ''}):\n${issues.join('\n')}`, details, requiredSchema } }];

The 400 Respond reads the result:

={{ { error: 'validation_error', message: $json.validationError, details: $json.details, request_schema: $json.requiredSchema } }}

The output contract (the Respond depends on these key names):

  • Valid: { valid: true, validationError: null }

  • Invalid: { valid: false, validationError: <summary string>, details: { <field>: <message> }, requiredSchema: <schema echoed back> }

FIELDS is the one place to edit: the checks and the echoed schema are built from it, so they can't drift apart. Unknown fields are reported, which is what additionalProperties: false in the echoed schema promises.

Constraint cookbook
NeedIF rule (boolean expression)FIELDS entry (Code exception)
Required, non-empty stringtypeof $json.body.name === 'string' && $json.body.name.length > 0type: 'string', required: true, minLength: 1
Pattern/^\S+@\S+\.\S+$/.test($json.body.email ?? '')pattern: '^\\S+@\\S+\\.\\S+$'
Enum['a', 'b'].includes($json.body.plan)enum: ['a', 'b']
Integer in rangeNumber.isInteger($json.body.n) && $json.body.n >= 1 && $json.body.n <= 500type: 'integer', minimum: 1, maximum: 500
Array with itemsArray.isArray($json.body.tags) && $json.body.tags.length > 0add an array branch to the type check
Conditional rulea second IF after the first, or $json.body.type !== 'X' || <rule>a when key the loop checks
Escaping in workflow JSON

In the IF rules the regex is a JavaScript literal inside the expression: /^\S+@\S+\.\S+$/. In the Code node it is a string passed to new RegExp, so each backslash is written twice ('^\\S+@\\S+\\.\\S+$'). When either lands in workflow JSON (an expression string or jsCode), every backslash doubles once more. If an email check never matches, count the backslashes.


5xx: route by cause, one static Respond per status

Error outputs from several fallible nodes can fan in to one Switch that classifies the failure. Each output goes to a Respond with a fixed code and a fixed body; the fallback output catches everything else as 500. Every status is visible on the canvas, and adding a case is adding a rule.

error outputs (main[1]) ─→ Switch "Classify error"
                             ├─ message contains "timeout"    → Respond 504 upstream_timeout
                             ├─ message contains "rate limit" → Respond 503 service_unavailable
                             └─ fallback (extra output)       → Respond 500 internal_error
{
  "type": "n8n-nodes-base.switch",
  "typeVersion": 3.4,
  "name": "Classify error",
  "parameters": {
    "mode": "rules",
    "rules": {
      "values": [
        {
          "conditions": {
            "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict", "version": 3 },
            "conditions": [ { "leftValue": "={{ $json.error?.message ?? '' }}", "rightValue": "timeout", "operator": { "type": "string", "operation": "contains" } } ],
            "combinator": "and"
          },
          "renameOutput": true,
          "outputKey": "upstream_timeout"
        },
        {
          "conditions": {
            "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict", "version": 3 },
            "conditions": [ { "leftValue": "={{ $json.error?.message ?? '' }}", "rightValue": "rate limit", "operator": { "type": "string", "operation": "contains" } } ],
            "combinator": "and"
          },
          "renameOutput": true,
          "outputKey": "service_unavailable"
        }
      ]
    },
    "options": { "fallbackOutput": "extra", "renameFallbackOutput": "internal_error" }
  }
}

Each Respond is static, for example:

{
  "type": "n8n-nodes-base.respondToWebhook",
  "name": "Respond 504",
  "parameters": {
    "respondWith": "json",
    "responseBody": "={{ { error: 'upstream_timeout', message: 'External service did not respond in time' } }}",
    "options": { "responseCode": 504 }
  }
}
  • Without fallbackOutput: "extra", an error no rule matches is dropped and the caller waits until it times out.

  • A single Respond with a computed code is fine for exactly two outcomes, as one short ternary in its Response Code field (options.responseCode = ={{ $json.error?.message?.includes('timeout') ? 504 : 500 }}). From three outcomes on, use the Switch.


Don't leak internals

The tempting one-liner:

responseBody: "={{ JSON.stringify({ error: 'internal_error', details: $json.error }) }}"  // ❌

$json.error can carry stack traces, internal node names, connection strings, and upstream response bodies with embedded tokens. Surfacing it hands attackers a map and gives callers nothing useful.

Instead: log the full error privately, return a sanitized message.

// Error output → Log node (sends full $json.error to Sentry/Slack/your logger)
{ type: "addConnection", source: "Call External", target: "Log Full Error", sourceIndex: 1 }
{ type: "addConnection", source: "Log Full Error", target: "Respond Error",  sourceIndex: 0 }
// Respond Error keeps the body clean:
{ "responseBody": "={{ { error: 'upstream_error', message: 'External service failed' } }}",
  "options": { "responseCode": 502 } }

The caller sees a clean message; the detail stays internal. Full do-not-leak list in RESPONSE_SHAPES.md.


Correlation IDs (optional)

If you run distributed tracing or log correlation, add a request_id consistently across every success and error response (partial coverage is worse than none). Two sources:

  • Caller-supplied — read an X-Request-ID header, pass it through. Better for tracing across systems.

  • Generated — use {{ $execution.id }} or a UUID. Easier.

Don't conflate this with the job_id an async (202) endpoint returns — that's how the caller polls for work later, not a correlation field.


Async / 202 pattern

If the work takes longer than the caller wants to wait, respond 202 immediately and continue async:

Webhook → validate → Respond (202, { job_id }) → continue processing → callback / queue / email on completion

It has its own gotchas (idempotency, callback retries, status tracking) — build it deliberately. The job_id is intrinsic (it's how the work is found later), distinct from the optional request_id.


Verifying the API workflow

Before activating:

  1. Test the success path with n8n_test_workflow. Confirm shape and code. API workflows almost always have side effects (DB writes, third-party calls, comms) — ask the user before running a test that triggers them.

  2. Trigger an error path — feed input that breaks a processing node, run, confirm the error Respond fires with the right code and body.

  3. Verify connections with n8n_get_workflow: every fallible node has onError: "continueErrorOutput" AND main[1] wired. (NODE_ERROR_OUTPUTS.md.)

  4. Confirm no internal detail leaks in the error body.

  5. Inspect real failures afterward with n8n_executions to confirm the codes you expected are what actually went out.

If any check fails, fix before activating.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Python, Needs Node.js

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Romuald Czlonkowski
Publisher
Romuald Czlonkowski (Community)
Source
czlonkowski/n8n-skills · skills/n8n-error-handling · commit e037563
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Romuald Czlonkowski or Romuald Czlonkowski.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download n8n-error-handling.zip