A Meta Ads → enrichment → scoring pipeline that actually routes leads
Paid social generates volume; it rarely generates context. A Meta lead ad hands you a name, an email, and maybe a company — not nearly enough to decide whether a rep should call today, tomorrow, or never. The gap between "a lead arrived" and "we know what to do with it" is where most paid budget quietly dies.
This is a concrete pipeline that closes that gap: Meta webhook → identity and firmographic enrichment (People Data Labs) → vendor-stack enrichment (VendorStacks) → a single composite score → routing. Every step is a normal HTTP call, and the whole thing runs in a serverless function.
The shape
Meta Lead Ad
└─> webhook (your endpoint)
├─> People Data Labs → person + company firmographics
├─> VendorStacks → vendor stack + evidence
└─> score() → route: rep / nurture / disqualify → CRMThe two enrichment calls are independent, so run them in parallel. The whole pipeline should complete well inside a second for indexed domains, which is fast enough to route before the lead cools.
Step 1 — receive the lead
Meta's lead ads webhook posts a leadgen_id; you exchange it for the field values via the Graph API. Verify the signature, then normalize to an email and a company domain — the domain is the join key for everything downstream.
// app/api/webhooks/meta/route.ts
export async function POST(req: Request) {
const body = await req.json();
const leadId = body.entry?.[0]?.changes?.[0]?.value?.leadgen_id;
const lead = await fetch(
`https://graph.facebook.com/v21.0/${leadId}?access_token=${process.env.META_TOKEN}`
).then(r => r.json());
const fields = Object.fromEntries(
(lead.field_data ?? []).map((f: any) => [f.name, f.values?.[0]])
);
const email = fields.email?.toLowerCase();
// Free-mail signups have no company domain — fall back to the typed company.
const domain = email?.includes("@") ? email.split("@")[1] : null;
await enrichAndRoute({ email, domain, name: fields.full_name });
return Response.json({ received: true });
}One practical note: strip free-mail domains (gmail, outlook, yahoo) before you spend an enrichment call on them. They're a large share of paid-social leads and they'll never resolve to a company.
Step 2 — enrich in parallel
People Data Labs answers who and what: the person's role and seniority, the company's size, industry, and location. VendorStacks answers what they run: the vendor stack across 13 categories, each match carrying the quoted source it came from.
const FREEMAIL = new Set(["gmail.com","outlook.com","hotmail.com","yahoo.com","icloud.com"]);
async function enrich(email: string, domain: string | null) {
const usable = domain && !FREEMAIL.has(domain);
const [person, stack] = await Promise.all([
fetch(`https://api.peopledatalabs.com/v5/person/enrich?email=${email}`, {
headers: { "X-Api-Key": process.env.PDL_KEY! },
}).then(r => r.ok ? r.json() : null).catch(() => null),
usable
? fetch(`https://api.vendorstacks.com/v1/check?url=${domain}`, {
headers: { Authorization: `Bearer ${process.env.VENDORSTACKS_KEY}` },
// unindexed domains trigger a live scan — allow for it
signal: AbortSignal.timeout(120_000),
}).then(r => r.json()).catch(() => null)
: null,
]);
return { person, stack };
}Two things worth knowing about the VendorStacks call. Unindexed domains trigger a live scan that can take 15–90 seconds, so set a generous timeout (or enqueue and score asynchronously for those). And you're only billed for successful results — if a domain returns no vendor data, that call costs nothing, which keeps paid-social's inevitable junk leads from becoming an enrichment bill.
Step 3 — score
The point of combining sources is that each one covers the other's blind spot. Firmographics tell you whether a company could buy. Stack data tells you whether they already buy things like yours — and whether a competitor is currently in the account.
const COMPETITORS = ["Twilio", "MessageBird", "Sinch"];
const COMPLEMENTS = ["Segment", "Snowflake", "Salesforce"];
function score({ person, stack }: Enriched) {
let points = 0;
const reasons: string[] = [];
// ── firmographic fit (People Data Labs)
const size = person?.job_company_size;
if (size && /51-200|201-500|501-1000/.test(size)) { points += 20; reasons.push("company size in ICP"); }
const title = (person?.job_title ?? "").toLowerCase();
if (/vp|head|director|chief|founder/.test(title)) { points += 20; reasons.push("senior title"); }
else if (/manager|lead/.test(title)) { points += 10; reasons.push("mid-level title"); }
// ── stack fit (VendorStacks)
const vendors = Object.values(stack?.vendor_stack ?? {}).flat() as string[];
const rival = vendors.find(v => COMPETITORS.includes(v));
if (rival) {
points += 35; // paying for the category already
reasons.push(`uses competitor: ${rival}`);
}
const overlap = vendors.filter(v => COMPLEMENTS.includes(v));
if (overlap.length) {
points += 10 * overlap.length;
reasons.push(`complementary stack: ${overlap.join(", ")}`);
}
if (vendors.length >= 8) { points += 10; reasons.push("technically mature stack"); }
return { points, reasons, evidence: stack?.subprocessor_urls ?? [] };
}Notice that the competitor signal is weighted highest. A company already paying for your category has proven budget, proven need, and a renewal date — that's a fundamentally better lead than a same-size company with no category spend at all.
Step 4 — route, with the evidence attached
async function route(lead: Lead, s: Score) {
const band = s.points >= 60 ? "sales" : s.points >= 30 ? "nurture" : "disqualify";
await crm.upsertLead({
email: lead.email,
domain: lead.domain,
score: s.points,
band,
// give the rep the "why" — and the citation
score_reasons: s.reasons.join("; "),
stack_evidence_urls: s.evidence.join(", "),
});
if (band === "sales") await slack.notify("#inbound", `🔥 ${lead.domain} — ${s.points}: ${s.reasons[0]}`);
}Write the reasons and the evidence URLs into the CRM record, not just the number. A score with no explanation gets ignored by reps within a week. A score that says "uses Twilio — see their trust page" gets used, because it hands the rep their opening line.
What this costs
Assume 1,000 leads a month, of which ~600 have a real company domain after free-mail filtering. That's ~600 VendorStacks credits — roughly $5.50 at the Starter pack, and less in practice because domains that return no vendor data aren't billed at all. People Data Labs is priced separately per match. Against even a modest paid-social budget, the enrichment layer rounds to zero.
Where teams get this wrong
Scoring synchronously on unindexed domains. If the domain isn't in the index yet, the live scan takes 15–90 seconds. Don't block the webhook response on it — return 200 immediately, enqueue the enrichment, and score when it lands.
Treating absence as absence. A found: false means we located no public evidence, not that the company uses nothing. Score it as unknown, never as a negative.
Over-weighting a single signal. The competitor flag is the strongest input, but a 12-person startup running your competitor still isn't an enterprise deal. Combine, don't substitute.
Get an API key and the pipeline above is maybe an afternoon of work. The API reference has the full response shape, and this post lists eleven other plays once the data is flowing.