{"name":"Ad fatigue detector workflow","nodes":[{"id":"f0a71c3d-0001-4a11-8b22-9c3344556677","name":"Daily Fatigue Scan","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-40,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":12}]}}},{"id":"f0a71c3d-0002-4a11-8b22-9c3344556677","name":"Fetch Ad Insights (Last 7d)","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[220,180],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"ad"},{"name":"fields","value":"ad_id,ad_name,adset_id,campaign_name,spend,impressions,reach,frequency,clicks,inline_link_clicks,ctr,cpm,actions,action_values,quality_ranking,engagement_rate_ranking"},{"name":"date_preset","value":"last_7d"},{"name":"filtering","value":"[{\"field\":\"ad.effective_status\",\"operator\":\"IN\",\"value\":[\"ACTIVE\"]}]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"f0a71c3d-0003-4a11-8b22-9c3344556677","name":"Read First-Week Baselines","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[220,500],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"First Week Baselines"},"options":{}}},{"id":"f0a71c3d-0004-4a11-8b22-9c3344556677","name":"Flatten Meta Insight Rows","type":"n8n-nodes-base.code","typeVersion":2,"position":[480,180],"parameters":{"jsCode":"// Flatten Meta insights (level=ad) into one flat row per ad.\n// Meta returns action-based metrics as arrays of {action_type, value}.\nconst pick = (arr, type) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find(a => a.action_type === type);\n  return hit ? Number(hit.value) : 0;\n};\nconst num = v => (v === undefined || v === null || v === '' ? 0 : Number(v));\n\nconst rows = [];\nfor (const item of $input.all()) {\n  const payload = item.json.data ? item.json.data : [item.json];\n  for (const r of payload) {\n    const impressions = num(r.impressions);\n    const clicks = num(r.inline_link_clicks || r.clicks);\n    const spend = num(r.spend);\n    const reach = num(r.reach);\n    const purchases = pick(r.actions, 'offsite_conversion.fb_pixel_purchase');\n    const revenue = pick(r.action_values, 'offsite_conversion.fb_pixel_purchase');\n\n    rows.push({\n      ad_id: String(r.ad_id || ''),\n      ad_name: r.ad_name || '',\n      adset_id: String(r.adset_id || ''),\n      campaign_name: r.campaign_name || '',\n      date_start: r.date_start,\n      date_stop: r.date_stop,\n      impressions,\n      reach,\n      clicks,\n      spend: Number(spend.toFixed(2)),\n      // frequency can come back as a string; recompute if reach is present\n      frequency: Number((num(r.frequency) || (reach ? impressions / reach : 0)).toFixed(3)),\n      ctr: Number((impressions ? (clicks / impressions) * 100 : 0).toFixed(4)),\n      cpm: Number((impressions ? (spend / impressions) * 1000 : 0).toFixed(2)),\n      cpc: Number((clicks ? spend / clicks : 0).toFixed(2)),\n      purchases,\n      purchase_roas: Number((spend ? revenue / spend : 0).toFixed(3)),\n      quality_ranking: r.quality_ranking || 'UNKNOWN',\n      engagement_rate_ranking: r.engagement_rate_ranking || 'UNKNOWN',\n    });\n  }\n}\n\n// Meta can return the same ad twice when the window straddles a breakdown edge.\nconst merged = new Map();\nfor (const r of rows) {\n  const prev = merged.get(r.ad_id);\n  if (!prev) { merged.set(r.ad_id, r); continue; }\n  const imp = prev.impressions + r.impressions;\n  const clk = prev.clicks + r.clicks;\n  const spd = prev.spend + r.spend;\n  merged.set(r.ad_id, {\n    ...prev,\n    impressions: imp,\n    reach: prev.reach + r.reach,\n    clicks: clk,\n    spend: Number(spd.toFixed(2)),\n    purchases: prev.purchases + r.purchases,\n    ctr: Number((imp ? (clk / imp) * 100 : 0).toFixed(4)),\n    cpm: Number((imp ? (spd / imp) * 1000 : 0).toFixed(2)),\n    frequency: Number((prev.reach + r.reach ? imp / (prev.reach + r.reach) : 0).toFixed(3)),\n  });\n}\n\nreturn [...merged.values()].map(r => ({ json: r }));"}},{"id":"f0a71c3d-0005-4a11-8b22-9c3344556677","name":"Keep Ads With Real Volume","type":"n8n-nodes-base.filter","typeVersion":2,"position":[740,180],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"v1","leftValue":"={{ $json.impressions }}","rightValue":4000,"operator":{"type":"number","operation":"gte"}},{"id":"v2","leftValue":"={{ $json.spend }}","rightValue":50,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"f0a71c3d-0006-4a11-8b22-9c3344556677","name":"Merge Live Metrics With Baselines","type":"n8n-nodes-base.merge","typeVersion":3,"position":[1000,300],"parameters":{"mode":"combine","combineBy":"combineByFields","fieldsToMatchString":"ad_id","joinMode":"enrichInput1","options":{}}},{"id":"f0a71c3d-0007-4a11-8b22-9c3344556677","name":"Score Creative Fatigue","type":"n8n-nodes-base.code","typeVersion":2,"position":[1260,300],"parameters":{"jsCode":"// ---------------------------------------------------------------\n// FATIGUE SCORING\n// Three independent decay signals, each normalised 0-100, then blended:\n//   1. Frequency saturation   (weight 0.30)\n//   2. CTR decay vs the ad's own first-week baseline (weight 0.45)\n//   3. CPM inflation vs that same baseline            (weight 0.25)\n// Baselines come from the \"First Week Baselines\" sheet, written the\n// first time an ad clears 5k impressions.\n// ---------------------------------------------------------------\nconst W_FREQ = 0.30, W_CTR = 0.45, W_CPM = 0.25;\n\n// frequency: nothing below 1.8, fully saturated at 4.5\nconst FREQ_FLOOR = 1.8, FREQ_CEIL = 4.5;\n// ctr decay: 0% decay = healthy, 45% below baseline = fully fatigued\nconst CTR_CEIL = 0.45;\n// cpm inflation: 50% above baseline cpm = fully fatigued\nconst CPM_CEIL = 0.50;\n\nconst CRITICAL = 70, MODERATE = 45, WATCH = 25;\nconst MIN_SPEND = 50;          // ignore ads that have not spent enough to judge\nconst MIN_IMPRESSIONS = 4000;\nconst COOLDOWN_DAYS = 7;       // do not re-action the same ad twice in a week\n\nconst clamp01 = v => Math.max(0, Math.min(1, v));\nconst round = (v, d = 2) => Number(v.toFixed(d));\n\n// Build the baseline lookup from the Sheets branch.\nconst baselines = {};\nfor (const b of $('Read First-Week Baselines').all()) {\n  const row = b.json;\n  const id = String(row.ad_id || '');\n  if (id) baselines[id] = row;\n}\n\nconst today = new Date();\nconst daysBetween = (a, b) => Math.round((a - b) / 86400000);\n\nconst out = [];\nfor (const item of $input.all()) {\n  const r = item.json;\n  const base = baselines[r.ad_id] || {};\n\n  const baselineCtr = Number(base.baseline_ctr || 0);\n  const baselineCpm = Number(base.baseline_cpm || 0);\n  const launchedAt = base.launched_at ? new Date(base.launched_at) : null;\n  const lastActionAt = base.last_action_at ? new Date(base.last_action_at) : null;\n  const daysLive = launchedAt ? daysBetween(today, launchedAt) : null;\n  const daysSinceAction = lastActionAt ? daysBetween(today, lastActionAt) : 999;\n\n  // --- signal 1: frequency saturation\n  const freqScore = clamp01((r.frequency - FREQ_FLOOR) / (FREQ_CEIL - FREQ_FLOOR)) * 100;\n\n  // --- signal 2: CTR decay vs first-week baseline\n  const ctrDecay = baselineCtr > 0 ? clamp01((baselineCtr - r.ctr) / baselineCtr) : 0;\n  const ctrScore = clamp01(ctrDecay / CTR_CEIL) * 100;\n\n  // --- signal 3: CPM inflation vs first-week baseline\n  const cpmInflation = baselineCpm > 0 ? Math.max(0, (r.cpm - baselineCpm) / baselineCpm) : 0;\n  const cpmScore = clamp01(cpmInflation / CPM_CEIL) * 100;\n\n  let score = freqScore * W_FREQ + ctrScore * W_CTR + cpmScore * W_CPM;\n\n  // With no baseline on file we can only trust frequency - cap the score so a\n  // brand new ad never gets retired on a missing row.\n  const hasBaseline = baselineCtr > 0 && baselineCpm > 0;\n  if (!hasBaseline) score = Math.min(score, WATCH + 5);\n\n  // Meta's own delivery rankings are a cheap confirmation signal.\n  if (r.quality_ranking === 'BELOW_AVERAGE_10' || r.engagement_rate_ranking === 'BELOW_AVERAGE_10') score += 8;\n  if (r.quality_ranking === 'ABOVE_AVERAGE') score -= 5;\n\n  // A creative still printing profit is not fatigued yet, whatever the curve says.\n  if (r.purchase_roas >= 2.5) score -= 12;\n  score = Math.max(0, Math.min(100, score));\n\n  // --- pacing projection: how many days until this ad crosses CRITICAL,\n  // extrapolating the decay it has accumulated over its life so far.\n  let daysToCritical = null;\n  if (daysLive && daysLive > 7 && score > 5) {\n    const scorePerDay = score / daysLive;\n    daysToCritical = score >= CRITICAL ? 0 : Math.ceil((CRITICAL - score) / Math.max(scorePerDay, 0.01));\n    if (daysToCritical > 60) daysToCritical = null;\n  }\n\n  let level = 'healthy';\n  if (score >= CRITICAL) level = 'critical';\n  else if (score >= MODERATE) level = 'moderate';\n  else if (score >= WATCH) level = 'watch';\n\n  // Guardrails: not enough data, or we already acted on this ad recently.\n  const thinData = r.spend < MIN_SPEND || r.impressions < MIN_IMPRESSIONS;\n  const inCooldown = daysSinceAction < COOLDOWN_DAYS;\n  if (thinData || inCooldown) level = 'healthy';\n\n  const drivers = [];\n  if (freqScore > 60) drivers.push('frequency ' + r.frequency);\n  if (ctrScore > 60) drivers.push('CTR -' + round(ctrDecay * 100, 1) + '% vs baseline');\n  if (cpmScore > 60) drivers.push('CPM +' + round(cpmInflation * 100, 1) + '% vs baseline');\n\n  out.push({ json: {\n    ad_id: r.ad_id,\n    ad_name: r.ad_name,\n    adset_id: r.adset_id,\n    campaign_name: r.campaign_name,\n    spend: r.spend,\n    impressions: r.impressions,\n    frequency: r.frequency,\n    ctr: r.ctr,\n    cpm: r.cpm,\n    purchase_roas: r.purchase_roas,\n    baseline_ctr: baselineCtr,\n    baseline_cpm: baselineCpm,\n    ctr_decay_pct: round(ctrDecay * 100, 1),\n    cpm_inflation_pct: round(cpmInflation * 100, 1),\n    freq_score: round(freqScore),\n    ctr_score: round(ctrScore),\n    cpm_score: round(cpmScore),\n    fatigue_score: round(score),\n    fatigue_level: level,\n    days_live: daysLive,\n    days_to_critical: daysToCritical,\n    has_baseline: hasBaseline,\n    skipped_reason: thinData ? 'insufficient_volume' : (inCooldown ? 'cooldown' : null),\n    drivers: drivers.join(' | ') || 'none',\n    scanned_at: today.toISOString(),\n  }});\n}\n\n// Worst offenders first so Slack reads top-down.\nout.sort((a, b) => b.json.fatigue_score - a.json.fatigue_score);\nreturn out;"}},{"id":"f0a71c3d-0008-4a11-8b22-9c3344556677","name":"Fatigue Critical?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1520,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.fatigue_level }}","rightValue":"critical","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"f0a71c3d-0009-4a11-8b22-9c3344556677","name":"Retire Fatigued Creative","type":"n8n-nodes-base.facebookGraphApi","typeVersion":1,"position":[1780,60],"parameters":{"hostUrl":"graph.facebook.com","httpRequestMethod":"POST","graphApiVersion":"v21.0","node":"={{ $json.ad_id }}","options":{"queryParametersUi":{"parameter":[{"name":"status","value":"PAUSED"}]}}}},{"id":"f0a71c3d-0010-4a11-8b22-9c3344556677","name":"Alert Creative Team (Retired)","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2040,60],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-team","mode":"name"},"text":"=:skull: *RETIRED — fatigue {{ $json.fatigue_score }}/100*\n*{{ $json.ad_name }}* ({{ $json.campaign_name }})\nFrequency {{ $json.frequency }} · CTR {{ $json.ctr }}% (-{{ $json.ctr_decay_pct }}% vs first week) · CPM ${{ $json.cpm }} (+{{ $json.cpm_inflation_pct }}%)\nROAS {{ $json.purchase_roas }} on ${{ $json.spend }} over {{ $json.days_live }} days live.\nDrivers: {{ $json.drivers }}\nAd is paused. Ship a net-new angle, not a colour swap.","otherOptions":{}}},{"id":"f0a71c3d-0011-4a11-8b22-9c3344556677","name":"Fatigue Moderate?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1780,500],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"or","conditions":[{"id":"m1","leftValue":"={{ $json.fatigue_level }}","rightValue":"moderate","operator":{"type":"string","operation":"equals"}},{"id":"m2","leftValue":"={{ $json.fatigue_level }}","rightValue":"watch","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"f0a71c3d-0012-4a11-8b22-9c3344556677","name":"Draft Creative Refresh Brief","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[2040,360],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a direct response creative strategist. You write refresh briefs for fatiguing Meta ads. Be concrete: name the new angle, the hook line, and the format. Never suggest cosmetic tweaks. Max 120 words."},{"content":"=Ad \"{{ $json.ad_name }}\" in campaign \"{{ $json.campaign_name }}\" is fatiguing.\nFatigue score: {{ $json.fatigue_score }}/100 ({{ $json.fatigue_level }})\nFrequency: {{ $json.frequency }}\nCTR: {{ $json.ctr }}% vs first-week baseline {{ $json.baseline_ctr }}% ({{ $json.ctr_decay_pct }}% decay)\nCPM: ${{ $json.cpm }} vs baseline ${{ $json.baseline_cpm }} (+{{ $json.cpm_inflation_pct }}%)\nROAS: {{ $json.purchase_roas }}, spend ${{ $json.spend }}, {{ $json.days_live }} days live.\nProjected days until critical: {{ $json.days_to_critical }}\nMain drivers: {{ $json.drivers }}\n\nWrite the refresh brief."}]},"options":{}}},{"id":"f0a71c3d-0013-4a11-8b22-9c3344556677","name":"Create Refresh Task","type":"n8n-nodes-base.airtable","typeVersion":2.1,"position":[2300,360],"parameters":{"operation":"create","base":{"__rl":true,"value":"appXXXXXXXX","mode":"id"},"table":{"__rl":true,"value":"tblXXXXXXXX","mode":"id"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}}},{"id":"f0a71c3d-0014-4a11-8b22-9c3344556677","name":"Alert Creative Team (Refresh)","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2560,360],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-team","mode":"name"},"text":"=:warning: *REFRESH QUEUE — fatigue {{ $('Score Creative Fatigue').item.json.fatigue_score }}/100*\n*{{ $('Score Creative Fatigue').item.json.ad_name }}* still running, but decaying.\nFrequency {{ $('Score Creative Fatigue').item.json.frequency }} · CTR -{{ $('Score Creative Fatigue').item.json.ctr_decay_pct }}% · CPM +{{ $('Score Creative Fatigue').item.json.cpm_inflation_pct }}%\nProjected {{ $('Score Creative Fatigue').item.json.days_to_critical }} days until it hits critical.\n\n>{{ $json.message.content }}","otherOptions":{}}},{"id":"f0a71c3d-0015-4a11-8b22-9c3344556677","name":"Mark Creative Healthy","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2040,680],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"ad_id","value":"={{ $json.ad_id }}","type":"string"},{"id":"h2","name":"ad_name","value":"={{ $json.ad_name }}","type":"string"},{"id":"h3","name":"fatigue_score","value":"={{ $json.fatigue_score }}","type":"number"},{"id":"h4","name":"fatigue_level","value":"healthy","type":"string"},{"id":"h5","name":"action_taken","value":"none","type":"string"},{"id":"h6","name":"note","value":"={{ $json.skipped_reason || \"within tolerance\" }}","type":"string"}]},"options":{}}},{"id":"f0a71c3d-0016-4a11-8b22-9c3344556677","name":"Log Fatigue Snapshot","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2820,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Fatigue Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"f0a71c3d-0017-4a11-8b22-9c3344556677","name":"Section: Collect","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[140,-40],"parameters":{"content":"## 1. COLLECT\nPull last-7d ad-level insights from Meta and the stored first-week baselines\n(baseline_ctr / baseline_cpm / launched_at / last_action_at) from the sheet.\nOnly ads with >=4k impressions and >=$50 spend are judged.","height":300,"width":420,"color":4}},{"id":"f0a71c3d-0018-4a11-8b22-9c3344556677","name":"Section: Score","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[980,-40],"parameters":{"content":"## 2. SCORE FATIGUE\nBlend three normalised signals:\nfrequency saturation (0.30) + CTR decay vs baseline (0.45) + CPM inflation (0.25).\nROAS >= 2.5 subtracts 12; below-average delivery rankings add 8.\ncritical >= 70 · moderate >= 45 · watch >= 25.\nAlso projects days-to-critical from the decay rate.","height":320,"width":440,"color":5}},{"id":"f0a71c3d-0019-4a11-8b22-9c3344556677","name":"Section: Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1900,-180],"parameters":{"content":"## 3. ACT\nCritical -> pause the ad and tell the creative team to ship a new angle.\nModerate/watch -> LLM refresh brief, Airtable task, Slack nudge.\nHealthy -> logged only. Everything lands in the Fatigue Log sheet.","height":300,"width":440,"color":3}}],"connections":{"Daily Fatigue Scan":{"main":[[{"node":"Fetch Ad Insights (Last 7d)","type":"main","index":0},{"node":"Read First-Week Baselines","type":"main","index":0}]]},"Fetch Ad Insights (Last 7d)":{"main":[[{"node":"Flatten Meta Insight Rows","type":"main","index":0}]]},"Flatten Meta Insight Rows":{"main":[[{"node":"Keep Ads With Real Volume","type":"main","index":0}]]},"Keep Ads With Real Volume":{"main":[[{"node":"Merge Live Metrics With Baselines","type":"main","index":0}]]},"Read First-Week Baselines":{"main":[[{"node":"Merge Live Metrics With Baselines","type":"main","index":1}]]},"Merge Live Metrics With Baselines":{"main":[[{"node":"Score Creative Fatigue","type":"main","index":0}]]},"Score Creative Fatigue":{"main":[[{"node":"Fatigue Critical?","type":"main","index":0}]]},"Fatigue Critical?":{"main":[[{"node":"Retire Fatigued Creative","type":"main","index":0}],[{"node":"Fatigue Moderate?","type":"main","index":0}]]},"Retire Fatigued Creative":{"main":[[{"node":"Alert Creative Team (Retired)","type":"main","index":0}]]},"Alert Creative Team (Retired)":{"main":[[{"node":"Log Fatigue Snapshot","type":"main","index":0}]]},"Fatigue Moderate?":{"main":[[{"node":"Draft Creative Refresh Brief","type":"main","index":0}],[{"node":"Mark Creative Healthy","type":"main","index":0}]]},"Draft Creative Refresh Brief":{"main":[[{"node":"Create Refresh Task","type":"main","index":0}]]},"Create Refresh Task":{"main":[[{"node":"Alert Creative Team (Refresh)","type":"main","index":0}]]},"Alert Creative Team (Refresh)":{"main":[[{"node":"Log Fatigue Snapshot","type":"main","index":0}]]},"Mark Creative Healthy":{"main":[[{"node":"Log Fatigue Snapshot","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}