Quick answer: You can send lead-quality updates from a Google Sheet to the Meta Conversions API with a free Google Apps Script: store your token in Script Properties, build a payload with the lead_id and hashed contact data, fire it when a status changes to Qualified or Converted, test with test_event_code, then verify in Events Manager.
If you run Meta lead ads, you probably already have a Google Sheet full of leads. Your sales team calls them, marks some as "Qualified", and celebrates the ones that become "Converted".
Here is the problem. Meta never sees any of that. As far as Meta's delivery system is concerned, a junk lead and a paying customer look exactly the same. So it keeps finding you more of whatever is cheapest to get.
The fix is to send those status updates back to Meta through the Conversions API (CAPI). In this tutorial, I will show you how to do it yourself, using nothing but Google Sheets and a free Google Apps Script. Every parameter in this guide comes from Meta's and Google's official documentation, and I have listed the sources (with the dates I checked them) at the end.
What is the Meta Conversions API, and why use it for leads?
The Conversions API is Meta's server-to-server way of sharing events with its ad system. Instead of a browser pixel firing on a web page, your own system sends a secure HTTP request with details of what happened.
For lead ads, Meta has a specific flavour of this called the Conversions API for CRM integration. Meta describes it as a way to "upload offline events from your customer relationship management (CRM) system" when you use Facebook or Instagram to generate leads.
Why bother? Because it unlocks the Conversion Leads performance goal. Meta says integrating your CRM and using this goal "may yield higher quality leads that are more likely to convert". In plain words: you teach Meta which leads actually matter, and it goes looking for more people like them.
Your Google Sheet can play the role of that CRM. It just needs the right columns and a small script.
Is your business a good fit?
Before you write any code, check Meta's own fit guidelines for the CRM integration. Meta lists these:
- You use Facebook or Instagram Lead Ads (Instant Forms).
- You store the 15 to 17 digit Meta Lead ID for each lead.
- You generate at least 200 leads per month.
- You can upload data at least once per day.
- The stage you want to optimise for happens within 28 days of the lead coming in.
- That stage has a conversion rate between 1% and 40%.
If you are below 200 leads a month, you can still send the events. Just expect the optimisation to have less to learn from.
Prerequisites
You will need:
- A Meta Business Suite (business portfolio) with admin access, and an ad account running lead ads with Instant Forms.
- A dataset (pixel) for CRM events. In Events Manager, click Connect Data Sources, choose CRM, then create a new dataset or convert an existing one. Meta recommends a new dataset so CRM events do not mix with your website events.
- Your dataset ID (pixel ID). You will find it in Events Manager. Meta's endpoint calls it
PIXEL_ID. - A Conversions API access token (we will generate it below).
- A Google Sheet where each lead row includes the Meta Lead ID.
- Basic comfort with Google Apps Script. You will paste code, not write it from scratch.
One important warning from Meta: the integration is tied to the dataset. Do not switch a completed integration to a different pixel, because that restarts the setup and training.
Step 1: Set up your Google Sheet layout
Create a tab called Leads with these headers in row 1:
| Column | Header | What goes in it |
|---|---|---|
| A | lead_id | The Meta Lead ID, stored as plain text |
| B | created_time | When the lead arrived (for your reference) |
| C | The lead's email, as captured | |
| D | phone | The lead's phone, as captured |
| E | status | New, Qualified or Converted (use a dropdown) |
| F | event_sent | Filled by the script: stages already sent to Meta |
| G | sent_at | Filled by the script: last successful send time |
| H | capi_response | Filled by the script: Meta's reply or error |
A few practical tips:
- Format column A as Plain text (Format > Number > Plain text) before any IDs land in it. Lead IDs are 15 to 17 digits long, and Sheets will otherwise turn them into numbers in scientific notation and quietly change the last digits.
- Add a dropdown to column E (Insert > Dropdown) with exactly
New,QualifiedandConverted. Typos like "qualifed" will simply be ignored by the script. - Never edit columns F to H by hand. The script uses them to remember what it already sent.
Where does the lead_id come from?
Meta's guide on finding the Lead ID says it is the id field of the lead if you download leads with a Webhook or the Graph API, or the leadgen_id field in the leadgen Webhook. Zapier, LeadsBridge and Make also expose it as the lead's id. If you manually download a CSV from Ads Manager, it is the first column, labelled id, and you may need to strip a leading label before using it.
Meta recommends sending the Lead ID with every event. It is the highest priority match key, and the system rejects events that carry an invalid one.
Step 2: Generate your Conversions API access token
Meta's getting started guide recommends generating the token in Events Manager:
- Open Events Manager and choose the dataset you will send CRM events to.
- Go to the Settings tab.
- Find the Conversions API section and click Generate access token under Set up manually. Follow the pop-up.
If you do not see the link, that is expected for some users. Meta notes the link is only visible to people with developer privileges for the business.
Meta also says that generating the token this way creates a Conversions API app and system user for you, and there is no App Review and no extra permissions to request. If you already have your own app and system user, you can instead generate a token in Business Settings by assigning the pixel to the system user and clicking Generate Token.
Copy the token somewhere safe for a minute. We are about to lock it away inside Apps Script.
Step 3: Store your secrets in Script Properties
Open your sheet and go to Extensions > Apps Script. Then open Project Settings (the gear icon) and scroll to Script Properties. Add these:
| Property | Example value | Required? |
|---|---|---|
| META_ACCESS_TOKEN | the token from Step 2 | Yes |
| META_PIXEL_ID | your dataset ID | Yes |
| META_API_VERSION | v26.0 | Optional (the code defaults to v26.0) |
| TEST_EVENT_CODE | the code from the Test Events tab | Only while testing |
Why not just paste the token into the code? Because anyone who can view the script could copy it. Google's PropertiesService keeps it out of the source. Script properties are shared by everyone who can run the script, so only give editor access to people you trust.
A note on the version: when I checked Meta's Graph API changelog on 4 November 2026, the latest version was v26.0, released on 29 July 2026. Meta asks you to update the version before it expires, so check the changelog every few months and change META_API_VERSION when needed.
Step 4: Understand the payload Meta expects
For lead ads, Meta publishes a dedicated payload specification. These are the fields that matter:
| Field | Value in our setup | Notes from Meta's docs |
|---|---|---|
event_name |
e.g. qualified_lead |
Free-form. Name your CRM stages. Send all stages, including the initial lead. |
event_time |
Unix time in seconds | When the stage changed. Up to 7 days in the past, and after the lead was created. |
action_source |
system_generated |
Required value for all Conversion Leads events. |
user_data.lead_id |
15 to 17 digit number | Do not hash. Highest priority match key. |
user_data.em |
SHA-256 hash | Trim spaces and lowercase before hashing. |
user_data.ph |
SHA-256 hash | Digits only, no leading zeros, include the country code, then hash. |
custom_data.event_source |
crm |
Required value for all Conversion Leads events. |
custom_data.lead_event_source |
e.g. Google Sheets |
The name of the tool the leads come from. |
event_id |
e.g. 1234567890123456_qualified_lead |
Optional, but recommended for deduplication. |
Here is what one event looks like, based on Meta's own example:
{
"data": [
{
"event_name": "qualified_lead",
"event_time": 1793800000,
"action_source": "system_generated",
"event_id": "1234567890123456_qualified_lead",
"user_data": {
"lead_id": 1234567890123456,
"em": ["<sha256 of the normalised email>"],
"ph": ["<sha256 of the normalised phone>"]
},
"custom_data": {
"lead_event_source": "Google Sheets",
"event_source": "crm"
}
}
]
}
It is sent as a POST request to:
https://graph.facebook.com/API_VERSION/PIXEL_ID/events?access_token=ACCESS_TOKEN
Two details trip people up:
- Send every stage, in order. Meta's example: if your campaigns generate 100 leads, it expects 100 initial lead events, then more events as leads become qualified and converted. If a lead reaches "Converted", the earlier stages should already have been sent. The script below handles this for you.
- Only lead-ad leads belong here. Meta warns that this payload specification is only for Conversion Leads events tied to Meta lead ads. Do not reuse it for website form leads.
Step 5: Add the Apps Script code
In the Apps Script editor, replace everything in Code.gs with the code below. Read the comments; they explain each part.
Part A: Settings and helpers
/**
* Meta Conversions API (CRM / Conversion Leads) from Google Sheets.
* Sends a stage event to Meta when a lead's status changes.
*/
// ----- Sheet settings -----
const SHEET_NAME = 'Leads';
const COL = { // 1-based column numbers
LEAD_ID: 1, // A
CREATED: 2, // B
EMAIL: 3, // C
PHONE: 4, // D
STATUS: 5, // E
EVENT_SENT: 6, // F
SENT_AT: 7, // G
RESPONSE: 8 // H
};
// ----- Funnel settings -----
// Order matters: earlier stages are sent first if they were skipped.
// The event names are free-form; keep them stable once you go live.
const STAGES = [
{ status: 'New', eventName: 'initial_lead' },
{ status: 'Qualified', eventName: 'qualified_lead' },
{ status: 'Converted', eventName: 'converted' }
];
const DEFAULT_COUNTRY_CODE = '91'; // India; change for your market
const LEAD_EVENT_SOURCE = 'Google Sheets'; // the tool your leads live in
// Read secrets from Script Properties (never hard-code the token).
function getConfig_() {
const props = PropertiesService.getScriptProperties();
const token = props.getProperty('META_ACCESS_TOKEN');
const pixelId = props.getProperty('META_PIXEL_ID');
if (!token || !pixelId) {
throw new Error('Add META_ACCESS_TOKEN and META_PIXEL_ID in Script Properties.');
}
return {
token: token,
pixelId: pixelId,
apiVersion: props.getProperty('META_API_VERSION') || 'v26.0',
testEventCode: props.getProperty('TEST_EVENT_CODE') || ''
};
}
Part B: Normalise and hash
Meta is strict about normalisation. An email must be trimmed and lowercased. A phone number must be digits only, with no leading zeros, and must include the country code, "even if all of your data is from the same country". Only then do you hash with SHA-256.
// SHA-256 hash, returned as a lowercase hex string.
function sha256_(value) {
const bytes = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
value,
Utilities.Charset.UTF_8
);
// computeDigest returns signed bytes (-128..127); convert each to 2 hex chars.
return bytes.map(function (b) {
return ('0' + (b & 0xff).toString(16)).slice(-2);
}).join('');
}
// Email: trim and lowercase (per Meta's customer information parameters).
function normaliseEmail_(raw) {
const email = String(raw || '').trim().toLowerCase();
return email.indexOf('@') > 0 ? email : '';
}
// Phone: digits only, drop leading zeros, make sure the country code is present.
function normalisePhone_(raw) {
let digits = String(raw || '').replace(/\D/g, ''); // remove +, spaces, dashes, brackets
digits = digits.replace(/^0+/, ''); // remove leading zeros
if (digits.length === 10) {
digits = DEFAULT_COUNTRY_CODE + digits; // 98XXXXXXXX becomes 9198XXXXXXXX
}
return digits.length >= 11 ? digits : ''; // too short to be a full number
}
// Lead ID: keep as text so no digits are lost. Meta's Lead IDs are 15 to 17 digits.
function cleanLeadId_(raw) {
const id = String(raw || '').replace(/^[a-z]+:/i, '').trim(); // strip a text prefix like "l:" if present
return /^\d{15,17}$/.test(id) ? id : '';
}
A quick sanity check: Meta's docs give John_Smith@gmail.com as an example input. After normalising it becomes john_smith@gmail.com, and the expected SHA-256 is 62a14e44f765419d10fea99367361a727c12365e2520f32218d505ed9aa0f62f. Run Logger.log(sha256_(normaliseEmail_('John_Smith@gmail.com'))) once in the editor and compare.
Part C: Build the payload
There is one subtle trap here. Meta's examples send lead_id as a number, but JavaScript numbers cannot safely hold every 17-digit ID. So we keep the ID as text while building the event, then write it into the JSON as raw digits.
// Build one CAPI event for a given lead row and stage.
function buildEvent_(leadId, email, phone, eventName) {
const userData = { lead_id: 'LEADID_' + leadId }; // placeholder, swapped for raw digits later
const em = normaliseEmail_(email);
if (em) userData.em = [sha256_(em)];
const ph = normalisePhone_(phone);
if (ph) userData.ph = [sha256_(ph)];
return {
event_name: eventName,
event_time: Math.floor(Date.now() / 1000), // Unix seconds, when the stage changed
action_source: 'system_generated', // required for Conversion Leads
event_id: leadId + '_' + eventName, // stable ID: same lead + same stage = same event
user_data: userData,
custom_data: {
lead_event_source: LEAD_EVENT_SOURCE, // required: the tool the leads come from
event_source: 'crm' // required value for Conversion Leads
}
};
}
// Turn the events into a JSON string, with lead_id written as a JSON number.
function buildBody_(events, cfg) {
const body = { data: events };
if (cfg.testEventCode) body.test_event_code = cfg.testEventCode; // testing only
return JSON.stringify(body).replace(/"LEADID_(\d+)"/g, '$1');
}
Part D: Send it and read the reply
// POST the events to the Conversions API and return the status code and parsed body.
function sendToMeta_(bodyString, cfg) {
const url = 'https://graph.facebook.com/' + cfg.apiVersion + '/' +
cfg.pixelId + '/events?access_token=' + encodeURIComponent(cfg.token);
const response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
payload: bodyString,
muteHttpExceptions: true // so we can log Meta's error instead of crashing
});
let json = {};
try { json = JSON.parse(response.getContentText()); } catch (err) { /* keep {} */ }
return { code: response.getResponseCode(), json: json };
}
A successful call returns a small JSON object with events_received, a messages list and an fbtrace_id. A failed call returns an error object with a message, a code and an fbtrace_id. We store a short version of either in column H.
Part E: Process a row, with dedup built in
This is the heart of the script. It works out which stages this row still needs, sends only those, and records them in event_sent. A lock stops two triggers from processing the same row at the same moment.
// Send any stages this row has reached but not yet sent.
function processRow_(sheet, row) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) return; // another run is busy; the hourly sweep will retry
try {
const values = sheet.getRange(row, 1, 1, COL.RESPONSE).getValues()[0];
const leadId = cleanLeadId_(values[COL.LEAD_ID - 1]);
const status = String(values[COL.STATUS - 1] || '').trim();
const alreadySent = String(values[COL.EVENT_SENT - 1] || '')
.split(',').map(function (s) { return s.trim(); }).filter(String);
// Find where this status sits in the funnel.
const stageIndex = STAGES.findIndex(function (s) { return s.status === status; });
if (stageIndex === -1) return; // not a status we report
if (!leadId) {
sheet.getRange(row, COL.RESPONSE).setValue('SKIPPED: missing or invalid lead_id');
return;
}
// Every stage up to the current one that has not been sent yet.
const pending = STAGES.slice(0, stageIndex + 1).filter(function (s) {
return alreadySent.indexOf(s.status) === -1;
});
if (pending.length === 0) return; // dedup: nothing new to send
const cfg = getConfig_();
const events = pending.map(function (s) {
return buildEvent_(leadId, values[COL.EMAIL - 1], values[COL.PHONE - 1], s.eventName);
});
const result = sendToMeta_(buildBody_(events, cfg), cfg);
if (result.code === 200 && result.json.events_received) {
const nowSent = alreadySent.concat(pending.map(function (s) { return s.status; }));
sheet.getRange(row, COL.EVENT_SENT).setValue(nowSent.join(','));
sheet.getRange(row, COL.SENT_AT).setValue(new Date());
sheet.getRange(row, COL.RESPONSE).setValue(
'OK: events_received=' + result.json.events_received +
(cfg.testEventCode ? ' (TEST)' : '') +
' fbtrace_id=' + (result.json.fbtrace_id || ''));
} else {
const e = result.json.error || {};
sheet.getRange(row, COL.RESPONSE).setValue(
('ERROR ' + result.code + ' code=' + (e.code || '') + ': ' +
(e.message || 'unknown error')).slice(0, 500));
}
} finally {
lock.releaseLock();
}
}
Notice how dedup works on two levels:
- In the sheet. A stage is added to
event_sentonly after Meta confirms it received it. Edit the same row ten times and the script still sends each stage once. - At Meta. Each event carries a stable
event_idmade from the Lead ID and the stage. Meta's docs say that when a batch is retried, events it already stored with anevent_id"are dropped as duplicates, with no double-counting".
Step 6: Trigger the script when the status changes
Here is something many tutorials get wrong. The simple onEdit(e) function will not work for this, because Google says simple triggers "can't access services that require authorization", and UrlFetchApp needs authorisation. You need an installable trigger.
We will use two triggers:
- An installable edit trigger that fires when someone changes the status column.
- An hourly time-driven trigger that sweeps the sheet and retries anything that failed or was missed.
Why the sweep? Google's docs say "script executions and API requests don't cause triggers to run". So if a tool like Zapier or another script updates the status for you, the edit trigger never fires. The hourly sweep catches those rows.
// Runs on every edit (installable trigger). Only reacts to the status column.
function onLeadStatusEdit(e) {
const sheet = e.range.getSheet();
if (sheet.getName() !== SHEET_NAME) return;
if (e.range.getColumn() > COL.STATUS || e.range.getLastColumn() < COL.STATUS) return;
// Handles single-cell edits and pasted ranges.
for (let r = e.range.getRow(); r <= e.range.getLastRow(); r++) {
if (r > 1) processRow_(sheet, r); // skip the header row
}
}
// Runs every hour. Catches rows updated by other tools and retries failures.
function sweepUnsent() {
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
const lastRow = sheet.getLastRow();
for (let r = 2; r <= lastRow; r++) {
processRow_(sheet, r); // does nothing for rows that are already up to date
}
}
// Run this ONCE from the editor to install both triggers.
function setupTriggers() {
// Remove old copies so you never end up with duplicate triggers.
ScriptApp.getProjectTriggers().forEach(function (t) {
const fn = t.getHandlerFunction();
if (fn === 'onLeadStatusEdit' || fn === 'sweepUnsent') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('onLeadStatusEdit')
.forSpreadsheet(SpreadsheetApp.getActive())
.onEdit()
.create();
ScriptApp.newTrigger('sweepUnsent')
.timeBased()
.everyHours(1)
.create();
}
Select setupTriggers in the function dropdown and click Run. Google will ask you to authorise the script to access the spreadsheet, connect to an external service and manage triggers. Approve it with the Google account that should own the integration, because installable triggers always run under the account of the person who created them.
Should you also send "New" leads?
Yes, if you can. Meta's docs ask for all stages, "including the raw lead". With the code above, any row whose status is New gets an initial_lead event on the next sweep. If a lead jumps straight to Converted, the script sends initial_lead, qualified_lead and converted together, in order.
Just remember the timing rules: event_time must be after the lead was created, and no more than 7 days in the past. If any event in a request is older than 7 days, Meta says it returns an error for the whole request. So do not let rows sit unsent for a week.
Step 7: Test with test_event_code
Never test on your live funnel blind. Use the Test Events tool:
- In Events Manager, open your CRM dataset and go to the Test Events tab.
- Copy the test code shown for server events. Meta notes this value can change over time, so grab it fresh each session.
- Paste it into the
TEST_EVENT_CODEscript property. - In your sheet, pick a row with a real, valid Lead ID and set its status to
Qualified. - Check column H. You should see
OK: events_received=... (TEST). - Go back to the Test Events tab. The event should appear after a short time.
One thing to know: Meta says events sent with test_event_code "are not dropped. They flow into Events Manager and are used for targeting and ads measurement purposes." So use real leads you genuinely qualified, not made-up data.
When everything looks right, delete the TEST_EVENT_CODE property. Meta says the field "should be used only for testing" and must be removed for production.
If you prefer to test the payload before touching code, Meta's CRM guide also suggests the Graph API Explorer link inside the Test Events tab, and the Payload Helper tool, which checks your JSON for errors.
Step 8: Verify in Events Manager (and a note on Event Match Quality)
Once live events are flowing:
- Overview tab. Meta says you should start seeing events in Events Manager within about an hour.
- Open an event and check its parameters. Meta's guide says each CRM event must show
lead_event_sourceandevent_source. Without them, it will not count as a Conversion Leads event. - CRM integration status. After about a day, a green check should appear next to the Send a CRM event step if Meta detects a valid event.
- Lead Coverage. Meta wants at least 60% of your leads to have matching events. You can see it via View Reports in the dataset's Settings tab. Sending the initial lead stage with the Lead ID is the best way to raise it.
- Diagnostics tab. Any integration errors show up here with instructions to fix them.
- The 7-day validation. Meta's FAQ says you must keep sending at least seven days of data (not necessarily in a row) with at least two funnel stages, ideally three, before you can move on to configuring your sales funnel.
What about Event Match Quality (EMQ)? EMQ is Meta's score, out of 10, for how well your customer information matches events to Meta accounts. Meta recommends aiming for 6.0 or higher. However, Meta's Help Center says EMQ is available for website events sent with the action source set to website. Because our events use system_generated, you may not see an EMQ score for them. For lead-ad CRM events, the Lead ID, Lead Coverage and the Diagnostics tab are the signals to watch. Sending hashed email and phone alongside the Lead ID still helps matching.
Troubleshooting common errors
| What you see | Likely cause | What to do |
|---|---|---|
ERROR 400 code=190 |
Access token expired, revoked or invalid | Generate a new token in Events Manager and update META_ACCESS_TOKEN. |
An error mentioning event_time |
An event is more than 7 days old | Send stage changes promptly. Never fake timestamps to backfill. |
An error about lead_id |
The ID is wrong, truncated or not from a Meta lead ad | Check column A is plain text and holds the full 15 to 17 digits. |
SKIPPED: missing or invalid lead_id |
Blank ID, or Sheets converted it to scientific notation | Re-paste the ID into a plain text cell. |
OK in column H but nothing in Test Events |
TEST_EVENT_CODE missing or out of date |
Copy the current code from the Test Events tab. |
| Status changed but nothing happened | The edit came from another script, Zapier or the API | Wait for the hourly sweep, or run sweepUnsent manually. |
| Events arrive but are not counted for Conversion Leads | Missing event_source: crm, lead_event_source or action_source: system_generated |
Compare your payload with Step 4. |
code=4 or code=17 |
Throttling | Wait and retry. The hourly sweep does this for you. |
| "Exception: Service invoked too many times" | You hit an Apps Script daily quota | See the limits section below. |
For any Meta error, keep the fbtrace_id. Meta's error handling guide says it helps their support team find the log, though it expires shortly.
Privacy and security notes
- Hash all personal data. Meta requires email and phone to be normalised and hashed with SHA-256 before sending. The Lead ID is the exception: Meta says "Do not hash" it.
- Keep the token secret. Store it only in Script Properties. Do not paste it into the sheet, a shared doc or a chat. Anyone with the token can send events to your dataset. If it leaks, generate a new one.
- Limit who can edit the script. Script properties are visible to everyone who can run the script.
- Consent and notice. Your lead form's privacy policy and any consent wording should cover sharing lead outcomes with advertising platforms. In India, check your obligations under the Digital Personal Data Protection Act, 2023. In other regions, follow your local rules. This is not legal advice, so ask your advisor if you are unsure.
- Send the minimum. You do not need names, addresses or deal values for this integration. The Lead ID plus hashed email and phone is enough.
Limits of the manual approach
This setup works, and it is a great way to understand CAPI. But be honest with yourself about its limits:
- Apps Script quotas. For a free Google account, Google lists 20,000 URL Fetch calls per day, 90 minutes of total trigger runtime per day, and 6 minutes per execution. A big sheet swept every hour can hit these.
- No status history. Meta wants every stage change. If someone flips a lead from New to Converted and back within an hour, the sheet only keeps the current value.
- Fragile data. One person sorting the sheet badly, deleting column F, or pasting IDs as numbers can break things quietly.
- Single owner. The triggers run as the person who created them. If that person leaves the company or loses access, the integration stops.
- Version upkeep. You have to update the Graph API version yourself before it expires.
- No alerts. Errors only show in column H unless you build notifications.
- You still need the leads in the sheet. This tutorial assumes the Lead ID is already there. Getting leads from Meta into Sheets in real time is a separate job.
The no-code option
If you would rather not maintain scripts, a tool can handle both directions for you. LeadSmart (leadsmart.pro), the product my team built, captures Meta lead-ad leads into Google Sheets and sends lead-quality labels back to Meta through the Conversions API automatically. You map each status label (like Qualified or Converted) to a Meta event once, and it takes care of hashing, retries and dedup. LeadSmart is a verified Meta Tech Provider. Whichever route you pick, the principles in this guide stay the same.
FAQ
Do I need a website or the Meta Pixel code for this?
No. You need a dataset (pixel) in Events Manager to receive the events, but you do not need any code on a website. The CRM integration is server-to-server and works with lead ads that use Instant Forms.
Which action_source should I use for lead-ad CRM events?
Use system_generated. Meta's Conversion Leads payload specification says to set this value for all Conversion Leads events, together with event_source: crm and a lead_event_source inside custom_data.
Should I hash the lead_id?
No. Meta's customer information parameters page says "Do not hash" for lead_id. Hash email, phone and other personal details with SHA-256 after normalising them.
How often should I send events?
At least once a day, and ideally in real time. Meta recommends daily uploads at minimum. Events must be no more than 7 days old when you send them, and a single request can hold up to 1,000 events.
Can I backfill old leads?
Only up to 7 days. Meta warns not to change event_time values to backfill older data, because the model relies on accurate timestamps and the events may be discarded.
Why don't I see an Event Match Quality score?
Meta's Help Center describes EMQ as available for website events with the action source set to website. Lead-ad CRM events use system_generated, so watch Lead Coverage and the Diagnostics tab instead.
Conclusion
Sending lead quality back to Meta is one of the most useful things you can do for a lead-ads account. Without it, Meta optimises for form fills. With it, Meta can learn what a good lead looks like for your business.
The manual Google Sheets route is very doable: a clean sheet layout, a token in Script Properties, a small Apps Script that normalises, hashes and sends each stage once, and two triggers. Test with test_event_code, check Events Manager, and keep an eye on the Diagnostics tab.
Start with one campaign, get your first green check, and build from there. If you have questions about your own setup, feel free to reach out to me through Ndimensions.
Sources
All sources were checked on 4 November 2026. The "updated" dates are those shown on each page at that time.
- Meta for Developers, "Get started with the Conversions API" (updated 28 June 2026): https://developers.facebook.com/docs/marketing-api/conversions-api/get-started
- Meta for Developers, "Conversions API for CRM integration" (updated 27 October 2026): https://developers.facebook.com/docs/marketing-api/conversions-api/conversion-leads-integration
- Meta for Developers, "Conversion Leads: Payload Specification": https://developers.facebook.com/documentation/ads-commerce/conversions-api/conversion-leads-integration/payload-specification
- Meta for Developers, "3: Developer Implementation" (CRM integration): https://developers.facebook.com/documentation/ads-commerce/conversions-api/conversion-leads-integration/crm-integration/3-implementing-the-crm-integration
- Meta for Developers, "2: Getting Started With the CRM Integration": https://developers.facebook.com/documentation/ads-commerce/conversions-api/conversion-leads-integration/crm-integration/2-getting-started-with-integration
- Meta for Developers, "4: Verify Your Data": https://developers.facebook.com/documentation/ads-commerce/conversions-api/conversion-leads-integration/crm-integration/4-verify-your-data
- Meta for Developers, "How to Find the Meta Lead ID": https://developers.facebook.com/documentation/ads-commerce/conversions-api/conversion-leads-integration/how-to-find-the-lead-id
- Meta for Developers, "Conversions API FAQ" (Conversion Leads): https://developers.facebook.com/documentation/ads-commerce/conversions-api/conversion-leads-integration/faq
- Meta for Developers, "Customer Information Parameters" (updated 9 January 2026): https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
- Meta for Developers, "Server Event Parameters" (updated 15 October 2026): https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event
- Meta for Developers, "Using the Conversions API" (updated 17 July 2026): https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api
- Meta for Developers, "Handling Duplicate Pixel and Conversions API Events" (updated 28 June 2026): https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events
- Meta for Developers, "Verifying Your Setup": https://developers.facebook.com/docs/marketing-api/conversions-api/verifying-setup
- Meta Business Help Center, "About Event Match Quality": https://www.facebook.com/business/help/765081237991954
- Meta for Developers, Graph API "Changelog" and "Versions" (latest version v26.0, released 29 July 2026): https://developers.facebook.com/docs/graph-api/changelog
- Meta for Developers, Graph API "Handling Errors": https://developers.facebook.com/docs/graph-api/guides/error-handling
- Google for Developers, Apps Script "Class UrlFetchApp" (last updated 15 May 2026): https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app
- Google for Developers, Apps Script "Class Utilities" (computeDigest) (last updated 13 April 2026): https://developers.google.com/apps-script/reference/utilities/utilities
- Google for Developers, Apps Script "Installable Triggers" (last updated 3 September 2026): https://developers.google.com/apps-script/guides/triggers/installable
- Google for Developers, Apps Script "Simple Triggers" (last updated 3 September 2026): https://developers.google.com/apps-script/guides/triggers
- Google for Developers, Apps Script "Class PropertiesService" (last updated 13 April 2026): https://developers.google.com/apps-script/reference/properties/properties-service
- Google for Developers, Apps Script "Quotas for Google Services" (last updated 3 September 2026): https://developers.google.com/apps-script/guides/services/quotas
- Google for Developers, Apps Script "Class LockService": https://developers.google.com/apps-script/reference/lock/lock-service