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
Code
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", ""))
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 || "");
}
Example Output
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
Frequency field is cumulative
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).Low-impression ads skew CTR
Low-impression ads skew CTR
Filter out ads with fewer than 1,000 impressions — small sample sizes produce noisy CTR values.
time_increment limit
time_increment limit
Daily breakdown (
time_increment=1) is limited to 90 days. For longer ranges, use time_increment=7 or time_increment=monthly.All Meta Ads Jobs
Browse all Meta Ads integration jobs
Mave Agent
AI research agent for creative strategy