n8n Workflow Engineer
When to trigger
- Editing or building n8n workflow JSON
- Debugging n8n: "node not executing", "paired item unavailable", "static data not persisting"
- Bulk operations (rename, retry, delete) via n8n REST API
- Telegram, Gmail, HTTP Request, Code, Switch, Merge node patterns
- Scheduled trigger / timezone / cron behavior in n8n
- n8n v2.x UI behavior (Publish/Unpublish, Archive)
Hard rules
- Audit before edit. Read the full workflow JSON → present findings → confirm → apply in one pass. Never patch blindly.
- Test via scheduled trigger. Manual "Execute" resets
$workflow.staticData. Dedup proof requires a real cron run. - Verify the real result. Check the execution log and the end state (the message arrived, the row exists). A node returning
ok: trueis a good sign, not proof. UI sync lag fools observers. - Match Code node mode to return signature. Wrong mode + wrong return = silent partial processing.
Quick reference — the most common bugs
| Symptom | Root cause | Fix |
|---|---|---|
HTTP Request to localhost:4000 returns refused/timeout | n8n in Docker bridge mode | Use the host gateway: often 172.17.0.1 on the default bridge (check with docker network inspect bridge), or map host.docker.internal:host-gateway in extra_hosts |
| Telegram message never arrives, no error | IPv6 DNS resolution timeout | NODE_OPTIONS=--dns-result-order=ipv4first in docker-compose |
new Date() returns UTC despite cron in EST | Container missing TZ env var | Set both TZ AND GENERIC_TIMEZONE to your time zone |
parse_mode: Markdown fails silently or garbles | Unmatched * or _ in AI output | Use parse_mode: HTML + sanitize before send |
| 401 on LiteLLM HTTP Request node | Missing auth header | Add Authorization: Bearer {{ $env.LITELLM_API_KEY }} |
ECONNABORTED: timeout of 15000ms on Claude call | Default 15s timeout | Set options.timeout: 90000 |
PUT to /api/v1/workflows/:id returns 400 | Extra fields in body | Strip to: name, nodes, connections, settings, staticData only |
| Node rename breaks workflow silently | Connection refs reference old name | Replace_all the old name in both nodes[].name AND connections{} keys |
| Paired item unavailable after Code aggregation | N→1→M lost item lineage | Set executeOnce: true, iterate $('UpstreamNode').all() explicitly |
| Static data dedup "not working" on manual run | Manual Execute resets static data | Test via scheduled cron only |
Code node — the mode/return rules
Every Code node has a mode parameter. Mismatch = silent bugs.
| Mode | Access pattern | Return |
|---|---|---|
runOnceForAllItems (default) | const items = $input.all(); | return items.map(...) — must be array |
runOnceForEachItem | $json is current item | return { json: ... } — single object, NOT array |
Symptoms of mismatch:
runOnceForAllItems+ accessing$json→ only first item processed silentlyrunOnceForEachItem+ returning array →"A 'json' property isn't an object"validation error
Dedup pattern (cross-run state)
const store = $getWorkflowStaticData('global');
store.jobIds = store.jobIds || [];
const newItems = items.filter(item => !store.jobIds.includes(item.json.id));
// Persist new IDs, cap to last 500 to avoid unbounded growth
store.jobIds = [...store.jobIds, ...newItems.map(i => i.json.id)].slice(-500);
return newItems;
Critical: staticData persists across scheduled runs only. Manual Execute resets it. Verify dedup by watching two consecutive scheduled runs, not by clicking Execute twice.
Per-run cache cleanup
// At top of first Code node — clean transient caches but preserve cross-run state
const store = $getWorkflowStaticData('global');
for (const k of Object.keys(store)) {
if (k.startsWith('cjob_') || k.startsWith('cprep_') || k === 'pendingJobs') {
delete store[k];
}
}
// NEVER delete jobIds — cross-run dedup depends on it
Pipeline observability pattern
Set counters at each major Code node. Read at the formatting step. Append to message.
// Stage 1 — Build Tickers
const store = $getWorkflowStaticData('global');
store.pipeline = { ticker_count: tickers.length, fixed_count: fixed.length };
// Stage 2 — Calculate Signals
store.pipeline = store.pipeline || {};
store.pipeline.signal_count = results.length;
// Final — Format message
const pl = store.pipeline || {};
const pf = (n) => (n === undefined || n === null) ? '?' : String(n);
const pipelineBlock =
'\n\n📊 Pipeline\n' +
'Build Tickers: ' + pf(pl.ticker_count) + '\n' +
'Signals: ' + pf(pl.signal_count);
Paired-item issue + workaround
Symptom: After an aggregation node (N items → 1 prompt → 1 LLM response → split to M items), downstream $('UpstreamNode').item.json throws Paired item data for item from node 'X' is unavailable.
Workarounds (in order of preference):
-
Set
executeOnce: trueon the downstream Code node, iterate explicitly:const upstream = $('UpstreamNode').all(); const outputs = []; for (let i = 0; i < parsed.length; i++) { outputs.push({ json: { ...parsed[i], original: upstream[i].json } }); } return outputs; -
Pass IDs through static data; look them up explicitly downstream.
-
pairedItem.sourceOverwrite— fragile, last resort.
Telegram patterns
HTML mode — only 7 tags allowed
<b>, <i>, <u>, <s>, <a>, <code>, <pre> — anything else throws 400 Bad Request: Unsupported start tag.
Three-layer defense for AI-generated content
- Prompt: tell the AI explicitly which tags are allowed
- Sanitize: strip unsupported tags in code before send
- Escape: HTML-escape user/AI content (
&→&,<→<,>→>) before embedding
Suppress n8n attribution footer
"additionalFields": {
"parse_mode": "HTML",
"appendAttribution": false,
"disable_web_page_preview": true
}
Token-in-.env vs credential
Prefer the env-var + HTTP Request pattern over the built-in Telegram node + credential:
- Portable across migrations
- Easier to rotate (
nano .env+ container restart) - No DB-encryption-key lockout risk
- Pattern:
POST https://api.telegram.org/bot{{ $env.BOT_TOKEN }}/sendMessage - Tradeoff: the token sits in the request URL, so it can appear in execution data, proxy logs and error messages. Turn off saving of execution data for this workflow, or use the built-in Telegram credential if others can read your logs.
Gmail OAuth patterns
| Issue | Resolution |
|---|---|
invalid_grant ... refresh token revoked | OAuth in "Testing" mode expires ~7 days. Re-auth via UI, or publish app to Production. |
Empty payload.headers (From/Subject blank) | Default simple: true strips headers. Set options.simple: false. |
| Missing security alerts / receipts | Don't exclude -category:promotions -category:social -category:updates — Gmail routes 90% of relevant mail there. Use is:unread newer_than:Nd only. |
| Need to mark read across multiple accounts | Need per-account credentials. One Gmail credential cannot operate on another account's message IDs. |
REST API patterns (bulk operations)
Auth: X-N8N-API-KEY: <key> header.
| Endpoint | Notes |
|---|---|
GET /api/v1/workflows | Returns all (paginated). Filter client-side. |
GET /api/v1/workflows/:id | Full workflow JSON. |
PUT /api/v1/workflows/:id | Strict: only name, nodes, connections, settings, staticData. settings itself has an allowlist (see below). Strip everything else from a GET-then-PUT round trip. |
DELETE /api/v1/workflows/:id | Actually deletes (UI only offers Archive). Use for bulk cleanup. |
POST /api/v1/workflows/:id/activate | Same as Publish in v2.x UI. |
settings field allowlist (PUT body)
executionOrder, timezone, saveExecutionProgress, saveDataErrorExecution, saveDataSuccessExecution, saveManualExecutions, callerPolicy, callerIds, errorWorkflow, executionTimeout
Node type is immutable post-creation
A PUT that changes node.type returns 200 but the type stays the same. To convert (e.g. telegram → httpRequest): replace the entire node object (new UUID for id, same name). Connections route by name, so name-preserving replacement is safe.
n8n v2.x UI specifics
| Old (v1.x) | New (v2.x) |
|---|---|
| Activate toggle | Publish (green button, top right) / Unpublish |
| Delete workflow | Archive only (use REST API for real delete) |
HTTP Request node — must-haves for LLM calls
{
"method": "POST",
"url": "http://172.17.0.1:4000/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"},
{"name": "Authorization", "value": "=Bearer {{ $env.LITELLM_API_KEY }}"}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/json",
"body": "={{ JSON.stringify({model:'<your-model-alias>',messages:[{role:'user',content:$json.prompt}],temperature:0.3}) }}",
"options": {
"timeout": 90000,
"retry": { "tries": 2, "waitBetweenTries": 5000 }
}
}
n8n container has no curl
For in-container HTTP tests, use Node's built-in https module via docker exec:
docker exec n8n node -e "const https=require('https'); /* request */"
SSH pre-flight (before any heredoc on VPS)
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=10 <user>@<host>
# Verify prompt: [<user>@<host> ...]$
# Only THEN paste the heredoc
Pasting a bash heredoc into PowerShell after an SSH drop = hundreds of parse errors.
Eval suite pattern (logic-only)
Mock-input tests via the n8n Evaluations feature catch logic regressions without burning API credits. Re-run before AND after any change to filter logic, keyword lists, or validation rules.
Anti-patterns
| Anti-pattern | What to do instead |
|---|---|
| "Run it again, see if it works" | Read execution log end-to-end first |
| Patching one node at a time over many round trips | Audit full file → bundled patch script → verify in one pass |
Using parse_mode: Markdown for AI output | parse_mode: HTML + sanitizer |
| Hardcoding API keys / chat IDs in workflow JSON | Reference {{ $env.KEY }}, store in <secret-file> (chmod 600) |
| Renaming nodes without updating connections | replace_all on the node name in both nodes[].name and connections{} keys |
| Manual Execute to test dedup | Wait for scheduled trigger |
| Default httpRequest timeout for LLM calls | Set 90000ms minimum |
Mixing runOnceForAllItems and $json | Match mode to access pattern |