Back to N8n Mcp

Response Shapes

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

2.63.19.7 KB
Original Source

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:

FieldWhen to include
detailsValidation errors, with a field-by-field map
retry_afterRate limits (also set the Retry-After header)
request_idWhen you run distributed tracing (then on every response, not just errors)
documentation_urlPublic 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

CodeMeaning
validation_errorRequired field missing / type wrong
invalid_inputField present but value invalid
unauthorizedNo auth or expired auth
forbiddenAuthenticated but not allowed
not_foundResource doesn't exist
conflictConflicts with current state (duplicate key, race)
rate_limit_exceededToo many requests
unsupported_media_typeContent-Type wrong

5xx — your fault

CodeMeaning
internal_errorCatch-all, something failed unexpectedly
upstream_errorThird-party API returned an error
upstream_timeoutThird-party API didn't respond in time
service_unavailableTemporarily can't process (down, or rate-limited upstream)
not_implementedOperation 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 includeWhy
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 valuesEven 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.)