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…

, description: 'Contact email address' },\n { name: 'plan', type: 'string', required: true, enum: ['starter', 'pro', 'enterprise'], description: 'Subscription plan' },\n { name: 'seat_count', type: 'integer', required: true, minimum: 1, maximum: 500, description: 'Number of licensed seats' },\n];\nconst details = {};\nfor (const f of FIELDS) {\n const v = body[f.name];\n if (v === undefined) { if (f.required) details[f.name] = `Missing required field \"${f.name}\"`; continue; }\n const typeOk = f.type === 'integer' ? Number.isInteger(v) : typeof v === f.type;\n if (!typeOk) details[f.name] = `Expected type \"${f.type}\"`;\n else if (f.minLength !== undefined && v.length \u003c f.minLength) details[f.name] = `Must be at least ${f.minLength} character(s)`;\n else if (f.pattern && !new RegExp(f.pattern).test(v)) details[f.name] = `\"${v}\" is not valid`;\n else if (f.enum && !f.enum.includes(v)) details[f.name] = `\"${v}\" is not allowed. Must be one of: ${f.enum.join(', ')}`;\n else if ((f.minimum !== undefined && v \u003c f.minimum) || (f.maximum !== undefined && v > f.maximum)) details[f.name] = `Must be between ${f.minimum} and ${f.maximum}`;\n}\nfor (const key of Object.keys(body)) if (!FIELDS.some(f => f.name === key)) details[key] = 'Unknown field';\n\nconst requiredSchema = {\n type: 'object', additionalProperties: false,\n required: FIELDS.filter(f => f.required).map(f => f.name),\n properties: Object.fromEntries(FIELDS.map(({ name, required, ...rule }) => [name, rule])),\n};\nconst issues = Object.entries(details).map(([field, msg]) => `• ${field}: ${msg}`);\nreturn [{ json: issues.length === 0\n ? { valid: true, validationError: null }\n : { valid: false, validationError: `Validation failed (${issues.length} issue${issues.length > 1 ? 's' : ''}):\\n${issues.join('\\n')}`, details, requiredSchema } }];\n```\n\nThe 400 Respond reads the result:\n\n```\n={{ { error: 'validation_error', message: $json.validationError, details: $json.details, request_schema: $json.requiredSchema } }}\n```\n\nThe output contract (the Respond depends on these key names):\n\n- Valid: `{ valid: true, validationError: null }`\n- Invalid: `{ valid: false, validationError: \u003csummary string>, details: { \u003cfield>: \u003cmessage> }, requiredSchema: \u003cschema echoed back> }`\n\n`FIELDS` is the one place to edit: the checks and the echoed schema are built from it, so they can't drift apart.\nUnknown fields are reported, which is what `additionalProperties: false` in the echoed schema promises.\n\n### Constraint cookbook\n\n| Need | IF rule (boolean expression) | `FIELDS` entry (Code exception) |\n|---|---|---|\n| Required, non-empty string | `typeof $json.body.name === 'string' && $json.body.name.length > 0` | `type: 'string', required: true, minLength: 1` |\n| Pattern | `/^\\S+@\\S+\\.\\S+$/.test($json.body.email ?? '')` | `pattern: '^\\\\S+@\\\\S+\\\\.\\\\S+

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…

` |\n| Enum | `['a', 'b'].includes($json.body.plan)` | `enum: ['a', 'b']` |\n| Integer in range | `Number.isInteger($json.body.n) && $json.body.n >= 1 && $json.body.n \u003c= 500` | `type: 'integer', minimum: 1, maximum: 500` |\n| Array with items | `Array.isArray($json.body.tags) && $json.body.tags.length > 0` | add an `array` branch to the type check |\n| Conditional rule | a second IF after the first, or `$json.body.type !== 'X' \\|\\| \u003crule>` | a `when` key the loop checks |\n\n### Escaping in workflow JSON\n\nIn the IF rules the regex is a JavaScript literal inside the expression: `/^\\S+@\\S+\\.\\S+$/`. In the Code node it is a\nstring passed to `new RegExp`, so each backslash is written twice (`'^\\\\S+@\\\\S+\\\\.\\\\S+

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…

