n8n 101: AI Workflow Automation
This isn't a reference doc I wrote once and forgot. It's the accumulated scar tissue from building real automations across real clients — the failures that cost money, the fixes that saved weekends, and the configs that actually work in production. 25 modules. Narrative first. Technical second. No paywalls.
n8n is fair-code licensed (source-available, self-hostable). The license allows unlimited self-hosting with no per-execution fees. You can run it on a $6/month VPS and process as many workflows as your server can handle. The tradeoff: you maintain the server.
What "fair-code" actually means
n8n uses the Sustainable Use License for hosted/commercial use — you can't build a competing product on top of it. But for internal use, client work, and self-hosted deployments, it's effectively open source. Read the license at n8n.io/legal before building commercial products on top of it.
n8n vs the alternatives — the honest positioning
Zapier is better for non-technical teams that need quick, simple integrations and don't expect volume. Make sits in the middle — visual, credit-based, no self-host. n8n is for technical operators who want control, need volume, or are building complex AI workflows. If you're reading this Grimoire, n8n is probably for you.
Don't build client systems on a pricing model that punishes success. n8n inverts that equation. The only limit is your server — which you control. The only ongoing cost is the VPS — which doesn't scale with usage. Once you internalize this, you start building bigger, bolder automations. Because the cost of running them is the same as the cost of not running them.
.env file — which contains every API key you've stored in n8n. GHL, OpenAI, Stripe, SMTP, everything. Fixed in v1.121.0+. Check your version now.# Check running version docker inspect n8n | grep -i image # Or visit: https://your-n8n.com/rest/settings # Upgrade (Docker) docker pull n8nio/n8n:1.121.0 docker-compose down && docker-compose up -d
1. Pull and restart: docker pull n8nio/n8n:1.121.0 + docker-compose up -d
2. Rotate every credential stored in n8n — GHL, OpenAI, SMTP, Stripe, webhooks, OAuth tokens.
3. Add N8N_BLOCK_NODE_ACCESS=true to your .env (blocks filesystem node access).
4. Put n8n behind a reverse proxy (Nginx/Caddy). Never expose port 5678 directly.
5. Add IP allowlisting for admin routes (/rest/, /settings) in your proxy config.
6. Set N8N_SECURE_COOKIE=true and N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true.
7. Enable daily automated database backups. You need them before an incident, not after.
8. Audit your webhook URLs — remove any with no authentication.
Nginx reverse proxy config (hardened)
# /etc/nginx/sites-available/n8n server { listen 443 ssl; server_name n8n.yourdomain.com; # Block admin REST API from non-whitelisted IPs location ~ ^/rest/(settings|users|credentials) { allow 203.0.113.10; # your office IP deny all; proxy_pass http://localhost:5678; } # Webhooks — public location /webhook/ { proxy_pass http://localhost:5678; proxy_set_header X-Real-IP $remote_addr; } # Editor UI — auth required (handled by n8n) location / { proxy_pass http://localhost:5678; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }
Prerequisites
2 vCPU, 2GB RAM
DigitalOcean Droplet or Hetzner CX21. Hetzner is half the price for equivalent specs. I run 4 clients on a $12/mo server.
Docker + Compose v2
apt install docker.io docker-compose-plugin on Ubuntu 22.04. Compose v2 uses docker compose (no hyphen).
A record → server IP
n8n.yourdomain.com → your VPS IP. SSL needs DNS propagation (5–30 min). Do this first before installing anything.
80, 443, 22 open
Block port 5678 from public access. n8n runs internally; Nginx proxies it to 443. Never expose 5678 directly.
Production docker-compose.yml
version: '3.8' services: n8n: image: n8nio/n8n:1.121.0 # pin version, never use 'latest' in prod container_name: n8n restart: unless-stopped ports: - "127.0.0.1:5678:5678" # bind to localhost only environment: - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} - N8N_HOST=${N8N_HOST} - N8N_PORT=5678 - N8N_PROTOCOL=https - WEBHOOK_URL=https://${N8N_HOST} - N8N_SECURE_COOKIE=true - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true - N8N_BLOCK_NODE_ACCESS=true - DB_TYPE=postgresdb - DB_POSTGRESDB_HOST=postgres - DB_POSTGRESDB_PORT=5432 - DB_POSTGRESDB_DATABASE=n8n - DB_POSTGRESDB_USER=n8n - DB_POSTGRESDB_PASSWORD=${DB_PASSWORD} - EXECUTIONS_DATA_PRUNE=true - EXECUTIONS_DATA_MAX_AGE=720 # 30 days of logs - GENERIC_TIMEZONE=Europe/London volumes: - ./n8n_data:/home/node/.n8n depends_on: postgres: condition: service_healthy postgres: image: postgres:15-alpine container_name: n8n_postgres restart: unless-stopped environment: - POSTGRES_DB=n8n - POSTGRES_USER=n8n - POSTGRES_PASSWORD=${DB_PASSWORD} volumes: - ./postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U n8n"] interval: 10s timeout: 5s retries: 5
Automated backups
#!/bin/bash # crontab -e → 0 3 * * * /root/backup.sh DATE=$(date +%Y%m%d-%H%M) BACKUP_DIR=/root/backups mkdir -p $BACKUP_DIR docker exec n8n_postgres pg_dump -U n8n n8n > $BACKUP_DIR/n8n-db-$DATE.sql tar -czf $BACKUP_DIR/n8n-data-$DATE.tar.gz /root/n8n_data aws s3 cp $BACKUP_DIR/n8n-db-$DATE.sql s3://your-bucket/n8n-backups/ find $BACKUP_DIR -name "*.sql" -mtime +14 -delete find $BACKUP_DIR -name "*.tar.gz" -mtime +14 -delete
If you're not comfortable with Nginx, use Caddy. Caddyfile: just write n8n.yourdomain.com { reverse_proxy localhost:5678 } and Caddy handles SSL automatically via Let's Encrypt. No certbot, no renewal crons.
Start with PostgreSQL from day one, even for local testing. The migration path from SQLite to Postgres is painful — export workflows, rebuild the database, re-enter credentials manually. It's a half-day of work. Postgres from the start costs nothing extra. SQLite is a trap.
n8n offers three deployment options. The decision matrix is more nuanced than "cloud is easy, self-host is hard."
n8n Cloud
$20/mo starter
Self-Hosted (VPS)
$6–20/mo server
Self-Hosted (Enterprise)
Custom pricing
Start on n8n Cloud. Learn the tool. Build your first five workflows. Then, when you understand your volume and complexity needs, decide whether self-hosting is worth the ops overhead. Most people never need to self-host. Some absolutely do. Know which you are before committing.
The five zones you live in
The workflow graph
Nodes are the boxes. Connections are the arrows. Click + drag to pan. Scroll to zoom. Cmd/Ctrl+A to select all. Double-click empty canvas to add a node.
Left sidebar
Search for nodes. Filter by category (AI, Core, Communication). Drag to canvas or press Enter on a highlighted node. Cmd/Ctrl+K opens search from anywhere.
Right panel
Click any node to configure it. Parameters, credentials, output preview. Tab between fields. Click the lightning bolt to test the node in isolation.
Top-right menu
Full log of past runs. Click any execution to see the exact data at each node. Essential for debugging. Filter by workflow, status, date.
Keyboard shortcuts worth memorizing
| Shortcut | Action | Why it matters |
|---|---|---|
Cmd/Ctrl + K | Node search | Fastest way to add any node without touching the sidebar |
Cmd/Ctrl + Enter | Execute workflow | Test without leaving the canvas |
Tab | Move to next field | Navigate node editor without mouse |
Ctrl + Shift + D | Duplicate node | Clone config for similar nodes |
F2 | Rename node | Name nodes descriptively — essential for complex flows |
Cmd/Ctrl + A | Select all nodes | Move entire workflow at once |
Cmd/Ctrl + Z / Y | Undo / Redo | 20 undo levels available |
Space + drag | Pan canvas | Faster than scrollbars on large workflows |
Cmd/Ctrl + \ | Toggle sidebar | More canvas space when debugging |
Workflow organization
[category] action — description e.g. [lead] D0 — welcome email + GHL tagAfter running a workflow, click any node on the canvas to see the exact input/output data for that node. This is the fastest debugging tool in n8n — faster than console.log, faster than writing to Sheets. Learn to read the JSON output panel.
The item model
n8n passes data between nodes as an array of items. Each item has a json property (your data) and optionally a binary property (files). Understanding this is fundamental.
// A node receives items — an array of objects: [ { "json": { "email": "alice@co.com", "score": 85 } }, { "json": { "email": "bob@co.com", "score": 42 } } ] // By default, most nodes process each item independently (1-in, 1-out) // Some nodes merge items (Summarize, Aggregate) // Some nodes split items (Split Out, SplitInBatches)
The expression context variables
Expressions use {{ }} mustache syntax. Inside expressions, you have access to these variables:
json property of the current item from the previous node. Equivalent to $input.item.json.$json.contact.emailmanual (test run) or trigger (production). Use to skip side effects in test mode.$now.toISO(), $now.toFormat('yyyy-MM-dd'), etc.$now.startOf('day').Common expression patterns
// String interpolation Hello {{ $json.firstName }}, your booking for {{ $json.propertyAddress }} is confirmed. // Conditional value {{ $json.budget >= 2000 ? 'HOT' : 'WARM' }} // Date formatting {{ $now.toFormat('dd MMM yyyy') }} // "14 May 2026" {{ $now.minus({days: 7}).toISO() }} // 7 days ago // Null coalescing {{ $json.phone || 'no phone provided' }} // Access upstream node data {{ $node["Webhook"].json.body.email }} // Skip side effects in test mode {{ $execution.mode === 'manual' ? 'test@example.com' : $json.email }}
The Code node — when expressions aren't enough
// Code node runs JS. Access all items:
const items = $input.all();
return items.map(item => {
const { email, budget, move_in_date } = item.json;
const daysUntil = Math.ceil(
(new Date(move_in_date) - new Date()) / (1000 * 60 * 60 * 24)
);
let score = 'COLD';
if (budget >= 2000 && daysUntil <= 30) score = 'HOT';
else if (budget >= 1500 || daysUntil <= 60) score = 'WARM';
return {
json: { ...item.json, score, days_until_move_in: daysUntil }
};
});
items = _input.all()./webhook-test/[path]) only works when the editor is open and you've clicked "Listen for test event." The production URL (/webhook/[path]) works when the workflow is active. Always test with the test URL, then switch the sending system to the production URL.lead-inbound-{{ $env.WEBHOOK_PATH_SUFFIX }}X-Webhook-Token → Value: {{ $env.WEBHOOK_SECRET }}{"received": true, "id": "{{ $execution.id }}"}Webhook authentication options
| Method | Security Level | When to Use |
|---|---|---|
| None | 🔴 None | Testing only, localhost — never production |
| Header Auth | 🟡 Good | Custom integrations, server-to-server |
| Basic Auth | 🟡 Good | Legacy systems, some CMS |
| JWT | 🟢 Strong | User-facing webhooks, mobile apps |
| Signature Verify | 🟢 Strong | Stripe, GitHub, Shopify webhooks — verify HMAC in Code node |
Idempotency — the problem most people ignore until it's too late
// After webhook, before creating records — check if already processed HTTP Request: GET Sheets — check Processed!A:A for submission token Code node: const processed = $input.first().json.values?.flat() || []; const submissionId = $node["Webhook"].json.body.token; if (processed.includes(submissionId)) { return []; // empty array stops execution — duplicate } return [{ json: { submission_id: submissionId, ...$node["Webhook"].json.body } }];
Always set webhooks to "Respond Immediately" for production integrations. Returns HTTP 200 instantly, then processes async. If you use "Last Node," the calling system waits for your entire workflow — which can timeout for long-running AI workflows. Typeform, Stripe, and Facebook all have short timeout windows.
If a service has a REST API, the HTTP Request node can integrate with it. GHL, custom CRMs, internal tools, legacy systems — anything with an API endpoint. This is the most powerful node in n8n after the Code node.
Authentication patterns
| Auth Type | Config | Common APIs |
|---|---|---|
| Bearer Token | Authorization: Bearer {{ $env.API_KEY }} | GHL, OpenAI, most modern REST APIs |
| API Key (header) | Custom header e.g. X-API-Key: {{ $env.KEY }} | Airtable, SendGrid, various |
| Basic Auth | Username + password in Credentials | Legacy systems, WooCommerce |
| OAuth2 | Set up credential once, n8n handles refresh | Google APIs, Salesforce, HubSpot |
| Custom (Code node) | Sign request in Code before HTTP node | AWS (SigV4), Stripe (HMAC) |
Pagination
HTTP Request node — cursor-based pagination: Pagination Type: Response Contains Next URL Next URL Expression: {{ $response.body.meta.nextPageUrl }} // Or for offset-based: Pagination Type: Update a Parameter Parameter Name: offset Value: += 100 Max Pages: 50 (safety limit) Stop When: response returns empty array
I used to hardcode API keys in HTTP Request node configuration. Then I'd export a workflow to share with a client — and my keys went with it. Now every secret lives in $env.VARIABLE_NAME. Workflow JSONs can be safely shared and version controlled. The secrets stay in .env on the server.
Add, rename, remove fields
The most-used transformation node. Add calculated fields, rename API response fields to your schema, remove sensitive fields before logging.
Merge, split, aggregate
Split a field that contains an array into separate items. Or merge multiple items into one. Essential for API responses that return arrays.
Combine data streams
Join two branches of data by a key (like SQL JOIN), wait for both branches, or choose between branches.
Combine all items into one
Collapse N items into 1 with summary statistics. Use for report generation: all leads → {total: N, hot: M}.
Merge node — the four modes
| Mode | What it does | Use case |
|---|---|---|
| Merge by Index | Pairs item 1 from A with item 1 from B | When two branches process the same items in parallel |
| Merge by Key | Joins by matching field value (like SQL JOIN) | Enrich lead data from two different API calls using email as key |
| Append | Concatenates all items from A and B | Combine results from two searches into one result set |
| Wait | Waits for both branches to complete | When you need both A and B to finish before the next step |
// Incoming webhook (raw, messy): { "lead_first_name": "Alice", "budget_pcm": "£2,500", "properties": ["flat","2-bed"] } // Edit Fields node — reshape to schema: fullName = {{ $json.lead_first_name + ' ' + $json.lead_last_name }} budget = {{ parseInt($json.budget_pcm.replace(/[^0-9]/g, '')) }} property_type = {{ $json.properties.join(', ') }} // Output: { "fullName": "Alice Chen", "budget": 2500, "property_type": "flat, 2-bed" }
Two-way branch
True/False paths. Conditions: equal, not equal, contains, regex match, empty, exists. Stack multiple conditions with AND/OR.
Multi-way branch
Route items to N outputs based on a field value. Perfect for: residential→team1, commercial→team2, industrial→team3.
Delay or pause
Wait fixed time, until a datetime, or until a webhook resumes the execution. Essential for drip sequences.
Remove items
Discard items that don't meet a condition. Unlike IF, this doesn't branch — it just drops unwanted items and continues.
Loop over batches
Process 100 items at a time instead of all at once. Prevents API rate limit errors.
Call sub-workflow
Trigger another workflow and optionally wait for its result. The key to modular architecture. See M10.
If your n8n server restarts while a workflow is paused at a Wait node, that execution will continue from where it stopped — because Wait state is persisted to the database. This is why PostgreSQL is non-negotiable for drip sequences. SQLite can corrupt on restart and lose your paused executions.
The Error Trigger workflow — build this before anything else
// Workflow name: [system] global error handler Trigger: Error Trigger ← under "Core" in node panel ↓ Code node — format alert: const err = $input.first().json; return [{ json: { workflow: err.workflow.name, error: err.execution.error?.message, url: `https://your-n8n.com/workflow/${err.workflow.id}/executions/${err.execution.id}` } }]; ↓ Slack node — post to #automation-alerts: 🚨 *{{ $json.workflow }}* failed: {{ $json.error }} ↓ Google Sheets append — dead letter log: Timestamp | Workflow | Error | URL | Resolved
| Setting | Behavior | Use when |
|---|---|---|
| Retry on Fail: On | Retries N times before failing | Almost always. Transient network errors are common. |
| Continue on Fail: Off | Workflow stops, triggers Error Workflow | Critical operations (payment, CRM create) |
| Continue on Fail: On | Error data passes to next node, workflow continues | Non-critical (analytics, logging, enrichment) |
A dead letter queue only helps if someone reads it. Add a Monday 9am scheduled workflow that counts PENDING rows in your dead letter Sheets tab. If > 0, post to Slack: "🟡 Dead letter queue: 4 items need review." This turns error tracking from reactive to proactive.
// ENTRY POINT WORKFLOWS (triggered by external events) [lead] webhook — GHL form → calls → [lead] process new lead [lead] webhook — WhatsApp inbound → calls → [lead] process new lead // CORE WORKFLOW (the one job) [lead] process new lead ↓ calls → [util] create GHL contact ↓ calls → [util] send welcome email ↓ IF hot → calls → [alert] slack hot lead ↓ calls → [lead] start drip sequence // UTILITY WORKFLOWS (reusable) [util] create GHL contact ← called from: 3 entry points [util] send welcome email ← called from: 2 workflows // SCHEDULED WORKFLOWS [report] weekly KPI summary → every Monday 09:00 [maint] check dead letter queue → every Monday 09:05
Execute Workflow node — sync vs async
| Mode | Behavior | Use when |
|---|---|---|
| Wait for result | Pauses until sub-workflow returns data | You need the sub-workflow's output to continue |
| Fire and forget | Triggers sub-workflow and immediately continues | Side effects that don't affect the main flow |
Every workflow gets a prefix: [lead], [util], [report], [alert], [maint]. Filter the workflow list by prefix. When debugging you immediately know what category you're in. When onboarding someone new, they understand the architecture before opening a single workflow.
The AI Agent node is n8n's most powerful AI feature. Unlike a simple LLM call (question → answer), an AI Agent can use tools — nodes it can call to take actions — and reason over multiple steps before returning a final answer.
Simple transformation or generation
Classify a lead, write an email, extract fields from text. One input → one output. Faster and cheaper than an agent.
Multi-step reasoning with actions
Answer a support question by searching a knowledge base AND checking a CRM AND maybe sending an email. The agent decides which tools to use.
The reasoning loop
User message → Agent
Agent receives the input, plus its system prompt and available tools.
LLM decides next action
The model reasons: call a tool, call multiple tools, or respond directly.
Tool executes
n8n runs the tool (HTTP Request, vector search, calculator) and returns the result.
Repeat or respond
If the model needs more information, it calls another tool. If it has enough, it responds. Max iterations prevent infinite loops.
Good agent system prompts have four parts: (1) Role definition, (2) Constraints ("Only answer using the knowledge base tool"), (3) Escalation rule ("If confidence < 0.7, escalate to human"), (4) Output format ("Always respond in under 200 words"). Without explicit constraints, the agent will improvise — fine in demos, dangerous in production.
RAG (Retrieval-Augmented Generation) means: before answering, search a knowledge base for relevant context, then give that context to the LLM along with the question. The LLM answers based on your data, not just its training data. This is how you build AI that knows your business.
Trigger: Manual / Schedule ↓ Google Drive node: Get all docs from /knowledge-base folder ↓ Text Splitter node: Chunk Size: 800 tokens Chunk Overlap: 80 tokens (10% overlap retains context across boundaries) Splitter: Recursive Character ↓ Embeddings node: text-embedding-3-small ↓ Vector Store (Pinecone / Qdrant): Operation: Upsert Metadata: { source, doc_title, chunk_index, last_updated }
Input: { "query": "How do I submit a maintenance request?" } ↓ Embeddings node: embed the query text ↓ Vector Store node: Operation: Search | Top K: 5 | Score Threshold: 0.72 ↓ Code node: format context const context = $input.all() .filter(c => c.json.score >= 0.72) .map((c, i) => `[${i+1}] ${c.json.text}`) .join('\n\n'); ↓ LLM node: System: Answer ONLY using the provided context. If not in context, say "I need to check on that" and return confidence: 0.3. User: Context:\n{{ $json.context }}\n\nQuestion: {{ $json.query }}
When a human answers an escalated question, that Q&A pair is gold. Feed every escalated Q&A back into the knowledge base as a new chunk. Your bot gets smarter from its own failures. Without this loop, your bot never improves. I added it three months late. The improvement was immediate.
Step 1: Webhook — receive Slack event Auth: Slack request signature verification (HMAC) Respond: Immediately (Slack has 3s timeout) Step 2: Code node — extract message const text = payload.event.text.replace(/<@[^>]+>/g, '').trim(); return [{ json: { userId: payload.event.user, text, channel: payload.event.channel } }]; Step 3: Vector Search (Qdrant) Top K: 5 | Score Threshold: 0.70 Step 4: AI Agent Model: gpt-4o-mini (temperature: 0.2) Memory: PostgreSQL (keyed by userId) System: You are a support agent for Whitmore Property Management (UK). Answer ONLY using the provided knowledge base context. Be concise — max 150 words. Use UK English. If answer not in context, set confidence = 0.2 and say: "I'd like to check this with the team." Step 5: IF — confidence >= 0.65? TRUE → Send AI reply to user via Slack DM FALSE → Post to #support-escalations for human review Step 6: Google Sheets append — log all interactions timestamp | user | question | response | confidence | escalated
Step 1: Webhook Receives: name, email, phone, property_type, budget_pcm, move_in_date, source Step 2: LLM Node (classification only — not an agent) Model: gpt-4o-mini | Temperature: 0 System: You are a lead scoring engine. Output ONLY valid JSON. User: Score this rental lead as HOT, WARM, or COLD. HOT: budget >= 2000 AND days_to_move <= 30 WARM: budget >= 1500 OR days_to_move <= 60 COLD: all others Lead: {{ JSON.stringify($json) }} Return: { "score": "HOT|WARM|COLD", "reason": "...", "priority_action": "..." } Step 3: Switch node HOT → GHL contact + Slack alert + WhatsApp template WARM → GHL contact + email sequence COLD → GHL contact + monthly newsletter only Step 4: Google Sheets append timestamp | name | email | budget | score | reason | source
// Create contact (v2 API) POST https://services.leadconnectorhq.com/contacts/ Authorization: Bearer {{ $env.GHL_API_KEY }} { "email": "{{ $json.email }}", "firstName": "{{ $json.first_name }}", "tags": ["{{ $json.score.toLowerCase() }}", "{{ $json.source_clean }}"], "locationId": "{{ $env.GHL_LOCATION_ID }}" } // Add to pipeline POST https://services.leadconnectorhq.com/opportunities/ { "pipelineId": "{{ $env.GHL_PIPELINE_HOT }}", "pipelineStageId": "{{ $env.GHL_STAGE_NEW }}", "contactId": "{{ $json.contact_id }}" }
Setup checklist
Meta Business Account
Must be verified. Requires business registration documents. Allow 3–7 days.
WhatsApp Business Account (WABA)
Create inside Meta Business Manager. Link a phone number (UK mobile or VoIP).
Message Template Approval
Submit templates to Meta. 1–3 business days. Submit 3–5 templates at once.
Webhook Configuration
Configure your n8n webhook URL in Meta Developer Portal.
Test with sandbox number
Meta provides a test number for development. Test all templates before going live.
Step 1: Webhook — receive Meta Cloud API POST IF $json.hub?.challenge → return challenge (text/plain) Step 2: Extract message const msg = $input.first().json.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; if (!msg || msg.type !== 'text') return []; return [{ json: { from: msg.from, text: msg.text.body } }]; Step 3: LLM extract — parse landlord intent Return JSON: { property_type, bedrooms, location, asking_rent_pcm, missing_fields } Step 4: IF — missing required fields? TRUE → Send template requesting missing info FALSE → Create GHL contact + send confirmation template Send template: POST https://graph.facebook.com/v19.0/{{ $env.WABA_PHONE_ID }}/messages { "messaging_product": "whatsapp", "to": "{{ $json.from }}", "type": "template", "template": { "name": "property_details_request", "language": { "code": "en_GB" } } }
Step 1: Schedule Trigger — Every Monday at 09:00 (Europe/London) Step 2: HTTP Request — fetch leads from GHL (last 7 days) createdAfter: {{ $now.minus({days:7}).toISO() }} Step 3: Code node — compute metrics const contacts = $input.all().map(i => i.json); const total = contacts.length; const hot = contacts.filter(c => c.tags?.includes('lead-hot')).length; const converted = contacts.filter(c => c.tags?.includes('converted')).length; return [{ json: { period_start: new Date(Date.now()-7*86400000).toISOString().slice(0,10), total, hot, converted, conversion_rate: total ? ((converted/total)*100).toFixed(1) : 0 } }]; Step 4: LLM node — 3-sentence executive summary from the numbers Step 5: Parallel branches A → Google Sheets append (historical log) B → Email to client (SMTP) C → Slack to #client-reports (team quick view)
I started with email-only reports. Open rate: ~30%. Then I added the Slack preview with key metrics. Now the team sees the numbers the moment they open Slack Monday morning. Don't assume people will open their email. Put the summary where they already are.
| Dimension | Telegram | WhatsApp Business API |
|---|---|---|
| Setup time | ~15 minutes | 3–10 days (verification + approvals) |
| Template approval | Not required | Required for first outbound message |
| Open rates | ~45% (tech-savvy users) | ~90% (mainstream UK/EU audiences) |
| Free tier | Unlimited | 1,000 conversations/month free |
| Best use case | Internal ops alerts, developer tools | Customer-facing B2C communication |
Telegram setup (complete)
# 1. Create bot via @BotFather → /newbot → get TOKEN # 2. Get your chat ID curl https://api.telegram.org/botTOKEN/getUpdates # 3. Set webhook to receive messages curl -X POST https://api.telegram.org/botTOKEN/setWebhook \ -d url=https://your-n8n.com/webhook/telegram \ -d secret_token=your-secret # 4. Send message from n8n POST https://api.telegram.org/botTOKEN/sendMessage { "chat_id": "{{ $json.chat_id }}", "text": "Your message here", "parse_mode": "Markdown" }
Telegram for internal alerts (server down, workflow failed). WhatsApp for customer-facing communication in the UK (90%+ open rates). Don't try to use WhatsApp for internal alerts — the template approval process makes it too slow for operational needs.
POST https://slack.com/api/chat.postMessage
Authorization: Bearer {{ $env.SLACK_BOT_TOKEN }}
{
"channel": "#hot-leads",
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "🔥 Hot Lead — Action Required" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Name*\n{{ $json.fullName }}" },
{ "type": "mrkdwn", "text": "*Budget*\n£{{ $json.budget }}/mo" }
]
},
{
"type": "actions",
"elements": [{
"type": "button",
"style": "primary",
"text": { "type": "plain_text", "text": "View in GHL" },
"url": "https://app.gohighlevel.com/contacts/{{ $json.ghl_id }}"
}]
}
]
}
Email — SMTP vs transactional API
| Method | Setup | Best for |
|---|---|---|
| SMTP (Gmail/Outlook) | 5 min, App Password | Low volume (<500/day), internal reports, client emails |
| SendGrid API | 15 min, API key | Marketing emails, high volume, transactional |
| Postmark | 15 min, API key | Receipts, confirmations, time-sensitive |
| Amazon SES | 30 min, IAM setup | >10,000 emails/month |
OAuth + API auth
Managed by n8n. Encrypted with your N8N_ENCRYPTION_KEY. Used by native nodes (Slack, Google, etc). Do not export.
$env.VARIABLE
Set in .env or Docker environment. Accessible in expressions. Never appear in exported JSON.
$vars.variable
Set in workflow settings. Shared across all executions. Good for client-specific config (location IDs, pipeline IDs).
# ─── n8n core ─── N8N_ENCRYPTION_KEY=64-char-hex N8N_HOST=n8n.yourdomain.com DB_PASSWORD=strong-db-password # ─── GHL ─── GHL_API_KEY_CLIENT_A=... GHL_LOCATION_ID_CLIENT_A=... # ─── AI ─── OPENAI_API_KEY=sk-... # ─── channels ─── SLACK_BOT_TOKEN=xoxb-... META_TOKEN=EAA... TELEGRAM_BOT_TOKEN=... # ─── email ─── SMTP_USER=ops@yourdomain.com SMTP_PASS=gmail-app-password
.env, n8n_data/, postgres_data/.env files. Never test against production data.services: n8n-main: # handles UI + webhook reception image: n8nio/n8n:1.121.0 environment: - EXECUTIONS_MODE=queue - QUEUE_BULL_REDIS_HOST=redis n8n-worker: # handles workflow execution image: n8nio/n8n:1.121.0 command: n8n worker --concurrency=10 deploy: replicas: 3 # 3 workers × 10 = 30 parallel executions redis: image: redis:7-alpine command: redis-server --appendonly yes
--concurrency=N — workflows per worker simultaneously. Default: 10. Memory-heavy AI workflows: use 3–5.Node-level test
Click the test button (lightning bolt) on an individual node with mock input. Verify output before connecting downstream.
Partial workflow test
Right-click any node → "Test from here" — runs from that node forward using real or mock data.
Full workflow test
Click "Test Workflow" button. Uses real credentials, real APIs. Verify in staging/test accounts.
Test mode isolation
Use {{ $execution.mode === 'manual' ? 'test@...' : $json.email }} to redirect real side-effects during testing.
Pre-deploy checklist
// Auth: X-N8N-API-KEY header (create in Settings → n8n API) // List all workflows GET /api/v1/workflows // Export workflow JSON (safe to git-commit) GET /api/v1/workflows/{id} // Bulk-set error workflow on all workflows PATCH /api/v1/workflows/{id} { "settings": { "errorWorkflow": "error-handler-workflow-id" } } // Get all failed executions for a workflow GET /api/v1/executions?workflowId={id}&status=error&limit=20 // Trigger workflow via API POST /api/v1/workflows/{id}/execute { "runData": { "Start": [{ "json": { "your": "data" } }] } }
Auto-backup workflow (Sunday 2am)
Trigger: Schedule → Sunday 02:00 ↓ HTTP Request: GET /api/v1/workflows ↓ SplitInBatches: size 1 ↓ HTTP Request: GET /api/v1/workflows/{{ $json.id }} ↓ GitHub node: create/update file Path: workflows/{{ $json.name.replace(/[^a-z0-9]/gi,'-').toLowerCase() }}.json Commit: "Auto-backup {{ $json.name }} — {{ $now.toFormat('yyyy-MM-dd') }}"
# Via n8n UI (easiest) Settings → Community Nodes → Install → enter npm package name # Via CLI (inside Docker container) docker exec -it n8n n8n nodes install n8n-nodes-packagename
| Package | What it does | Quality signal |
|---|---|---|
n8n-nodes-browserless | Browser automation (scraping, PDF generation) | High downloads, active maintenance |
n8n-nodes-mcp | Connect to MCP servers from n8n workflows | Growing, watch actively |
n8n-nodes-redis | Direct Redis operations — rate limiting, session storage | Stable, widely used |
n8n-nodes-document-generator | Generate PDF/DOCX from HTML templates | Active, good docs |
Before installing: (1) Check npm last-publish date — anything over 12 months without updates is risky. (2) Check GitHub issues — are critical bugs open? (3) Check if it's been updated for n8n 1.0+ API. When in doubt, use the HTTP Request node — it's always maintained.
| Dimension | n8n | Make | Zapier |
|---|---|---|---|
| Pricing model | Self-host (server cost) or $20/mo Cloud | Credit-based (~$9–$29/mo) | Task-based ($20–$800+/mo) |
| Per-execution cost | None (self-host) | Yes (credits consumed) | Yes (tasks consumed) |
| Self-host option | ✓ Yes, Docker | ✗ No | ✗ No |
| Native integrations | 1,200+ | 3,000+ | 6,000+ |
| Code support | Full JS + Python | Limited (basic functions) | Limited (Code by Zapier) |
| AI features | AI Agent, RAG, vector stores, LLM node | Make AI Agents (improving) | Zapier AI (GPT-based, basic) |
| Community nodes | ✓ npm ecosystem | ✗ | ✗ |
| Learning curve | Steep (technical comfort needed) | Moderate | Easy (non-technical friendly) |
| Error handling | Full — Error Trigger, retry, dead letter | Good — built in | Basic — email alerts |
| Best for | High-volume, AI workflows, technical teams | Marketing automation, visual builders | Non-technical teams, quick simple integrations |
| Worst for | Non-technical teams, quick no-code demos | Very high volume (credits scale fast) | High-volume, complex data transforms |
I use all three. Zapier for quick client integrations when the app isn't in n8n's catalog and volume is low. Make for marketing teams who need visual clarity and moderate volume. n8n for everything I run myself at scale. The $847 Zapier bill didn't make me hate Zapier — it made me understand that per-execution pricing is a bad fit for scalable systems. Use the right tool for the volume and complexity.
Data access
// Current item {{ $json.fieldName }} // simple field {{ $json["field-with-dashes"] }} // bracket notation {{ $json.address?.city }} // optional chaining (safe for null) {{ $json.items[0].name }} // array index {{ $json.contact?.email || 'no email' }} // null coalescing // Upstream nodes {{ $node["Webhook"].json.body.email }} {{ $node["GHL Create"].json.id }} // Execution context {{ $execution.id }} // unique run ID {{ $execution.mode }} // 'manual' or 'trigger' {{ $workflow.name }} // workflow name {{ $itemIndex }} // 0-based index of current item
Date & time (Luxon)
{{ $now.toISO() }} // "2026-05-14T09:30:00.000Z"
{{ $now.toFormat('dd MMM yyyy') }} // "14 May 2026"
{{ $now.toFormat('yyyy-MM-dd') }} // "2026-05-14"
{{ $now.plus({days: 7}).toISO() }} // 7 days from now
{{ $now.minus({months: 1}).toISO() }} // 1 month ago
{{ $now.startOf('week').toISO() }} // start of current week
{{ $now.endOf('month').toISO() }} // end of current month
{{ $today }} // today at midnight UTC
String operations
{{ $json.name.toUpperCase() }}
{{ $json.name.trim() }}
{{ $json.name.replace(/\s+/g, '-') }} // regex replace
{{ $json.email.split('@')[0] }} // split + index
{{ $json.description.substring(0, 100) + '...' }}// truncate
{{ [$json.firstName, $json.lastName].filter(Boolean).join(' ') }}
Numbers, arrays, conditionals
// Numbers {{ Math.round($json.budget * 12) }} {{ ($json.converted / $json.total * 100).toFixed(1) }} {{ parseInt($json.budgetStr.replace(/[^0-9]/g,'')) }} // Arrays {{ $json.tags.length }} {{ $json.tags.includes('vip') }} {{ $json.tags.join(', ') }} {{ $json.items.map(i => i.name).join(', ') }} {{ $json.items.filter(i => i.active).length }} {{ $json.scores.reduce((sum, s) => sum + s, 0) }} // Conditionals {{ $json.score >= 80 ? 'HOT' : $json.score >= 50 ? 'WARM' : 'COLD' }} {{ $json.company || 'Individual' }} {{ $execution.mode === 'manual' ? 'test@example.com' : $json.email }}
Code node patterns
// Deduplicate items by field const seen = new Set(); return $input.all().filter(item => { const key = item.json.email; if (seen.has(key)) return false; seen.add(key); return true; }); // Array → individual items const rows = $input.first().json.contacts; return rows.map(row => ({ json: row })); // Aggregate multiple items into one const items = $input.all(); return [{ json: { total: items.length, emails: items.map(i => i.json.email), sum: items.reduce((s, i) => s + i.json.budget, 0) } }]; // Safe JSON parse (handle LLM output formatting) function safeJson(str, fallback = {}) { try { return JSON.parse(str.replace(/```json|```/g, '').trim()); } catch { return fallback; } } const parsed = safeJson($json.llm_output);