data/skills/n8n-error-handling/RESPONSE_SHAPES.md
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.
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:
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:
error/message/request_id.validation_error vs bad_request vs INVALID_INPUT).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.
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:
{
"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:
{} or 204 No Content.{ "items": [...] } (friendlier to future pagination metadata).{
"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.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 branchThis 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.
{ "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".
The status code is the caller's first signal; be deliberate.
Distinguishing 4xx from 5xx matters because the caller's tooling depends on it:
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 |
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:
{
"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.
{
"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.
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.
Success:
{
"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:
{
"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:
Content-Type: application/json explicitly. Default behavior depends on the body shape and isn't reliable.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.)