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 gotchas
  • docs/playbooks/n8n/n8n-ssh-worker-pattern.md — SSH Execute pattern for Claude calls
  • docs/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 -AutoSize

bash:

# 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"
fi

Rules:

  • If the API returns nextCursor in the response, there are more than 250 workflows — paginate using ?cursor=<nextCursor>.
  • Webhook path values 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 -AutoSize

2. 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 connections
curl -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, settings in the POST body
  • Every node needs a proper UUID id
  • Use the S1 helper for single-output connections (avoids the PowerShell ConvertTo-Json nested-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:

GoalfieldExtra 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: true and onError: "continueRegularOutput" — otherwise SSH failure kills the execution silently.
  • Check $json.stdout exists downstream; its absence means SSH failed.
  • Set SSH node timeout ≥ 120 000 ms (2 min) — claude -p can take 30–90 s.
  • Use /usr/bin/claude (full path) to avoid PATH issues after su -s /bin/bash claude-runner.
  • For prompts with quotes/newlines, write to /tmp/n8n_prompt_{{ $execution.id }}.txt instead — see n8n-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.

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 Alert uses runOnceForAllItems Code node to build the Discord payload as a JSON string — required by specifyBody: "json" (see n8n-http-request-quirks.md §Quirk 1).
  • Prepare Alert fans out to both Discord Alert and IF: Create GH Issue? simultaneously — so the IF node sees the Code node’s output, not the Discord HTTP response (see n8n-http-request-quirks.md §Quirk 3).
  • IF: Create GH Issue? uses typeVersion: 1 with string "yes"/"no" — not boolean isTrue (see n8n-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:

  1. Set continueOnFail: true on the HTTP Request node.
  2. Add an IF node (typeVersion: 1, string equality) checking $json.error isNotEmpty.
  3. True branch: send Discord alert.
  4. 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

QuirkWrongCorrect
HTTP body not sentspecifyBody: "keypairs" or "string"specifyBody: "json" + jsonBody with JSON string produced by JSON.stringify() in Code node
IF always routes falseisTrue operator, typeVersion: 2.2typeVersion: 1, string "yes"/"no" equality
Downstream $json is HTTP responseLinear chain: Code → HTTP → IFParallel 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 .active

Important: 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 response
  • name — exact name as created
  • notes — trigger type, main function, source file if one exists under infra-src/n8n-workflows/

9. Escalation Path

ProblemAction
API returns 400Strip extra fields (id, versionId, active, tags) from payload — only name, nodes, connections, settings allowed
Webhook path conflictList all webhook paths (§1 above), choose a unique path
SSH Execute times outIncrease 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 repeatedlyCheck executions list in n8n UI for the pattern; check Discord channel infra-alerts for context
Duplicate workflow namesUse GET /api/v1/workflows?limit=250 to list, then rename/delete the duplicate via PUT /{id} or DELETE /{id}
n8n instance unreachableSSH to bms-4, check: docker compose -f /opt/p24-infra/bms-4/docker-compose.yml ps n8n