Skip to content
Skip to main content
Supabase + n8n Lead Database Tutorial
12 min readBy Carlos Aragon

Supabase + n8n: Build a Serverless Lead Database in 1 Hour

One webhook. Any lead source. One Postgres table that never loses a contact. Here's the exact stack I built at VIXI to unify leads from Retell AI, Typeform, Meta Ads, and manual entries — and why it cost us almost nothing to run.

The Problem: Leads Disappearing Into Spreadsheet Chaos

Before we fixed this, our lead data was scattered across at least five places. Typeform responses lived in one Google Sheet. Retell AI call outcomes sat in another. Meta lead ad exports came in as CSV files on Tuesdays. Manual entries from sales calls were in a Notion table that three people edited simultaneously. And somewhere in the middle of all that, a few hundred leads just fell through the cracks entirely.

We measured it one quarter and found we were losing about 20% of leads to what I call spreadsheet chaos — not because anyone was negligent, but because the data was never in one place, never normalized, and never queryable in any useful way. When you're running a multi-channel lead gen operation out of Allen, TX with AI agents firing webhooks at all hours, that leakage adds up fast.

The real cost isn't just the missed follow-ups. It's the duplicate outreach. It's calling someone who came in twice from two different sources and not knowing they're the same person. It's not being able to answer the simple question “which channel is closing the most leads?” without spending an afternoon reconciling spreadsheets.

The fix was obvious once I saw it: a single webhook endpoint in n8n, a normalized Supabase table, and a deduplication rule. Everything from every source flows into one place. No custom backend code. No infrastructure to manage. The whole thing costs roughly zero dollars per month at our scale.

What you'll have at the end of this tutorial:

  • A leads table in Supabase with RLS policies and proper indexing
  • An n8n webhook workflow that accepts leads from any source
  • Normalization logic that maps messy input to clean records
  • Email-level deduplication at the database layer
  • Optional Slack notification on every new lead

Prerequisites: Supabase account (free tier works), n8n (cloud or self-hosted), 30–60 minutes.

Stack Overview: What We're Building

Supabase is Postgres-as-a-service with a generous free tier, built-in auth, realtime subscriptions, and a dashboard that makes table management painless. For this project we're using it purely as a database — no auth, no storage, just a Postgres table accessed via its REST API.

n8n is the workflow automation layer. It's self-hostable (I run mine on a $6/month VPS), has 400+ integrations, and its visual editor makes it easy to see exactly what data is flowing where. If you're on n8n Cloud, the free tier covers what we need here.

The architecture is simple:

// Data flow

[Form / Retell / Meta Ads / Manual]

↓ HTTP POST

[n8n Webhook]

↓ normalize + validate

[n8n Code Node]

↓ upsert (dedup on email)

[Supabase leads table]

↓ optional

[Slack notification]

The key insight is the upsert. We're not doing a plain INSERT — we're using Supabase's conflict resolution to merge duplicate emails instead of creating duplicate rows. If the same person comes in from Retell AI and then again from a Typeform three days later, their record gets updated with the latest metadata. One row per contact, always.

Setting Up Supabase: Schema & RLS

Create a new Supabase project, then open the SQL Editor. Run this schema exactly — we'll walk through each decision below.

supabase/schema.sqlSQL
create table leads (
  id uuid default gen_random_uuid() primary key,
  created_at timestamptz default now(),
  updated_at timestamptz default now(),
  email text unique not null,
  name text,
  phone text,
  source text,        -- 'retell', 'typeform', 'meta_ads', 'manual'
  status text default 'new',
  metadata jsonb,     -- raw payload from source
  notes text,
  assigned_to text    -- agent email or team member
);

-- Indexes for common query patterns
create index on leads(status);
create index on leads(source);
create index on leads(created_at desc);
create index on leads(email);

-- Auto-update updated_at on changes
create or replace function update_updated_at_column()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end;
$$ language plpgsql;

create trigger set_updated_at
  before update on leads
  for each row
  execute function update_updated_at_column();

A few decisions worth explaining: email text unique not null is doing double duty — it enforces data quality (every lead needs an email) and enables the upsert dedup we'll use in n8n. The metadata jsonb column stores the raw payload from whatever source sent the lead. This is valuable. Six months from now you might want to backfill a field that was buried in metadata — if you stored the raw payload you can do that. If you didn't, you can't.

Now enable Row Level Security and add policies:

supabase/rls.sqlSQL
-- Enable RLS
alter table leads enable row level security;

-- Service role (n8n) can read and write everything
create policy "Service role full access" on leads
  using (auth.role() = 'service_role')
  with check (auth.role() = 'service_role');

