Every MVP client I've worked with has the same early problem: leads come in from multiple sources — a landing page form, a Calendly booking, a LinkedIn DM — and someone has to manually copy data into a CRM, send a follow-up email, and ping the sales channel on Slack. It's 20 minutes of copy-paste work per lead, it's error-prone, and it scales to exactly zero.
I've standardised on n8n for this class of problem. It's self-hostable, the node library covers 95% of what you need, and the economics are far better than Zapier or Make at even modest volumes. Here's the exact pipeline I set up for a recent client, and how I've reused variants of it across SandyTech's own lead capture flows.
You don't need anything fancy. A 1 vCPU / 1 GB RAM VPS (Hetzner CX11 or DigitalOcean Basic) handles hundreds of workflow executions per day without breaking a sweat.
# docker-compose.yml
version: '3.8'
services:
n8n:
image: n8nio/n8n:latest
restart: unless-stopped
ports:
- "5678:5678"
environment:
- N8N_HOST=workflows.yourdomain.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://workflows.yourdomain.com/
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=admin
- N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${DB_PASSWORD}
volumes:
- n8n_data:/home/node/.n8n
postgres:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_DB: n8n
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/dataPut this behind Nginx with a Let's Encrypt certificate and you have a production-grade automation server for under $10/month.
Here's the workflow structure for a typical inbound lead flow.
Every form submission hits a unique n8n webhook URL. For a React/Next.js frontend:
// In your form submit handler
const response = await fetch(
'https://workflows.yourdomain.com/webhook/lead-capture',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
email: formData.email,
company: formData.company,
source: 'landing-page',
timestamp: new Date().toISOString()
})
}
);The webhook node in n8n receives this and passes it downstream.
Raw form data is rarely clean enough to push directly to a CRM. I always add a JavaScript function node to normalise the payload:
// n8n Function node
const item = $input.first().json;
return [{
json: {
firstName: item.name.split(' ')[0],
lastName: item.name.split(' ').slice(1).join(' ') || '',
email: item.email.toLowerCase().trim(),
company: item.company?.trim() || 'Unknown',
source: item.source,
leadScore: item.company ? 8 : 4, // basic scoring
createdAt: item.timestamp,
tags: [item.source, 'inbound-2026']
}
}];n8n has native nodes for HubSpot, Salesforce, Pipedrive, and Airtable. For clients using HubSpot:
The HubSpot node is configured with "Upsert Contact" operation on the email field — so duplicate submissions from the same person update the existing record rather than creating duplicates. I always upsert on email rather than insert, because form submissions are never idempotent in practice.
A simple Slack node posts to a #leads channel with the key details:
*New Lead* — {{$json.firstName}} {{$json.lastName}}
Company: {{$json.company}}
Email: {{$json.email}}
Source: {{$json.source}}
Score: {{$json.leadScore}}/10
For high-volume flows, I filter this to only post leads above a score threshold — otherwise the channel becomes noise.
An email node (SendGrid or SMTP) sends the lead a personalised acknowledgement. I use n8n's expression syntax to personalise:
Subject: Your enquiry to SandyTech — {{$json.firstName}}
Hi {{$json.firstName}},
Thanks for reaching out. I'll review your details and get back to you
within one business day.
— Sandeep
The default n8n behaviour on node failure is to stop execution and mark the workflow as failed. For production pipelines, you need more than this.
I configure two patterns:
Per-node retry: For HTTP-based nodes (CRM API, Slack), enable "Retry on Fail" with 3 attempts and 1-second delay. Most transient API errors resolve within a retry or two.
Error workflow: Under workflow settings, assign a dedicated error workflow that catches any unhandled failure, logs it to a Postgres table, and sends an alert to a #automation-errors Slack channel. The error workflow receives the full execution context, so you can inspect exactly which node failed and why.
// Error workflow function node — log to DB
const error = $input.first().json;
return [{
json: {
workflowId: error.workflow.id,
workflowName: error.workflow.name,
nodeName: error.execution.lastNodeExecuted,
errorMessage: error.execution.error?.message,
timestamp: new Date().toISOString(),
inputData: JSON.stringify(error.execution.data)
}
}];Zapier is the easiest to start with but the economics turn bad fast. Their Starter plan allows 750 tasks/month; a busy lead flow blows through that in days. At SandyTech volumes (a few hundred automations/month), Zapier Professional costs $49/month. Make (formerly Integromat) is cheaper and more powerful, but the scenario editor is quirky and the free tier has operation limits.
n8n self-hosted is $0 compute beyond the VPS cost. The n8n Cloud plan starts at $24/month and is worth it if you don't want to manage the server. The node library is extensive, the JavaScript function nodes give you full flexibility when a native integration falls short, and the execution logs are excellent for debugging.
Most of what you need in a lead pipeline can be done with native nodes. Reach for a Code node (JavaScript or Python) when:
Avoid putting business logic in Code nodes that belongs in your application. n8n is a glue layer, not a logic layer.
The whole pipeline I've described takes about 15 minutes to set up once you have n8n running. For MVP clients, it's often the first thing I build — getting the lead capture loop closed before the product is even finished.