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

# Ad Fatigue Detector

> Detect ad fatigue by monitoring frequency and CTR trends per ad, then use Mave to research fresh creative angles before performance craters.

### Scenario

Your ads start strong then decay — frequency climbs, CTR drops, CPA spikes. By the time you notice, you've wasted budget. This job pulls frequency and performance time series per ad, detects when frequency exceeds a threshold while CTR drops below its initial level, and triggers Mave to research fresh creative angles before performance craters further.

### Architecture

```mermaid theme={"dark"}
flowchart LR
    A["Meta GET insights (daily, frequency + CTR)"] --> B["Detect fatigue (threshold + CTR decline)"] --> C["POST /api/v1/mave/chat"] --> D[Fresh angle recommendations]
```

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  import os, requests
  from datetime import datetime, timedelta

  META = os.environ["META_ACCESS_TOKEN"]
  ACCT = os.environ["META_AD_ACCOUNT_ID"]
  MV = os.environ["MAVERA_API_KEY"]
  GRAPH = "https://graph.facebook.com/v24.0"
  MB = "https://app.mavera.io/api/v1"
  MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}

  FREQUENCY_THRESHOLD = 3.0
  CTR_DROP_PCT = 0.25

  # 1. Pull daily performance per ad (last 14 days)
  insights = requests.get(
      f"{GRAPH}/{ACCT}/insights",
      params={
          "access_token": META,
          "fields": "ad_id,ad_name,frequency,ctr,cpc,impressions,clicks,spend",
          "level": "ad",
          "time_increment": 1,
          "date_preset": "last_14d",
          "limit": 500,
      },
  ).json().get("data", [])

  # 2. Group by ad and analyze trend
  from collections import defaultdict
  ad_series = defaultdict(list)
  for row in insights:
      ad_series[row["ad_id"]].append({
          "date": row.get("date_start"),
          "frequency": float(row.get("frequency", 0)),
          "ctr": float(row.get("ctr", 0)),
          "cpc": float(row.get("cpc", 0)),
          "spend": float(row.get("spend", 0)),
          "name": row.get("ad_name", ""),
      })

  fatigued = []
  for ad_id, series in ad_series.items():
      series.sort(key=lambda x: x["date"])
      if len(series) < 3:
          continue

      first_3_ctr = sum(d["ctr"] for d in series[:3]) / 3
      last_3_ctr = sum(d["ctr"] for d in series[-3:]) / 3
      latest_freq = series[-1]["frequency"]
      total_spend = sum(d["spend"] for d in series)

      ctr_decline = (first_3_ctr - last_3_ctr) / max(first_3_ctr, 0.01)

      if latest_freq > FREQUENCY_THRESHOLD and ctr_decline > CTR_DROP_PCT:
          fatigued.append({
              "ad_id": ad_id,
              "name": series[0]["name"],
              "frequency": latest_freq,
              "ctr_initial": first_3_ctr,
              "ctr_current": last_3_ctr,
              "ctr_decline": ctr_decline,
              "total_spend": total_spend,
          })

  print(f"Fatigued ads: {len(fatigued)} / {len(ad_series)} active")

  if not fatigued:
      print("No fatigued ads detected.")
  else:
      # 3. Build context for Mave
      fatigue_report = "\n".join(
          f"- \"{f['name']}\": freq={f['frequency']:.1f}, CTR dropped {f['ctr_decline']:.0%} "
          f"({f['ctr_initial']:.2f}% → {f['ctr_current']:.2f}%), spend=${f['total_spend']:.0f}"
          for f in fatigued
      )

      # 4. Get fresh angles from Mave
      research = requests.post(f"{MB}/mave/chat", headers=MH, json={
          "message": f"""These Meta ads are showing fatigue — frequency is high and CTR is declining.
  Research fresh creative angles to replace or refresh them.

  FATIGUED ADS:
  {fatigue_report}

  For each fatigued ad:
  1. Why the current angle is likely fatiguing (audience saturation, message wear-out, etc.)
  2. 3 fresh creative angles that maintain the core value prop but use new hooks
  3. Recommended format changes (video length, static vs carousel, UGC vs polished)
  4. Targeting adjustments to reduce frequency without losing quality"""
      }).json()

      print("\n=== Ad Fatigue Analysis & Recommendations ===")
      print(research.get("content", ""))
  ```

  ```javascript JavaScript theme={"dark"}
  const META = process.env.META_ACCESS_TOKEN;
  const ACCT = process.env.META_AD_ACCOUNT_ID;
  const MV = process.env.MAVERA_API_KEY;
  const GRAPH = "https://graph.facebook.com/v24.0";
  const MB = "https://app.mavera.io/api/v1";
  const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };

  const FREQUENCY_THRESHOLD = 3.0;
  const CTR_DROP_PCT = 0.25;

  // 1. Pull daily performance
  const insights = await fetch(
    `${GRAPH}/${ACCT}/insights?access_token=${META}&fields=ad_id,ad_name,frequency,ctr,cpc,impressions,clicks,spend&level=ad&time_increment=1&date_preset=last_14d&limit=500`
  ).then(r => r.json()).then(d => d.data || []);

  // 2. Group by ad
  const adSeries = {};
  for (const row of insights) {
    (adSeries[row.ad_id] ??= []).push({
      date: row.date_start, frequency: parseFloat(row.frequency || "0"),
      ctr: parseFloat(row.ctr || "0"), cpc: parseFloat(row.cpc || "0"),
      spend: parseFloat(row.spend || "0"), name: row.ad_name || "",
    });
  }

  const fatigued = [];
  for (const [adId, series] of Object.entries(adSeries)) {
    series.sort((a, b) => a.date.localeCompare(b.date));
    if (series.length < 3) continue;

    const first3Ctr = series.slice(0, 3).reduce((s, d) => s + d.ctr, 0) / 3;
    const last3Ctr = series.slice(-3).reduce((s, d) => s + d.ctr, 0) / 3;
    const latestFreq = series[series.length - 1].frequency;
    const totalSpend = series.reduce((s, d) => s + d.spend, 0);
    const ctrDecline = (first3Ctr - last3Ctr) / Math.max(first3Ctr, 0.01);

    if (latestFreq > FREQUENCY_THRESHOLD && ctrDecline > CTR_DROP_PCT) {
      fatigued.push({
        ad_id: adId, name: series[0].name, frequency: latestFreq,
        ctr_initial: first3Ctr, ctr_current: last3Ctr,
        ctr_decline: ctrDecline, total_spend: totalSpend,
      });
    }
  }

  console.log(`Fatigued ads: ${fatigued.length} / ${Object.keys(adSeries).length}`);

  if (fatigued.length) {
    const report = fatigued.map(f =>
      `- "${f.name}": freq=${f.frequency.toFixed(1)}, CTR dropped ${(f.ctr_decline * 100).toFixed(0)}% (${f.ctr_initial.toFixed(2)}% → ${f.ctr_current.toFixed(2)}%), spend=$${f.total_spend.toFixed(0)}`
    ).join("\n");

    const research = await fetch(`${MB}/mave/chat`, {
      method: "POST", headers: MH,
      body: JSON.stringify({
        message: `These Meta ads show fatigue:\n\n${report}\n\nFor each: 1) Why fatiguing 2) 3 fresh angles 3) Format changes 4) Targeting adjustments`,
      }),
    }).then(r => r.json());

    console.log("\n=== Recommendations ===");
    console.log(research.content || "");
  }
  ```
</CodeGroup>

### Example Output

```text theme={"dark"}
Fatigued ads: 3 / 18 active

