Scenario
Your team launches LinkedIn Sponsored Content campaigns, but creative decisions rely on gut feel. This job pulls active creatives from your ad account, extracts the copy and imagery metadata, then runs a Mavera Focus Group with B2B personas asking “Rate this LinkedIn ad for relevance to your role.” You get Likert-scale scores and open-ended feedback before spending another dollar on distribution.Architecture
Code
import os, requests, time
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"}
AD_ACCOUNT_ID = "508000001"
# 1. Pull active creatives
r = requests.get(f"{LI_BASE}/adAccounts/{AD_ACCOUNT_ID}/creatives",
headers=LI_H,
params={"q": "search", "search.status.values[0]": "ACTIVE", "count": 20})
if r.status_code == 429:
retry_after = int(r.headers.get("Retry-After", 60))
time.sleep(retry_after)
r = requests.get(f"{LI_BASE}/adAccounts/{AD_ACCOUNT_ID}/creatives",
headers=LI_H,
params={"q": "search", "search.status.values[0]": "ACTIVE", "count": 20})
r.raise_for_status()
creatives = r.json().get("elements", [])
# 2. Extract ad content
ads = []
for cr in creatives:
content = cr.get("content", {})
text = content.get("textAd", {}).get("text", "")
headline = content.get("textAd", {}).get("headline", "")
intro = cr.get("intendedStatus", "")
commentary = cr.get("commentary", "")
if commentary or text:
ads.append({
"id": cr.get("id", ""),
"copy": commentary or text,
"headline": headline,
"format": cr.get("content", {}).get("contentType", "SINGLE_IMAGE"),
})
if not ads:
raise SystemExit("No active creatives found. Check ad account ID and token scopes.")
# 3. Create B2B personas
ROLES = [
{"title": "VP of Marketing", "desc": "Senior marketer evaluating MarTech. Budget authority. Cares about ROI and team efficiency."},
{"title": "Director of Sales", "desc": "Sales leader. Evaluates tools for pipeline acceleration. Skeptical of marketing fluff."},
{"title": "Product Manager", "desc": "Builds product roadmaps. Evaluates solutions for user adoption and feature alignment."},
{"title": "CFO / Finance Lead", "desc": "Controls budget. Needs clear ROI justification. Risk-averse to new vendors."},
]
persona_ids = []
for role in ROLES:
p = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": f"LinkedIn B2B: {role['title']}",
"description": role["desc"],
"demographic": {"job_titles": [role["title"]]},
}).json()
persona_ids.append({"id": p["id"], "title": role["title"]})
time.sleep(0.3)
# 4. Build stimulus from ads
stimulus = "\n\n---\n\n".join(
f"AD {i+1} ({a['format']}):\nHeadline: {a['headline']}\nCopy: {a['copy'][:400]}"
for i, a in enumerate(ads[:5])
)
# 5. Run Focus Group
fg = requests.post(f"{MV_BASE}/focus-groups", headers=MV_H, json={
"name": "LinkedIn Sponsored Content Review",
"persona_ids": [p["id"] for p in persona_ids],
"questions": [
f"Review these LinkedIn ads:\n\n{stimulus}\n\nRate each ad 1-5 for relevance to your role (1=irrelevant, 5=highly relevant). Explain your ratings.",
"Which ad would you most likely click on in your LinkedIn feed? Why?",
"What is missing from these ads that would make them more compelling for someone in your position?",
"If you saw this ad from a competitor, would it make you reconsider your current solution?",
],
"responses_per_persona": 2,
}).json()
# 6. Poll for results
for _ in range(20):
time.sleep(5)
data = requests.get(f"{MV_BASE}/focus-groups/{fg['id']}", headers=MV_H).json()
if data.get("status") == "completed":
break
for resp in data.get("responses", []):
title = next((p["title"] for p in persona_ids if p["id"] == resp.get("persona_id")), "?")
print(f"[{title}] {resp.get('question','')[:60]}...")
print(f" → {resp.get('answer','')[:300]}\n")
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 AD_ACCOUNT_ID = "508000001";
// 1. Pull active creatives
let res = await fetch(
`${LI_BASE}/adAccounts/${AD_ACCOUNT_ID}/creatives?q=search&search.status.values[0]=ACTIVE&count=20`,
{ headers: LI_H }
);
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get("Retry-After") || "60", 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
res = await fetch(
`${LI_BASE}/adAccounts/${AD_ACCOUNT_ID}/creatives?q=search&search.status.values[0]=ACTIVE&count=20`,
{ headers: LI_H }
);
}
if (!res.ok) throw new Error(`LinkedIn ${res.status}: ${await res.text()}`);
const creatives = (await res.json()).elements || [];
// 2. Extract ad content
const ads = creatives.map(cr => {
const text = cr.content?.textAd?.text || "";
const headline = cr.content?.textAd?.headline || "";
const commentary = cr.commentary || "";
return (commentary || text) ? {
id: cr.id, copy: commentary || text,
headline, format: cr.content?.contentType || "SINGLE_IMAGE",
} : null;
}).filter(Boolean);
if (!ads.length) throw new Error("No active creatives found.");
// 3. B2B personas
const ROLES = [
{ title: "VP of Marketing", desc: "Senior marketer. Budget authority. Cares about ROI." },
{ title: "Director of Sales", desc: "Sales leader. Evaluates pipeline tools. Skeptical of fluff." },
{ title: "Product Manager", desc: "Builds roadmaps. Evaluates for user adoption." },
{ title: "CFO / Finance Lead", desc: "Controls budget. Needs clear ROI. Risk-averse." },
];
const personaIds = [];
for (const role of ROLES) {
const p = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: `LinkedIn B2B: ${role.title}`,
description: role.desc,
demographic: { job_titles: [role.title] },
}),
}).then(r => r.json());
personaIds.push({ id: p.id, title: role.title });
await new Promise(r => setTimeout(r, 300));
}
// 4. Build stimulus
const stimulus = ads.slice(0, 5).map((a, i) =>
`AD ${i + 1} (${a.format}):\nHeadline: ${a.headline}\nCopy: ${a.copy.slice(0, 400)}`
).join("\n\n---\n\n");
// 5. Run Focus Group
const fg = await fetch(`${MV_BASE}/focus-groups`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: "LinkedIn Sponsored Content Review",
persona_ids: personaIds.map(p => p.id),
questions: [
`Review these LinkedIn ads:\n\n${stimulus}\n\nRate each ad 1-5 for relevance to your role. Explain your ratings.`,
"Which ad would you most likely click on in your LinkedIn feed? Why?",
"What is missing from these ads that would make them more compelling?",
"If you saw this from a competitor, would it make you reconsider your current solution?",
],
responses_per_persona: 2,
}),
}).then(r => r.json());
// 6. Poll
let data;
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 5000));
data = await fetch(`${MV_BASE}/focus-groups/${fg.id}`, { headers: MV_H }).then(r => r.json());
if (data.status === "completed") break;
}
for (const resp of data.responses || []) {
const title = personaIds.find(p => p.id === resp.persona_id)?.title || "?";
console.log(`[${title}] ${(resp.question || "").slice(0, 60)}...`);
console.log(` → ${(resp.answer || "").slice(0, 300)}\n`);
}
Example Output
[VP of Marketing] Rate each ad 1-5 for relevance to your role...
→ AD 1: 4/5 — The ROI stat is specific and credible. "60% faster campaign launches"
speaks directly to my quarterly goals. Would benefit from a customer logo.
AD 2: 2/5 — Too generic. "Transform your business" means nothing to me.
AD 3: 5/5 — Case study format with named company and metric. I'd click.
[Director of Sales] Which ad would you most likely click on...
→ AD 3. It names a company I recognize in our space and shows pipeline impact.
I'd forward this to my team as competitive intelligence.
[CFO / Finance Lead] What is missing from these ads...
→ None of these mention total cost of ownership or implementation timeline.
I see ROI claims but no payback period. Add "ROI in 90 days" and I'd engage.
Error Handling
LinkedIn-Version header required
LinkedIn-Version header required
Every LinkedIn REST API call requires the
LinkedIn-Version header (format: YYYYMM). Omitting it returns 400. Update when new API versions ship.Empty creatives list
Empty creatives list
If no creatives return, verify: (1) the ad account ID matches your token’s permissions, (2) at least one campaign has
ACTIVE status, (3) your app has the r_ads scope approved.Rate limit handling
Rate limit handling
LinkedIn returns
429 with a Retry-After header (seconds). The code respects this. For batch jobs, add a 500ms delay between calls.