Case studies
CASE STUDY — Marketing

Lead capture automation — deciding who's a duplicate before the CRM ever sees them

Deployed at B2B SaaS5 days to build
WebhookPostgresSlackCRM (optional)

Landing forms, ad leads, and trade-show lists each arrived in their own spreadsheet. Sales began every morning by tidying data, and leads went cold while they did it.

The bottleneck was the copying

Selling is about responding, yet the hours went into collating, de-duplicating, and CRM data entry. A lead took a day to get registered, and first contact came after that.

So every channel was funnelled into one flow that reaches the CRM without anyone touching it.

lead-capture.workflowLIVE
FMForms · adsGSCollect · cleanDDDeduplicateCRMCRM sync

Pipeline

  • Every inbound channel collected through a single webhook
  • Automatic merging on email and phone number
  • Lead score and source tags applied, then synced to the CRM in real time
  • Immediate notification to the owner when a genuinely new lead arrives

Deduplication — how do you recognise the same person?

"Merge on email and phone" is a single line to write and where most of the time actually disappears, because the same person arrives as different strings. These are the three rules this workflow really applies.

  1. Email normalisation — trim whitespace, lowercase everything, then strip any +tag. Hans+ads@Example.com and hans@example.com are one person. If your team tags addresses per ad channel, this single rule removes half your duplicates.
  2. Phone normalisation — keep digits only. Hyphens, brackets, and spaces go; a leading +82 or 82 country code is replaced with 0. +82 10 1234 5678, 010-1234-5678, and (010) 1234 5678 all become 01012345678.
  3. Merge precedence — use the normalised email as the identifier when present, otherwise the normalised phone. With neither, it isn't treated as a lead: it's marked `needs_review` and set aside. Never drop it silently — that's how a broken form goes unnoticed.
These rules are not perfect and cannot be. One person writing in from both a work and a personal address stays two records. That is not automatically solvable, so don't chase it — instead leave a path for a salesperson to merge manually in the CRM. The goal of automation here isn't 100%; it's reducing how much a human has to look at.

What you need

  • n8n — self-hosted or Cloud. Forms and ad platforms need to reach it, so a publicly addressable URL is required.
  • One Postgres database — for a single leads table. Enforcing uniqueness as a database constraint is the core of this design.
  • A Postgres credential — n8n → Credentials → Postgres. Docker n8n with a host database needs host.docker.internal.
  • A Slack Bot User OAuth Token — scopes chat:write and channels:read, for new-lead notifications.
  • A form or ad channel that can send leads — most form builders and ad platforms support webhooks. For one that doesn't, add a collection node just for it.
  • A CRM is optional — read the CRM section below first. This workflow is complete with nothing but a sheet or the database.

Step 1 — create the leads table

CREATE TABLE IF NOT EXISTS leads (
  dedupe_key   text PRIMARY KEY,   -- normalised email or phone; the duplicate defence
  name         text,
  email        text,
  phone        text,
  company      text,
  message      text,
  source       text,                -- landing / ads / expo ...
  utm_source   text,
  utm_campaign text,
  touch_count  integer     NOT NULL DEFAULT 1,   -- how often this person returned
  created_at   timestamptz NOT NULL DEFAULT now(),
  last_seen_at timestamptz NOT NULL DEFAULT now()
);
Making dedupe_key the PRIMARY KEY matters. The database prevents duplicates, not the workflow code. However buggy the workflow, however often a form double-submits, however many times an ad platform retries — one row. touch_count tells you how often someone came back, and a high count usually means a warm lead.

Step 2 — the merge query

INSERT INTO leads
  (dedupe_key, name, email, phone, company, message, source, utm_source, utm_campaign)
VALUES
  ('{{ $json.dedupe_key }}', '{{ $json.name }}', '{{ $json.email }}',
   '{{ $json.phone }}', '{{ $json.company }}', '{{ $json.message }}',
   '{{ $json.source }}', '{{ $json.utm_source }}', '{{ $json.utm_campaign }}')
ON CONFLICT (dedupe_key) DO UPDATE SET
  name         = COALESCE(EXCLUDED.name, leads.name),
  company      = COALESCE(EXCLUDED.company, leads.company),
  last_seen_at = now(),
  touch_count  = leads.touch_count + 1
RETURNING dedupe_key, name, email, phone, company, source,
          touch_count, (xmax = 0) AS is_new;

COALESCE(EXCLUDED.name, leads.name) means keep what you have when the incoming value is empty. A trade-show list arriving later with a name but no company won't wipe the company you already captured.