-- Authenticated users can read leads assigned to them
create policy "Users see assigned leads" on leads
  for select
  using (assigned_to = auth.email());

After running this, go to Settings → API in your Supabase dashboard and copy your service_role key. This key bypasses RLS — which is exactly what we want for n8n. Keep it out of your frontend. Store it as an n8n credential, not hardcoded anywhere.

⚠️ Security note

Never expose your service_role key in client-side code. It bypasses all RLS. Only use it in server-side contexts like n8n, Edge Functions, or your backend API.

Building the n8n Workflow

Create a new workflow in n8n. We need four nodes: a webhook trigger, a normalization code node, a Supabase upsert via HTTP Request, and an optional Slack notification. Here's each one.

Node 1: Webhook Trigger

Add a Webhook node. Set method to POST and authentication to Header Auth with a secret you define (store this as an n8n credential too). Set Response Mode to Immediately — you want to return 200 fast before doing any processing. Copy the webhook URL; you'll use it in every lead source.

Node 2: Normalize Data

Add a Code node. This is where we handle the messiness of different source formats. Retell AI sends a call object. Typeform sends an answers array. Meta Lead Ads has its own field names. We normalize all of it to the same shape before touching the database.

n8n Code Node — normalize.jsJavaScript
// Normalize across different source formats
const body = $input.first().json.body || $input.first().json;

function normalizeEmail(email) {
  return email ? String(email).toLowerCase().trim() : null;
}

function normalizePhone(phone) {
  if (!phone) return null;
  const digits = String(phone).replace(/\D/g, '');
  if (digits.length === 10) return `+1${digits}`;
  if (digits.length === 11 && digits[0] === '1') return `+${digits}`;
  return digits.length >= 7 ? `+${digits}` : null;
}

function extractName(body) {
  return (
    body.name ||
    body.Name ||
    body.full_name ||
    body.fullName ||
    [body.first_name, body.last_name].filter(Boolean).join(' ') ||
    ''
  );
}

const email = normalizeEmail(
  body.email || body.Email || body.emailAddress
);

if (!email) {
  throw new Error('No email found in payload');
}

return [{
  json: {
    email,
    name: extractName(body),
    phone: normalizePhone(body.phone || body.Phone || body.phoneNumber),
    source: body.source || body._source || 'webhook',
    metadata: body  // store entire raw payload
  }
}];

Node 3: Upsert to Supabase

Add an HTTP Request node. Configure it as follows:

  • Method: POST
  • URL: https://[your-project-ref].supabase.co/rest/v1/leads
  • Headers:
    • apikey: your service_role key
    • Authorization: Bearer [service_role_key]
    • Content-Type: application/json
    • Prefer: resolution=merge-duplicates
  • Body: Map from the Code node output (email, name, phone, source, metadata)

The Prefer: resolution=merge-duplicates header is Supabase's upsert instruction. When a POST hits a unique constraint (same email), instead of erroring it merges the new data into the existing row. That's your deduplication, handled at the database layer.

Node 4: Slack Notification (Optional)

Add a Slack node. Post to your #leads channel with a message like: “New lead: {{name}} ({{email}}) via {{source}}”. This is optional but it's genuinely useful — seeing leads flow in real-time is a good signal that your pipeline is healthy.

Connecting Your Lead Sources

Retell AI (Voice Agent Leads)

In your Retell dashboard, go to your agent's settings and add a Post Call Webhook pointing to your n8n URL. Retell sends a payload like this after each call:

Retell POST call webhook payloadJSON
{
  "call_id": "call_abc123",
  "from_number": "+12145551234",
  "to_number": "+12145559999",
  "call_status": "ended",
  "transcript": "Agent: Hi, I'm calling about...",
  "custom_data": {
    "email": "john@example.com",
    "name": "John Smith",
    "source": "retell"
  }
}

The normalization code handles this — it looks for email at both root level and nested inside custom_data. You'll want to configure your Retell agent to capture email during the call and write it to custom_data.

Typeform / Tally Forms

Both support native webhooks. In Typeform, go to Connect → Webhooks → Add Webhook and paste your n8n URL. The payload structure is different (answers are an array), so update your Code node to handle it:

// Typeform-specific extraction (add to normalization node)
if (body.form_response && body.form_response.answers) {
  const answers = body.form_response.answers;
  const emailAnswer = answers.find(a => a.type === 'email');
  const nameAnswer = answers.find(a => a.field?.title?.toLowerCase().includes('name'));

  if (emailAnswer) body.email = emailAnswer.email;
  if (nameAnswer) body.name = nameAnswer.text;
  body.source = 'typeform';
}

Meta Lead Ads

