Playbook: n8n Workflow Creation on bms-4
Applies to: n8n queue-mode instance on bms-4 (https://n8n.bms-4.infra.zintegrowana.online)
Last updated: 2026-06-28
See also:
.claude/commands/n8n.md— API reference, payload rules, step-by-step build guide.claude/commands/n8n-nodes.md— full node type templates and known gotchasdocs/playbooks/n8n/n8n-ssh-worker-pattern.md— SSH Execute pattern for Claude callsdocs/playbooks/n8n/n8n-http-request-quirks.md— known bugs in n8n 2.26.x
0. Session Setup
Load the n8n API key from SOPS before any API call. Never read credentials from .env.local if the SOPS file is available.
PowerShell (Windows dev workstation):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$n8nHost = "https://n8n.bms-4.infra.zintegrowana.online"
$env:BMS4_N8N_API_KEY = (
sops --decrypt --input-type dotenv --output-type dotenv C:\code_2026\p24-infra\secrets\n8n-bms4.env.sops |
Select-String "^BMS4_N8N_API_KEY="
).ToString().Split("=", 2)[1]
$headers = @{ "X-N8N-API-KEY" = $env:BMS4_N8N_API_KEY; "Content-Type" = "application/json" }bash (Linux / VPS agents):
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
N8N_HOST="https://n8n.bms-4.infra.zintegrowana.online"
BMS4_N8N_API_KEY=$(sops --decrypt --input-type dotenv --output-type dotenv \
/opt/p24-infra/secrets/n8n-bms4.env.sops | grep "^BMS4_N8N_API_KEY=" | cut -d= -f2-)
# Use as: -H "X-N8N-API-KEY: $BMS4_N8N_API_KEY"1. Name Overlap Pre-Check
Before creating a workflow, verify the chosen name does not already exist. n8n allows duplicate names (no uniqueness constraint), but duplicates cause confusion during incident response.
PowerShell:
$all = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows?limit=250" -Headers $headers -Method GET
# Check for exact name match
$desired = "my-new-workflow"
$existing = $all.data | Where-Object { $_.name -eq $desired }
if ($existing) {
Write-Warning "Name '$desired' already used by workflow ID: $($existing.id) (active: $($existing.active))"
Write-Warning "Choose a different name or deactivate/delete the existing workflow first."
} else {
Write-Host "Name '$desired' is free — safe to create."
}
# Also useful: print all existing names for a manual scan
$all.data | Sort-Object name | Select-Object id, name, active | Format-Table -AutoSizebash:
# List all workflow names (up to 250)
curl -s -H "X-N8N-API-KEY: $BMS4_N8N_API_KEY" \
"$N8N_HOST/api/v1/workflows?limit=250" \
| jq -r '.data[] | [.id, .name, (.active | tostring)] | @tsv' | sort -k2
# Check for exact match
DESIRED="my-new-workflow"
MATCH=$(curl -s -H "X-N8N-API-KEY: $BMS4_N8N_API_KEY" \
"$N8N_HOST/api/v1/workflows?limit=250" \
| jq -r --arg n "$DESIRED" '.data[] | select(.name == $n) | .id')
if [ -n "$MATCH" ]; then
echo "CONFLICT: '$DESIRED' already exists as workflow ID $MATCH"
else
echo "OK: '$DESIRED' is free"
fiRules:
- If the API returns
nextCursorin the response, there are more than 250 workflows — paginate using?cursor=<nextCursor>. - Webhook
pathvalues must also be unique across the instance. Check them separately:
# Extract all webhook paths in use
$all.data | ForEach-Object {
$_.nodes | Where-Object { $_.type -eq "n8n-nodes-base.webhook" } |
ForEach-Object { [PSCustomObject]@{ workflow = $_.name; path = $_.parameters.path } }
} | Format-Table -AutoSize2. Workflow ID/UID Discovery
The n8n API returns a workflow’s id (alphanumeric string, 16 chars) on creation and in every list/get response. There is no separate “UID” — the id field is what you use everywhere.
After creation
$result = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows" -Headers $headers -Method POST -Body $body
$workflowId = $result.id
Write-Host "Created: $workflowId — $($result.name)"Find an existing workflow by name
$all = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows?limit=250" -Headers $headers -Method GET
$wf = $all.data | Where-Object { $_.name -eq "my-workflow-name" } | Select-Object -First 1
Write-Host "ID: $($wf.id) | Active: $($wf.active) | Updated: $($wf.updatedAt)"# Find by name (bash)
curl -s -H "X-N8N-API-KEY: $BMS4_N8N_API_KEY" \
"$N8N_HOST/api/v1/workflows?limit=250" \
| jq -r '.data[] | select(.name == "my-workflow-name") | "\(.id) \(.name) active=\(.active)"'Get full workflow definition by ID
$wf = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$workflowId" -Headers $headers -Method GET
$wf | ConvertTo-Json -Depth 20 # inspect full JSON, including nodes and connectionscurl -s -H "X-N8N-API-KEY: $BMS4_N8N_API_KEY" \
"$N8N_HOST/api/v1/workflows/$WORKFLOW_ID" | jq .Webhook test URL discovery
Once a workflow is active, its webhook URL is:
https://n8n.bms-4.infra.zintegrowana.online/webhook/<path> # production (active)
https://n8n.bms-4.infra.zintegrowana.online/webhook-test/<path> # test (manual trigger from UI)
The <path> comes from the Webhook node’s parameters.path.
3. Basic Workflow Templates
All templates follow the rules in .claude/commands/n8n.md:
- Only
name,nodes,connections,settingsin the POST body - Every node needs a proper UUID
id - Use the
S1helper for single-output connections (avoids the PowerShellConvertTo-Jsonnested-array bug — see.claude/commands/n8n-nodes.md §PowerShell ConvertTo-Json bug)
# Helper: single-output connection
function S1 { param($n) @{ main = @(, @(@{node=$n;type="main";index=0})) } }Template A: Webhook Trigger Workflow
Suitable for: event-driven workflows, GitHub webhooks, WAHA callbacks, Supabase function triggers.
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$n8nHost = "https://n8n.bms-4.infra.zintegrowana.online"
$env:BMS4_N8N_API_KEY = (sops --decrypt --input-type dotenv --output-type dotenv `
C:\code_2026\p24-infra\secrets\n8n-bms4.env.sops | Select-String "^BMS4_N8N_API_KEY=").ToString().Split("=",2)[1]
$headers = @{ "X-N8N-API-KEY" = $env:BMS4_N8N_API_KEY; "Content-Type" = "application/json" }
function S1 { param($n) @{ main = @(, @(@{node=$n;type="main";index=0})) } }
$webhookId = [System.Guid]::NewGuid().ToString()
$processId = [System.Guid]::NewGuid().ToString()
$respondId = [System.Guid]::NewGuid().ToString()
$assignId1 = [System.Guid]::NewGuid().ToString()
$payload = @{
name = "my-webhook-workflow"
nodes = @(
@{
id = $webhookId
name = "Webhook"
type = "n8n-nodes-base.webhook"
typeVersion = 2
position = @(240, 300)
parameters = @{
httpMethod = "POST"
path = "my-webhook-path" # MUST be unique across instance
responseMode = "lastNode"
}
},
@{
id = $processId
name = "Process"
type = "n8n-nodes-base.set"
typeVersion = 3.4
position = @(460, 300)
parameters = @{
mode = "manual"
assignments = @{
assignments = @(
@{ id = $assignId1; name = "result"; value = "processed"; type = "string" }
)
}
options = @{}
}
},
@{
id = $respondId
name = "Respond to Webhook"
type = "n8n-nodes-base.respondToWebhook"
typeVersion = 1.1
position = @(680, 300)
parameters = @{
respondWith = "text"
responseBody = "OK"
options = @{}
}
}
)
connections = @{
"Webhook" = S1 "Process"
"Process" = S1 "Respond to Webhook"
}
settings = @{ executionOrder = "v1" }
}
$body = $payload | ConvertTo-Json -Depth 20
$result = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows" -Headers $headers -Method POST -Body $body
Write-Host "Created: $($result.id) — $($result.name)"
# Activate
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$($result.id)/activate" -Headers $headers -Method POST | Out-Null
Write-Host "Activated. Test URL: $n8nHost/webhook/my-webhook-path"Template B: Cron (Scheduled) Trigger Workflow
Suitable for: periodic data sync, health checks, daily reports, cleanup jobs.
$scheduleId = [System.Guid]::NewGuid().ToString()
$codeId = [System.Guid]::NewGuid().ToString()
$payload = @{
name = "my-cron-workflow"
nodes = @(
@{
id = $scheduleId
name = "Every 5 Minutes"
type = "n8n-nodes-base.scheduleTrigger"
typeVersion = 1.3
position = @(240, 300)
parameters = @{
rule = @{
interval = @(
@{ field = "minutes"; minutesInterval = 5 }
)
}
}
},
@{
id = $codeId
name = "Do Work"
type = "n8n-nodes-base.code"
typeVersion = 2
position = @(460, 300)
parameters = @{
mode = "runOnceForAllItems"
jsCode = "// Your logic here`nreturn [{ json: { status: 'ok', ts: new Date().toISOString() } }];"
}
}
)
connections = @{
"Every 5 Minutes" = S1 "Do Work"
# "Do Work" has no connections — terminal node for cron
}
settings = @{ executionOrder = "v1" }
}
$body = $payload | ConvertTo-Json -Depth 20
$result = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows" -Headers $headers -Method POST -Body $body
Write-Host "Created: $($result.id)"
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$($result.id)/activate" -Headers $headers -Method POST | Out-Null
Write-Host "Activated cron workflow."Common schedule intervals:
| Goal | field | Extra params |
|---|---|---|
| Every N minutes | "minutes" | "minutesInterval": N |
| Every N hours | "hours" | "hoursInterval": N |
| Daily at HH:MM | "days" | "triggerAtHour": H, "triggerAtMinute": M |
| Weekly Mon at 06:00 | "weeks" | "triggerAtDay": [1], "triggerAtHour": 6, "triggerAtMinute": 0 |
| Arbitrary cron | "cronExpression" | "expression": "0 6 * * 1" |
Template C: SSH Execute — Claude Call Workflow
Suitable for: n8n triggering a Claude agent on vps-i1 or bms-4. See docs/playbooks/n8n/n8n-ssh-worker-pattern.md for full details.
Primary target: bms-4-root-ssh (AI-Dev-BMS4-1, 32 GB, 4 slots)
Fallback: vps-i1-root-ssh (AI-Dev-IO1, 8 GB, 2–3 slots)
$trigId = [System.Guid]::NewGuid().ToString()
$buildId = [System.Guid]::NewGuid().ToString()
$sshId = [System.Guid]::NewGuid().ToString()
$checkId = [System.Guid]::NewGuid().ToString()
$alertId = [System.Guid]::NewGuid().ToString()
$doneId = [System.Guid]::NewGuid().ToString()
$condId = [System.Guid]::NewGuid().ToString()
$payload = @{
name = "my-claude-worker-workflow"
nodes = @(
@{
id = $trigId
name = "Webhook"
type = "n8n-nodes-base.webhook"
typeVersion = 2
position = @(240, 300)
parameters = @{ httpMethod = "POST"; path = "my-claude-trigger"; responseMode = "onReceived" }
},
@{
id = $buildId
name = "Build Prompt"
type = "n8n-nodes-base.code"
typeVersion = 2
position = @(460, 300)
parameters = @{
mode = "runOnceForAllItems"
jsCode = @"
const body = \$input.first().json;
const prompt = `Implement GitHub issue #\${body.issue_number}: \${body.title}`;
return [{ json: { prompt, issue_number: body.issue_number } }];
"@
}
},
@{
id = $sshId
name = "SSH: bms-4 claude-runner"
type = "n8n-nodes-base.ssh"
typeVersion = 1
position = @(680, 300)
continueOnFail = $true
onError = "continueRegularOutput"
parameters = @{
authentication = "privateKey"
command = "su -s /bin/bash claude-runner -c '/usr/bin/claude -p `"{{ \$json.prompt }}`"'"
}
credentials = @{
sshPrivateKey = @{ id = "W8fOGECM0UwCagjd"; name = "bms-4-root-ssh" }
}
},
@{
id = $checkId
name = "IF: SSH OK?"
type = "n8n-nodes-base.if"
typeVersion = 1
position = @(900, 300)
parameters = @{
conditions = @{
string = @(
@{ id = $condId; value1 = "={{ \$json.stdout }}"; operation = "isNotEmpty" }
)
}
}
},
@{
id = $doneId
name = "Done"
type = "n8n-nodes-base.set"
typeVersion = 3.4
position = @(1120, 200)
parameters = @{
mode = "manual"
assignments = @{ assignments = @(
@{ id = [System.Guid]::NewGuid().ToString(); name = "status"; value = "ok"; type = "string" }
)}
options = @{}
}
},
@{
id = $alertId
name = "Discord Alert"
type = "n8n-nodes-base.httpRequest"
typeVersion = 4.2
position = @(1120, 400)
parameters = @{
method = "POST"
url = "={{ \$('Build Prompt').first().json.discord_webhook }}"
sendBody = $true
bodyContentType = "json"
specifyBody = "json"
jsonBody = "={{ JSON.stringify({ embeds: [{ title: '🔴 claude-runner SSH failed', color: 15158332, description: \$json.error || 'No stdout returned' }] }) }}"
options = @{}
}
}
)
connections = @{
"Webhook" = S1 "Build Prompt"
"Build Prompt" = S1 "SSH: bms-4 claude-runner"
"SSH: bms-4 claude-runner" = S1 "IF: SSH OK?"
"IF: SSH OK?" = @{ main = @(
@(@{node="Done";type="main";index=0}),
@(@{node="Discord Alert";type="main";index=0})
)}
}
settings = @{ executionOrder = "v1" }
}
$body = $payload | ConvertTo-Json -Depth 20
$result = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows" -Headers $headers -Method POST -Body $body
Write-Host "Created SSH workflow: $($result.id)"Key rules for SSH Execute:
- Always
continueOnFail: trueandonError: "continueRegularOutput"— otherwise SSH failure kills the execution silently. - Check
$json.stdoutexists downstream; its absence means SSH failed. - Set SSH node timeout ≥ 120 000 ms (2 min) —
claude -pcan take 30–90 s. - Use
/usr/bin/claude(full path) to avoid PATH issues aftersu -s /bin/bash claude-runner. - For prompts with quotes/newlines, write to
/tmp/n8n_prompt_{{ $execution.id }}.txtinstead — seen8n-ssh-worker-pattern.md.
4. Testing with a Session-Scoped Webhook Secret
Webhook paths on bms-4 n8n are accessible without authentication by default. For testing, protect them with a temporary secret injected as a path segment or query parameter.
Method A: Secret-as-path-segment (recommended)
Embed a random token in the webhook path. The token is never stored — it exists only in the workflow definition and in your shell session.
# 1. Generate a session-scoped token
$testToken = [System.Guid]::NewGuid().ToString("N") # 32-char hex, no dashes
Write-Host "Test token: $testToken" # Keep this in your terminal — not persisted anywhere
# 2. Create the workflow with the token embedded in the path
$path = "test-my-feature-$testToken" # e.g. "test-my-feature-a3f2c1d04e5b6789abcdef01"
# Use $path when building the Webhook node:
@{
id = $webhookId
name = "Webhook"
type = "n8n-nodes-base.webhook"
typeVersion = 2
position = @(240, 300)
parameters = @{ httpMethod = "POST"; path = $path; responseMode = "onReceived" }
}
# 3. After create+activate, test:
$testUrl = "$n8nHost/webhook/$path"
Invoke-RestMethod -Uri $testUrl -Method POST -Body '{"test": true}' -ContentType "application/json"
# 4. After testing: deactivate the workflow (optionally delete it)
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$workflowId/deactivate" -Headers $headers -Method POST | Out-Null
Write-Host "Webhook deactivated — token no longer valid"# bash equivalent
TEST_TOKEN=$(python3 -c "import secrets; print(secrets.token_hex(16))")
PATH_VALUE="test-my-feature-$TEST_TOKEN"
echo "Test URL: $N8N_HOST/webhook/$PATH_VALUE"
# After testing, deactivate:
curl -s -X POST -H "X-N8N-API-KEY: $BMS4_N8N_API_KEY" \
"$N8N_HOST/api/v1/workflows/$WORKFLOW_ID/deactivate"Method B: Header secret check (in-workflow validation)
For webhooks that must remain active and accept external calls, add a Code node immediately after the Webhook node to validate a secret header:
// Code node: "Validate Secret" — runs runOnceForEachItem
const expectedSecret = "my-session-secret-here"; // replace with actual check against env/config
const incoming = $input.first().json.headers?.["x-webhook-secret"] || "";
if (incoming !== expectedSecret) {
throw new Error("Unauthorized: invalid webhook secret");
}
return [$input.first()];Then callers must include X-Webhook-Secret: my-session-secret-here in their requests.
Important: Do not store the secret value as a hardcoded string in the workflow — instead, store it in a Set node that reads from a workflow variable, or rotate the path segment after testing.
Cleaning up test webhooks
Always deactivate test webhooks immediately after use. Active webhooks with guessable paths are a security risk. After deactivation, delete the workflow if it was created only for testing:
# Deactivate then delete
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$workflowId/deactivate" -Headers $headers -Method POST | Out-Null
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$workflowId" -Headers $headers -Method DELETE | Out-Null
Write-Host "Test workflow deleted"5. Self-Monitoring and Self-Healing
5a. Error Trigger — catch all failures from a workflow
n8n’s built-in errorTrigger fires whenever any execution in the same workflow fails. Add it as a separate workflow that monitors the primary workflow.
Error handler workflow:
$errTriggerId = [System.Guid]::NewGuid().ToString()
$prepAlertId = [System.Guid]::NewGuid().ToString()
$discordId = [System.Guid]::NewGuid().ToString()
$ghIssueId = [System.Guid]::NewGuid().ToString()
$ifGhId = [System.Guid]::NewGuid().ToString()
$condGhId = [System.Guid]::NewGuid().ToString()
$discordWebhookUrl = "{{ \$env.P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL }}"
$errorPayload = @{
name = "my-workflow-error-handler"
nodes = @(
@{
id = $errTriggerId
name = "Error Trigger"
type = "n8n-nodes-base.errorTrigger"
typeVersion = 1
position = @(240, 300)
parameters = @{}
},
@{
id = $prepAlertId
name = "Prepare Alert"
type = "n8n-nodes-base.code"
typeVersion = 2
position = @(460, 300)
parameters = @{
mode = "runOnceForAllItems"
jsCode = @"
const err = \$input.first().json;
const wfName = err.workflow?.name || 'unknown';
const execId = err.execution?.id || 'unknown';
const errMsg = err.execution?.error?.message || 'No error message';
const execUrl = `https://n8n.bms-4.infra.zintegrowana.online/workflow/\${err.workflow?.id}/executions/\${execId}`;
const discord_payload = JSON.stringify({
embeds: [{
title: `🔴 n8n ERROR — \${wfName}`,
color: 15158332,
description: errMsg,
url: execUrl,
fields: [
{ name: 'Execution ID', value: execId, inline: true },
{ name: 'Workflow', value: wfName, inline: true }
]
}]
});
const create_gh_issue = err.execution?.retryOf ? 'no' : 'yes';
return [{ json: { discord_payload, create_gh_issue, wfName, errMsg, execUrl } }];
"@
}
},
@{
id = $discordId
name = "Discord Alert"
type = "n8n-nodes-base.httpRequest"
typeVersion = 4.2
position = @(680, 200)
parameters = @{
method = "POST"
url = "YOUR_DISCORD_WEBHOOK_URL_HERE" # set from config node or env
sendBody = $true
bodyContentType = "json"
specifyBody = "json"
jsonBody = "={{ \$json.discord_payload }}"
options = @{}
}
},
@{
id = $ifGhId
name = "IF: Create GH Issue?"
type = "n8n-nodes-base.if"
typeVersion = 1
position = @(680, 400)
parameters = @{
conditions = @{
string = @(@{
id = $condGhId
value1 = "={{ \$json.create_gh_issue }}"
operation = "equal"
value2 = "yes"
})
}
}
},
@{
id = $ghIssueId
name = "Create GH Issue"
type = "n8n-nodes-base.httpRequest"
typeVersion = 4.2
position = @(900, 400)
parameters = @{
method = "POST"
url = "https://api.github.com/repos/radieu/p24-infra/issues"
sendHeaders = $true
headerParameters = @{ parameters = @(
@{ name = "Authorization"; value = "Bearer YOUR_GH_TOKEN_HERE" }, # use credential node
@{ name = "Accept"; value = "application/vnd.github.v3+json" }
)}
sendBody = $true
bodyContentType = "json"
specifyBody = "json"
jsonBody = "={{ JSON.stringify({ title: '🔴 [n8n] ' + \$json.wfName + ' failed', body: '## Error\\n\\n' + \$json.errMsg + '\\n\\nExecution: ' + \$json.execUrl, labels: ['bug'] }) }}"
options = @{}
}
}
)
connections = @{
"Error Trigger" = @{ main = @(
# Fanout: both Discord and IF node receive data from Error Trigger simultaneously
@(
@{node="Prepare Alert"; type="main"; index=0}
)
)}
"Prepare Alert" = @{ main = @(
@(
@{node="Discord Alert"; type="main"; index=0},
@{node="IF: Create GH Issue?"; type="main"; index=0}
)
)}
"IF: Create GH Issue?" = @{ main = @(
@(@{node="Create GH Issue"; type="main"; index=0}),
@() # false branch — no action
)}
}
settings = @{ executionOrder = "v1"; errorWorkflow = "" } # no recursive error handler
}
$body = $errorPayload | ConvertTo-Json -Depth 20
$errWf = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows" -Headers $headers -Method POST -Body $body
Write-Host "Error handler created: $($errWf.id)"
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$($errWf.id)/activate" -Headers $headers -Method POST | Out-Null
# Now link the primary workflow to this error handler:
# Update primary workflow's settings to include errorWorkflow ID
$primary = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$primaryWorkflowId" -Headers $headers -Method GET
$updatePayload = @{
name = $primary.name
nodes = $primary.nodes
connections = $primary.connections
settings = @{ executionOrder = "v1"; errorWorkflow = $errWf.id }
}
$body = $updatePayload | ConvertTo-Json -Depth 20
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$primaryWorkflowId" -Headers $headers -Method PUT -Body $body | Out-Null
Write-Host "Primary workflow updated to use error handler $($errWf.id)"Key patterns in the error handler:
Prepare AlertusesrunOnceForAllItemsCode node to build the Discord payload as a JSON string — required byspecifyBody: "json"(seen8n-http-request-quirks.md §Quirk 1).Prepare Alertfans out to bothDiscord AlertandIF: Create GH Issue?simultaneously — so the IF node sees the Code node’s output, not the Discord HTTP response (seen8n-http-request-quirks.md §Quirk 3).IF: Create GH Issue?usestypeVersion: 1with string"yes"/"no"— not booleanisTrue(seen8n-http-request-quirks.md §Quirk 2).create_gh_issue = 'no'when the execution is a retry — avoids duplicate GH issues on transient failures.
5b. Heartbeat pattern — workflow proves itself alive
For critical cron workflows (e.g., wa-processing-watchdog), add a heartbeat ping at the end of each successful execution. The monitoring stack can alert if the heartbeat goes stale.
Heartbeat Code node (add as final node in the cron workflow):
// Code node: "Heartbeat Ping" — runOnceForAllItems
// Sends a timestamp to a Supabase heartbeat table so Prometheus can scrape it.
// Alternatively: POST to a monitoring endpoint or update a static data key.
const executionId = $execution.id;
const now = new Date().toISOString();
// Option A: write to workflow static data (checked by a separate watchdog workflow)
// Use the n8n.workflowStaticData() helper — only available in Code nodes.
// Note: static data is per-workflow and not accessible across workflows via API.
// Option B: return a result that a downstream HTTP Request posts to Supabase
return [{ json: { heartbeat_at: now, execution_id: executionId, status: 'ok' } }];Downstream: Upsert heartbeat row into Supabase
{
"method": "POST",
"url": "https://mwkqmgadqnkkihjdeqsi.supabase.co/rest/v1/dev_r_heartbeats",
"sendHeaders": true,
"headerParameters": { "parameters": [
{ "name": "apikey", "value": "<SUPABASE_PUBLISHABLE_KEY>" },
{ "name": "Authorization", "value": "Bearer <SUPABASE_SERVICE_KEY>" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Prefer", "value": "resolution=merge-duplicates" }
]},
"sendBody": true,
"bodyContentType": "json",
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ workflow_id: $('Webhook').first().json.workflow_id || 'my-cron-workflow', last_seen: $json.heartbeat_at }) }}"
}5c. Retry logic — transient failure recovery
For workflows calling external APIs (Supabase, GitHub, Discord), wrap the critical HTTP Request in a loop with exponential backoff:
// Code node: "Retry Wrapper" — runOnceForAllItems
// Retries the action up to 3 times with 5s / 10s / 20s backoff.
// Downstream HTTP Request node should use continueOnFail: true.
const MAX_RETRIES = 3;
const backoffMs = [5000, 10000, 20000];
// This node sets up retry metadata — the actual retry loop is built with
// a splitInBatches node feeding back into the HTTP call.
// Simpler pattern: just flag retry count in $json and use an IF node.
const attempt = $input.first().json.attempt || 1;
const delay = backoffMs[attempt - 1] || 20000;
return [{ json: {
...$input.first().json,
attempt,
delay_ms: delay,
max_retries: MAX_RETRIES,
should_retry: attempt <= MAX_RETRIES
}}];Simpler approach — use continueOnFail + an IF check:
- Set
continueOnFail: trueon the HTTP Request node. - Add an IF node (
typeVersion: 1, string equality) checking$json.error isNotEmpty. - True branch: send Discord alert.
- False branch: continue normally.
This is simpler than a full retry loop and sufficient for most cases.
5d. Self-healing — automatic restart on stuck state
For workflows like wa-processing-watchdog that reset stuck records, the self-healing is built into the workflow logic itself:
// Code node: "Detect Stuck Items"
// Queries Supabase for items in 'processing' state for >10 minutes
// and resets them to 'queued'. The cron trigger runs this every 10 min.
const stuckThreshold = new Date(Date.now() - 10 * 60 * 1000).toISOString();
// downstream: HTTP Request → Supabase PATCH with status_filter=processing&updated_at=lt.threshold
return [{ json: { threshold: stuckThreshold } }];Pattern: Detect → Reset → Alert in a single cron execution. No external trigger needed.
6. Known n8n 2.26.x Quirks (Summary)
Full details: docs/playbooks/n8n/n8n-http-request-quirks.md
| Quirk | Wrong | Correct |
|---|---|---|
| HTTP body not sent | specifyBody: "keypairs" or "string" | specifyBody: "json" + jsonBody with JSON string produced by JSON.stringify() in Code node |
| IF always routes false | isTrue operator, typeVersion: 2.2 | typeVersion: 1, string "yes"/"no" equality |
Downstream $json is HTTP response | Linear chain: Code → HTTP → IF | Parallel fanout: Code outputs to both HTTP node and IF node simultaneously |
| UUID required on every node | "id": "my-node" (human-readable) | "id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789" (proper UUID) |
| Single-output connection serialized wrong | @(@(@{node=...})) | @(, @(@{node=...})) (unary comma — see n8n-nodes.md) |
7. Activation / Deactivation
# Activate
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$workflowId/activate" -Headers $headers -Method POST | Out-Null
Write-Host "Activated: $workflowId"
# Deactivate
Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$workflowId/deactivate" -Headers $headers -Method POST | Out-Null
Write-Host "Deactivated: $workflowId"
# Verify status
$wf = Invoke-RestMethod -Uri "$n8nHost/api/v1/workflows/$workflowId" -Headers $headers -Method GET
Write-Host "Active: $($wf.active)"# Activate (bash)
curl -s -X POST -H "X-N8N-API-KEY: $BMS4_N8N_API_KEY" \
"$N8N_HOST/api/v1/workflows/$WORKFLOW_ID/activate" | jq .active
# Deactivate (bash)
curl -s -X POST -H "X-N8N-API-KEY: $BMS4_N8N_API_KEY" \
"$N8N_HOST/api/v1/workflows/$WORKFLOW_ID/deactivate" | jq .activeImportant: PATCH /activate (from older n8n docs) does not publish — use POST /{id}/activate (confirmed on 2.26.x).
8. Known Workflow Registry
After creating a new workflow, add it to .claude/commands/n8n.md §Known Workflows. Update the table with:
id— from API responsename— exact name as created- notes — trigger type, main function, source file if one exists under
infra-src/n8n-workflows/
9. Escalation Path
| Problem | Action |
|---|---|
| API returns 400 | Strip extra fields (id, versionId, active, tags) from payload — only name, nodes, connections, settings allowed |
| Webhook path conflict | List all webhook paths (§1 above), choose a unique path |
| SSH Execute times out | Increase timeout parameter on the SSH node to 180 000 ms; check claude-runner auth on the target with su -s /bin/bash claude-runner -c '/usr/bin/claude -p say-ok' |
| Error handler fires repeatedly | Check executions list in n8n UI for the pattern; check Discord channel infra-alerts for context |
| Duplicate workflow names | Use GET /api/v1/workflows?limit=250 to list, then rename/delete the duplicate via PUT /{id} or DELETE /{id} |
| n8n instance unreachable | SSH to bms-4, check: docker compose -f /opt/p24-infra/bms-4/docker-compose.yml ps n8n |