Lead Generation

n8n WhatsApp + Supabase: Capture and Qualify Leads Without a Human

2026-10-20 · 8 min read

A WhatsApp message arrives at 11pm and nobody replies until morning - by then the prospect has booked elsewhere. Here is how to wire WhatsApp Business API into n8n and Supabase so every inbound message becomes a scored, stored lead the second it lands.

The gap nobody closes: a message is not a lead

Most businesses treat a WhatsApp message like an email - read it, reply it, forget it. That works right up until you get enough volume that replies slip, and suddenly your pipeline is a folder of screenshots nobody can query.

The real problem is not speed, it is structure. A message that arrives at 11pm is exactly as valuable as one that arrives at 11am, but only if it is captured the same way. Manual capture is also the most lossy thing in the funnel: you copy the number, you retype the requirement, you forget the intent, and three days later you cannot tell a hot lead from a price inquiry.

Automating the capture is not glamorous, which is exactly why most teams never do it. It is also the single highest-leverage automation in a service business.

The three pieces

Why not just a Google Sheet?

A Sheet is a fine prototype and a poor database. Sheets caps out around 10,000 cells of read throughput per API call, has no real concurrency control, and every inbound webhook writing to it needs its own de-duplication logic. The moment two people message you at the same second you get either a rate-limit error or a silently lost row. Supabase gives you a real relational store, an index on the phone number, and RLS so a multi-tenant setup cannot leak one shop's leads into another's view.

Step 1 - The webhook, and the verification trap

Meta does not just start sending. When you register the callback URL, Meta sends a GET request with a hub.challenge query parameter and expects the raw value echoed back in the body. If you point the callback at an n8n Webhook node that only accepts POST, verification fails and the whole integration is dead on arrival.

Two ways out. Set the n8n Webhook node to accept both methods and branch on webhook.httpMethod, or point Meta's callback at a tiny proxy that handles the challenge and forwards everything else. The second is cleaner if you ever need to add signature verification in front.

Once it works, the inbound payload arrives with the shape you would expect:

{
  "entry": [{
    "changes": [{
      "value": {
        "messages": [{
          "from": "[PHONE_PHONE]",
          "text": { "body": "How much for a landing page automation?" },
          "timestamp": "[PHONE]"
        }],
        "contacts": [{ "profile": { "name": "Dana" } }]
      }
    }]
  }]
}

The extraction node that turns that into something a workflow can use is short:

const body = $input.item.json.body;
const value = body.entry?.[0]?.changes?.[0]?.value;
const message = value?.messages?.[0];
const contact = value?.contacts?.[0];

return {
  phone: message?.from,
  name: contact?.profile?.name,
  text: message?.text?.body,
  ts: message?.timestamp
};

Do not trust the phone number blindly

WhatsApp sends the sender in international format with no + and no spaces. Store it normalized - E.164 with a leading + - or every lookup you write later will miss. The same message can also arrive from a number you have seen under two formats if the customer changed SIMs or used a landline gateway.

Step 2 - Qualify before you store, or after?

Store the raw message first, then qualify. That ordering matters: if your classifier breaks, or you rewrite the logic next month, you still have every original message to reprocess. A workflow that classifies before persisting throws away the evidence you need to fix the classifier.

Three branches cover most inbound volume:

An LLM node does the classification better than keyword matching, but keep the keywords path as a fallback - a model provider hiccup should not mean a lead goes uncaptured.

Step 3 - Upsert into Supabase

Use an upsert keyed on the phone number, not an insert. The same prospect asks a follow-up question two days later, and you want one row with a history - not two rows that split their thread.

// n8n Supabase node - upsert on phone_number
{
  "phone_number": "+[PHONE_PHONE]",
  "full_name": "Dana",
  "last_message": "How much for a landing page automation?",
  "intent": "hot",
  "status": "new",
  "score": 82,
  "last_seen_at": "2026-10-20T11:03:22Z"
}

The upsert has to not clobber fields a human already edited. If you always write the whole row, a lead your team just renamed to "Dana - Agency (budget 3k)" loses that context the next time the prospect sends a message. Either write only the columns you computed, or use on_conflict with an update mask that excludes human-owned columns.

The multi-tenant trap

One CRM, several shops, one database. The moment you add a second tenant, every query needs a tenant filter - and every query you forget becomes a data leak between businesses.

Supabase's Row Level Security fixes this at the database level rather than the application level. The policy reads the tenant from the JWT, not from a parameter the client controls, so a user cannot select a different tenant by changing the request:

-- The policy everyone should copy
create policy "tenant_isolation" on leads
  for all
  using (
    tenant_id = (
      select tenant_id from profiles
      where id = auth.uid()
    )
  );

Two rules make this actually hold. First, enable RLS on the table and verify it is enabled - a table with policies but RLS off has no protection at all. Second, the service-role key bypasses RLS entirely, so never ship it to the browser or use it for user-facing queries; it is for admin scripts only.

The bug that leaks a tenant

Using the service_role key in the browser bundle. It bypasses every RLS policy, ships in plain JavaScript to every visitor, and turns your policy from "the second line of defence" into "documentation". Use the anon key client-side and let RLS do the work.

Verify it before you trust it

The only test that matters is a negative one. Create two tenants, insert a lead in each, then query as tenant A and confirm you get A's rows and nothing else. A test that only proves the happy path proves nothing - a missing RLS policy and a working one look identical from the inside.

Then check the webhook is actually delivering: log the raw payload at the entry node, send yourself a WhatsApp message, and confirm the row appears. Most "the integration is broken" reports are a webhook pointed at the wrong URL, not a broken workflow.

Where this goes next

Once capture is solid, the same pattern works for every channel you answer on - Telegram, Instagram, email. Normalize each into one lead shape, and the qualification logic becomes one workflow instead of three. That is the point of building the schema first: the connectors change, the pipeline does not.

Want the workflow, not the write-up?

Every Matemplates workflow imports into your own n8n instance — you own it, run it free, and edit it. Grab one and ship today.

Browse the store →