Scenario
Your LinkedIn ads are performing, but your team recycles the same copy patterns. This job pulls existing ad copy alongside engagement metrics, distills a Brand Voice from top performers, then uses Mavera’s Generate endpoint with the LinkedIn Ad app template to produce fresh variations that match your proven tone. Each variant is ready to paste into Campaign Manager.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 all creatives
creatives_resp = requests.get(f"{LI_BASE}/adAccounts/{AD_ACCOUNT_ID}/creatives",
headers=LI_H,
params={"q": "search", "count": 50})
if creatives_resp.status_code == 429:
time.sleep(int(creatives_resp.headers.get("Retry-After", 60)))
creatives_resp = requests.get(f"{LI_BASE}/adAccounts/{AD_ACCOUNT_ID}/creatives",
headers=LI_H, params={"q": "search", "count": 50})
creatives_resp.raise_for_status()
creatives = creatives_resp.json().get("elements", [])
# 2. Pull analytics for each creative
creative_data = []
for cr in creatives:
cr_urn = f"urn:li:sponsoredCreative:{cr.get('id', '')}"
r = requests.get(f"{LI_BASE}/adAnalytics",
headers=LI_H,
params={
"q": "analytics", "pivot": "CREATIVE",
"timeGranularity": "ALL",
"creatives[0]": cr_urn,
"dateRange.start.year": 2025, "dateRange.start.month": 1, "dateRange.start.day": 1,
"dateRange.end.year": 2025, "dateRange.end.month": 12, "dateRange.end.day": 31,
"fields": "impressions,clicks,likes,comments,shares",
})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 60)))
continue
metrics = r.json().get("elements", [{}])[0] if r.ok else {}
copy = cr.get("commentary", "") or cr.get("content", {}).get("textAd", {}).get("text", "")
headline = cr.get("content", {}).get("textAd", {}).get("headline", "")
impressions = metrics.get("impressions", 0)
clicks = metrics.get("clicks", 0)
if copy and impressions > 0:
creative_data.append({
"copy": copy, "headline": headline,
"impressions": impressions, "clicks": clicks,
"ctr": round((clicks / impressions) * 100, 2) if impressions else 0,
"engagement": metrics.get("likes", 0) + metrics.get("comments", 0) + metrics.get("shares", 0),
})
time.sleep(0.3)
# 3. Create Brand Voice from top performers
top_ads = sorted(creative_data, key=lambda x: -x["ctr"])[:10]
samples = "\n\n---\n\n".join(
f"Headline: {a['headline']}\nCopy: {a['copy'][:500]}\nCTR: {a['ctr']}%"
for a in top_ads
)
bv = requests.post(f"{MV_BASE}/brand-voices", headers=MV_H, json={
"name": "LinkedIn Top-Performing Ad Voice",
"samples": [samples],
}).json()
print(f"Brand Voice: {bv['id']}")
# 4. Generate new ad copy variants
gen = requests.post(f"{MV_BASE}/generations", headers=MV_H, json={
"brand_voice_id": bv["id"],
"app_template": "linkedin_ad",
"prompt": (
"Generate 5 LinkedIn Sponsored Content ad variants for a B2B SaaS product. "
"Each variant needs: intro text (under 150 chars for mobile), headline, "
"description, and CTA button text. Match the winning voice from our top ads. "
"Vary the hooks: use a stat, a question, a bold claim, social proof, and a trend."
),
"count": 5,
}).json()
for i, g in enumerate(gen.get("results", [gen]), 1):
print(f"\n--- Variant {i} ---")
print(g.get("content", g.get("text", ""))[:400])
# 5. Performance benchmark
avg_ctr = sum(a["ctr"] for a in top_ads) / len(top_ads) if top_ads else 0
print(f"\nBenchmark: Top-performer avg CTR = {avg_ctr:.2f}%")
print(f"Generated {len(gen.get('results', [gen]))} variants with brand voice {bv['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 AD_ACCOUNT_ID = "508000001";
// 1. Pull creatives
let res = await fetch(
`${LI_BASE}/adAccounts/${AD_ACCOUNT_ID}/creatives?q=search&count=50`,
{ headers: LI_H }
);
if (res.status === 429) {
await new Promise(r => setTimeout(r, parseInt(res.headers.get("Retry-After") || "60", 10) * 1000));
res = await fetch(`${LI_BASE}/adAccounts/${AD_ACCOUNT_ID}/creatives?q=search&count=50`, { headers: LI_H });
}
const creatives = (await res.json()).elements || [];
// 2. Analytics per creative
const creativeData = [];
for (const cr of creatives) {
const crUrn = `urn:li:sponsoredCreative:${cr.id}`;
const params = new URLSearchParams({
q: "analytics", pivot: "CREATIVE", timeGranularity: "ALL",
"creatives[0]": crUrn,
"dateRange.start.year": "2025", "dateRange.start.month": "1", "dateRange.start.day": "1",
"dateRange.end.year": "2025", "dateRange.end.month": "12", "dateRange.end.day": "31",
fields: "impressions,clicks,likes,comments,shares",
});
const r = await fetch(`${LI_BASE}/adAnalytics?${params}`, { headers: LI_H });
if (r.status === 429) { await new Promise(res => setTimeout(res, 60000)); continue; }
const metrics = r.ok ? ((await r.json()).elements || [{}])[0] : {};
const copy = cr.commentary || cr.content?.textAd?.text || "";
const headline = cr.content?.textAd?.headline || "";
const impressions = metrics.impressions || 0;
const clicks = metrics.clicks || 0;
if (copy && impressions > 0) {
creativeData.push({
copy, headline, impressions, clicks,
ctr: parseFloat(((clicks / impressions) * 100).toFixed(2)),
engagement: (metrics.likes || 0) + (metrics.comments || 0) + (metrics.shares || 0),
});
}
await new Promise(r => setTimeout(r, 300));
}
// 3. Brand Voice from top ads
const topAds = creativeData.sort((a, b) => b.ctr - a.ctr).slice(0, 10);
const samples = topAds.map(a =>
`Headline: ${a.headline}\nCopy: ${a.copy.slice(0, 500)}\nCTR: ${a.ctr}%`
).join("\n\n---\n\n");
const bv = await fetch(`${MV_BASE}/brand-voices`, {
method: "POST", headers: MV_H,
body: JSON.stringify({ name: "LinkedIn Top-Performing Ad Voice", samples: [samples] }),
}).then(r => r.json());
console.log(`Brand Voice: ${bv.id}`);
// 4. Generate variants
const gen = await fetch(`${MV_BASE}/generations`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
brand_voice_id: bv.id,
app_template: "linkedin_ad",
prompt: "Generate 5 LinkedIn Sponsored Content ad variants for B2B SaaS. Each needs: intro text (<150 chars), headline, description, CTA. Match winning voice. Vary hooks: stat, question, bold claim, social proof, trend.",
count: 5,
}),
}).then(r => r.json());
(gen.results || [gen]).forEach((g, i) => {
console.log(`\n--- Variant ${i + 1} ---`);
console.log((g.content || g.text || "").slice(0, 400));
});
const avgCtr = topAds.reduce((s, a) => s + a.ctr, 0) / (topAds.length || 1);
console.log(`\nBenchmark: Top-performer avg CTR = ${avgCtr.toFixed(2)}%`);
Example Output
Brand Voice: bv_li_ads_4k9m
--- Variant 1 (Stat Hook) ---
Intro: Marketing teams waste 12 hrs/week on reports. Here's the fix.
Headline: Cut Reporting Time by 60% — Without New Headcount
Description: See how 200+ B2B teams automated their marketing analytics.
CTA: Get the Case Study
--- Variant 2 (Question Hook) ---
Intro: Still building dashboards manually?
Headline: What Would You Do With 12 Extra Hours Every Week?
Description: AI-powered marketing analytics that actually saves time.
CTA: See It in Action
--- Variant 3 (Bold Claim) ---
Intro: Your marketing stack is lying to you.
Headline: The Real Cost of Fragmented Analytics
Description: One platform. Every channel. Real attribution in minutes.
CTA: Start Free Trial
--- Variant 4 (Social Proof) ---
Intro: Trusted by marketing teams at Stripe, Notion, and Ramp.
Headline: Why 200+ B2B Teams Switched to [Product]
Description: $2.3M in pipeline attributed in the first 90 days.
CTA: Book a Demo
--- Variant 5 (Trend) ---
Intro: AI is changing marketing analytics. Are you keeping up?
Headline: The 2025 Marketing Analytics Playbook
Description: How top B2B teams use AI to move from data to decisions.
CTA: Download Free
Benchmark: Top-performer avg CTR = 2.87%
Error Handling
Creative analytics mismatch
Creative analytics mismatch
Analytics require the creative URN in the exact format
urn:li:sponsoredCreative:{id}. If analytics return empty, verify the URN format and date range.Brand Voice requires sufficient samples
Brand Voice requires sufficient samples
Under ~200 words of combined ad copy produces a generic voice. Include at least 5 ads with 30+ words each.
LinkedIn Ad character limits
LinkedIn Ad character limits
Intro text: 150 chars (mobile truncation). Headline: 70 chars. Description: 100 chars. The prompt specifies these constraints, but verify generated output before uploading.
Token expiration during long runs
Token expiration during long runs
LinkedIn access tokens expire after 60 days. For batch jobs pulling 50+ creatives, implement token refresh middleware to avoid mid-run failures.