=== Ad Fatigue Analysis & Recommendations ===

### 1. "Summer Sale Hero — 30s" (freq 5.2, CTR -42%)
**Why:** Audience has seen this 5+ times. The promotional urgency ("limited time")
loses credibility after repeated exposure.

**Fresh angles:**
- Customer success story: "How [Company] saved 40% — and what they did with the savings"
- Problem-agitation: "Still spending 4 hours on reports? Here's what changed for 200 teams"
- Social proof carousel: 3 customer quotes with results

**Format:** Switch from 30s video to 15s Reel with text overlay — lower production, higher novelty.
**Targeting:** Exclude past converters and high-frequency viewers. Expand to 2% lookalike.

### 2. "Product Demo — Features" (freq 4.1, CTR -35%)
**Why:** Feature demos fatigue fastest — once they've seen it, there's no new information.

**Fresh angles:**
- Before/after workflow comparison (problem → solution framing)
- "Day in the life" UGC-style showing real usage
- Competitor comparison (without naming) — "The old way vs the new way"
```

### Error Handling

<AccordionGroup>
  <Accordion title="Frequency field is cumulative">Meta's `frequency` is a lifetime metric for the ad, not daily. For daily frequency, divide daily impressions by daily reach (requires `reach` field).</Accordion>
  <Accordion title="Low-impression ads skew CTR">Filter out ads with fewer than 1,000 impressions — small sample sizes produce noisy CTR values.</Accordion>
  <Accordion title="time_increment limit">Daily breakdown (`time_increment=1`) is limited to 90 days. For longer ranges, use `time_increment=7` or `time_increment=monthly`.</Accordion>
</AccordionGroup>

<CardGroup cols={2}>
  <Card title="All Meta Ads Jobs" icon="meta" href="/integrations/meta-ads">
    Browse all Meta Ads integration jobs
  </Card>

  <Card title="Mave Agent" icon="brain" href="/features/mave-agent">
    AI research agent for creative strategy
  </Card>
</CardGroup>
