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

# Sponsored Content → Focus Group Testing

> Pull active LinkedIn Sponsored Content creatives and run them through a Mavera Focus Group with B2B personas for relevance scoring and feedback.

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

```mermaid theme={"dark"}
flowchart LR
    A["LinkedIn GET /adAccounts/{id}/creatives"] --> B["Extract ad copy & visual metadata"]
    B --> C["Mavera POST /personas (B2B roles)"]
    C --> D["POST /focus-groups (Likert + open-ended)"]
    D --> E["Creative scorecard"]
```

### 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 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")
  ```

  ```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 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`);
  }
  ```
</CodeGroup>

### Example Output

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

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

***

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

  <Card title="Focus Groups" icon="users" href="/features/focus-groups" />
</CardGroup>