(xmax = 0) AS is_new is the most useful line in this query. In Postgres it's true for a row that was just INSERTed and false when an existing row was UPDATEd. That's what lets returning leads refresh their record while notifications fire only for genuinely new people. Without it, one person clicking your ad three times rings the sales channel three times.

When you need an approval gate

Firing an automatic email the instant a lead arrives can be risky. Real briefs frequently ask for "manual approval checkpoints so nothing goes out without me signing off first." If you don't want automatic sending, restructure like this.

  1. Don't wire a send node straight after Alert Only If New. Add an approved boolean DEFAULT false column to leads.
  2. Put the details an owner needs — company, enquiry text — into the Slack notification.
  3. When the owner approves, flip approved to true; a separate scheduled workflow picks up only approved rows and sends.
  4. To automate the approval step itself, collect it via buttons in your team messenger — our telegram-approval case covers exactly that structure.

Choosing a CRM — HubSpot isn't the answer everywhere

This workflow deliberately ships without a CRM node, because the right answer differs by market.

  • Overseas B2B SaaS — HubSpot, Salesforce, or Pipedrive are the natural fit. Add one HTTP Request node calling that CRM's create-contact API.
  • Small and mid-sized Korean companies mostly don't run those. In practice it's Google Sheets, Notion, ChannelTalk, or an in-house system. A workflow built around a CRM nobody uses is a workflow nobody uses.
  • If you have nothing, leave it out. The leads table already does a CRM's minimum job. Query with SQL, share by exporting a sheet, and attach a CRM when you actually need one. More projects stall trying to pick a CRM up front than fail from lacking one.
⬇︎ Download the workflow (lead-capture.json)
One Postgres credential, one Slack credential, and a channel name gets collection, normalisation, merging, and notification running. Add a single node for CRM sync when your environment calls for it.
Being precise about what was verified. The normalisation rules were executed in Node.js across 12 inputs — mixed-case and padded emails, +tag addresses, hyphenated/bracketed/spaced phone numbers, both +82 and 82 country codes, precedence when email and phone are both present, and three unidentifiable cases (neither field, a string with no @, a too-short number) correctly separated as needs_review. All passed. The merge query was run on PostgreSQL 17 by inserting the same lead three times, confirming one surviving row, `touch_count` rising 1→2→3, and `is_new` true only on the first pass. Node types and versions match our already-published workflows. Live Slack posting and live form/ad-platform integration were not verified. Last verified: 2026-08-15.

Setup (20 minutes)

  1. Create the table — run the Step 1 SQL.
  2. Register the Postgres credential — n8n → Credentials → PostgresTest for a green check.
  3. Register the Slack credential — create an app at api.slack.com/apps → OAuth & Permissions → add chat:write and channels:read → Install to Workspace → paste the xoxb- token into n8n → Credentials → Slack API.
  4. Invite the bot/invite @your-bot-name in your notification channel.
  5. Import the workflow — n8n → Workflows → ...Import from File.
  6. Set the channel and credentials — replace REPLACE_WITH_YOUR_CHANNEL_NAME_OR_ID in Notify Sales and attach credentials to both nodes.
  7. Activate — the Production webhook URL only goes live once activated.
  8. Copy the webhook URL — open Lead Webhook and copy the Production URL.
  9. Connect your forms and ad channels — paste that URL into each platform's webhook settings. Send fields named name, email, phone, company, message, source. Different names are fine — adjust the mapping in the Normalize node.
  10. Test the duplicate path — submit the form twice with the same email. Slack should notify once, and SELECT dedupe_key, touch_count FROM leads; should show touch_count at 2. Don't skip this check.

What happens in edge cases

  • A lead with a phone but no email — the phone becomes the identifier. Handled normally.
  • A submission with neither — marked needs_review: true. Nothing is silently dropped, so a broken form becomes visible. Review these periodically or route them to their own alert.
  • One person writing from two different addresses — stays two records. Not automatically solvable; give sales a manual merge path.
  • Spam from ad forms — this workflow is not a spam filter. At volume, segment by source and route only certain sources through an approval gate.
  • The CRM API being down — if you added CRM sync and that call fails, the lead is already safely in the `leads` table. That's precisely why the database write comes before the CRM push. Replay the unsent ones later.
  • A form double-postingON CONFLICT absorbs it. Only touch_count moves.
1 day → 7 min
time to first response
100%
CRM sync rate
Zero
manual collation
20 min
setup time
Slack knows before I do. I call them while they're still warm.Sales lead

Your operation belongs
in here, too.

Tell us the most repetitive task you have. We'll map an automation scenario for it.