> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mavera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# LinkedIn Ad Copy Generation

> Pull existing LinkedIn ad copy and engagement metrics, distill a brand voice from top performers, and generate fresh ad copy variants with Mavera Generate.

### 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

```mermaid theme={"dark"}
flowchart LR
    A["LinkedIn GET creatives + GET /adAnalytics"] --> B["Filter top performers"]
    B --> C["Mavera POST /brand-voices"]
    C --> D["POST /generations (LinkedIn Ad template)"]
    D --> E["New ad copy variants"]
```

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  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']}")
  ```

  ```javascript JavaScript theme={"dark"}
  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)}%`);
  ```
</CodeGroup>

### Example Output

```text theme={"dark"}
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

<AccordionGroup>
  <Accordion title="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.</Accordion>
  <Accordion title="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.</Accordion>
  <Accordion title="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.</Accordion>
  <Accordion title="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.</Accordion>
</AccordionGroup>

***

<CardGroup cols={2}>
  <Card title="All LinkedIn Marketing jobs" icon="linkedin" href="/integrations/linkedin-marketing" />

  <Card title="Brand Voice" icon="microphone" href="/features/brand-voice" />
</CardGroup>
