Table of Contents
13 sections
How to Send Qualified Leads Back to Meta with n8n
Here's a question for you. Last month, how many of your Meta leads actually picked up the phone and turned out to be real?
Now the harder question. Does Meta know which ones those were?
It doesn't. And that's costing you money every single day.
1. The Problem: Meta Is Flying Blind
When you run Lead Ads with Instant Forms, Meta's job ends the moment someone hits Submit. A form fill is a "lead." That's the only signal the algorithm gets.
So Meta optimizes for people who fill forms. Not people who answer your call. Not people who book. Not people who pay.
You know the result. The sheet fills up with leads. Your team calls them. Half are wrong numbers, time-wasters, or people who "just wanted to check." You mark them Lost. Meta never hears about any of it, so it happily goes and finds you more of the same.
These are ghost leads. You paid for them. They were never real.
2. The Fix: Conversions API for CRM
Meta has a feedback channel built exactly for this. It's called the Conversions API for CRM, and it powers an optimization goal called Conversion Leads.
The idea is simple. Every lead from an Instant Form gets a unique ID from Meta itself, the leadgen_id. It's a 15 to 17 digit number, and if your leads land in a Google Sheet through any decent integration, it's already sitting in your id column looking something like l:1993202861289031.
When your team qualifies a lead, you send that ID back to Meta with one message: "this one was good."
Do this consistently, and you unlock the Conversion Leads optimization goal on your ad sets. From that point, Meta stops chasing form-fillers and starts hunting for people who look like your qualified leads. Same budget. Better quality. That's the entire game.
No names, no phone numbers, no hashing headaches. Just the ID Meta generated in the first place. Meta already has the lead. You're just telling it how the story ended.
3. What You Need Before You Start
- A Google Sheet with your leads, including the Meta lead ID column and a status column where your team marks leads as QUALIFIED (or whatever word you use).
- n8n. Cloud or self-hosted, both work. This is our automation engine.
- Admin access to Events Manager for the pixel/dataset connected to your lead campaigns. You need it to generate an access token.
- Volume. Meta recommends 200+ leads per month for the Conversion Leads goal to perform. Below that, still send the data. The signal compounds.
4. The Architecture (5 Nodes, One Job)
The whole workflow is five nodes:
- Schedule Trigger → fires automatically (weekly works, daily is better, more on that below)
- Google Sheets (Read) → pulls all your lead rows
- Code node → filters for qualified leads that have not been sent yet, builds the payload
- HTTP Request → POSTs the events to Meta's Conversions API
- Code node + Google Sheets (Update) → stamps a
meta_syncedtimestamp on each sent row so nothing ever gets sent twice
That last part matters. The deduplication is ours, not Meta's. Meta will happily accept the same event again and again. The timestamp column is what guarantees one send per lead. And because we stamp after a confirmed send, a failed run retries automatically next time. Send first, stamp second. That ordering is deliberate.
5. Step-by-Step Build
Step 1: Add One Column to Your Sheet
Open your leads sheet. In the header row, add a column named exactly meta_synced. Lowercase, one word, underscore. Leave it empty. The workflow fills it.
Step 2: Get Your Dataset ID and Access Token
Go to Events Manager and make sure you're in the right business. Every pixel is also a "dataset" that accepts server events.
- Pick the pixel/dataset connected to the ad account running your lead campaigns. Copy its ID (a long number).
- Open it → Settings tab → scroll to the Conversions API section → Generate access token. Copy the token somewhere safe.
While you're in there: look for the CRM setup / Conversion Leads integration section and map your funnel stages (lead → qualified → converted). This tells Meta what your event_name values mean.
Step 3: Build the Workflow
Node 1 - Schedule Trigger. Set it to whatever cadence you want. Important constraint: Meta discards events whose event_time is older than 7 days. Weekly works, but a lead you qualify the day after a run waits almost a full week. Daily is the better default.
Node 2 - Google Sheets, Get Row(s). Connect your Google account, point it at your document and tab. No filters needed. We filter in code.
Node 3 - Code node. This does the thinking. Paste this (adjust the column names if yours differ):
// Filter rows: QUALIFIED, has a Meta lead id, not already synced
const rows = $input.all().map(i => i.json);
const toSend = rows.filter(r => {
const status = String(r.lead_status || '').toUpperCase();
const id = String(r.id || '').replace(/^l:/, '').trim();
return status.includes('QUALIFIED') && /^\d{15,17}$/.test(id) && !r.meta_synced;
});
if (!toSend.length) {
return []; // nothing new, workflow stops here
}
const now = Math.floor(Date.now() / 1000);
// Build raw JSON manually so 17-digit lead IDs keep full precision
const eventStrs = toSend.map(r => {
const leadId = String(r.id).replace(/^l:/, '').trim();
return `{"event_name":"qualified","event_time":${now},"action_source":"system_generated","user_data":{"lead_id":${leadId}},"custom_data":{"lead_event_source":"Google Sheets","event_source":"crm"}}`;
});
return [{
json: {
body: `{"data":[${eventStrs.join(',')}]}`,
rowIds: toSend.map(r => r.id),
count: toSend.length
}
}];
Why build the JSON as a raw string instead of a normal object? Because JavaScript numbers lose precision past 16 digits, and Meta lead IDs go up to 17. Let a JSON serializer touch a 17-digit ID and it silently rounds it. Meta then rejects the event for an invalid lead_id, or worse, matches nothing. The string trick sidesteps that completely.
Node 4 - HTTP Request. This is where most people break things, so follow it exactly:
- Method: POST
- URL:
https://graph.facebook.com/v23.0/YOUR_DATASET_ID/events - Query parameter:
access_token= your token from Step 2 - Send Body: ON → Body Content Type: Raw → Content Type: application/json
- Body (expression mode):
{{ $json.body }}
Node 5 - Code node ("one item per lead"). Paste this:
const rowIds = $('YOUR_PAYLOAD_NODE_NAME').first().json.rowIds;
return rowIds.map(id => ({ json: { id } }));
Replace YOUR_PAYLOAD_NODE_NAME with whatever you named Node 3.
Node 6 - Google Sheets, Update. Same document and tab. Column to match on: id. Map id to {{ $json.id }} and meta_synced to {{ $now.toFormat('yyyy-MM-dd HH:mm') }}.
6. The Payload Meta Actually Wants
Each event in the batch looks like this:
{
"event_name": "qualified",
"event_time": 1765526400,
"action_source": "system_generated",
"user_data": { "lead_id": 1993202861289031 },
"custom_data": {
"lead_event_source": "Google Sheets",
"event_source": "crm"
}
}
Three fields do the heavy lifting:
action_source: system_generatedtells Meta this is a CRM stage update, not a website conversion. It routes the event into the Conversion Leads pipeline instead of normal pixel attribution.event_source: crmconfirms it.event_nameis free-form. Meta treats it as a funnel stage label, which is exactly what the CRM stage mapping in Events Manager connects to.
Meta replies with {"events_received": 3, ...} and then asynchronously joins each lead ID back to the exact ad, ad set, and campaign that produced it. No GCLID-style cookie gymnastics. Exact ID match.
7. The Two Errors You Will Probably Hit
I'm giving you these because I hit both. Save yourself the hour.
Error 1: "(#100) The parameter data is required"
Meta returns HTTP 400 even though your token and URL are fine. The cause: your HTTP node is in JSON/key-value body mode, so n8n wrapped your payload one level too deep. Meta received { "body": "{\"data\":[...]}" } and couldn't find data.
The fix is in Step 3 above: Body Content Type must be Raw, content type application/json, body as the expression. Don't use "Using JSON" mode with a pre-built string either. It can re-escape your string and mangle the lead IDs.
Error 2: The sheet update silently processes 0 rows
Everything is green, Meta says events_received, but no timestamps get written. The cause: the HTTP Request node replaces the item flowing through the workflow with Meta's API response. Your rowIds field is gone. Anything downstream that expects it finds nothing and quietly does nothing.
That's why Node 5 reaches back to the payload node with $('Node Name').first().json.rowIds instead of reading its own input. Cross-node reference. Problem solved.
8. Test, Verify, Activate
- Click Execute workflow manually. Every node should go green.
- The HTTP node should return
events_receivedequal to your qualified count. - Your sheet should show fresh
meta_syncedtimestamps on exactly those rows. - Within a few minutes, Events Manager → your dataset → Overview should show
qualifiedevents with connection method Server. - Flip the workflow to Active.
Then the patient part. After 2 to 4 weeks of consistent events, edit your lead campaign ad sets and switch Optimization for ad delivery → Conversion Leads. That's the moment the feedback loop starts paying you back.
9. The Copy-Paste Prompts (Let AI Build It For You)
I didn't click through most of this by hand. I had an AI agent in the browser do the setup while I reviewed. If you're using Claude, ChatGPT with browsing, or any browser agent, here are the exact prompts. Replace the bracketed parts.
Prompt 1 - the initial setup:
Complete the setup of my n8n workflow that sends qualified leads to Meta. Do these steps in order:
STEP 1 - Add tracking column to Google Sheet
Open [YOUR SHEET URL]
In the "[YOUR TAB NAME]" tab, find the first empty column in row 1 (header row) and type exactly: meta_synced
(lowercase, one word, underscore)
STEP 2 - Get a Conversions API access token from Meta
Open https://business.facebook.com/events_manager2 and make sure the business "[YOUR BUSINESS NAME]" is selected.
Open the dataset "[YOUR PIXEL NAME]" (ID [YOUR DATASET ID]) → Settings tab → scroll to the "Conversions API" section → click "Generate access token". Copy the token.
STEP 3 - Configure the HTTP node in n8n
Go to my open n8n tab, open the workflow.
Open the node that posts to Meta:
- In the URL field, set: https://graph.facebook.com/v23.0/[YOUR DATASET ID]/events
- In Query Parameters, set access_token to the token you copied.
Save the node.
STEP 4 - Configure the sheet update node
Open the final Google Sheets node:
- Credential: select my Google Sheets credential
- Document: select my leads sheet from the list
- Sheet: select the right tab from the list
- Column to match on: id
- Map "id" to the expression {{ $json.id }} and "meta_synced" to {{ $now.toFormat('yyyy-MM-dd HH:mm') }}
Save the node.
STEP 5 - Test
Click "Execute workflow" (manual run). Report back:
- Did every node show a green tick?
- What did the Meta node return (events_received count or any error)?
- Did meta_synced timestamps appear in the sheet for QUALIFIED rows?
Do NOT activate the workflow yet - I'll review the test results first.
Prompt 2 - if you hit the "(#100) parameter data is required" error:
In my n8n workflow, open the HTTP node that posts to Meta and fix the request body:
1. Keep Method POST, URL and the access_token query parameter exactly as they are.
2. Make sure "Send Body" is ON.
3. Set "Body Content Type" to: Raw
4. Set "Content Type" to: application/json
5. In the "Body" field, switch to Expression mode and set it to: {{ $json.body }}
(the field should show a resolved preview starting with {"data":[{"event_name":"qualified",...)
6. If there is a manual Content-Type header under "Send Headers", remove it - the Raw content type already sends it.
7. Save, then click "Execute workflow" again and report the exact JSON response.
Prompt 3 - if events send but the sheet never updates:
In my n8n workflow, the Meta call succeeds but the sheet update processes 0 items, because the HTTP node replaced the item with Meta's response and the rowIds field is lost. Fix it:
1. Between the Meta HTTP node and the Google Sheets update node, add a Code node (JavaScript) with exactly this code:
const rowIds = $('[NAME OF YOUR PAYLOAD CODE NODE]').first().json.rowIds;
return rowIds.map(id => ({ json: { id } }));
2. In the Google Sheets update node, set the "id" match value to the expression: {{ $json.id }}
3. Save and execute. Expected: the Code node outputs one item per qualified lead, and each matching row gets a meta_synced timestamp.
4. If all rows are stamped, activate the workflow.
One honest warning: Prompt 1 puts a Meta access token in an AI agent's hands and pastes it in plain text inside n8n. That's how n8n HTTP nodes normally work, but treat that token like a password. Don't screenshot that node, and rotate the token if it ever leaks.
Frequently Asked Questions
Why not just upload a CSV to Events Manager every week?
A: Because CSV upload doesn't feed the Conversion Leads optimization goal.
Manual uploads record the conversions, but the real prize is switching your ad sets to Conversion Leads so the algorithm trains on your qualified leads. That requires the Conversions API with action_source: system_generated. And you'll forget to upload the CSV. Everyone does.
Do I need 200 leads a month for this to work?
A: You need it for the optimization goal, not for sending the data.
Meta recommends 200+ leads per month before the Conversion Leads delivery goal performs well. If you're below that, send the events anyway. The data accumulates, your stage mapping gets verified, and the day you cross the threshold you flip one setting instead of starting from zero.
What customer data leaves my sheet?
A: Only the lead ID Meta generated itself.
No names, no phone numbers, no emails. The lead_id is the highest-priority match key in Meta's own spec, and since Meta created it, there's nothing to hash and no fuzzy matching. You're not giving Meta new personal data. You're labeling data it already has.
Why does my qualified lead from 10 days ago not show up?
A: The 7-day window.
Meta discards events whose event_time is more than 7 days old, and the timestamp must be after the lead was generated. This is the strongest argument for a daily schedule instead of weekly. Fresher signal also means faster algorithm learning. There's no downside to daily.
Should I only send the QUALIFIED stage?
A: Start there, but the full funnel is the real unlock.
Meta's spec wants every stage as it updates: new lead, qualified, lost, converted. The model needs positives and negatives to learn what separates your real patient from the guy who disconnects the call. Once the qualified flow works, duplicate the filter logic for your other stages. The converted event for people who actually show up and pay is the strongest signal you can send.
Where do I find the lead ID in the first place?
A: It's the leadgen_id from your Instant Form.
Any integration that pipes Meta leads into a sheet or CRM (native CRM integrations, Zapier, Make, n8n's own Facebook Lead Ads trigger) carries it. It's a 15 to 17 digit number, sometimes prefixed like l:1993202861289031. If your current integration drops it, fix that first. Without the lead ID you're back to hashed phone/email matching, which works but matches fewer leads.
Let Us Find the Gaps in Your Lead Acquisition.
Is marketing complexity slowing down your practice?
If managing your growth feels overwhelming, you don't need another tool - you need a partner. We help medical practices and wellness businesses handle their end-to-end digital infrastructure.
We will deep-dive into your website and funnel to understand exactly where the problem is. Our team fixes the gaps in your positioning and deploys the Nomiris P2P Acquisition System to generate insane ROI:
- Complete Branding, Strategy & Gap Analysis
- High-Converting Landing Pages
- End-to-End Ad Management
- Lead Recovery & Nurturing
- ROI & Attribution Tracking
If you want to stop guessing and start scaling with a data-driven infrastructure, let’s connect.

Hemanth M Reddy
Author
End-to-End Performance Marketing Specialist. I don't just 'run ads'; I fix the foundation. From correcting broken conversion tracking to integrating CRMs, I ensure you own your data and stop wasting budget on ghost leads.