Scenario
LinkedIn Lead Gen Forms capture high-quality B2B leads with job title, company size, and industry — but this data sits in Campaign Manager unused. This job pulls form responses, aggregates them by title cluster, company size tier, and industry vertical, then feeds each segment into Mavera’s persona discovery engine. The result is a set of Custom Personas grounded in who actually fills out your forms, not who you think your audience is.Architecture
Code
import os, requests, time
from collections import defaultdict
LI = os.environ["LINKEDIN_ACCESS_TOKEN"]
MV = os.environ["MAVERA_API_KEY"]
LI_BASE = "https://api.linkedin.com/rest"
MV_BASE = "https://app.mavera.io/api/v1"
LI_H = {"Authorization": f"Bearer {LI}", "LinkedIn-Version": "202401", "X-Restli-Protocol-Version": "2.0.0"}
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
SPONSORED_ACCOUNT_ID = "508000001"
# 1. Pull lead gen form responses (paginated)
responses, start = [], 0
while True:
r = requests.get(f"{LI_BASE}/leadFormResponses",
headers=LI_H,
params={
"q": "owner",
"owner": f"urn:li:sponsoredAccount:{SPONSORED_ACCOUNT_ID}",
"start": start, "count": 100,
})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 60)))
continue
r.raise_for_status()
data = r.json()
elements = data.get("elements", [])
responses.extend(elements)
if len(elements) < 100:
break
start += 100
time.sleep(0.5)
# 2. Extract and aggregate fields
segments = defaultdict(list)
for resp in responses:
answers = {a.get("fieldName", ""): a.get("fieldValue", "") for a in resp.get("answers", [])}
title = answers.get("jobTitle", "Unknown")
company_size = answers.get("companySize", "Unknown")
industry = answers.get("industry", "Unknown")
title_cluster = title.split(",")[0].strip()
segments[(title_cluster, industry, company_size)].append(answers)
# 3. Create personas from top segments
SIZE_MAP = {
"1-10": "Startup", "11-50": "Small Business", "51-200": "Mid-Market",
"201-500": "Mid-Market", "501-1000": "Enterprise", "1001+": "Enterprise",
}
created = []
for (title, industry, size), leads in sorted(segments.items(), key=lambda x: -len(x[1]))[:8]:
if len(leads) < 2:
continue
tier = SIZE_MAP.get(size, "Mid-Market")
emails = [l.get("email", "") for l in leads if l.get("email")]
p = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": f"LinkedIn Lead: {title} ({tier})",
"description": (
f"Derived from {len(leads)} LinkedIn lead gen responses. "
f"Role: {title}. Industry: {industry}. Company size: {size} ({tier}). "
f"These leads actively filled out a form — high intent."
),
"demographic": {
"job_titles": [title],
"industries": [industry],
"company_size": tier,
},
"psychographic": {
"intent_level": "high",
"source": "linkedin_lead_gen_form",
},
}).json()
created.append({"persona_id": p["id"], "title": title, "industry": industry, "tier": tier, "n": len(leads)})
time.sleep(0.3)
print(f"Created {len(created)} personas from {len(responses)} lead gen responses")
for c in created:
print(f" {c['title']} | {c['industry']} | {c['tier']} | N={c['n']} | {c['persona_id']}")
const LI = process.env.LINKEDIN_ACCESS_TOKEN;
const MV = process.env.MAVERA_API_KEY;
const LI_BASE = "https://api.linkedin.com/rest";
const MV_BASE = "https://app.mavera.io/api/v1";
const LI_H = { Authorization: `Bearer ${LI}`, "LinkedIn-Version": "202401", "X-Restli-Protocol-Version": "2.0.0" };
const MV_H = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const SPONSORED_ACCOUNT_ID = "508000001";
// 1. Pull lead gen responses (paginated)
const responses = [];
let start = 0;
while (true) {
const res = await fetch(
`${LI_BASE}/leadFormResponses?q=owner&owner=urn:li:sponsoredAccount:${SPONSORED_ACCOUNT_ID}&start=${start}&count=100`,
{ headers: LI_H }
);
if (res.status === 429) {
await new Promise(r => setTimeout(r, parseInt(res.headers.get("Retry-After") || "60", 10) * 1000));
continue;
}
if (!res.ok) throw new Error(`LinkedIn ${res.status}`);
const data = await res.json();
const elements = data.elements || [];
responses.push(...elements);
if (elements.length < 100) break;
start += 100;
await new Promise(r => setTimeout(r, 500));
}
// 2. Aggregate by segment
const segments = {};
for (const resp of responses) {
const answers = Object.fromEntries(
(resp.answers || []).map(a => [a.fieldName || "", a.fieldValue || ""])
);
const title = (answers.jobTitle || "Unknown").split(",")[0].trim();
const industry = answers.industry || "Unknown";
const size = answers.companySize || "Unknown";
const key = `${title}|${industry}|${size}`;
(segments[key] ??= []).push(answers);
}
// 3. Create personas
const SIZE_MAP = {
"1-10": "Startup", "11-50": "Small Business", "51-200": "Mid-Market",
"201-500": "Mid-Market", "501-1000": "Enterprise", "1001+": "Enterprise",
};
const created = [];
const sorted = Object.entries(segments).sort(([, a], [, b]) => b.length - a.length).slice(0, 8);
for (const [key, leads] of sorted) {
if (leads.length < 2) continue;
const [title, industry, size] = key.split("|");
const tier = SIZE_MAP[size] || "Mid-Market";
const p = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: `LinkedIn Lead: ${title} (${tier})`,
description: `Derived from ${leads.length} LinkedIn lead gen responses. Role: ${title}. Industry: ${industry}. Size: ${size} (${tier}). High intent — form fills.`,
demographic: { job_titles: [title], industries: [industry], company_size: tier },
psychographic: { intent_level: "high", source: "linkedin_lead_gen_form" },
}),
}).then(r => r.json());
created.push({ persona_id: p.id, title, industry, tier, n: leads.length });
await new Promise(r => setTimeout(r, 300));
}
console.log(`Created ${created.length} personas from ${responses.length} responses`);
created.forEach(c => console.log(` ${c.title} | ${c.industry} | ${c.tier} | N=${c.n} | ${c.persona_id}`));
Example Output
{
"total_responses": 847,
"personas_created": 6,
"segments": [
{ "title": "VP of Marketing", "industry": "Technology", "tier": "Enterprise", "n": 142, "persona_id": "per_li_vpm_01" },
{ "title": "Head of Growth", "industry": "SaaS", "tier": "Mid-Market", "n": 98, "persona_id": "per_li_hog_02" },
{ "title": "Marketing Manager", "industry": "Financial Services", "tier": "Enterprise", "n": 87, "persona_id": "per_li_mm_03" },
{ "title": "Director of Demand Gen", "industry": "Technology", "tier": "Mid-Market", "n": 64, "persona_id": "per_li_ddg_04" },
{ "title": "CMO", "industry": "Healthcare", "tier": "Enterprise", "n": 41, "persona_id": "per_li_cmo_05" },
{ "title": "Content Strategist", "industry": "Technology", "tier": "Small Business", "n": 29, "persona_id": "per_li_cs_06" }
]
}
Error Handling
r_ads_leadgen_automation scope required
r_ads_leadgen_automation scope required
Lead gen form responses need the
r_ads_leadgen_automation scope, which requires LinkedIn partner approval. Without it, the endpoint returns 403.Field name variations
Field name variations
LinkedIn lead gen form field names are defined per form. Common names:
jobTitle, companySize, industry, email. Inspect your form configuration if fields return empty.Deduplication
Deduplication
Leads can submit the same form multiple times. Deduplicate by email before aggregation for accurate persona counts.