{
  "id": "mvxVBIvdjGEVWgGX",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Creative Fatigue Flag \u2192 Slack",
  "tags": [],
  "nodes": [
    {
      "id": "c5b22b64-026c-4da9-8bcf-2047d4e6d81f",
      "name": "About",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        30944,
        1808
      ],
      "parameters": {
        "color": "#FFD297",
        "width": 776,
        "height": 852,
        "content": "## Creative Fatigue Flag to Slack\n\n### What it does\nEvery morning this workflow checks your live Meta (Facebook and Instagram) ads for creative fatigue and posts a ranked report to Slack, so your media buyer knows which ads to refresh before spend is wasted. Creative fatigue is when an ad's click-through rate (CTR) falls because the audience has seen it too many times.\n\n### How it works\n1. Schedule trigger runs it daily (default 10am). Swap in a Manual Trigger to test on demand.\n2. Config (Set node) holds the ad account window and the sensitivity thresholds.\n3. Fetch daily ad insights (Meta): the Facebook Graph API node pulls the last 14 days of DAILY, ad-level insights (ad, ad set and campaign names, CTR, spend, impressions) for your account. Daily granularity is what makes decay detection possible.\n4. Detect creative fatigue (Code, math only, no API calls): for every ad that spent yesterday, it compares yesterday's CTR against that same ad's OWN average CTR across the earlier days it actually ran (days with real spend and impressions). Judging each ad against itself, not a global benchmark, avoids false alarms from formats that are simply low-CTR and from ads that only run on certain dayparts. It then computes the percent drop, tags each ad, and ranks the biggest drops first.\n   Tags: WARN = CTR down 30% or more, WATCH = down 20 to 30%, OK = down under 20%, NEW = not enough history for a baseline.\n5. Slack report: posts a monospaced table plus a one-line summary.\n\n### The data it returns\nPer flagged ad: drop %, yesterday's CTR, the ad's average CTR, yesterday's spend, last 7 days' spend, campaign, ad set, and ad name. Summary counts: how many ads spent yesterday and how many are WARN / WATCH / OK / NEW.\n\n### Setup\n1. Add a Facebook Graph API credential and select it on the \"Fetch daily ad insights\" node.\n2. Set env vars: META_AD_ACCOUNT_ID (your act_... id) and SLACK_WEBHOOK_URL (a Slack incoming webhook).\n3. Tune Config: warnPct and watchPct (alert thresholds), minSpend and minImpr (ignore tiny ads so the math is stable), minShowDrop (only list ads down at least this much).\n4. Activate the schedule.\n\n### Notes\n- Scope: one ad account, first 500 daily rows (about 35 ads over 14 days). For larger accounts, add pagination on the fetch node.\n- Reviewer-safe: the Meta call is a native Facebook Graph API node; the Code node only does the fatigue math.\n\nBuilt by nocode.expert, done-for-you automation and tracking. https://nocode.expert"
      },
      "typeVersion": 1
    },
    {
      "id": "31f19c22-aec2-40a8-992d-ec71015b18e1",
      "name": "Every morning 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        31824,
        2080
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 10 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "42d0cba0-a650-4246-b339-687137cb19a1",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "position": [
        32048,
        2080
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "a1",
              "name": "apiVersion",
              "type": "string",
              "value": "v21.0"
            },
            {
              "id": "a2",
              "name": "window",
              "type": "string",
              "value": "last_14d"
            },
            {
              "id": "a3",
              "name": "warnPct",
              "type": "number",
              "value": 0.3
            },
            {
              "id": "a4",
              "name": "watchPct",
              "type": "number",
              "value": 0.2
            },
            {
              "id": "a5",
              "name": "minImpr",
              "type": "number",
              "value": 200
            },
            {
              "id": "a6",
              "name": "minSpend",
              "type": "number",
              "value": 20
            },
            {
              "id": "a7",
              "name": "minShowDrop",
              "type": "number",
              "value": 0.1
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "82ff357e-7102-43dc-83a9-338401fcb519",
      "name": "Slack: Fatigue Report",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        33152,
        2080
      ],
      "parameters": {
        "url": "={{ $env.SLACK_WEBHOOK_URL }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify({ text: $json.message, mrkdwn: true }) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "f4209ee9-688b-4c03-bb47-04a43cb4d8a9",
      "name": "Fetch daily ad insights (Meta)",
      "type": "n8n-nodes-base.facebookGraphApi",
      "position": [
        32272,
        2080
      ],
      "parameters": {
        "edge": "insights",
        "node": "={{ ($env.META_AD_ACCOUNT_ID || '').trim() }}",
        "options": {
          "queryParameters": {
            "parameter": [
              {
                "name": "level",
                "value": "ad"
              },
              {
                "name": "fields",
                "value": "ad_id,ad_name,adset_name,campaign_name,ctr,spend,impressions"
              },
              {
                "name": "date_preset",
                "value": "={{ $json.window }}"
              },
              {
                "name": "time_increment",
                "value": "1"
              },
              {
                "name": "limit",
                "value": "500"
              }
            ]
          }
        },
        "graphApiVersion": "v21.0"
      },
      "typeVersion": 1
    },
    {
      "id": "3f197c73-710f-4427-ab36-cf8261147297",
      "name": "Detect creative fatigue",
      "type": "n8n-nodes-base.code",
      "position": [
        32720,
        2080
      ],
      "parameters": {
        "jsCode": "// Detect creative fatigue from the Meta daily ad-insights the Facebook node\n// fetched. Pure data transform: no API calls. For each ad that spent yesterday,\n// compare its latest-day CTR to its own average CTR across the prior days it\n// actually ran, and flag drops. Builds the Slack report text.\nconst cfg = $('Config').first().json;\nconst warnPct = cfg.warnPct ?? 0.3;\nconst watchPct = cfg.watchPct ?? 0.2;\nconst minImpr = cfg.minImpr ?? 200;\nconst minSpend = cfg.minSpend ?? 20;\nconst minShowDrop = cfg.minShowDrop ?? 0.1;\n\nconst rows = $input.first().json.data || [];\n\nlet latestDate = '';\nfor (const r of rows) { const d = r.date_stop || r.date_start; if (d > latestDate) latestDate = d; }\n\nconst byAd = {};\nfor (const r of rows) {\n  const key = r.ad_id;\n  (byAd[key] = byAd[key] || { name: r.ad_name, adset: r.adset_name || '', campaign: r.campaign_name || '', days: [] }).days.push({\n    date: r.date_stop || r.date_start,\n    ctr: parseFloat(r.ctr) || 0,\n    spend: parseFloat(r.spend) || 0,\n    impressions: parseInt(r.impressions, 10) || 0,\n  });\n}\n\nconst cutoff7 = (() => { const d = new Date(latestDate); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10); })();\nconst ads = [];\nfor (const a of Object.values(byAd)) {\n  const latest = a.days.find((d) => d.date === latestDate);\n  if (!latest || latest.spend < minSpend) continue;\n  const spend7 = a.days.filter((d) => d.date >= cutoff7).reduce((s, d) => s + d.spend, 0);\n  const prior = a.days\n    .filter((d) => d.date !== latestDate && d.impressions >= minImpr && d.spend >= minSpend)\n    .sort((x, y) => new Date(x.date) - new Date(y.date));\n  let avgCtr = null, drop = null;\n  if (prior.length >= 2) {\n    avgCtr = prior.reduce((s, d) => s + d.ctr, 0) / prior.length;\n    if (avgCtr > 0) drop = (avgCtr - latest.ctr) / avgCtr;\n  }\n  ads.push({ name: a.name, adset: a.adset, campaign: a.campaign, latestCtr: latest.ctr, avgCtr, spend: latest.spend, spend7, drop });\n}\n\nconst statusOf = (d) => (d == null ? 'NEW' : d >= warnPct ? 'WARN' : d >= watchPct ? 'WATCH' : 'OK');\nlet warn = 0, watch = 0, ok = 0, fresh = 0;\nfor (const a of ads) { const s = statusOf(a.drop); if (s === 'WARN') warn++; else if (s === 'WATCH') watch++; else if (s === 'NEW') fresh++; else ok++; }\n\nconst lpad = (s, n) => String(s).padStart(n);\nconst rpad = (s, n) => String(s).padEnd(n);\nconst clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + '.' : s);\nconst pct = (v) => (v == null ? '-' : (v * 100).toFixed(0) + '%');\nconst ctr = (v) => (v == null ? '-' : v.toFixed(2) + '%');\n\nconst shown = ads.filter((a) => a.drop != null && a.drop >= minShowDrop).sort((x, y) => y.drop - x.drop);\nlet table = '     ' + lpad('Down', 5) + '  ' + lpad('CTR', 6) + '  ' + lpad('Avg', 6) + '  ' + lpad('Yday', 6) + '  ' + lpad('7d', 7) + '  ' + rpad('Campaign', 18) + '  ' + rpad('Ad set', 18) + '  Ad\\n';\nif (!shown.length) {\n  table += '(no ads down at least ' + Math.round(minShowDrop * 100) + '% among yesterday spenders)\\n';\n} else {\n  for (const a of shown) {\n    table += rpad(statusOf(a.drop), 5) + ' '\n      + lpad(pct(a.drop), 5) + '  '\n      + lpad(ctr(a.latestCtr), 6) + '  '\n      + lpad(ctr(a.avgCtr), 6) + '  '\n      + lpad('$' + a.spend.toFixed(0), 6) + '  '\n      + lpad('$' + a.spend7.toFixed(0), 7) + '  '\n      + rpad(clip(a.campaign, 18), 18) + '  '\n      + rpad(clip(a.adset, 18), 18) + '  '\n      + clip(a.name, 28) + '\\n';\n  }\n}\n\nconst date = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' });\nconst bt = '```';\nconst parts = [warn + ' need action', watch + ' keep an eye', ok + ' healthy'];\nif (fresh) parts.push(fresh + ' new (no baseline)');\nconst message =\n  '*Creative Fatigue - Daily Report* (' + date + ')\\n' +\n  '_ads that spent yesterday (>= $' + minSpend + '), CTR down >= ' + Math.round(minShowDrop * 100) + '% vs the ad own running-day average_\\n' +\n  bt + '\\n' + table + bt + '\\n' +\n  '*Summary:* ' + ads.length + ' ads spent yesterday - ' + parts.join(' - ') + '.\\n' +\n  '_Legend: WARN down >= ' + Math.round(warnPct * 100) + '%, WATCH ' + Math.round(watchPct * 100) + '-' + Math.round(warnPct * 100) + '%, OK < ' + Math.round(watchPct * 100) + '%, NEW not enough history._';\n\nreturn [{ json: { message, warn, watch, ok, fresh, total: ads.length } }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "bd8c9fe2-6cca-440b-b8b6-f3fa318c4bf3",
      "name": "Section: Fetch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        31776,
        1952
      ],
      "parameters": {
        "color": "#383838",
        "width": 740,
        "height": 300,
        "content": "## 1. Configure and fetch\nSet the account window and thresholds, then pull 14 days of daily ad-level insights from Meta."
      },
      "typeVersion": 1
    },
    {
      "id": "20a1456d-6f08-4abe-a51a-e40ee6f55e5a",
      "name": "Section: Detect",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        32608,
        1952
      ],
      "parameters": {
        "color": "#383838",
        "width": 348,
        "height": 300,
        "content": "## 2. Detect fatigue\nCompare each ad's latest CTR to its own running-day average and flag drops."
      },
      "typeVersion": 1
    },
    {
      "id": "0f70b891-6825-492b-b75f-a6ca7a7d3e8e",
      "name": "Section: Report",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        33056,
        1952
      ],
      "parameters": {
        "color": "#383838",
        "width": 320,
        "height": 300,
        "content": "## 3. Report\nPost the ranked table to Slack (swap for email or a dashboard)."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "584b1250-d00d-4eb4-b153-2d6ac8bf4748",
  "connections": {
    "Config": {
      "main": [
        [
          {
            "node": "Fetch daily ad insights (Meta)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every morning 9am": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect creative fatigue": {
      "main": [
        [
          {
            "node": "Slack: Fatigue Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch daily ad insights (Meta)": {
      "main": [
        [
          {
            "node": "Detect creative fatigue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}