AutomationFlowsSocial Media › Flag Meta Ad Creative Fatigue with Facebook Graph API and Send Ranked…

Flag Meta Ad Creative Fatigue with Facebook Graph API and Send Ranked…

Original n8n title: Flag Meta Ad Creative Fatigue with Facebook Graph API and Send Ranked Reports to Slack

ByNocode Expert @iamrup on n8n.io

This workflow runs every morning, pulls 14 days of daily ad-level insights from the Meta (Facebook Graph) API, detects creative fatigue by comparing yesterday’s CTR to each ad’s own historical average, and posts a ranked fatigue report to Slack via an incoming webhook. Runs…

Cron / scheduled trigger★★★★☆ complexity9 nodesHTTP RequestFacebook Graph Api
Social Media Trigger: Cron / scheduled Nodes: 9 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #17093 — we link there as the canonical source.

This workflow follows the Facebookgraphapi → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "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
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow runs every morning, pulls 14 days of daily ad-level insights from the Meta (Facebook Graph) API, detects creative fatigue by comparing yesterday’s CTR to each ad’s own historical average, and posts a ranked fatigue report to Slack via an incoming webhook. Runs…

Source: https://n8n.io/workflows/17093/ — original creator credit. Request a take-down →

More Social Media workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Social Media

📘 Multi-Photo Facebook Post (Windows Directory) – How to Use ✅ Requirements To run this automation, make sure you have the following:

Read Write File, Execute Command, Facebook Graph Api +1
Social Media

How it works

Google Sheets, Google Drive, HTTP Request +1
Social Media

This automation enables you to automatically fetch daily campaign data from Facebook Ads without manual exports. It requires connection to the Facebook Graph API and a Google Sheets account where all

Google Sheets, HTTP Request, Facebook Graph Api
Social Media

Generate Instagram Content from Top Trends with AI Image Generation. Uses telegram, splitInBatches, scheduleTrigger, stickyNote. Scheduled trigger; 44 nodes.

Telegram, HTTP Request, Postgres +2
Social Media

Generate Instagram Content from Top Trends with AI Image Generation. Uses telegram, splitInBatches, scheduleTrigger, stickyNote. Scheduled trigger; 44 nodes.

Telegram, HTTP Request, Postgres +2