Use n8n's native Facebook Lead Ads trigger node. It handles the OAuth flow and delivers leads with normalized field names. Map full_namename and emailemail. Add source: 'meta_ads' before passing to the HTTP Request node.

Deduplication & Data Quality

We measured a 15–20% duplicate rate across our multi-channel lead gen at VIXI. Same person coming in from Meta Ads and then converting on a Typeform three days later — same email, two rows in the old spreadsheet system, two salespeople calling the same contact.

The upsert-on-email approach eliminates this completely. But deduplication only works if the emails are normalized consistently. Here's the full normalization logic we use:

function normalizeEmail(email) {
  if (!email) return null;
  // lowercase, trim whitespace, remove plus-addressing
  const base = String(email).toLowerCase().trim();
  // optional: strip Gmail plus addresses (foo+bar@gmail.com → foo@gmail.com)
  return base.replace(/+[^@]*(@gmail.com)$/, '$1');
}

function normalizePhone(phone) {
  if (!phone) return null;
  const digits = String(phone).replace(/\D/g, '');
  if (digits.length === 10) return `+1${digits}`;
  if (digits.length === 11 && digits[0] === '1') return `+${digits}`;
  return digits.length >= 7 ? `+${digits}` : null;
}

The Gmail plus-address stripping is optional but worth including — some people use email+form@gmail.com for tracking which makes them look like unique contacts when they're not. Strip it at the normalization layer.

For status tracking: the status column starts as new and moves through contactedqualifiedclosed. Update it either through the Supabase table editor or via another n8n workflow when your CRM or sales tool changes the status.

Querying & Using Your Lead Data

Once data is flowing in, the value comes from being able to query it. Here are the SQL queries we actually use in production:

Common queriesSQL
-- All new leads from the last 24 hours
select name, email, phone, source, created_at
from leads
where status = 'new'
  and created_at > now() - interval '24 hours'
order by created_at desc;

-- Lead volume by source (last 30 days)
select source, count(*) as total
from leads
where created_at > now() - interval '30 days'
group by source
order by total desc;

-- Close rate by source (for ROI analysis)
select
  source,
  count(*) as total,
  count(*) filter (where status = 'closed') as closed,
  round(
    count(*) filter (where status = 'closed') * 100.0 / count(*),
    1
  ) as close_rate_pct
from leads
where created_at > now() - interval '90 days'
group by source
order by close_rate_pct desc;

-- Uncontacted leads older than 48 hours (follow-up queue)
select name, email, phone, source, created_at,
  now() - created_at as age
from leads
where status = 'new'
  and created_at < now() - interval '48 hours'
order by created_at asc;

The close rate by source query is the one that actually changes behavior. When you can see that Retell AI leads close at 34% and cold email leads close at 8%, you know where to put budget. You can't get that insight from disconnected spreadsheets.

For real-time use cases — dashboards, sales notifications, Mission Control updates — Supabase's Postgres changes subscription is the right tool:

Real-time subscriptionJavaScript
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

// Subscribe to new leads in real-time
const channel = supabase
  .channel('leads-live')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'leads' },
    (payload) => {
      console.log('New lead:', payload.new);
      // Update your dashboard state here
      addLeadToState(payload.new);
    }
  )
  .subscribe();

// Cleanup on unmount
return () => {
  supabase.removeChannel(channel);
};

We use this in our Mission Control dashboard — every new lead pops up in real-time without a page refresh. It's one of those features that seems like a small thing until you're watching leads flow in from a campaign launch and you can see immediately which channels are converting.

What This Actually Unlocks

The lead database is useful by itself. But the real leverage comes from what you can build on top of it.

Once leads are in Supabase, you can trigger follow-up workflows based on age, source, or status — another n8n workflow that polls for leads older than 24 hours with status = 'new' and fires a Retell outbound call. You can build a simple Retool dashboard so your team can see lead status without direct database access. You can connect it to a CRM via API and keep both systems in sync. You can run Claude API calls against the lead notes field to automatically qualify or score leads before they ever reach a salesperson.

None of that is possible when your leads are scattered across five spreadsheets. All of it becomes straightforward once they're in one normalized Postgres table.

Cost breakdown at our scale (~500 leads/month):

  • Supabase free tier: $0 (500MB database, more than enough)
  • n8n self-hosted on VPS: ~$6/month (or n8n Cloud free tier)
  • Retool free tier for dashboard: $0
  • Total: ~$6/month to unify your entire lead operation

Want This Set Up for Your Agency?

We build and deploy this exact stack for VIXI clients — Supabase schema, n8n workflows, custom normalization for your lead sources, and a dashboard. Usually takes a day to go live.

Book a Call