📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-07-22 10:09:47 +00:00
parent 316c012df0
commit 60364c6660
353 changed files with 24740 additions and 1264 deletions
@@ -0,0 +1,256 @@
# API Workflows
When a workflow is an HTTP API — a Webhook trigger that ends at a `Respond to Webhook` — error handling stops being optional. The caller is a machine waiting on a response, and the failure modes are unforgiving: a hanging branch becomes a timeout, a wrong status code breaks the caller's error handling, a leaked stack trace becomes a security finding.
This file covers wiring that pattern so it behaves under failure, not just on the happy path. For the per-node mechanics, see **NODE_ERROR_OUTPUTS.md**; for body conventions and status codes, **RESPONSE_SHAPES.md**.
---
## The shape
```
Webhook (responseMode: "responseNode")
→ validate input ──valid──→ process ──→ Respond (200, success body)
│ └─invalid─→ Respond (400, validation_error body)
└── (any fallible node's error output, sourceIndex 1)
→ Respond (5xx, structured error body)
→ optional: Log full error privately / notify
```
The non-negotiable: **every path ends at a Respond node.** Success, validation failure, execution failure — all of them. A path that doesn't reach a Respond is a hanging branch, and a hanging branch is a caller timeout.
Set `responseMode: "responseNode"` on the Webhook trigger — without it the trigger acknowledges immediately (`onReceived`) and the caller never sees your computed response. (See **n8n-node-configuration** NODE_FAMILY_GOTCHAS.md for the Webhook/Respond traps.)
---
## Wiring every fallible node
For each fallible node (HTTP, DB, third-party, file op), the two-step setup from NODE_ERROR_OUTPUTS.md:
1. `onError: "continueErrorOutput"` on the node.
2. `addConnection` from its `sourceIndex: 1` to your error Respond (directly, or via a logger).
A two-node processing chain, both fallible, both routing to one responder:
```javascript
// Turn on error outputs
{ type: "updateNode", nodeName: "Fetch User", changes: { onError: "continueErrorOutput" } }
{ type: "updateNode", nodeName: "Call External", changes: { onError: "continueErrorOutput" } }
// Success path
{ type: "addConnection", source: "Webhook", target: "Fetch User", sourceIndex: 0 }
{ type: "addConnection", source: "Fetch User", target: "Call External", sourceIndex: 0 }
{ type: "addConnection", source: "Call External",target: "Respond Success", sourceIndex: 0 }
// Error paths — both fan in to one responder
{ type: "addConnection", source: "Fetch User", target: "Respond Error", sourceIndex: 1 }
{ type: "addConnection", source: "Call External",target: "Respond Error", sourceIndex: 1 }
```
Three things to notice:
1. **One `Respond Error` for many sources.** Fan-in keeps it readable.
2. **Both nodes have `onError` set.** Miss it on either and that node's failure halts the workflow instead of routing — and the caller times out.
3. **If you surface the error message in the body, sanitize it.** See "Don't leak internals" below.
The error Respond node, in JSON:
```json
{
"type": "n8n-nodes-base.respondToWebhook",
"name": "Respond Error",
"parameters": {
"respondWith": "json",
"responseCode": 502,
"responseBody": "={{ JSON.stringify({ error: 'upstream_error', message: 'External service failed' }) }}",
"options": {
"responseHeaders": { "entries": [{ "name": "Content-Type", "value": "application/json" }] }
}
}
}
```
Always set `Content-Type: application/json` explicitly — the default depends on the body shape and isn't reliable.
---
## 4xx lives upstream, 5xx comes out of error outputs
This is the structural rule that keeps an API honest:
- **Validation / auth / not-found failures are *expected outcomes with a known response*.** They aren't nodes crashing. Check them **before** the work, with IF/Switch + a dedicated Respond, and return the right 4xx directly. Do not route them through error outputs.
- **Execution failures (a node actually throwing) are *unexpected*.** Those come out of error outputs as 5xx.
A real API usually needs several upstream checks, each its own IF/Switch + Respond, *before* the processing stage:
```
Webhook
→ Auth present & valid? ── no ──→ Respond 401 unauthorized
→ Input valid? ── no ──→ Respond 400 validation_error (with details)
→ Caller allowed this op? ── no ──→ Respond 403 forbidden
→ Target resource exists? ── no ──→ Respond 404 not_found
→ Processing stage (HTTP / DB / etc.) ←── this is where 5xx errors originate
```
That's not over-engineering — it's the difference between the caller getting an actionable `validation_error` and getting a generic 500 they can't act on.
---
## Input validation: the Set-node schema validator
For structured input validation, don't hand-roll an IF chain per field. Run the whole check as an **IIFE inside a single Set node**, branch on its result with one IF, and respond. One node does the work, and it's far faster than a recursive validator running in a Code node + sub-workflow (the sub-workflow invocation dominates that cost).
The validator node assigns one object field, `result`, computed by the expression below. The expression is **schema-specific** — edit the `REQUIRED_SCHEMA` constant and the per-field checks for your endpoint. The *output keys* are a contract the Respond node consumes — don't rename them.
```json
{
"type": "n8n-nodes-base.set",
"name": "Validate Schema",
"parameters": {
"mode": "manual",
"assignments": {
"assignments": [
{
"id": "a1",
"name": "result",
"type": "object",
"value": "={{ (() => { const body = $json.body || {}; const errors = []; const REQUIRED_SCHEMA = { type: 'object', properties: { name: { type: 'string', minLength: 1, description: 'Customer full name' }, email: { type: 'string', pattern: '^\\\\S+@\\\\S+\\\\.\\\\S+$', description: 'Contact email address' }, plan: { type: 'string', enum: ['starter','pro','enterprise'], description: 'Subscription plan' }, seat_count: { type: 'integer', minimum: 1, maximum: 500, description: 'Number of licensed seats' } }, required: ['name','email','plan','seat_count'], additionalProperties: false }; if (!('name' in body)) errors.push({ p: 'name', m: 'Missing required field \"name\"', d: 'Customer full name' }); else if (typeof body.name !== 'string') errors.push({ p: 'name', m: 'Expected type \"string\"', d: 'Customer full name' }); if (!('email' in body)) errors.push({ p: 'email', m: 'Missing required field \"email\"', d: 'Contact email address' }); else if (!/^\\S+@\\S+\\.\\S+$/.test(body.email)) errors.push({ p: 'email', m: '\"' + body.email + '\" is not valid', d: 'Contact email address' }); if (!('plan' in body)) errors.push({ p: 'plan', m: 'Missing required field \"plan\"', d: 'Subscription plan' }); else if (['starter','pro','enterprise'].indexOf(body.plan) === -1) errors.push({ p: 'plan', m: '\"' + body.plan + '\" is not allowed. Must be one of: starter, pro, enterprise', d: 'Subscription plan' }); if (!('seat_count' in body)) errors.push({ p: 'seat_count', m: 'Missing required field \"seat_count\"', d: 'Number of licensed seats' }); else { const v = body.seat_count; if (typeof v !== 'number' || !Number.isFinite(v) || Math.floor(v) !== v) errors.push({ p: 'seat_count', m: 'Expected type \"integer\"', d: 'Number of licensed seats' }); else if (v < 1 || v > 500) errors.push({ p: 'seat_count', m: 'Must be between 1 and 500', d: 'Number of licensed seats' }); } if (errors.length === 0) return { valid: true, validationError: null }; const lines = errors.map(e => '• ' + e.p + ': ' + e.m + (e.d ? ' - ' + e.d : '')); const details = {}; errors.forEach(e => { if (!(e.p in details)) details[e.p] = e.m; }); return { valid: false, validationError: 'Validation failed (' + errors.length + ' issue' + (errors.length > 1 ? 's' : '') + '):\\n' + lines.join('\\n'), details: details, requiredSchema: REQUIRED_SCHEMA }; })() }}"
}
]
},
"options": {}
}
}
```
Then an IF on `={{ $json.result.valid }}` (boolean → true) routes to your business logic (200) on the true branch, and to a 400 Respond on the false branch:
```json
{
"type": "n8n-nodes-base.respondToWebhook",
"name": "Respond 400",
"parameters": {
"respondWith": "json",
"responseCode": 400,
"responseBody": "={{ JSON.stringify({ error: 'validation_error', message: $json.result.validationError, details: $json.result.details, request_schema: $json.result.requiredSchema }) }}"
}
}
```
### The procedure for adapting it
1. **Lift the three-node shape** (Webhook → Validate Schema → IF → success/400 Respond) into your endpoint. Don't reinvent the graph.
2. **Edit `REQUIRED_SCHEMA` and the per-field checks** for your input. The pattern per field is mechanical: presence check → type check → constraint check → `errors.push(...)`.
3. **Leave the output keys alone.** The IIFE returns `{ valid, validationError, details, requiredSchema }` and the Respond node reads exactly those names. Rename one and the response body breaks.
The output contract:
- Valid: `{ valid: true, validationError: null }`
- Invalid: `{ valid: false, validationError: <summary string>, details: { <field>: <message> }, requiredSchema: <schema echoed back> }`
Echoing the schema back lets the caller — or an LLM driving the call — self-correct.
### Constraint cookbook
| Need | Inline check |
|---|---|
| Required field present | `if (!("name" in body)) errors.push(...)` |
| Type check | `else if (typeof body.name !== "string") errors.push(...)` |
| String length / regex | `body.name.length < N`, `/regex/.test(body.email)` |
| Number range | `body.seat_count < min`, `> max` |
| Integer | `Math.floor(v) !== v` (also reject non-numbers) |
| Enum | `["a","b","c"].indexOf(body.plan) === -1` |
| Array | `Array.isArray(body.tags)`, `body.tags.length < N` |
| Conditional | nest inside `if (body.type === "X") { ... }` |
### The escaping gotcha (regex backslashes)
Inside a JSON `responseBody`/`value` string, a regex like `\S` in the `REQUIRED_SCHEMA` literal needs **four** backslashes (`^\\\\S+...`) because it survives two layers of escaping — JSON string → JS string. The regex literal *executed* inside the IIFE (`/^\\S+@\\S+\\.\\S+$/`) needs only two per `\S`. If your email validation silently never matches, this is why.
---
## 5xx: differentiate the body, but keep it one responder
A single error responder for all 5xx is fine. Differentiate the *body* (and code) by inspecting which failure happened, with an expression instead of a Switch:
```javascript
// responseBody on one Respond node:
{{ (() => {
const err = $json.error ?? {};
const msg = err.message ?? '';
if (/timeout/i.test(msg)) return JSON.stringify({ error: 'upstream_timeout', message: 'External service did not respond in time' });
if (/rate limit/i.test(msg)) return JSON.stringify({ error: 'service_unavailable', message: 'Upstream rate limit hit' });
return JSON.stringify({ error: 'internal_error', message: 'An internal error occurred' });
})() }}
// responseCode on the same node:
{{ /timeout/i.test($json.error?.message ?? '') ? 504
: (/rate limit/i.test($json.error?.message ?? '') ? 503 : 500) }}
```
Reach for Switch + multiple Respond nodes only when the responses diverge *structurally* (different headers, redirect, different body shape). Same shape, different number = one expression-driven Respond.
---
## Don't leak internals
The tempting one-liner:
```javascript
responseBody: "={{ JSON.stringify({ error: 'internal_error', details: $json.error }) }}" // ❌
```
`$json.error` can carry stack traces, internal node names, connection strings, and upstream response bodies with embedded tokens. Surfacing it hands attackers a map and gives callers nothing useful.
Instead: log the full error privately, return a sanitized message.
```javascript
// Error output → Log node (sends full $json.error to Sentry/Slack/your logger)
{ type: "addConnection", source: "Call External", target: "Log Full Error", sourceIndex: 1 }
{ type: "addConnection", source: "Log Full Error", target: "Respond Error", sourceIndex: 0 }
```
```json
// Respond Error keeps the body clean:
{ "responseCode": 502,
"responseBody": "={{ JSON.stringify({ error: 'upstream_error', message: 'External service failed' }) }}" }
```
The caller sees a clean message; the detail stays internal. Full do-not-leak list in **RESPONSE_SHAPES.md**.
---
## Correlation IDs (optional)
If you run distributed tracing or log correlation, add a `request_id` consistently across **every** success and error response (partial coverage is worse than none). Two sources:
- **Caller-supplied** — read an `X-Request-ID` header, pass it through. Better for tracing across systems.
- **Generated** — use `{{ $execution.id }}` or a UUID. Easier.
Don't conflate this with the `job_id` an async (202) endpoint returns — that's how the caller polls for work later, not a correlation field.
---
## Async / 202 pattern
If the work takes longer than the caller wants to wait, respond 202 immediately and continue async:
```
Webhook → validate → Respond (202, { job_id }) → continue processing → callback / queue / email on completion
```
It has its own gotchas (idempotency, callback retries, status tracking) — build it deliberately. The `job_id` is intrinsic (it's how the work is found later), distinct from the optional `request_id`.
---
## Verifying the API workflow
Before activating:
1. **Test the success path** with `n8n_test_workflow`. Confirm shape and code. **API workflows almost always have side effects (DB writes, third-party calls, comms) — ask the user before running a test that triggers them.**
2. **Trigger an error path** — feed input that breaks a processing node, run, confirm the error Respond fires with the right code and body.
3. **Verify connections** with `n8n_get_workflow`: every fallible node has `onError: "continueErrorOutput"` AND `main[1]` wired. (NODE_ERROR_OUTPUTS.md.)
4. **Confirm no internal detail leaks** in the error body.
5. **Inspect real failures** afterward with `n8n_executions` to confirm the codes you expected are what actually went out.
If any check fails, fix before activating.
@@ -0,0 +1,178 @@
# Workflow-Level Error Workflows
Per-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".
What per-node outputs **don't** catch:
- Failures on nodes you forgot to wire.
- Crashes between nodes.
- Whole-workflow timeouts.
- Trigger failures.
When 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.
---
## What the error workflow receives
It starts with an **Error Trigger** node, which fires with roughly this payload:
```json
{
"execution": {
"id": "...",
"url": "https://your-n8n/workflow/<wfId>/executions/<execId>",
"retryOf": "...",
"error": {
"name": "NodeApiError",
"message": "...",
"description": "...",
"timestamp": 1715000000000
},
"lastNodeExecuted": "Fetch order",
"mode": "trigger"
},
"workflow": { "id": "...", "name": "Sync Stripe customers" }
}
```
Note 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).
---
## Minimal error workflow (capture → notify)
For most workflows, this is enough:
```
Error Trigger → Set (build alert message) → Slack / email (post to #incidents)
```
Three nodes. 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).
---
## What to put in the alert
A good notification lets on-call act without opening n8n first. Pull these from the payload:
| Field | Expression |
|---|---|
| Workflow name | `{{ $json.workflow.name }}` |
| Workflow ID | `{{ $json.workflow.id }}` |
| Editor link | `{{ $json.execution.url.split('/executions/')[0] }}` |
| Execution ID | `{{ $json.execution.id }}` |
| Execution link | `{{ $json.execution.url }}` |
| Failed node | `{{ $json.execution.lastNodeExecuted }}` |
| Error message | `{{ $json.execution.error.message }}` |
| Error description | `{{ $json.execution.error.description }}` (often empty, useful when set) |
| Timestamp | `{{ DateTime.fromMillis($json.execution.error.timestamp).toISO() }}` |
The `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.
A useful Slack body:
```
Workflow failure: *{{ $json.workflow.name }}* (`{{ $json.workflow.id }}`)
Open editor: {{ $json.execution.url.split('/executions/')[0] }}
Failed node: `{{ $json.execution.lastNodeExecuted }}`
Error: {{ $json.execution.error.message }}
Execution: {{ $json.execution.url }}
Time: {{ DateTime.fromMillis($json.execution.error.timestamp).toISO() }}
```
Two 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.
---
## Featureful version: recover the failing input
The 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:
```
Error Trigger
→ n8n (resource: Execution, operation: Get,
Execution ID: {{ $json.execution.id }},
Include Execution Details: true)
→ Set (extract failed-node input from the execution data)
→ Switch (route by severity)
├── high → PagerDuty
├── med → Slack #incidents
└── low → Slack #monitoring
→ Data Table (log for tracking)
```
"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[<lastNodeExecuted>]`. Now the on-call message can carry the actual offending payload (which customer, which order id), not just "node X errored".
Caveats, all of which can turn the error workflow itself into a *new* silent failure:
- **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*.
- **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.
- **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.
Minimal is enough most of the time. The featureful version earns its keep on production-critical workflows where on-call minutes matter.
---
## Assigning it (UI only — the MCP can't)
> 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.
So 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 '<name>'" — 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.
---
## When the error workflow fires (and when it doesn't)
**Fires** when:
- A node throws unhandled (not routed via a wired per-node error output).
- The workflow itself fails (timeout, OOM).
- A trigger fails (rare, possible for non-webhook triggers).
**Does NOT fire** when:
- A node's error output is wired — even if the handler does nothing. n8n considers the error *handled*.
- You manually stop an execution.
- The workflow is paused / inactive.
That 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.
---
## What the error workflow should NOT do
- **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.
- **Take significant time.** It runs synchronously; a slow error workflow compounds the original failure's impact.
Keep it fast: parse, notify, return.
---
## The recursion trap
If 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.
Mitigations:
- **Use a different channel than the monitored workflows.** If everything notifies Slack, the error workflow should use email (or vice versa).
- **Add a fallback** — write to a Data Table (`n8n_manage_datatable`) if the primary notification fails, so there's always a trace.
- **Lean on instance-level logging** (server logs, Sentry) so even an error-workflow failure surfaces somewhere outside n8n.
---
## Verifying it works
After building and assigning:
1. 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.
2. Run it.
3. Confirm the error workflow fires and the notification arrives.
This 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.
---
## Drift watch
The 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.
@@ -0,0 +1,171 @@
# Per-Node Error Outputs
This 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`.
The 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.
---
## The two-step setup (both are required)
Routing a node's failure takes exactly two changes. Either one alone looks finished and misbehaves.
### Step 1 — create the error output
Set `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.
```javascript
{ type: "updateNode", nodeName: "Google Sheets",
changes: { onError: "continueErrorOutput" } }
```
Surgical alternative if you're touching only this field:
```javascript
{ type: "patchNodeField", nodeName: "Google Sheets",
fieldPath: "onError", value: "continueErrorOutput" }
```
The valid `onError` values:
| Value | Effect |
|---|---|
| `"stopWorkflow"` (default) | Error halts the whole workflow. The right default for runs you watch. |
| `"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. |
| `"continueErrorOutput"` | The error item flows out a **separate** error output (`main[1]`). This is the one you wire below. |
### Step 2 — wire the error output
With `onError: "continueErrorOutput"`, the node has two outputs:
- `main[0]` → success path (`sourceIndex: 0`)
- `main[1]` → error path (`sourceIndex: 1`)
Wire the error output to a real handler:
```javascript
{ type: "addConnection",
source: "Google Sheets",
target: "Handle Error",
sourceIndex: 1 }
```
`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`.)
---
## Failure modes — why "one of two" is so dangerous
### `onError` set, error output NOT wired
```javascript
// onError: "continueErrorOutput" set on the node,
// but no addConnection from sourceIndex 1.
```
On 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.
**Fix:** wire `sourceIndex: 1` to a real handler, *or* set `onError` back to `"stopWorkflow"` so the failure is loud again.
### Error output wired, `onError` NOT set
```javascript
// addConnection from "Some Node" sourceIndex 1 → "Handle Error" exists,
// but the node still has the default onError: "stopWorkflow".
```
The 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.
**Fix:** set `onError: "continueErrorOutput"` on the node.
### Why validation won't save you
A 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).
---
## Common wiring shapes
### Single fallible node → error handler
```javascript
// Node config: onError: "continueErrorOutput"
{ type: "addConnection", source: "HTTP Request", target: "Respond Error", sourceIndex: 1 }
```
### Success path fans out, error path goes elsewhere
```javascript
{ type: "addConnection", source: "HTTP Request", target: "Save Result", sourceIndex: 0 }
{ type: "addConnection", source: "HTTP Request", target: "Notify Slack", sourceIndex: 0 }
{ type: "addConnection", source: "HTTP Request", target: "Respond Error", sourceIndex: 1 }
```
### Multiple fallible nodes → one shared error handler (fan-in)
```javascript
// Each of these nodes needs onError: "continueErrorOutput" on its own config.
{ type: "addConnection", source: "Fetch User", target: "Respond Error", sourceIndex: 1 }
{ type: "addConnection", source: "Call External", target: "Respond Error", sourceIndex: 1 }
{ type: "addConnection", source: "Write Database", target: "Respond Error", sourceIndex: 1 }
```
Fan-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.
### Both log AND respond on the same failure
Wiring the error output to two targets composes without conflict — both receive the error data:
```javascript
{ type: "addConnection", source: "Call External", target: "Log Full Error", sourceIndex: 1 }
{ type: "addConnection", source: "Call External", target: "Respond Error", sourceIndex: 1 }
```
Useful 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.)
---
## What counts as "fallible"
Wire an error output on anything that can throw at runtime:
- Network calls — HTTP Request, third-party API nodes, databases.
- Auth failures — expired credential, rotated token.
- Schema mismatches — missing DB column, JSON parse failure.
- Rate limits — 429 from upstream (configure `retryOnFail` first so these self-heal).
- File/binary operations — missing path, permission denied (see **n8n-binary-and-data**).
- Code nodes that can throw.
Usually **not** worth an error output:
- Set / Edit Fields on already-validated data.
- IF / Switch with simple expressions — if those throw it's a bug to fix, not a path to catch.
- Pure transformations with no I/O.
When unsure, wire it. The cost is one connection; the cost of not wiring it is a silent halt.
---
## Verification (do this every time)
After any create/update, pull the workflow with `n8n_get_workflow` and check **both halves** on each fallible node:
1. **Node config**`onError` is `"continueErrorOutput"` (or whatever you intended).
2. **Connections**`connections["<node>"].main[1]` contains the expected handler(s).
If either half is missing, you have a silent-failure setup. Fix before activating.
`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.
---
## When to use an error workflow instead
Per-node outputs handle the failure of *one node you remembered to wire*. They do **not** catch:
- Failures on nodes you forgot to wire.
- Crashes between nodes.
- Whole-workflow timeouts.
- Trigger failures.
For 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**.
@@ -0,0 +1,220 @@
# Response Shapes
Conventions 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.
This 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.
---
## First, match what's already on the instance
Before 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.
Search with the MCP, then read each result:
```javascript
search_nodes({ query: "webhook" }) // find webhook-shaped workflows via templates
n8n_list_workflows({ /* filter */ }) // list workflows on the instance
n8n_get_workflow({ id: "<id>" }) // read each one's Respond to Webhook nodes
```
In each existing `Respond to Webhook`, note:
- Top-level keys — envelope vs bare, presence of `error`/`message`/`request_id`.
- Whether success bodies wrap the payload or return it bare.
- The exact error-code strings in use (`validation_error` vs `bad_request` vs `INVALID_INPUT`).
- Header conventions (`Content-Type`, `Retry-After`, `X-Request-Id`).
If 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.
---
## Success shape
Return 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:
```json
{
"customer_id": "cus_123",
"balance": 4200,
"currency": "USD",
"created_at": "2026-04-25T12:34:00Z"
}
```
Returning 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.
Deviate only when:
- The resource is genuinely large and the caller doesn't need it → return the ID, document why.
- There is no resource (event ingestion, fire-and-forget) → `{}` or `204 No Content`.
- The payload is list-shaped → a top-level array, or `{ "items": [...] }` (friendlier to future pagination metadata).
---
## Error shape (the default envelope)
```json
{
"error": "<machine-readable code>",
"message": "<human-readable explanation>"
}
```
- `error` is a **stable string identifier**, not a sentence. Clients branch on it.
- `message` is the human version — safe to log, safe to show users *after* sanitization.
- No `ok: false` flag — the HTTP status code already separates success from failure.
Optional fields by case:
| Field | When to include |
|---|---|
| `details` | Validation errors, with a field-by-field map |
| `retry_after` | Rate limits (also set the `Retry-After` header) |
| `request_id` | When you run distributed tracing (then on *every* response, not just errors) |
| `documentation_url` | Public APIs where you want callers to RTFM |
---
## `responseCode` defaults to 200 — set it on every error branch
This 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.
**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.
So: set `responseCode` **explicitly** on every Respond node — not just the success one. (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.
```json
{ "responseCode": 502,
"responseBody": "={{ JSON.stringify({ error: 'upstream_error', message: 'External service failed' }) }}" }
```
For paths that differ only by number, set it with an expression instead of fanning out to N nodes — see **API_WORKFLOWS.md**, "5xx: differentiate the body".
---
## Status code → cause
The status code is the caller's first signal; be deliberate.
- **2xx** — success. 200 sync, 202 "accepted, processing".
- **4xx** — caller's fault. 400 bad input, 401 no auth, 403 not allowed, 404 not found, 409 conflict, 429 rate limited.
- **5xx** — your fault. 500 unexpected internal, 502 upstream broken, 503 temporarily down, 504 upstream timeout.
Distinguishing 4xx from 5xx matters because the caller's tooling depends on it:
- Caller monitoring alerts on 5xx (your fault) but not 4xx (their fault). Returning 500 for bad input fires *their* pager on *their* bug.
- 5xx implies "retry", 4xx implies "don't bother".
- Aggregated error rates segment by class — collapse everything to 500 and you lose that.
### Error codes (a small, stable set)
Adding a code is fine; renaming an existing one breaks callers.
**4xx — caller's fault**
| Code | Meaning |
|---|---|
| `validation_error` | Required field missing / type wrong |
| `invalid_input` | Field present but value invalid |
| `unauthorized` | No auth or expired auth |
| `forbidden` | Authenticated but not allowed |
| `not_found` | Resource doesn't exist |
| `conflict` | Conflicts with current state (duplicate key, race) |
| `rate_limit_exceeded` | Too many requests |
| `unsupported_media_type` | Content-Type wrong |
**5xx — your fault**
| Code | Meaning |
|---|---|
| `internal_error` | Catch-all, something failed unexpectedly |
| `upstream_error` | Third-party API returned an error |
| `upstream_timeout` | Third-party API didn't respond in time |
| `service_unavailable` | Temporarily can't process (down, or rate-limited upstream) |
| `not_implemented` | Operation not supported in this version |
---
## Validation error details (400)
For `validation_error`, include per-field detail so the caller can fix the request without guessing. The Set-node schema validator (API_WORKFLOWS.md) produces this directly:
```json
{
"error": "validation_error",
"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",
"details": { "name": "Missing required field \"name\"", "email": "\"not-an-email\" is not valid", "plan": "\"premium\" is not allowed" },
"request_schema": { "type": "object", "properties": { } }
}
```
`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.
---
## Rate-limit responses (429)
```json
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Retry after 30s.",
"retry_after": "2026-05-08T21:10:05.135Z"
}
```
Also set the HTTP `Retry-After` header (in the Respond node's `options.responseHeaders`). Well-behaved clients respect the header without parsing the body.
---
## What NOT to put in an error response
The body goes to the caller. Treat everything in it as public.
| Don't include | Why |
|---|---|
| **Stack traces**`{ "stack": "Error at line 42 of /opt/..." }` | Reveals paths, versions, library names. A gift to attackers, useless to callers. |
| **Upstream errors verbatim**`{ "details": "<raw upstream body>" }` | Upstream may embed *their* tokens and PII. Surface "upstream service failed" + a request id; details go to your logs. |
| **SQL queries**`{ "query": "SELECT * FROM users WHERE ..." }` | Exposes schema and access patterns. |
| **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. |
The 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.
---
## Respond node shape (JSON, for the community MCP)
Success:
```json
{
"type": "n8n-nodes-base.respondToWebhook",
"name": "Respond Success",
"parameters": {
"respondWith": "json",
"responseCode": 200,
"responseBody": "={{ JSON.stringify($json) }}",
"options": { "responseHeaders": { "entries": [{ "name": "Content-Type", "value": "application/json" }] } }
}
}
```
Error:
```json
{
"type": "n8n-nodes-base.respondToWebhook",
"name": "Respond Error",
"parameters": {
"respondWith": "json",
"responseCode": 502,
"responseBody": "={{ JSON.stringify({ error: 'upstream_error', message: 'External service failed' }) }}",
"options": { "responseHeaders": { "entries": [{ "name": "Content-Type", "value": "application/json" }] } }
}
}
```
Two notes that bite people:
- **Always set `Content-Type: application/json` explicitly.** Default behavior depends on the body shape and isn't reliable.
- **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.)