DEV Community

Pirate Prentice
Pirate Prentice

Posted on

How to automatically add new leads to your CRM with n8n (free workflow JSON)

Every lead capture form should do three things automatically: save the lead somewhere, notify the right person, and send a welcome email. Most people build this once, badly, and then rebuild it every time they start a new project.

I rebuilt it four times before I finally wrote it down properly as an n8n workflow. This is that workflow — free JSON at the end.

What this workflow does

  1. Receives a webhook POST from your form (Tally, Typeform, Webflow, or a plain HTML form)
  2. Saves the lead to Airtable (or Google Sheets — I'll show both)
  3. Posts a Slack message to your #leads channel
  4. Sends a personalized welcome email via Gmail or SMTP

No paid n8n nodes. No external services beyond what you're probably already using.

Prerequisites

  • n8n running (self-hosted free, or n8n Cloud — the workflow works on both)
  • An Airtable account (free tier works)
  • Slack workspace with a #leads channel
  • A form that can send a POST webhook (Tally is free and does this natively)

Step 1: Set up your Airtable base

Create a base called Leads with these fields:

Field Type
Name Single line text
Email Email
Source Single line text
Created Date
Status Single select (New / Contacted / Qualified)

Copy your Airtable base ID from the URL: https://airtable.com/appXXXXXXXXXXXXXX/... — that appXXX... part is your base ID.

Step 2: Build the n8n workflow

Open n8n and create a new workflow. Add these nodes in order:

Node 1 — Webhook (trigger)

  • Authentication: None (or Header Auth if you want security)
  • HTTP Method: POST
  • Path: /new-lead
  • Response Mode: Immediately

This gives you a URL like https://your-n8n.com/webhook/new-lead — that's what your form will POST to.

Node 2 — Set (normalize the data)

Map incoming form fields to consistent variable names. Form providers use different field names — Tally uses fields[0].value, Typeform uses answers[0].text. The Set node creates a clean intermediate shape:

name  → {{ $json.body.name ?? $json.body["Name"] ?? "Unknown" }}
email → {{ $json.body.email ?? $json.body["Email"] }}
source → {{ $json.body.source ?? "web" }}
Enter fullscreen mode Exit fullscreen mode

Node 3 — Airtable (save the lead)

  • Operation: Create Record
  • Base ID: your base ID from Step 1
  • Table: Leads
  • Fields: map name, email, source, Created (use {{ new Date().toISOString() }})

Node 4 — Slack (notify your team)

  • Channel: #leads
  • Message: 🎯 New lead: {{ $('Set').item.json.name }} ({{ $('Set').item.json.email }}) via {{ $('Set').item.json.source }}

Node 5 — Gmail / Send Email (welcome email)

  • To: {{ $('Set').item.json.email }}
  • Subject: Thanks for reaching out, {{ $('Set').item.json.name }}!
  • Body: Write a short, warm reply. Don't make it look automated.

Node 6 — Respond to Webhook

  • Return: { "status": "ok" }

Connect them 1 → 2 → 3, and from Node 2 also connect to 4 and 5 in parallel (right-click Node 2 → Add Output). Node 6 connects after Node 1 (immediate response) so the form doesn't time out waiting for your CRM writes.

Step 3: Connect your form

In Tally (free):

  1. Add a new block → Redirect / Webhook
  2. Paste your n8n webhook URL
  3. Test submit — you should see the execution appear in n8n immediately

In a plain HTML form:

<form id="lead-form">
  <input name="name" placeholder="Your name" required>
  <input name="email" type="email" placeholder="Email" required>
  <button type="submit">Get started</button>
</form>
<script>
document.getElementById('lead-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(e.target));
  await fetch('https://your-n8n.com/webhook/new-lead', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  // show success message
});
</script>
Enter fullscreen mode Exit fullscreen mode

Step 4: Google Sheets instead of Airtable (optional)

Replace Node 3 with a Google Sheets node:

  • Operation: Append Row
  • Spreadsheet ID: your sheet ID from the URL
  • Sheet: Sheet1
  • Columns: Name, Email, Source, Created At

Same downstream nodes; everything else stays identical.

The free workflow JSON

Drop a comment below and I'll share the full workflow JSON — import it directly into n8n with File → Import from JSON. It includes all 6 nodes pre-configured with placeholder credentials (swap in yours and it runs).

I've also packaged this workflow alongside two others (Stripe payment fulfillment + form-to-Sheets) into a starter pack if you want all three ready to go:

👉 n8n Workflow Starter Pack — $29 — includes documented JSON + a walkthrough for each workflow.

What to do if the webhook stops receiving data

Two common failures:

  • Form sends application/x-www-form-urlencoded instead of JSON — add a Code node after the Webhook and parse $input.item.binary manually, or switch your form to JSON mode.
  • n8n is behind a firewall / localhost — use ngrok to expose it temporarily while testing: ngrok http 5678.

That's the full build. If you're already running this and hit a specific snag, post it in the comments — I check back.

Top comments (1)

Collapse
 
pirateprentice profile image
Pirate Prentice

Here's the free workflow JSON as promised — paste this into n8n via File → Import from JSON:

{
  "name": "Lead Capture → CRM + Slack + Email",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "new-lead",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "webhook-1",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1.1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            { "id": "1", "name": "name", "value": "={{ $json.body.name ?? $json.body['Name'] ?? 'Unknown' }}", "type": "string" },
            { "id": "2", "name": "email", "value": "={{ $json.body.email ?? $json.body['Email'] }}", "type": "string" },
            { "id": "3", "name": "source", "value": "={{ $json.body.source ?? 'web' }}", "type": "string" }
          ]
        }
      },
      "id": "set-1",
      "name": "Set",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.3,
      "position": [460, 300]
    },
    {
      "parameters": {
        "operation": "create",
        "base": { "__rl": true, "value": "YOUR_BASE_ID", "mode": "id" },
        "table": { "__rl": true, "value": "Leads", "mode": "name" },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Name": "={{ $json.name }}",
            "Email": "={{ $json.email }}",
            "Source": "={{ $json.source }}",
            "Created": "={{ new Date().toISOString() }}",
            "Status": "New"
          }
        }
      },
      "id": "airtable-1",
      "name": "Airtable",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2,
      "position": [680, 200]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "value": "#leads", "mode": "name" },
        "text": "=🎯 New lead: {{ $('Set').item.json.name }} ({{ $('Set').item.json.email }}) via {{ $('Set').item.json.source }}",
        "otherOptions": {}
      },
      "id": "slack-1",
      "name": "Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [680, 340]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Set').item.json.email }}",
        "subject": "=Thanks for reaching out, {{ $('Set').item.json.name }}!",
        "message": "=Hi {{ $('Set').item.json.name }},\n\nThanks for getting in touch. I'll follow up shortly.\n\nBest,\nYour Name",
        "options": {}
      },
      "id": "email-1",
      "name": "Send Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [680, 480]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "{\"status\":\"ok\"}"
      },
      "id": "respond-1",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [460, 480]
    }
  ],
  "connections": {
    "Webhook": { "main": [[{ "node": "Set", "type": "main", "index": 0 }, { "node": "Respond to Webhook", "type": "main", "index": 0 }]] },
    "Set": { "main": [[{ "node": "Airtable", "type": "main", "index": 0 }, { "node": "Slack", "type": "main", "index": 0 }, { "node": "Send Email", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Swap in your Airtable Base ID, Slack channel, and Gmail credentials and it runs. If you hit any snags importing, drop a reply and I'll help debug.