Back to N8n Mcp

API Workflows

data/skills/n8n-error-handling/API_WORKFLOWS.md

2.63.114.0 KB
Original Source

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

NeedInline check
Required field presentif (!("name" in body)) errors.push(...)
Type checkelse if (typeof body.name !== "string") errors.push(...)
String length / regexbody.name.length < N, /regex/.test(body.email)
Number rangebody.seat_count < min, > max
IntegerMath.floor(v) !== v (also reject non-numbers)
Enum["a","b","c"].indexOf(body.plan) === -1
ArrayArray.isArray(body.tags), body.tags.length < N
Conditionalnest 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.