{"name":"Kill losing ads workflow","nodes":[{"id":"aa000001-1111-4222-8333-444455556601","name":"Every 6 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-460,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":6}]}}},{"id":"aa000002-1111-4222-8333-444455556602","name":"Fetch Ad Level Insights","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,adset_name,campaign_name,spend,impressions,clicks,ctr,cpm,frequency,actions,action_values,purchase_roas"},{"name":"action_attribution_windows","value":"7d_click,1d_view"},{"name":"date_preset","value":"last_7d"},{"name":"filtering","value":"[{\"field\":\"spend\",\"operator\":\"GREATER_THAN\",\"value\":150}]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"aa000003-1111-4222-8333-444455556603","name":"Fetch Ad Delivery Status","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-220,420],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/ads","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,effective_status,created_time,adset{id,name,daily_budget}"},{"name":"effective_status","value":"[\"ACTIVE\"]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"aa000004-1111-4222-8333-444455556604","name":"Merge Insights With Status","type":"n8n-nodes-base.merge","typeVersion":3,"position":[20,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"aa000005-1111-4222-8333-444455556605","name":"Score Ads Against Kill Rules","type":"n8n-nodes-base.code","typeVersion":2,"position":[260,300],"parameters":{"jsCode":"// ---- Kill rules config (edit here, not in the branches below) -------------\nconst CFG = {\n  lookbackDays: 7,\n  minSpend: 150,        // don't judge an ad before it has had a fair shot\n  minImpressions: 2000, // guard against thin-data false kills\n  targetCpa: 45,        // your break-even CPA\n  cpaKillMultiple: 2,   // kill at 2x target CPA\n  breakevenRoas: 1.6,\n  freqCeiling: 3.2,     // fatigue signal\n  autoPauseConfidence: 0.75\n};\n\nconst num = (v) => {\n  const n = typeof v === 'string' ? parseFloat(v) : v;\n  return Number.isFinite(n) ? n : 0;\n};\n\n// Meta returns actions/purchase_roas 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 ? num(hit.value) : 0;\n};\n\nconst rows = $input.all().map((i) => i.json);\n\n// ---- 0. status lookup -----------------------------------------------------\n// The merge is positional, so an insights row can end up carrying the delivery\n// status of a DIFFERENT ad. Rebuild the map from the /ads side (keyed by its\n// own `id`) and look each ad up by its real ad_id instead of trusting the pair.\nconst statusById = new Map();\nfor (const r of rows) {\n  if (r.id && r.effective_status) statusById.set(String(r.id), String(r.effective_status).toUpperCase());\n}\n\n// ---- 1. dedupe: insights can return one row per ad per breakdown ---------\nconst byAd = new Map();\nfor (const r of rows) {\n  const id = String(r.ad_id || r.id || '');\n  if (!id) continue;\n  const prev = byAd.get(id);\n  if (!prev) { byAd.set(id, { ...r }); continue; }\n  // same ad twice -> sum the money/volume fields, keep the latest labels\n  prev.spend = num(prev.spend) + num(r.spend);\n  prev.impressions = num(prev.impressions) + num(r.impressions);\n  prev.clicks = num(prev.clicks) + num(r.clicks);\n  prev.actions = [...(prev.actions || []), ...(r.actions || [])];\n  prev.action_values = [...(prev.action_values || []), ...(r.action_values || [])];\n}\n\nconst out = [];\n\nfor (const r of byAd.values()) {\n  const spend = num(r.spend);\n  const impressions = num(r.impressions);\n  const clicks = num(r.clicks);\n  const frequency = num(r.frequency);\n  const purchases = pick(r.actions, 'purchase') + pick(r.actions, 'omni_purchase');\n  const revenue = pick(r.action_values, 'purchase') || num(r.purchase_roas_value);\n  const roas = Array.isArray(r.purchase_roas)\n    ? pick(r.purchase_roas, 'purchase') || num(r.purchase_roas[0] && r.purchase_roas[0].value)\n    : num(r.purchase_roas) || (spend ? revenue / spend : 0);\n  const cpa = purchases > 0 ? spend / purchases : null;\n  const ctr = impressions ? (clicks / impressions) * 100 : 0;\n  const adId = String(r.ad_id || r.id || '');\n  const status = statusById.get(adId) || 'UNKNOWN';\n\n  // ---- 2. eligibility gates ---------------------------------------------\n  // Unknown status means the ad fell outside the ACTIVE fetch -> already off.\n  if (status !== 'ACTIVE') continue;                       // already off, nothing to reclaim\n  if (spend < CFG.minSpend) continue;                      // not enough spend to judge\n  if (impressions < CFG.minImpressions) continue;          // not enough delivery to judge\n\n  // ---- 3. kill reasons ---------------------------------------------------\n  const reasons = [];\n  let confidence = 0;\n\n  if (purchases === 0) {\n    // zero purchases past threshold: confidence scales with how far past it is\n    const overshoot = spend / CFG.minSpend;                 // 1.0 = exactly at threshold\n    reasons.push('0 purchases on $' + spend.toFixed(0) + ' spend');\n    confidence = Math.max(confidence, Math.min(0.95, 0.55 + 0.2 * (overshoot - 1)));\n  }\n  if (cpa !== null && cpa > CFG.targetCpa * CFG.cpaKillMultiple) {\n    const severity = cpa / (CFG.targetCpa * CFG.cpaKillMultiple); // 1.0 = exactly at 2x\n    reasons.push('CPA $' + cpa.toFixed(2) + ' vs $' + CFG.targetCpa + ' target (' +\n      (cpa / CFG.targetCpa).toFixed(1) + 'x)');\n    confidence = Math.max(confidence, Math.min(0.95, 0.6 + 0.25 * (severity - 1)));\n  }\n  if (purchases > 0 && roas > 0 && roas < CFG.breakevenRoas * 0.5) {\n    reasons.push('ROAS ' + roas.toFixed(2) + ' is under half of breakeven ' + CFG.breakevenRoas);\n    confidence = Math.max(confidence, 0.7);\n  }\n  if (frequency > CFG.freqCeiling && (cpa === null || cpa > CFG.targetCpa)) {\n    reasons.push('frequency ' + frequency.toFixed(2) + ' with no efficiency left');\n    confidence = Math.min(0.95, confidence + 0.05);\n  }\n\n  const isKill = reasons.length > 0;\n\n  // ---- 4. pacing projection: what this ad burns if left running ---------\n  const dailySpend = spend / CFG.lookbackDays;\n  const wastedSpend = purchases === 0\n    ? spend\n    : Math.max(0, spend - purchases * CFG.targetCpa); // spend above what the results were worth\n  const projected30d = Math.round(dailySpend * 30);\n\n  out.push({\n    json: {\n      ad_id: String(r.ad_id || r.id),\n      ad_name: r.ad_name || '(unnamed ad)',\n      adset_id: r.adset_id || null,\n      adset_name: r.adset_name || '',\n      campaign_name: r.campaign_name || '',\n      spend: Math.round(spend * 100) / 100,\n      impressions,\n      clicks,\n      ctr: Math.round(ctr * 100) / 100,\n      frequency: Math.round(frequency * 100) / 100,\n      purchases,\n      revenue: Math.round(revenue * 100) / 100,\n      roas: Math.round(roas * 100) / 100,\n      cpa: cpa === null ? null : Math.round(cpa * 100) / 100,\n      daily_spend: Math.round(dailySpend * 100) / 100,\n      wasted_spend: Math.round(wastedSpend * 100) / 100,\n      projected_30d_burn: projected30d,\n      is_kill: isKill,\n      confidence: Math.round(confidence * 100) / 100,\n      kill_reason: reasons.join(' + ') || 'passes all kill rules',\n      decided_at: new Date().toISOString()\n    }\n  });\n}\n\n// worst offenders first so the Slack post reads top-down\nout.sort((a, b) => b.json.wasted_spend - a.json.wasted_spend);\nreturn out;"}},{"id":"aa000006-1111-4222-8333-444455556606","name":"Failed A Kill Rule?","type":"n8n-nodes-base.if","typeVersion":2,"position":[500,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"k1","leftValue":"={{ $json.is_kill }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"aa000007-1111-4222-8333-444455556607","name":"Flag Survivors For Watchlist","type":"n8n-nodes-base.code","typeVersion":2,"position":[740,560],"parameters":{"jsCode":"// The ads that survived this sweep still deserve a watchlist entry: anything\n// within 25% of a kill rule gets flagged so we notice the slide before it costs us.\nconst CFG = { targetCpa: 45, warnRatio: 1.5 };\nreturn $input.all().map((i) => i.json).map((r) => {\n  const cpa = r.cpa === null || r.cpa === undefined ? null : Number(r.cpa);\n  const nearCpaKill = cpa !== null && cpa > CFG.targetCpa * CFG.warnRatio;\n  const nearFatigue = Number(r.frequency) > 2.6;\n  const watch = nearCpaKill || nearFatigue;\n  return { json: {\n    ad_id: r.ad_id,\n    ad_name: r.ad_name,\n    campaign_name: r.campaign_name,\n    spend: r.spend,\n    purchases: r.purchases,\n    roas: r.roas,\n    cpa: r.cpa,\n    frequency: r.frequency,\n    status: 'kept',\n    watchlist: watch,\n    watch_reason: watch\n      ? [nearCpaKill ? 'CPA drifting toward 2x target' : null,\n         nearFatigue ? 'frequency climbing' : null].filter(Boolean).join(' + ')\n      : 'healthy',\n    checked_at: new Date().toISOString()\n  } };\n});"}},{"id":"aa000008-1111-4222-8333-444455556608","name":"Log Watchlist To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[980,560],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Watchlist"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"aa000009-1111-4222-8333-444455556609","name":"Confident Enough To Auto Pause?","type":"n8n-nodes-base.if","typeVersion":2,"position":[740,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.confidence }}","rightValue":0.75,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"aa00000a-1111-4222-8333-44445555660a","name":"Ask Buyer To Review Borderline Ad","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[980,60],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-review","mode":"name"},"text":"=🟡 Borderline kill (confidence {{ $json.confidence }}) — *{{ $json.ad_name }}* in {{ $json.campaign_name }}\n${{ $json.spend }} spent · {{ $json.purchases }} purchases · CPA {{ $json.cpa }} · freq {{ $json.frequency }}\nReason: {{ $json.kill_reason }}\nPause it manually or let it run 24h more.","otherOptions":{}}},{"id":"aa00000b-1111-4222-8333-44445555660b","name":"Loop Over Kill List","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[980,300],"parameters":{"batchSize":1,"options":{}}},{"id":"aa00000c-1111-4222-8333-44445555660c","name":"Pause Ad Via Graph API","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1220,480],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.ad_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ status: \"PAUSED\" }) }}","options":{}}},{"id":"aa00000d-1111-4222-8333-44445555660d","name":"Mark Ad As Paused","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1460,480],"parameters":{"assignments":{"assignments":[{"id":"s1","name":"ad_id","value":"={{ $('Loop Over Kill List').item.json.ad_id }}","type":"string"},{"id":"s2","name":"ad_name","value":"={{ $('Loop Over Kill List').item.json.ad_name }}","type":"string"},{"id":"s3","name":"campaign_name","value":"={{ $('Loop Over Kill List').item.json.campaign_name }}","type":"string"},{"id":"s4","name":"spend","value":"={{ $('Loop Over Kill List').item.json.spend }}","type":"number"},{"id":"s5","name":"purchases","value":"={{ $('Loop Over Kill List').item.json.purchases }}","type":"number"},{"id":"s6","name":"cpa","value":"={{ $('Loop Over Kill List').item.json.cpa }}","type":"number"},{"id":"s7","name":"wasted_spend","value":"={{ $('Loop Over Kill List').item.json.wasted_spend }}","type":"number"},{"id":"s8","name":"daily_spend","value":"={{ $('Loop Over Kill List').item.json.daily_spend }}","type":"number"},{"id":"s9","name":"projected_30d_burn","value":"={{ $('Loop Over Kill List').item.json.projected_30d_burn }}","type":"number"},{"id":"s10","name":"kill_reason","value":"={{ $('Loop Over Kill List').item.json.kill_reason }}","type":"string"},{"id":"s11","name":"paused","value":"={{ true }}","type":"boolean"}]},"options":{}}},{"id":"aa00000e-1111-4222-8333-44445555660e","name":"Log Kill To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1700,480],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Kill Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"aa00000f-1111-4222-8333-44445555660f","name":"Build Kill Report","type":"n8n-nodes-base.code","typeVersion":2,"position":[1220,300],"parameters":{"jsCode":"const killed = $input.all().map((i) => i.json).filter((r) => r.paused === true || r.is_kill === true);\n\nif (!killed.length) {\n  return [{ json: {\n    headline: '✅ No ads hit the kill rules this run.',\n    slack_text: '✅ *Kill sweep clean* — every active ad is inside the spend / CPA guardrails.',\n    killed_count: 0, reclaimed_spend: 0, projected_savings: 0\n  } }];\n}\n\nconst money = (n) => '$' + Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 0 });\n\nconst reclaimed = killed.reduce((s, r) => s + Number(r.wasted_spend || 0), 0);\nconst dailyReclaimed = killed.reduce((s, r) => s + Number(r.daily_spend || 0), 0);\nconst projected = killed.reduce((s, r) => s + Number(r.projected_30d_burn || 0), 0);\nconst zeroPurchase = killed.filter((r) => Number(r.purchases) === 0).length;\n\nconst lines = killed\n  .sort((a, b) => Number(b.wasted_spend) - Number(a.wasted_spend))\n  .slice(0, 15)\n  .map((r, i) => {\n    const cpa = r.cpa === null || r.cpa === undefined ? 'no CPA' : money(r.cpa) + ' CPA';\n    return (i + 1) + '. *' + r.ad_name + '* — ' + money(r.spend) + ' spent, ' +\n      r.purchases + ' purchases, ' + cpa + '\\n     ↳ ' + r.kill_reason +\n      ' | wasted ' + money(r.wasted_spend);\n  });\n\nconst slack_text = [\n  '🔪 *Kill list — ' + killed.length + ' ads paused*',\n  money(reclaimed) + ' of wasted spend reclaimed · ' + money(dailyReclaimed) + '/day freed up',\n  zeroPurchase + ' of them had zero purchases · ' + money(projected) + ' of 30-day burn avoided',\n  '',\n  ...lines\n].join('\\n');\n\nreturn [{ json: {\n  killed_count: killed.length,\n  reclaimed_spend: Math.round(reclaimed),\n  daily_spend_freed: Math.round(dailyReclaimed),\n  projected_savings: Math.round(projected),\n  zero_purchase_count: zeroPurchase,\n  slack_text\n} }];"}},{"id":"aa000010-1111-4222-8333-444455556610","name":"Post Kill List To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1460,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"aa000011-1111-4222-8333-444455556611","name":"Note Fetch","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-500,20],"parameters":{"content":"## 1. PULL THE ACCOUNT\nLast 7 days of ad-level insights (spend, purchases, purchase_roas, frequency) merged with live effective_status so we never try to pause something already off.","height":260,"width":420,"color":4}},{"id":"aa000012-1111-4222-8333-444455556612","name":"Note Rules","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[240,20],"parameters":{"content":"## 2. KILL RULES\nMin $150 spend + 2k impressions before judging. Kill if: 0 purchases past threshold, CPA > 2x target ($45), ROAS under half of breakeven, or frequency > 3.2 with no efficiency. Confidence >= 0.75 auto-pauses; below that a human decides.","height":260,"width":440,"color":3}},{"id":"aa000013-1111-4222-8333-444455556613","name":"Note Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1180,700],"parameters":{"content":"## 3. PAUSE, LOG, REPORT\nOne ad at a time via POST /{ad_id} status=PAUSED, each kill appended to the Kill Log sheet, then one Slack post with the full kill list and total reclaimed / projected 30-day spend.","height":240,"width":440,"color":5}}],"connections":{"Every 6 Hours":{"main":[[{"node":"Fetch Ad Level Insights","type":"main","index":0},{"node":"Fetch Ad Delivery Status","type":"main","index":0}]]},"Fetch Ad Level Insights":{"main":[[{"node":"Merge Insights With Status","type":"main","index":0}]]},"Fetch Ad Delivery Status":{"main":[[{"node":"Merge Insights With Status","type":"main","index":1}]]},"Merge Insights With Status":{"main":[[{"node":"Score Ads Against Kill Rules","type":"main","index":0}]]},"Score Ads Against Kill Rules":{"main":[[{"node":"Failed A Kill Rule?","type":"main","index":0}]]},"Failed A Kill Rule?":{"main":[[{"node":"Confident Enough To Auto Pause?","type":"main","index":0}],[{"node":"Flag Survivors For Watchlist","type":"main","index":0}]]},"Flag Survivors For Watchlist":{"main":[[{"node":"Log Watchlist To Sheet","type":"main","index":0}]]},"Confident Enough To Auto Pause?":{"main":[[{"node":"Loop Over Kill List","type":"main","index":0}],[{"node":"Ask Buyer To Review Borderline Ad","type":"main","index":0}]]},"Loop Over Kill List":{"main":[[{"node":"Build Kill Report","type":"main","index":0}],[{"node":"Pause Ad Via Graph API","type":"main","index":0}]]},"Pause Ad Via Graph API":{"main":[[{"node":"Mark Ad As Paused","type":"main","index":0}]]},"Mark Ad As Paused":{"main":[[{"node":"Log Kill To Sheet","type":"main","index":0}]]},"Log Kill To Sheet":{"main":[[{"node":"Loop Over Kill List","type":"main","index":0}]]},"Build Kill Report":{"main":[[{"node":"Post Kill List To Slack","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}