`). When either lands in workflow\nJSON (an expression string or `jsCode`), every backslash doubles once more. If an email check never matches, count the\nbackslashes.\n\n---\n\n## 5xx: route by cause, one static Respond per status\n\nError outputs from several fallible nodes can fan in to one **Switch** that classifies the failure. Each output goes to a\nRespond with a fixed code and a fixed body; the fallback output catches everything else as 500. Every status is visible\non the canvas, and adding a case is adding a rule.\n\n```\nerror outputs (main[1]) ─→ Switch \"Classify error\"\n ├─ message contains \"timeout\" → Respond 504 upstream_timeout\n ├─ message contains \"rate limit\" → Respond 503 service_unavailable\n └─ fallback (extra output) → Respond 500 internal_error\n```\n\n```json\n{\n \"type\": \"n8n-nodes-base.switch\",\n \"typeVersion\": 3.4,\n \"name\": \"Classify error\",\n \"parameters\": {\n \"mode\": \"rules\",\n \"rules\": {\n \"values\": [\n {\n \"conditions\": {\n \"options\": { \"caseSensitive\": false, \"leftValue\": \"\", \"typeValidation\": \"strict\", \"version\": 3 },\n \"conditions\": [ { \"leftValue\": \"={{ $json.error?.message ?? '' }}\", \"rightValue\": \"timeout\", \"operator\": { \"type\": \"string\", \"operation\": \"contains\" } } ],\n \"combinator\": \"and\"\n },\n \"renameOutput\": true,\n \"outputKey\": \"upstream_timeout\"\n },\n {\n \"conditions\": {\n \"options\": { \"caseSensitive\": false, \"leftValue\": \"\", \"typeValidation\": \"strict\", \"version\": 3 },\n \"conditions\": [ { \"leftValue\": \"={{ $json.error?.message ?? '' }}\", \"rightValue\": \"rate limit\", \"operator\": { \"type\": \"string\", \"operation\": \"contains\" } } ],\n \"combinator\": \"and\"\n },\n \"renameOutput\": true,\n \"outputKey\": \"service_unavailable\"\n }\n ]\n },\n \"options\": { \"fallbackOutput\": \"extra\", \"renameFallbackOutput\": \"internal_error\" }\n }\n}\n```\n\nEach Respond is static, for example:\n\n```json\n{\n \"type\": \"n8n-nodes-base.respondToWebhook\",\n \"name\": \"Respond 504\",\n \"parameters\": {\n \"respondWith\": \"json\",\n \"responseBody\": \"={{ { error: 'upstream_timeout', message: 'External service did not respond in time' } }}\",\n \"options\": { \"responseCode\": 504 }\n }\n}\n```\n\n- Without `fallbackOutput: \"extra\"`, an error no rule matches is dropped and the caller waits until it times out.\n- A single Respond with a computed code is fine for exactly two outcomes, as one short ternary in its Response Code field\n (`options.responseCode` = `={{ $json.error?.message?.includes('timeout') ? 504 : 500 }}`). From three outcomes on, use the Switch.\n\n---\n\n## Don't leak internals\n\nThe tempting one-liner:\n\n```javascript\nresponseBody: \"={{ JSON.stringify({ error: 'internal_error', details: $json.error }) }}\" // ❌\n```\n\n`$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.\n\nInstead: log the full error privately, return a sanitized message.\n\n```javascript\n// Error output → Log node (sends full $json.error to Sentry/Slack/your logger)\n{ type: \"addConnection\", source: \"Call External\", target: \"Log Full Error\", sourceIndex: 1 }\n{ type: \"addConnection\", source: \"Log Full Error\", target: \"Respond Error\", sourceIndex: 0 }\n```\n\n```json\n// Respond Error keeps the body clean:\n{ \"responseBody\": \"={{ { error: 'upstream_error', message: 'External service failed' } }}\",\n \"options\": { \"responseCode\": 502 } }\n```\n\nThe caller sees a clean message; the detail stays internal. Full do-not-leak list in **RESPONSE_SHAPES.md**.\n\n---\n\n## Correlation IDs (optional)\n\nIf 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:\n\n- **Caller-supplied** — read an `X-Request-ID` header, pass it through. Better for tracing across systems.\n- **Generated** — use `{{ $execution.id }}` or a UUID. Easier.\n\nDon'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.\n\n---\n\n## Async / 202 pattern\n\nIf the work takes longer than the caller wants to wait, respond 202 immediately and continue async:\n\n```\nWebhook → validate → Respond (202, { job_id }) → continue processing → callback / queue / email on completion\n```\n\nIt 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`.\n\n---\n\n## Verifying the API workflow\n\nBefore activating:\n\n1. **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.**\n2. **Trigger an error path** — feed input that breaks a processing node, run, confirm the error Respond fires with the right code and body.\n3. **Verify connections** with `n8n_get_workflow`: every fallible node has `onError: \"continueErrorOutput\"` AND `main[1]` wired. (NODE_ERROR_OUTPUTS.md.)\n4. **Confirm no internal detail leaks** in the error body.\n5. **Inspect real failures** afterward with `n8n_executions` to confirm the codes you expected are what actually went out.\n\nIf any check fails, fix before activating.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":17277,"content_sha256":"f71d8c42411011fa9909631f1ab8fe247210fc9fbffd8da927a302f96a883f7d"},{"filename":"ERROR_WORKFLOWS.md","content":"# Workflow-Level Error Workflows\n\nPer-node error outputs handle the failures you anticipated on the nodes you remembered to wire. A **workflow-level error workflow** is the catch-all for everything else — and for an unattended workflow (scheduled, cron, queue worker), it's the difference between \"the job silently stopped three days ago\" and \"an alert arrived the moment it broke\".\n\nWhat per-node outputs **don't** catch:\n\n- Failures on nodes you forgot to wire.\n- Crashes between nodes.\n- Whole-workflow timeouts.\n- Trigger failures.\n\nWhen an unhandled error escapes any of those, n8n invokes the designated **error workflow** with the failure context. You build that workflow once; it serves every workflow that points at it.\n\n---\n\n## What the error workflow receives\n\nIt starts with an **Error Trigger** node, which fires with roughly this payload:\n\n```json\n{\n \"execution\": {\n \"id\": \"...\",\n \"url\": \"https://your-n8n/workflow/\u003cwfId>/executions/\u003cexecId>\",\n \"retryOf\": \"...\",\n \"error\": {\n \"name\": \"NodeApiError\",\n \"message\": \"...\",\n \"description\": \"...\",\n \"timestamp\": 1715000000000\n },\n \"lastNodeExecuted\": \"Fetch order\",\n \"mode\": \"trigger\"\n },\n \"workflow\": { \"id\": \"...\", \"name\": \"Sync Stripe customers\" }\n}\n```\n\nNote what's **not** there: the payload carries the error message and the failed node's *name* (`lastNodeExecuted`), but **not the input data** that caused the failure. Recovering that takes an extra step (below).\n\n---\n\n## Minimal error workflow (capture → notify)\n\nFor most workflows, this is enough:\n\n```\nError Trigger → Slack / email (post to #incidents)\n```\n\nTwo nodes: the alert text is built with expressions right in the message field (below), so no Set is needed. Fast, hard to get wrong, and it turns silence into a message. Build it with `n8n_create_workflow` (or the partial-update ops), then assign it in the UI (see \"Assigning it\" below).\n\n---\n\n## What to put in the alert\n\nA good notification lets on-call act without opening n8n first. Pull these from the payload:\n\n| Field | Expression |\n|---|---|\n| Workflow name | `{{ $json.workflow.name }}` |\n| Workflow ID | `{{ $json.workflow.id }}` |\n| Editor link | `{{ $json.execution.url.split('/executions/')[0] }}` |\n| Execution ID | `{{ $json.execution.id }}` |\n| Execution link | `{{ $json.execution.url }}` |\n| Failed node | `{{ $json.execution.lastNodeExecuted }}` |\n| Error message | `{{ $json.execution.error.message }}` |\n| Error description | `{{ $json.execution.error.description }}` (often empty, useful when set) |\n| Timestamp | `{{ DateTime.fromMillis($json.execution.error.timestamp).toISO() }}` |\n\nThe `timestamp` is a Unix-ms number — format it with Luxon's `DateTime.fromMillis(...)`. The execution `url` is `{base}/workflow/{id}/executions/{execId}`, so stripping the `/executions/...` tail gives the editor URL.\n\nA useful Slack body:\n\n```\nWorkflow failure: *{{ $json.workflow.name }}* (`{{ $json.workflow.id }}`)\nOpen editor: {{ $json.execution.url.split('/executions/')[0] }}\nFailed node: `{{ $json.execution.lastNodeExecuted }}`\nError: {{ $json.execution.error.message }}\nExecution: {{ $json.execution.url }}\nTime: {{ DateTime.fromMillis($json.execution.error.timestamp).toISO() }}\n```\n\nTwo links matter: the **editor link** so on-call can start fixing, and the **execution link** so they can see the exact failed run. Skipping either costs a step. \"Workflow failed.\" is not an alert — it's a notification that you'll have to investigate from scratch.\n\n---\n\n## Featureful version: recover the failing input\n\nThe Error Trigger payload tells you *which* node failed, not *what data* broke it. To get the offending payload, fetch the execution with the **n8n** node:\n\n```\nError Trigger\n → n8n (resource: Execution, operation: Get,\n Execution ID: {{ $json.execution.id }},\n Include Execution Details: true)\n → Set (extract failed-node input from the execution data)\n → Switch (route by severity)\n ├── high → PagerDuty\n ├── med → Slack #incidents\n └── low → Slack #monitoring\n → Data Table (log for tracking)\n```\n\n\"Include Execution Details: true\" hits `GET /executions/{id}?includeData=true` and returns the full run data, so you can pluck the failed node's input out of `data.resultData.runData[\u003clastNodeExecuted>]`. Now the on-call message can carry the actual offending payload (which customer, which order id), not just \"node X errored\".\n\nCaveats, all of which can turn the error workflow itself into a *new* silent failure:\n\n- **Requires an n8n API credential** on this workflow (Settings → API → personal access token, then attach it to the n8n node). Without it the node throws a 401 — an unhandled error *inside the error workflow*.\n- **Requires the failing workflow to persist execution data** (Save Execution Data, instance default or per-workflow). If it doesn't, the API returns metadata only.\n- **The n8n node call can itself fail** (API down, rate-limited). Wire its error output (`sourceIndex: 1`) to a fallback that still notifies, or the original error vanishes behind a fetch failure.\n\nMinimal is enough most of the time. The featureful version earns its keep on production-critical workflows where on-call minutes matter.\n\n---\n\n## Assigning it (UI only — the MCP can't)\n\n> The error workflow is assigned in the n8n **UI**: per workflow under **Workflow Settings → Error Workflow**, or as an instance-wide default. There is **no community-MCP tool** to set this assignment. `n8n_update_partial_workflow` exposes an `updateSettings` op, but the error-workflow setting is not reliably writable through it — confirm in the UI.\n\nSo the agent's job is: **build the error workflow with the MCP, then hand the user the exact UI step** — \"Open the failing workflow → Settings → Error Workflow → select '\u003cname>'\" — and remind them to do it for *every* unattended workflow (or set the instance default once). Building the workflow without assigning it does nothing; the trigger only fires for workflows that point at it.\n\n---\n\n## When the error workflow fires (and when it doesn't)\n\n**Fires** when:\n\n- A node throws unhandled (not routed via a wired per-node error output).\n- The workflow itself fails (timeout, OOM).\n- A trigger fails (rare, possible for non-webhook triggers).\n\n**Does NOT fire** when:\n\n- A node's error output is wired — even if the handler does nothing. n8n considers the error *handled*.\n- You manually stop an execution.\n- The workflow is paused / inactive.\n\nThat second case is the subtle one: **a per-node error output wired to a no-op that drops the data will *suppress* the error workflow.** From n8n's perspective the error was handled, even though it was swallowed. So only catch per-node when you're genuinely acting on the error; if you want a failure to bubble up to the catch-all, leave it unwired.\n\n---\n\n## What the error workflow should NOT do\n\n- **Make external calls that can themselves fail without a fallback.** If the error workflow fails, the original error disappears — you've added a second silent failure on top of the first.\n- **Take significant time.** It runs synchronously; a slow error workflow compounds the original failure's impact.\n\nKeep it fast: parse, notify, return.\n\n---\n\n## The recursion trap\n\nIf your monitored workflows alert Slack, and the *error* workflow also alerts Slack, then a Slack outage takes out both — the error workflow fails and the failure goes nowhere. n8n won't re-trigger on its own failure (no infinite loop), but you've lost the alert.\n\nMitigations:\n\n- **Use a different channel than the monitored workflows.** If everything notifies Slack, the error workflow should use email (or vice versa).\n- **Add a fallback** — write to a Data Table (`n8n_manage_datatable`) if the primary notification fails, so there's always a trace.\n- **Lean on instance-level logging** (server logs, Sentry) so even an error-workflow failure surfaces somewhere outside n8n.\n\n---\n\n## Verifying it works\n\nAfter building and assigning:\n\n1. Make a throwaway workflow that always fails — e.g. an HTTP Request to an invalid URL, with **no** error output wired so the failure is unhandled.\n2. Run it.\n3. Confirm the error workflow fires and the notification arrives.\n\nThis catches the setup mistakes that otherwise stay invisible until a real incident: wrong workflow assigned, wrong channel, missing API credential. Do it once before you rely on the alerting.\n\n---\n\n## Drift watch\n\nThe Error Trigger payload shape can shift between n8n versions. If a field isn't where this file says, check current n8n docs and update your expressions — a renamed field fails silently as an empty alert, not a thrown error.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8724,"content_sha256":"3e0c60aca7b1cd81cf3097e1a95a2555a358e976f77a3b83ef30b2b27051225a"},{"filename":"NODE_ERROR_OUTPUTS.md","content":"# Per-Node Error Outputs\n\nThis file is about the **error output on a single node** — the second `main` output that fires when that node throws — and the two-step setup that trips up nearly everyone. For the workflow-level catch-all (Error Trigger workflows) and the webhook/Respond shape, see the rest of `n8n-error-handling`.\n\nThe whole point: a node failing should route somewhere *you* control, instead of halting the run. The cost of forgetting half the setup is one of the worst silent-failure modes in n8n — a run that shows green while quietly dropping its work.\n\n---\n\n## The two-step setup (both are required)\n\nRouting a node's failure takes exactly two changes. Either one alone looks finished and misbehaves.\n\n### Step 1 — create the error output\n\nSet `onError: \"continueErrorOutput\"` on the node. This is what *adds* the second output. Until you do, `main[1]` does not exist and nothing you wire to it can fire.\n\n```javascript\n{ type: \"updateNode\", nodeName: \"Google Sheets\",\n changes: { onError: \"continueErrorOutput\" } }\n```\n\nSurgical alternative if you're touching only this field:\n\n```javascript\n{ type: \"patchNodeField\", nodeName: \"Google Sheets\",\n fieldPath: \"onError\", value: \"continueErrorOutput\" }\n```\n\nThe valid `onError` values:\n\n| Value | Effect |\n|---|---|\n| `\"stopWorkflow\"` (default) | Error halts the whole workflow. The right default for runs you watch. |\n| `\"continueRegularOutput\"` | The error item flows out the **normal** output (`main[0]`) alongside successes. Rare and usually a mistake — downstream gets error-shaped data and keeps going. |\n| `\"continueErrorOutput\"` | The error item flows out a **separate** error output (`main[1]`). This is the one you wire below. |\n\n### Step 2 — wire the error output\n\nWith `onError: \"continueErrorOutput\"`, the node has two outputs:\n\n- `main[0]` → success path (`sourceIndex: 0`)\n- `main[1]` → error path (`sourceIndex: 1`)\n\nWire the error output to a real handler:\n\n```javascript\n{ type: \"addConnection\",\n source: \"Google Sheets\",\n target: \"Handle Error\",\n sourceIndex: 1 }\n```\n\n`sourceIndex: 1` is the error output. (IF nodes accept the friendly aliases `branch: \"true\"`/`branch: \"false\"` for index 0/1; a generic fallible node has no such alias — use the explicit `sourceIndex: 1`.)\n\n---\n\n## Failure modes — why \"one of two\" is so dangerous\n\n### `onError` set, error output NOT wired\n\n```javascript\n// onError: \"continueErrorOutput\" set on the node,\n// but no addConnection from sourceIndex 1.\n```\n\nOn failure the node emits to `main[1]`, which has **no targets**. The error data is silently discarded, downstream never fires, and — this is the trap — the execution is recorded as **succeeded**, because from n8n's perspective the error was \"handled\" by a branch that happens to go nowhere. No failed execution logged, nothing in the dashboard. The integration \"just stops working\" and there's no trail.\n\n**Fix:** wire `sourceIndex: 1` to a real handler, *or* set `onError` back to `\"stopWorkflow\"` so the failure is loud again.\n\n### Error output wired, `onError` NOT set\n\n```javascript\n// addConnection from \"Some Node\" sourceIndex 1 → \"Handle Error\" exists,\n// but the node still has the default onError: \"stopWorkflow\".\n```\n\nThe connection sits in the JSON, but the slot it feeds from never fires. The handler is unreachable. On failure the workflow simply **halts** (default behavior). Less dangerous than the first mode — at least it's loud — but the handler you built does nothing.\n\n**Fix:** set `onError: \"continueErrorOutput\"` on the node.\n\n### Why validation won't save you\n\nA half-wired error output **validates clean**. `validate_workflow` and `n8n_validate_workflow` don't flag \"`onError` is set but `main[1]` is empty\" or vice versa — both are structurally legal. This is a runtime behavior, not a schema violation. The only reliable check is to read the workflow back (see Verification below).\n\n---\n\n## Common wiring shapes\n\n### Single fallible node → error handler\n\n```javascript\n// Node config: onError: \"continueErrorOutput\"\n{ type: \"addConnection\", source: \"HTTP Request\", target: \"Respond Error\", sourceIndex: 1 }\n```\n\n### Success path fans out, error path goes elsewhere\n\n```javascript\n{ type: \"addConnection\", source: \"HTTP Request\", target: \"Save Result\", sourceIndex: 0 }\n{ type: \"addConnection\", source: \"HTTP Request\", target: \"Notify Slack\", sourceIndex: 0 }\n{ type: \"addConnection\", source: \"HTTP Request\", target: \"Respond Error\", sourceIndex: 1 }\n```\n\n### Multiple fallible nodes → one shared error handler (fan-in)\n\n```javascript\n// Each of these nodes needs onError: \"continueErrorOutput\" on its own config.\n{ type: \"addConnection\", source: \"Fetch User\", target: \"Respond Error\", sourceIndex: 1 }\n{ type: \"addConnection\", source: \"Call External\", target: \"Respond Error\", sourceIndex: 1 }\n{ type: \"addConnection\", source: \"Write Database\", target: \"Respond Error\", sourceIndex: 1 }\n```\n\nFan-in keeps the graph readable: one error responder, many sources. The handler can inspect which node failed (the error payload carries the failing node's name) to differentiate the response.\n\n### Both log AND respond on the same failure\n\nWiring the error output to two targets composes without conflict — both receive the error data:\n\n```javascript\n{ type: \"addConnection\", source: \"Call External\", target: \"Log Full Error\", sourceIndex: 1 }\n{ type: \"addConnection\", source: \"Call External\", target: \"Respond Error\", sourceIndex: 1 }\n```\n\nUseful when you want a sanitized response *and* a private full-detail log on the same failure. (Or chain them: error output → Log → Respond, so the log runs first.)\n\n---\n\n## What counts as \"fallible\"\n\nWire an error output on anything that can throw at runtime:\n\n- Network calls — HTTP Request, third-party API nodes, databases.\n- Auth failures — expired credential, rotated token.\n- Schema mismatches — missing DB column, JSON parse failure.\n- Rate limits — 429 from upstream (configure `retryOnFail` first so these self-heal).\n- File/binary operations — missing path, permission denied (see **n8n-binary-and-data**).\n- Code nodes that can throw.\n\nUsually **not** worth an error output:\n\n- Set / Edit Fields on already-validated data.\n- IF / Switch with simple expressions — if those throw it's a bug to fix, not a path to catch.\n- Pure transformations with no I/O.\n\nWhen unsure, wire it. The cost is one connection; the cost of not wiring it is a silent halt.\n\n---\n\n## Verification (do this every time)\n\nAfter any create/update, pull the workflow with `n8n_get_workflow` and check **both halves** on each fallible node:\n\n1. **Node config** — `onError` is `\"continueErrorOutput\"` (or whatever you intended).\n2. **Connections** — `connections[\"\u003cnode>\"].main[1]` contains the expected handler(s).\n\nIf either half is missing, you have a silent-failure setup. Fix before activating.\n\n`n8n_autofix_workflow` can repair some structural issues, but it won't infer that you *meant* to wire an error path — the intent to handle a given node's failure is yours to express. Treat the read-back as mandatory.\n\n---\n\n## When to use an error workflow instead\n\nPer-node outputs handle the failure of *one node you remembered to wire*. They do **not** catch:\n\n- Failures on nodes you forgot to wire.\n- Crashes between nodes.\n- Whole-workflow timeouts.\n- Trigger failures.\n\nFor those, you need a workflow-level **error workflow** (Error Trigger node). And note the inverse: a per-node error output that's wired to a no-op which drops the data counts as \"handled\" — so it will *suppress* the error workflow. Only catch per-node when you're genuinely acting on the error. See **ERROR_WORKFLOWS.md**.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7690,"content_sha256":"059d5704fd1161bd737781c917c5f190c091ad62b08cc2a0a4cf204e34c2d14f"},{"filename":"README.md","content":"# n8n Error Handling Skill\n\nWire n8n error handling so failures are loud, structured, and recoverable — instead of the default, where a single node throwing **halts the whole workflow** and the caller or operator gets nothing.\n\n---\n\n## ⚠️ The default is silent failure\n\nWhen an n8n node throws, the workflow stops. For a run you're watching that's fine — you see the red node. For anything unattended it's the wrong default:\n\n| Workflow | What the default does | What you wanted |\n|---|---|---|\n| Webhook / API | Caller gets a timeout or a bare 500 | A 4xx/5xx with a body that says what broke |\n| Scheduled / cron | Job stops; nobody is told | An alert the moment it fails |\n| Agent tool / queue worker | Silently drops work | A handled, recoverable failure |\n\nThis skill turns silence into a routed, structured, recoverable failure.\n\n---\n\n## What This Skill Teaches\n\n### Core Concepts\n1. **Per-node error outputs are a TWO-step setup** — `onError: \"continueErrorOutput\"` **and** wiring `main[1]`. One without the other is the #1 silent trap.\n2. **Self-healing first** — `retryOnFail` on network nodes so transient blips never reach an error path\n3. **API workflows respond on every path** — no hanging branches; success and error both end at a Respond\n4. **Status code maps to cause** — 4xx is the caller's fault, 5xx is yours; never 500 for everything\n5. **Workflow-level error workflows** — an Error Trigger catch-all for what per-node handling misses\n\n### Top traps this skill prevents\n1. `onError` set but error output unwired → run shows **succeeded** while dropping work\n2. Error output wired but `onError` unset → handler unreachable, workflow halts\n3. Error branch returns 200 → caller's error handling never fires\n4. One 500 `internal_error` for everything, including bad input\n5. Network node with no retry → transient 429s surface as 5xx and page on-call\n6. Unattended workflow with no error workflow → a genuine failure goes nowhere\n\n---\n\n## Skill Activation\n\nActivates when you:\n- Build any webhook/API workflow, or a scheduled/unattended one\n- Wire a per-node error output (`onError`, `continueErrorOutput`, error branch, `main[1]`)\n- Configure retries (`retryOnFail`, `maxTries`, `waitBetweenTries`)\n- Decide a Respond-to-Webhook status code (4xx/5xx)\n- Set up an Error Trigger workflow\n- Say \"my workflow fails silently\"\n\n**Example queries**:\n- \"My HTTP node has onError set but the workflow still halts on failure — why?\"\n- \"My webhook API returns 500 for everything, even bad input. How do I fix the status codes?\"\n- \"My scheduled workflow fails on a flaky API and nobody notices.\"\n- \"How do I wire a node's error output to a handler?\"\n- \"How do I retry an HTTP request before treating it as an error?\"\n\n---\n\n## File Structure\n\n### SKILL.md\nMain skill content — loaded when the skill activates.\n- The default (halt = silent failure) and when looser handling is OK\n- The two-step per-node error output and the failure-mode table\n- `retryOnFail` self-healing before wiring error paths\n- The canonical webhook/API shape (respond on every path)\n- Cause → status code mapping; a Switch with one static Respond per status\n- Workflow-level error workflows and the recursion trap\n- What the community MCP can't do (the error-workflow UI setting)\n- Anti-patterns table, integration with other skills, quick-reference checklist\n\n### NODE_ERROR_OUTPUTS.md\nThe two-step setup on a single node, in depth.\n- Step 1 (`onError` values) and step 2 (`addConnection` with `sourceIndex: 1`)\n- Both failure modes and why each is dangerous\n- Why validation won't catch a half-wired error output\n- Wiring shapes (single, fan-out, fan-in, log + respond)\n- What counts as \"fallible\"; mandatory `n8n_get_workflow` verification\n\n### API_WORKFLOWS.md\nThe webhook → Respond pattern under failure.\n- Wiring every fallible node to one responder\n- 4xx upstream vs 5xx from error outputs\n- Input validation as IF conditions, the per-field Code validator exception, and the regex-escaping rules\n- Differentiating 5xx with an expression; don't-leak-internals wiring\n- Correlation IDs, the async/202 pattern, verification steps\n\n### RESPONSE_SHAPES.md\nResponse body and status-code conventions.\n- Match the instance first; success and error envelopes\n- `responseCode` defaults to 200 — set it on every error branch\n- Status → cause table; the stable error-code set\n- Validation details, rate-limit shapes, the do-not-leak list\n- Respond-node JSON for the community MCP\n\n### ERROR_WORKFLOWS.md\nThe workflow-level catch-all.\n- The Error Trigger payload and a minimal capture → notify workflow\n- Alert-field expressions; the featureful \"recover the failing input\" version\n- Assigning it is UI-only (MCP can't); when it fires and when it doesn't\n- The recursion trap and verification\n\n---\n\n## Quick Reference\n\n### The two-step per-node error output\n```javascript\n// 1) create the error output\n{ type: \"updateNode\", nodeName: \"HTTP Request\",\n changes: { onError: \"continueErrorOutput\" } }\n\n// 2) wire it (sourceIndex 1 = error output)\n{ type: \"addConnection\", source: \"HTTP Request\", target: \"Handle Error\", sourceIndex: 1 }\n```\n\n### Self-healing on network nodes\n```javascript\n{ type: \"updateNode\", nodeName: \"HTTP Request\",\n changes: { retryOnFail: true, maxTries: 3, waitBetweenTries: 5000 } }\n```\n\n### Set the status code on an error Respond (don't leave it 200)\n```json\n{ \"responseBody\": \"={{ { error: 'upstream_error', message: 'External service failed' } }}\",\n \"options\": { \"responseCode\": 502 } }\n```\n\n### `onError` values\n- `\"stopWorkflow\"` (default) — halt the workflow\n- `\"continueErrorOutput\"` — route the error out `main[1]` (the one you wire)\n- `\"continueRegularOutput\"` — error flows out the normal output (rare, usually wrong)\n\n---\n\n## Integration with Other Skills\n\n**n8n-workflow-patterns**: the webhook/API and scheduled patterns are where error handling lives — use it for the shape, this skill to harden it.\n\n**n8n-node-configuration**: `onError`/`retryOnFail` are node config; NODE_FAMILY_GOTCHAS.md covers the Webhook/Respond response-code traps in detail.\n\n**n8n-validation-expert**: a half-wired error output is a connection/config audit item, not a validation error — this skill is the fix.\n\n**n8n-expression-syntax**: the Switch conditions on `$json.error.message` and the alert messages depend on correct `{{ }}` and `$json.error` access.\n\n**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 as success.\n\n**n8n-binary-and-data**: file/binary operations are fallible too — wire their error outputs like any network node.\n\n---\n\n## When error handling can be looser\n\n| Situation | Posture |\n|---|---|\n| Anyone but you sees the output (downstream system, end user, on-call) | Full handling — the rules above apply |\n| Internal one-off you run and watch yourself | `onError: \"stopWorkflow\"` is fine — you'll see it and re-run |\n\n---\n\n## Success Metrics\n\nAfter using this skill, you should be able to:\n\n- [ ] Wire a per-node error output correctly — `onError` **and** `main[1]`, verified via `n8n_get_workflow`\n- [ ] Recognize and fix both half-wired failure modes\n- [ ] Add `retryOnFail` so transient failures self-heal before reaching an error path\n- [ ] Build an API workflow where every path ends at a Respond with an explicit status code\n- [ ] Map a failure's cause to the right 4xx/5xx and keep internals out of the body\n- [ ] Build an Error Trigger workflow and tell the user the UI step to assign it\n- [ ] Avoid the recursion trap in the error workflow\n\n---\n\n## Sources\n\nAuthoritative facts in this skill come from:\n- n8n node `onError` semantics and the `main[1]` error output contract\n- n8n retry engine limits (`maxTries` ≤ 5, `waitBetweenTries` ≤ 5000ms; retries on any error)\n- The Error Trigger payload shape (`execution`/`workflow`/`error`)\n- n8n-mcp `n8n_update_partial_workflow` operations (`updateNode`, `patchNodeField`, `addConnection`, `activateWorkflow`)\n\n---\n\n## Version\n\n**Version**: 1.0.0\n**Compatibility**: n8n with per-node `onError` outputs; community n8n-mcp server for all workflow operations.\n\n---\n\n## Credits\n\nPart of the n8n-skills project.\n\n**Remember**: the default is silence. Make the failure **route** (per-node `onError` + wired output, or a catch-all error workflow) and make it **speak** (a truthful status code and body). Half a move is worse than none — it looks done.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8526,"content_sha256":"1733d1753a6924de0a7d62e5d4c11ede5a0dece696304edebf3945a25d50e4a0"},{"filename":"RESPONSE_SHAPES.md","content":"# Response Shapes\n\nConventions for webhook API response bodies — both success and error. The goal is **predictability**: a caller, a dashboard, or a retry loop should be able to branch on your response without guessing. Pick a shape and hold it across every endpoint on the instance.\n\nThis file is opinions with reasons. The one hard rule is consistency: **consistency within your project beats consistency with this file.** If your repo or company already has a documented API style, that wins.\n\n---\n\n## First, match what's already on the instance\n\nBefore adopting any shape here, look at the API workflows already running and reuse their conventions. A one-off custom shape is hard to undo once callers depend on it, and inconsistency across endpoints is worse than any single choice.\n\nSearch with the MCP, then read each result:\n\n```javascript\nsearch_nodes({ query: \"webhook\" }) // find webhook-shaped workflows via templates\nn8n_list_workflows({ /* filter */ }) // list workflows on the instance\nn8n_get_workflow({ id: \"\u003cid>\" }) // read each one's Respond to Webhook nodes\n```\n\nIn each existing `Respond to Webhook`, note:\n\n- Top-level keys — envelope vs bare, presence of `error`/`message`/`request_id`.\n- Whether success bodies wrap the payload or return it bare.\n- The exact error-code strings in use (`validation_error` vs `bad_request` vs `INVALID_INPUT`).\n- Header conventions (`Content-Type`, `Retry-After`, `X-Request-Id`).\n\nIf results are sparse, mixed, or you can't tell whether a convention exists — **ask the user.** \"Endpoints A and B use shape X, C uses Y; which is house style?\" saves a future migration. Don't invent a domain prefix or envelope from nothing.\n\n---\n\n## Success shape\n\nReturn the data bare. For requests that **create or update** a resource, prefer returning the **full resource** with a 200, not `{ \"ok\": true }` or just the new ID:\n\n```json\n{\n \"customer_id\": \"cus_123\",\n \"balance\": 4200,\n \"currency\": \"USD\",\n \"created_at\": \"2026-04-25T12:34:00Z\"\n}\n```\n\nReturning the resource saves the caller a follow-up GET, lets them confirm what actually persisted (server defaults, normalized values, generated timestamps), and makes the endpoint a single round-trip for a UI that renders the result immediately.\n\nDeviate only when:\n\n- The resource is genuinely large and the caller doesn't need it → return the ID, document why.\n- There is no resource (event ingestion, fire-and-forget) → `{}` or `204 No Content`.\n- The payload is list-shaped → a top-level array, or `{ \"items\": [...] }` (friendlier to future pagination metadata).\n\n---\n\n## Error shape (the default envelope)\n\n```json\n{\n \"error\": \"\u003cmachine-readable code>\",\n \"message\": \"\u003chuman-readable explanation>\"\n}\n```\n\n- `error` is a **stable string identifier**, not a sentence. Clients branch on it.\n- `message` is the human version — safe to log, safe to show users *after* sanitization.\n- No `ok: false` flag — the HTTP status code already separates success from failure.\n\nOptional fields by case:\n\n| Field | When to include |\n|---|---|\n| `details` | Validation errors, with a field-by-field map |\n| `retry_after` | Rate limits (also set the `Retry-After` header) |\n| `request_id` | When you run distributed tracing (then on *every* response, not just errors) |\n| `documentation_url` | Public APIs where you want callers to RTFM |\n\n---\n\n## `responseCode` defaults to 200 — set it on every error branch\n\nThis is the single most common API error-handling bug, and it's worth its own section because it produces a *worse-than-useless* result: the body says failure while the status says success.\n\n**Every `Respond to Webhook` node defaults `responseCode` to 200** — including the ones you wired to error paths. An error branch that returns 200 with `{ \"error\": \"...\" }` looks like success to the caller's HTTP client, so their error handling (which keys off the status code) **never fires**. They process your error body as if it were data.\n\nSo: set `options.responseCode` **explicitly** on every Respond node — not just the success one. It lives under `options`; a top-level `responseCode` is ignored, and the node still returns 200. (This trap is also documented in **n8n-node-configuration** NODE_FAMILY_GOTCHAS.md, \"Webhook / Respond to Webhook\".) A workflow can have many Respond nodes, one per response shape; n8n returns whichever fires first.\n\n```json\n{ \"responseBody\": \"={{ { error: 'upstream_error', message: 'External service failed' } }}\",\n \"options\": { \"responseCode\": 502 } }\n```\n\nTo pick the status by cause, route with a Switch to one static Respond per status — see **API_WORKFLOWS.md**, \"5xx: route by cause\".\n\n---\n\n## Status code → cause\n\nThe status code is the caller's first signal; be deliberate.\n\n- **2xx** — success. 200 sync, 202 \"accepted, processing\".\n- **4xx** — caller's fault. 400 bad input, 401 no auth, 403 not allowed, 404 not found, 409 conflict, 429 rate limited.\n- **5xx** — your fault. 500 unexpected internal, 502 upstream broken, 503 temporarily down, 504 upstream timeout.\n\nDistinguishing 4xx from 5xx matters because the caller's tooling depends on it:\n\n- Caller monitoring alerts on 5xx (your fault) but not 4xx (their fault). Returning 500 for bad input fires *their* pager on *their* bug.\n- 5xx implies \"retry\", 4xx implies \"don't bother\".\n- Aggregated error rates segment by class — collapse everything to 500 and you lose that.\n\n### Error codes (a small, stable set)\n\nAdding a code is fine; renaming an existing one breaks callers.\n\n**4xx — caller's fault**\n\n| Code | Meaning |\n|---|---|\n| `validation_error` | Required field missing / type wrong |\n| `invalid_input` | Field present but value invalid |\n| `unauthorized` | No auth or expired auth |\n| `forbidden` | Authenticated but not allowed |\n| `not_found` | Resource doesn't exist |\n| `conflict` | Conflicts with current state (duplicate key, race) |\n| `rate_limit_exceeded` | Too many requests |\n| `unsupported_media_type` | Content-Type wrong |\n\n**5xx — your fault**\n\n| Code | Meaning |\n|---|---|\n| `internal_error` | Catch-all, something failed unexpectedly |\n| `upstream_error` | Third-party API returned an error |\n| `upstream_timeout` | Third-party API didn't respond in time |\n| `service_unavailable` | Temporarily can't process (down, or rate-limited upstream) |\n| `not_implemented` | Operation not supported in this version |\n\n---\n\n## Validation error details (400)\n\nFor `validation_error`, include per-field detail so the caller can fix the request without guessing. The per-field Code validator (API_WORKFLOWS.md → \"Exception: per-field messages\") produces this directly:\n\n```json\n{\n \"error\": \"validation_error\",\n \"message\": \"Validation failed (3 issues):\\n• name: Missing required field \\\"name\\\"\\n• email: \\\"not-an-email\\\" is not valid - Contact email address\\n• plan: \\\"premium\\\" is not allowed. Must be one of: starter, pro, enterprise - Subscription plan\",\n \"details\": { \"name\": \"Missing required field \\\"name\\\"\", \"email\": \"\\\"not-an-email\\\" is not valid\", \"plan\": \"\\\"premium\\\" is not allowed\" },\n \"request_schema\": { \"type\": \"object\", \"properties\": { } }\n}\n```\n\n`message` is the human summary (safe to show), `details` is the structured per-field map (safe to bind to UI fields), and `request_schema` is the schema echoed back so an LLM-driven or programmatic caller can self-correct on the next attempt.\n\n---\n\n## Rate-limit responses (429)\n\n```json\n{\n \"error\": \"rate_limit_exceeded\",\n \"message\": \"Too many requests. Retry after 30s.\",\n \"retry_after\": \"2026-05-08T21:10:05.135Z\"\n}\n```\n\nAlso set the HTTP `Retry-After` header (in the Respond node's `options.responseHeaders`). Well-behaved clients respect the header without parsing the body.\n\n---\n\n## What NOT to put in an error response\n\nThe body goes to the caller. Treat everything in it as public.\n\n| Don't include | Why |\n|---|---|\n| **Stack traces** — `{ \"stack\": \"Error at line 42 of /opt/...\" }` | Reveals paths, versions, library names. A gift to attackers, useless to callers. |\n| **Upstream errors verbatim** — `{ \"details\": \"\u003craw upstream body>\" }` | Upstream may embed *their* tokens and PII. Surface \"upstream service failed\" + a request id; details go to your logs. |\n| **SQL queries** — `{ \"query\": \"SELECT * FROM users WHERE ...\" }` | Exposes schema and access patterns. |\n| **Tokens / credentials / auth values** | Even innocuous-looking `headers`, `config`, or `request` fields can carry token values. Audit error bodies — leaks are easier than you'd expect. |\n\nThe pattern is always the same: **log the full error privately, return a sanitized message.** See \"Don't leak internals\" in API_WORKFLOWS.md for the log-then-respond wiring.\n\n---\n\n## Respond node shape (JSON, for the community MCP)\n\nSuccess:\n\n```json\n{\n \"type\": \"n8n-nodes-base.respondToWebhook\",\n \"name\": \"Respond Success\",\n \"parameters\": {\n \"respondWith\": \"json\",\n \"responseBody\": \"={{ $json }}\",\n \"options\": { \"responseCode\": 200, \"responseHeaders\": { \"entries\": [{ \"name\": \"Content-Type\", \"value\": \"application/json\" }] } }\n }\n}\n```\n\nError:\n\n```json\n{\n \"type\": \"n8n-nodes-base.respondToWebhook\",\n \"name\": \"Respond Error\",\n \"parameters\": {\n \"respondWith\": \"json\",\n \"responseBody\": \"={{ { error: 'upstream_error', message: 'External service failed' } }}\",\n \"options\": { \"responseCode\": 502, \"responseHeaders\": { \"entries\": [{ \"name\": \"Content-Type\", \"value\": \"application/json\" }] } }\n }\n}\n```\n\nTwo notes that bite people:\n\n- **Always set `Content-Type: application/json` explicitly.** Default behavior depends on the body shape and isn't reliable.\n- **With `respondWith: \"json\"`, pass the object, not a stringified string.** If you hand it `JSON.stringify(obj)` it serializes that string *again* and you get a double-encoded body. Either use `respondWith: \"json\"` with an object expression (`={{ { error: 'x' } }}`), or keep `JSON.stringify(...)` and let the node treat it as the already-final body — pick one and be consistent. (See **n8n-node-configuration** NODE_FAMILY_GOTCHAS.md.)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10045,"content_sha256":"e02228d2f22837e4aeaf8464a3ee50cc92d7b5a136cf88589df53c415909d42f"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"n8n Error Handling","type":"text"}]},{"type":"paragraph","content":[{"text":"By default, when an n8n node throws, the ","type":"text"},{"text":"whole workflow halts","type":"text","marks":[{"type":"strong"}]},{"text":". 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.","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill is about making failures ","type":"text"},{"text":"loud, structured, and recoverable","type":"text","marks":[{"type":"strong"}]},{"text":" — and, best case, ","type":"text"},{"text":"self-healing","type":"text","marks":[{"type":"strong"}]},{"text":" so transient blips never reach a human at all.","type":"text"}]},{"type":"paragraph","content":[{"text":"The two ideas that prevent most silent failures:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Per-node error outputs","type":"text","marks":[{"type":"strong"}]},{"text":" — a node's failure routes down a second output you control, instead of killing the run.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A workflow-level error workflow","type":"text","marks":[{"type":"strong"}]},{"text":" — a catch-all that fires for anything that escapes per-node handling (timeouts, crashes between nodes, unwired failures).","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"When you actually need this","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Workflow shape","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error handling posture","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Webhook / API (anything with ","type":"text"},{"text":"Respond to Webhook","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Required.","type":"text","marks":[{"type":"strong"}]},{"text":" Every fallible node's error output wired; status code matches cause.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Scheduled / cron / queue worker / agent tool (unattended)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Required.","type":"text","marks":[{"type":"strong"}]},{"text":" A workflow-level error workflow, plus ","type":"text"},{"text":"retryOnFail","type":"text","marks":[{"type":"code_inline"}]},{"text":" on network nodes.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Internal one-off you run and watch yourself","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Optional.","type":"text","marks":[{"type":"strong"}]},{"text":" Default ","type":"text"},{"text":"onError: \"stopWorkflow\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" is fine — you'll see the red node and re-run.","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"The dividing line: ","type":"text"},{"text":"if anyone other than you sees the output","type":"text","marks":[{"type":"strong"}]},{"text":" — 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.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"The #1 silent trap: per-node error output is a TWO-step setup","type":"text"}]},{"type":"paragraph","content":[{"text":"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 ","type":"text"},{"text":"two","type":"text","marks":[{"type":"strong"}]},{"text":" changes, and doing only one looks complete but misbehaves:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Set ","type":"text","marks":[{"type":"strong"}]},{"text":"onError: \"continueErrorOutput\"","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" on the node. This is what ","type":"text"},{"text":"creates","type":"text","marks":[{"type":"em"}]},{"text":" the second output. Without it, ","type":"text"},{"text":"main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":" doesn't exist no matter what you wire.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Wire that error output","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"connections.\u003cnode>.main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":", i.e. ","type":"text"},{"text":"sourceIndex: 1","type":"text","marks":[{"type":"code_inline"}]},{"text":") to a real handler. Without a target, the error data is emitted into the void.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Get one without the other and you hit a failure mode:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What you did","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What happens at runtime","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" set, error output ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" wired","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error data is silently discarded. Downstream doesn't fire. The dashboard shows the run as ","type":"text"},{"text":"succeeded","type":"text","marks":[{"type":"strong"}]},{"text":". Worst case — no error logged anywhere.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error output wired, ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" set","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The slot never fires; the handler is unreachable. On failure the workflow just ","type":"text"},{"text":"halts","type":"text","marks":[{"type":"strong"}]},{"text":" (default ","type":"text"},{"text":"stopWorkflow","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Both done","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Failure routes down ","type":"text"},{"text":"main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":" to your handler. ✅","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Doing both with ","type":"text"},{"text":"n8n_update_partial_workflow","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"// 1) Turn on the error output (creates main[1])\n{ type: \"updateNode\", nodeName: \"HTTP Request\",\n changes: { onError: \"continueErrorOutput\" } }\n\n// 2) Wire the error output to a handler. sourceIndex: 1 = the error output.\n{ type: \"addConnection\",\n source: \"HTTP Request\",\n target: \"Handle Error\",\n sourceIndex: 1 }","type":"text"}]},{"type":"paragraph","content":[{"text":"sourceIndex: 0","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the success path, ","type":"text"},{"text":"sourceIndex: 1","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the error path. (For IF nodes the aliases ","type":"text"},{"text":"branch: \"true\"","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"\"false\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" map to index 0/1; for a generic fallible node, use the explicit ","type":"text"},{"text":"sourceIndex: 1","type":"text","marks":[{"type":"code_inline"}]},{"text":".)","type":"text"}]},{"type":"paragraph","content":[{"text":"Then verify.","type":"text","marks":[{"type":"strong"}]},{"text":" This trap doesn't surface in ","type":"text"},{"text":"validate_workflow","type":"text","marks":[{"type":"code_inline"}]},{"text":" — a half-wired error output validates clean. Pull the workflow with ","type":"text"},{"text":"n8n_get_workflow","type":"text","marks":[{"type":"code_inline"}]},{"text":" and confirm ","type":"text"},{"text":"both","type":"text","marks":[{"type":"strong"}]},{"text":" halves:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The node's ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" is ","type":"text"},{"text":"\"continueErrorOutput\"","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"connections[\"HTTP Request\"].main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":" contains your handler.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Valid ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" values:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Value","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Effect","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"stopWorkflow\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error halts the whole workflow.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"continueRegularOutput\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error item flows out the ","type":"text"},{"text":"normal","type":"text","marks":[{"type":"strong"}]},{"text":" output. Rare, usually wrong — downstream gets error-shaped data and keeps going.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"continueErrorOutput\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error item flows out the ","type":"text"},{"text":"separate","type":"text","marks":[{"type":"strong"}]},{"text":" error output (","type":"text"},{"text":"main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":"). The one you wire.","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Full failure-mode catalog, fan-in/fan-out shapes, and verification: ","type":"text"},{"text":"NODE_ERROR_OUTPUTS.md","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Failures the error output never sees","type":"text"}]},{"type":"paragraph","content":[{"text":"A correctly wired error output still misses whole classes of failure. Verified on n8n 2.38.5:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Failure","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"With ","type":"text"},{"text":"continueErrorOutput","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"With ","type":"text"},{"text":"continueRegularOutput","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"JS error ","type":"text"},{"text":"inside ","type":"text","marks":[{"type":"strong"}]},{"text":"{{ }}","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (TypeError on a missing path, ","type":"text"},{"text":"JSON.parse","type":"text","marks":[{"type":"code_inline"}]},{"text":" on bad input, a thrown ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":", a JMESPath syntax error)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"nothing fires; the field is ","type":"text"},{"text":"null","type":"text","marks":[{"type":"code_inline"}]},{"text":" and items take the ","type":"text"},{"text":"success","type":"text","marks":[{"type":"strong"}]},{"text":" path","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"same","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Python Code node ","type":"text"},{"text":"rejected before running","type":"text","marks":[{"type":"strong"}]},{"text":" (blocked import, dunder access: ","type":"text"},{"text":"Security violations detected","type":"text","marks":[{"type":"code_inline"}]},{"text":") or ","type":"text"},{"text":"bad return shape","type":"text","marks":[{"type":"strong"}]},{"text":" (list in each-item mode)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"node marked failed, but the ","type":"text"},{"text":"unchanged input items leave through the success output","type":"text","marks":[{"type":"strong"}]},{"text":"; ","type":"text"},{"text":"main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":" stays empty and the execution shows success","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"unchanged input items pass through","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Exception ","type":"text"},{"text":"while","type":"text","marks":[{"type":"strong"}]},{"text":" code runs (","type":"text"},{"text":"raise","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"throw","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"KeyError","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"NameError","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"routed to ","type":"text"},{"text":"main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":" as ","type":"text"},{"text":"{ error }","type":"text","marks":[{"type":"code_inline"}]},{"text":" ✅","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{ error }","type":"text","marks":[{"type":"code_inline"}]},{"text":" item on the main output","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"So an error branch alone can't guard these:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Guard the data, not just the node.","type":"text","marks":[{"type":"strong"}]},{"text":" After a transform whose output matters, check that the field you produced exists (IF/Filter on ","type":"text"},{"text":"{{ $json.total !== undefined && $json.total !== null }}","type":"text","marks":[{"type":"code_inline"}]},{"text":") and send the miss down the error path yourself.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Test-run and read node statuses and output values.","type":"text","marks":[{"type":"strong"}]},{"text":" The Python cases show a red node inside a green execution. The expression cases show nothing but ","type":"text"},{"text":"null","type":"text","marks":[{"type":"code_inline"}]},{"text":"s.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Root causes and fixes: ","type":"text"},{"text":"n8n-expression-syntax","type":"text","marks":[{"type":"strong"}]},{"text":" (Debugging) and ","type":"text"},{"text":"n8n-code-python","type":"text","marks":[{"type":"strong"}]},{"text":" (Errors and ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Self-healing first: ","type":"text"},{"text":"retryOnFail","type":"text","marks":[{"type":"code_inline"}]},{"text":" before you wire error paths","type":"text"}]},{"type":"paragraph","content":[{"text":"Before you build error branches, absorb the transient failures so they never reach those branches. On ","type":"text"},{"text":"any node that calls a network service","type":"text","marks":[{"type":"strong"}]},{"text":" — HTTP Request, comms (Gmail/Slack/Discord), databases, AI nodes, third-party integrations — set node-level retry:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"{ type: \"updateNode\", nodeName: \"HTTP Request\",\n changes: {\n retryOnFail: true,\n maxTries: 3,\n waitBetweenTries: 5000 // ms\n } }","type":"text"}]},{"type":"paragraph","content":[{"text":"Why this comes ","type":"text"},{"text":"first","type":"text","marks":[{"type":"strong"}]},{"text":": a 429 or a brief upstream hiccup will retry and usually succeed on its own. The error output then fires only on ","type":"text"},{"text":"real, persistent","type":"text","marks":[{"type":"em"}]},{"text":" failures — so your 5xx responses and on-call alerts reflect actual problems instead of noise.","type":"text"}]},{"type":"paragraph","content":[{"text":"Engine limits to know: retry fires on ","type":"text"},{"text":"any","type":"text","marks":[{"type":"strong"}]},{"text":" error (there's no per-status-code filter), ","type":"text"},{"text":"maxTries","type":"text","marks":[{"type":"code_inline"}]},{"text":" caps at 5, and ","type":"text"},{"text":"waitBetweenTries","type":"text","marks":[{"type":"code_inline"}]},{"text":" caps at 5000ms — so 5000 is both the max and a sensible default. See ","type":"text"},{"text":"n8n-node-configuration","type":"text","marks":[{"type":"strong"}]},{"text":" (NODE_FAMILY_GOTCHAS.md) for node-specific notes.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"API workflows: the canonical shape","type":"text"}]},{"type":"paragraph","content":[{"text":"A webhook-triggered workflow that responds to its caller has one rule that overrides everything else: ","type":"text"},{"text":"no hanging branches","type":"text","marks":[{"type":"strong"}]},{"text":". Every path — success and every error — must end at a ","type":"text"},{"text":"Respond to Webhook","type":"text","marks":[{"type":"code_inline"}]},{"text":", or the caller sits there until it times out.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Webhook (responseMode: \"responseNode\")\n ├── validate input → process → Respond (200, body)\n └── (any fallible node's error output → sourceIndex 1)\n → Respond (4xx/5xx, structured error body)\n → optional: log full error privately / notify","type":"text"}]},{"type":"paragraph","content":[{"text":"Three things make this work:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fan-in to one error responder.","type":"text","marks":[{"type":"strong"}]},{"text":" Many fallible nodes can route their ","type":"text"},{"text":"main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":" to a single ","type":"text"},{"text":"Respond","type":"text","marks":[{"type":"code_inline"}]},{"text":" node. Keeps the graph readable.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Validation failures (4xx) are checked ","type":"text","marks":[{"type":"strong"}]},{"text":"upstream","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":", not via error outputs.","type":"text","marks":[{"type":"strong"}]},{"text":" A missing field isn't a node ","type":"text"},{"text":"crashing","type":"text","marks":[{"type":"em"}]},{"text":" — 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 ","type":"text"},{"text":"unexpected","type":"text","marks":[{"type":"em"}]},{"text":" failures (5xx).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"options.responseCode","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" defaults to 200 — even on error branches.","type":"text","marks":[{"type":"strong"}]},{"text":" This is its own silent trap (see RESPONSE_SHAPES.md and ","type":"text"},{"text":"n8n-node-configuration","type":"text","marks":[{"type":"strong"}]},{"text":" 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 ","type":"text"},{"text":"responseCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" explicitly on every Respond node.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Input validation: conditions on the canvas","type":"text"}]},{"type":"paragraph","content":[{"text":"Put the checks in one ","type":"text"},{"text":"IF","type":"text","marks":[{"type":"strong"}]},{"text":" node: one boolean condition per rule (","type":"text"},{"text":"is true","type":"text","marks":[{"type":"code_inline"}]},{"text":"), combinator ","type":"text"},{"text":"and","type":"text","marks":[{"type":"code_inline"}]},{"text":". True → your logic (200); false → a 400 Respond with a static ","type":"text"},{"text":"validation_error","type":"text","marks":[{"type":"code_inline"}]},{"text":" body that echoes the expected schema, so the caller can self-correct. When the caller needs ","type":"text"},{"text":"per-field","type":"text","marks":[{"type":"strong"}]},{"text":" messages (an LLM client, a public API), replace the IF with one ","type":"text"},{"text":"Code","type":"text","marks":[{"type":"strong"}]},{"text":" node that loops over a schema list and returns ","type":"text"},{"text":"{ valid, validationError, details, requiredSchema }","type":"text","marks":[{"type":"code_inline"}]},{"text":", with the note ","type":"text"},{"text":"Why Code: IF considered — per-field messages need a loop over the schema","type":"text","marks":[{"type":"code_inline"}]},{"text":". Both patterns, the constraint cookbook and the escaping rules are in ","type":"text"},{"text":"API_WORKFLOWS.md","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Response shapes: map cause → status code","type":"text"}]},{"type":"paragraph","content":[{"text":"A 5xx with ","type":"text"},{"text":"text/plain \"Internal Server Error\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" is technically an error response and practically useless. And not every failure is a 5xx. ","type":"text"},{"text":"Match the status code to ","type":"text","marks":[{"type":"strong"}]},{"text":"why","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" the request failed","type":"text","marks":[{"type":"strong"}]},{"text":", 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\".","type":"text"}]},{"type":"paragraph","content":[{"text":"The common mistake:","type":"text","marks":[{"type":"strong"}]},{"text":" wiring everything — including bad input — to one ","type":"text"},{"text":"Respond","type":"text","marks":[{"type":"code_inline"}]},{"text":" that returns 500 ","type":"text"},{"text":"internal_error","type":"text","marks":[{"type":"code_inline"}]},{"text":". Now the caller can't tell their bug from your outage, and your error rates can't separate real incidents from client noise.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Cause","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Status","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"error","type":"text","marks":[{"type":"code_inline"}]},{"text":" code","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Where it's handled","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Required field missing / wrong type","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"400","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"validation_error","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Upstream check (IF conditions / per-field validator), not error output","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Auth missing or invalid","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"401","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"unauthorized","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Upstream check","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Authenticated but not allowed","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"403","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"forbidden","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Upstream check","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resource ID valid in request, absent in your data","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"404","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"not_found","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Branch on the lookup ","type":"text"},{"text":"result","type":"text","marks":[{"type":"em"}]},{"text":", not its error","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Conflicts with current state (duplicate, race)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"409","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"conflict","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Detect with logic","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Caller exceeded rate limit","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"429","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"rate_limit_exceeded","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Set ","type":"text"},{"text":"Retry-After","type":"text","marks":[{"type":"code_inline"}]},{"text":" header","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Node threw, cause unknown","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"500","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"internal_error","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error output path","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Third-party API returned an error","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"502","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"upstream_error","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error output of the HTTP node","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Can't process right now (downstream down)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"503","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"service_unavailable","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Detect specific error, hint retry","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Third-party API timed out","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"504","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"upstream_timeout","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error output filtered by message","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"So there are two distinct flows: ","type":"text"},{"text":"4xx is decided before the work","type":"text","marks":[{"type":"strong"}]},{"text":" (IF/Switch + dedicated Respond), ","type":"text"},{"text":"5xx comes out of error outputs","type":"text","marks":[{"type":"strong"}]},{"text":" (\"we tried, it broke\").","type":"text"}]},{"type":"paragraph","content":[{"text":"Route by cause with a Switch; one static Respond per status.","type":"text","marks":[{"type":"strong"}]},{"text":" 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 ","type":"text"},{"text":"options.responseCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" and a fixed body:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"error outputs (main[1]) ─→ Switch \"Classify error\" (on $json.error.message)\n ├─ contains \"INVALID_ID\" → Respond 400 validation_error\n ├─ contains \"429\" → Respond 429 rate_limit_exceeded\n ├─ contains \"timeout\" → Respond 504 upstream_timeout\n └─ fallback (extra output) → Respond 500 internal_error","type":"text"}]},{"type":"paragraph","content":[{"text":"Every status is visible on the canvas, and adding one is adding a rule. Set the Switch's ","type":"text"},{"text":"options.fallbackOutput","type":"text","marks":[{"type":"code_inline"}]},{"text":" to ","type":"text"},{"text":"extra","type":"text","marks":[{"type":"code_inline"}]},{"text":", 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 ","type":"text"},{"text":"API_WORKFLOWS.md","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"The default envelope is ","type":"text"},{"text":"{ \"error\": \"\u003ccode>\", \"message\": \"\u003chuman text>\" }","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the HTTP status already says success-vs-failure, so no ","type":"text"},{"text":"ok: false","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag. ","type":"text"},{"text":"Never leak internals","type":"text","marks":[{"type":"strong"}]},{"text":" (stack traces, SQL, upstream bodies, tokens) into the response — log those privately, return a sanitized message. Correlation IDs, ","type":"text"},{"text":"retry_after","type":"text","marks":[{"type":"code_inline"}]},{"text":", validation ","type":"text"},{"text":"details","type":"text","marks":[{"type":"code_inline"}]},{"text":", and the full do-not-leak list are in ","type":"text"},{"text":"RESPONSE_SHAPES.md","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Workflow-level error workflow (the catch-all)","type":"text"}]},{"type":"paragraph","content":[{"text":"Per-node outputs handle the failures you anticipated on the nodes you remembered to wire. An ","type":"text"},{"text":"error workflow","type":"text","marks":[{"type":"strong"}]},{"text":" 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\".","type":"text"}]},{"type":"paragraph","content":[{"text":"Build it as a separate workflow starting with an ","type":"text"},{"text":"Error Trigger","type":"text","marks":[{"type":"strong"}]},{"text":" node. n8n invokes it with the failure context:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"execution\": { \"id\": \"...\", \"url\": \"...\", \"lastNodeExecuted\": \"Fetch order\",\n \"error\": { \"name\": \"NodeApiError\", \"message\": \"...\", \"timestamp\": 1715000000000 } },\n \"workflow\": { \"id\": \"...\", \"name\": \"Sync Stripe customers\" }\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Minimal version — ","type":"text"},{"text":"capture → notify","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Error Trigger → Slack/email (post to #incidents; the alert text is built with expressions in the message field)","type":"text"}]},{"type":"paragraph","content":[{"text":"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 ","type":"text"},{"text":"real","type":"text","marks":[{"type":"strong"}]},{"text":" error message (not \"Workflow failed\"). Field expressions and the optional \"fetch the failing input via the n8n node\" upgrade are in ","type":"text"},{"text":"ERROR_WORKFLOWS.md","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Two traps worth flagging up front:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The recursion trap.","type":"text","marks":[{"type":"strong"}]},{"text":" 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 ","type":"text"},{"text":"different","type":"text","marks":[{"type":"em"}]},{"text":" 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.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A \"handled\" error won't bubble up.","type":"text","marks":[{"type":"strong"}]},{"text":" If a node's error output is wired to a no-op that drops the data, n8n considers the error ","type":"text"},{"text":"handled","type":"text","marks":[{"type":"em"}]},{"text":" and the error workflow does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" fire. Only catch per-node when you're actually doing something with the error.","type":"text"}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"What the community MCP can't do:","type":"text","marks":[{"type":"strong"}]},{"text":" assigning the error workflow (instance default or per-workflow override) is an n8n ","type":"text"},{"text":"UI setting","type":"text","marks":[{"type":"strong"}]},{"text":" — 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.","type":"text"}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"What's NOT available via the community MCP","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Want to do","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reality","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Set a workflow's ","type":"text"},{"text":"Error Workflow","type":"text","marks":[{"type":"strong"}]},{"text":" setting","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UI only (Workflow Settings → Error Workflow). No MCP tool. Build the workflow, then hand the user the UI step.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Toggle other ","type":"text"},{"text":"workflow settings","type":"text","marks":[{"type":"strong"}]},{"text":" (Save Execution Data, timezone, timeout, caller policy)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UI only. ","type":"text"},{"text":"n8n_update_partial_workflow","type":"text","marks":[{"type":"code_inline"}]},{"text":" has ","type":"text"},{"text":"updateSettings","type":"text","marks":[{"type":"code_inline"}]},{"text":", but the error-workflow assignment is not reliably exposed — confirm in the UI.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Enable instance-wide error logging (Sentry, server logs)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Instance config, outside n8n workflows entirely.","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"What the MCP ","type":"text"},{"text":"can","type":"text","marks":[{"type":"strong"}]},{"text":" do: build the error workflow, set ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"retryOnFail","type":"text","marks":[{"type":"code_inline"}]},{"text":" on nodes (","type":"text"},{"text":"updateNode","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"patchNodeField","type":"text","marks":[{"type":"code_inline"}]},{"text":"), wire error outputs (","type":"text"},{"text":"addConnection","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"sourceIndex: 1","type":"text","marks":[{"type":"code_inline"}]},{"text":"), validate (","type":"text"},{"text":"validate_workflow","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"n8n_validate_workflow","type":"text","marks":[{"type":"code_inline"}]},{"text":"), auto-fix common issues (","type":"text"},{"text":"n8n_autofix_workflow","type":"text","marks":[{"type":"code_inline"}]},{"text":"), test (","type":"text"},{"text":"n8n_test_workflow","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and inspect failures (","type":"text"},{"text":"n8n_executions","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Anti-patterns","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Anti-pattern","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What goes wrong","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" set but error output unwired","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error silently discarded; run shows as ","type":"text"},{"text":"succeeded","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Wire ","type":"text"},{"text":"sourceIndex: 1","type":"text","marks":[{"type":"code_inline"}]},{"text":" to a real handler, or revert ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" to ","type":"text"},{"text":"stopWorkflow","type":"text","marks":[{"type":"code_inline"}]},{"text":" so it's loud","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error output wired but ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" not set","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Slot never fires; handler unreachable; workflow halts on failure","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Set ","type":"text"},{"text":"onError: \"continueErrorOutput\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Webhook → process → respond, no error branch","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Caller gets a timeout or n8n's generic 500","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Wire every fallible node's error output to a Respond","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error branch returns 200 with an ","type":"text"},{"text":"{error}","type":"text","marks":[{"type":"code_inline"}]},{"text":" body","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Caller's client reads success; their error handling never fires","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Set ","type":"text"},{"text":"options.responseCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" to 4xx/5xx explicitly on error Responds","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"One 500 ","type":"text"},{"text":"internal_error","type":"text","marks":[{"type":"code_inline"}]},{"text":" for everything","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Caller can't tell their bad input from your outage","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Map cause → status (4xx caller, 5xx you)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Catching errors in a Code node and returning them as data","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Downstream processes error-shaped data and continues","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Let it throw; use ","type":"text"},{"text":"onError: \"continueErrorOutput\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" + wired path","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Network node with no ","type":"text"},{"text":"retryOnFail","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Every transient 429/blip surfaces as a 5xx; alerts fire on noise","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"retryOnFail: true, maxTries: 3, waitBetweenTries: 5000","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A function body in a Respond's Response Code or body","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the routing is hidden in a field, and a runtime error turns into a silent ","type":"text"},{"text":"null","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Switch on the cause → one static Respond per status","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Unattended workflow with no error workflow","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A genuine failure goes nowhere","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Build an Error Trigger workflow + assign it in the UI","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error workflow notifies the same channel the workflows monitor","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Channel down → error workflow also fails → error vanishes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use a different channel + a Data Table fallback","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Leaking ","type":"text"},{"text":"$json.error","type":"text","marks":[{"type":"code_inline"}]},{"text":" (stack/SQL/tokens) into the response","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Exposes internals to callers/attackers","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Log privately, return a sanitized message","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Reference files","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"File","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read when","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NODE_ERROR_OUTPUTS.md","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Wiring a per-node error output on individual fallible nodes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"API_WORKFLOWS.md","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Building/reviewing a webhook → Respond workflow, including input validation (IF conditions, per-field Code validator)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"RESPONSE_SHAPES.md","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Defining response body conventions, status codes, and what not to leak","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ERROR_WORKFLOWS.md","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Setting up the workflow-level catch-all for unattended workflows","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Integration with other skills","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"n8n-workflow-patterns","type":"text","marks":[{"type":"strong"}]},{"text":" — the webhook/API and scheduled patterns are where error handling lives. Use it for the overall shape; use this skill to harden it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"n8n-node-configuration","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"retryOnFail","type":"text","marks":[{"type":"code_inline"}]},{"text":" are node config; NODE_FAMILY_GOTCHAS.md covers the Webhook/Respond response-code traps in depth.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"n8n-validation-expert","type":"text","marks":[{"type":"strong"}]},{"text":" — 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.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"n8n-expression-syntax","type":"text","marks":[{"type":"strong"}]},{"text":" — the Switch conditions on ","type":"text"},{"text":"$json.error.message","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the alert-message expressions rely on correct ","type":"text"},{"text":"{{ }}","type":"text","marks":[{"type":"code_inline"}]},{"text":" syntax and ","type":"text"},{"text":"$json.error","type":"text","marks":[{"type":"code_inline"}]},{"text":" access.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"n8n-code-javascript / n8n-code-python","type":"text","marks":[{"type":"strong"}]},{"text":" — if you catch errors ","type":"text"},{"text":"inside","type":"text","marks":[{"type":"em"}]},{"text":" 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.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"n8n-code-tool","type":"text","marks":[{"type":"strong"}]},{"text":" — an agent's Code Tool surfaces thrown errors back to the LLM, which then retries; that's a different error contract from workflow nodes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"n8n-binary-and-data","type":"text","marks":[{"type":"strong"}]},{"text":" — file/binary operations are fallible too; wire their error outputs like any network node.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick reference checklist","type":"text"}]},{"type":"paragraph","content":[{"text":"For an ","type":"text"},{"text":"API / webhook","type":"text","marks":[{"type":"strong"}]},{"text":" workflow:","type":"text"}]},{"type":"checkbox_list","attrs":{"id":null},"content":[{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Webhook trigger uses ","type":"text"},{"text":"responseMode: \"responseNode\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Input validated upstream → 4xx Respond (IF conditions, or the per-field Code validator)","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Every fallible node has ","type":"text"},{"text":"onError: \"continueErrorOutput\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"and","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":" wired","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Network nodes have ","type":"text"},{"text":"retryOnFail: true, maxTries: 3, waitBetweenTries: 5000","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Error path ends at a Respond with an ","type":"text"},{"text":"explicit","type":"text","marks":[{"type":"strong"}]},{"text":" 4xx/5xx ","type":"text"},{"text":"options.responseCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a top-level ","type":"text"},{"text":"responseCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" is ignored)","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Status code matches cause (4xx caller, 5xx you)","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Error body is ","type":"text"},{"text":"{ error, message }","type":"text","marks":[{"type":"code_inline"}]},{"text":" — no stack traces, SQL, or tokens","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Verified with ","type":"text"},{"text":"n8n_get_workflow","type":"text","marks":[{"type":"code_inline"}]},{"text":": both ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"main[1]","type":"text","marks":[{"type":"code_inline"}]},{"text":" present on each fallible node","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For an ","type":"text"},{"text":"unattended","type":"text","marks":[{"type":"strong"}]},{"text":" (scheduled/cron/queue) workflow:","type":"text"}]},{"type":"checkbox_list","attrs":{"id":null},"content":[{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Network nodes have ","type":"text"},{"text":"retryOnFail","type":"text","marks":[{"type":"code_inline"}]},{"text":" configured","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"An Error Trigger workflow exists (capture → notify, optional retry)","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"The error workflow notifies on a different channel + has a fallback (recursion trap)","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"The error-workflow setting is assigned in the n8n UI (MCP can't do it — remind the user)","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"paragraph","content":[{"text":"Remember","type":"text","marks":[{"type":"strong"}]},{"text":": the default is silence. Error handling is two moves — make the failure ","type":"text"},{"text":"route","type":"text","marks":[{"type":"em"}]},{"text":" (per-node ","type":"text"},{"text":"onError","type":"text","marks":[{"type":"code_inline"}]},{"text":" + wired output, or a catch-all error workflow) and make it ","type":"text"},{"text":"speak","type":"text","marks":[{"type":"em"}]},{"text":" (a status code and body that tell the truth). Half a move is worse than none, because it looks done.","type":"text"}]}]},"metadata":{"date":"2026-11-02","name":"n8n-error-handling","author":"@skillopedia","source":{"stars":6572,"repo_name":"n8n-skills","origin_url":"https://github.com/czlonkowski/n8n-skills/blob/HEAD/skills/n8n-error-handling/SKILL.md","repo_owner":"czlonkowski","body_sha256":"cc4194e1ddd3130198162c26c458871f6298ef4bdf4ac778f9e9af8a23e6d7ab","cluster_key":"8263d1e36e4721afa8407e47ea0ea1bdde240b00c0de64aa4964b77905025430","clean_bundle":{"format":"clean-skill-bundle-v1","source":"czlonkowski/n8n-skills/skills/n8n-error-handling/SKILL.md","text_bytes":52262,"source_commit":"e037563e101749e630e6688d121d04529fe6ee7b","skills_sh_rank":8169,"attachment_count":5,"text_attachments":5,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/n8n-error-handling/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"integrations-apis","category_label":"Integrations"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"integrations-apis","import_tag":"skills-sh-top20k-2026-11","description":"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."}},"renderedAt":1793671677936}

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…