{"name":"Set and forget ads workflow","nodes":[{"id":"9f010000-49aa-4bbb-8ccc-ddddeeee0001","name":"Every 6 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-260,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":6}]}}},{"id":"9f020000-49aa-4bbb-8ccc-ddddeeee0002","name":"Fetch Ad Set Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[0,180],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"adset"},{"name":"fields","value":"adset_id,adset_name,campaign_name,spend,impressions,clicks,ctr,cpc,frequency,purchase_roas,actions"},{"name":"date_preset","value":"last_7d"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"9f030000-49aa-4bbb-8ccc-ddddeeee0003","name":"Fetch Ad Set Budgets","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[0,420],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/adsets","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,status,effective_status,daily_budget,lifetime_budget,bid_strategy,created_time"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"9f040000-49aa-4bbb-8ccc-ddddeeee0004","name":"Merge Insights With Budgets","type":"n8n-nodes-base.merge","typeVersion":3,"position":[260,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"9f050000-49aa-4bbb-8ccc-ddddeeee0005","name":"Normalize Meta Fields","type":"n8n-nodes-base.code","typeVersion":2,"position":[520,300],"parameters":{"jsCode":"// Meta returns everything as strings and purchase_roas as an array of {action_type, value}.\n// Flatten it into a clean numeric row per ad set, plus derived per-unit economics.\nconst AOV = 62;            // average order value, used to sanity check CPA vs ROAS\nconst TARGET_ROAS = 1.8;   // account breakeven + margin\n\nconst num = (v) => {\n  const n = typeof v === 'string' ? parseFloat(v) : v;\n  return Number.isFinite(n) ? n : 0;\n};\n\nconst rows = $input.all().map((item) => {\n  const r = item.json;\n  const spend = num(r.spend);\n  const impressions = num(r.impressions);\n  const clicks = num(r.clicks);\n  const roas = Array.isArray(r.purchase_roas)\n    ? num((r.purchase_roas.find(x => x.action_type === 'omni_purchase') || r.purchase_roas[0] || {}).value)\n    : num(r.purchase_roas);\n  const purchases = Array.isArray(r.actions)\n    ? num((r.actions.find(a => a.action_type === 'purchase' || a.action_type === 'omni_purchase') || {}).value)\n    : num(r.purchases);\n\n  // Meta budgets come back in minor units (cents).\n  const dailyBudget = num(r.daily_budget) / 100;\n  const lifetimeBudget = num(r.lifetime_budget) / 100;\n  const revenue = roas * spend;\n  const cpa = purchases > 0 ? spend / purchases : null;\n  const cpm = impressions > 0 ? (spend / impressions) * 1000 : 0;\n  const ctr = impressions > 0 ? (clicks / impressions) * 100 : num(r.ctr);\n  const cvr = clicks > 0 ? (purchases / clicks) * 100 : 0;\n\n  const ageDays = r.created_time\n    ? Math.max(1, Math.floor((Date.now() - new Date(r.created_time).getTime()) / 86400000))\n    : 7;\n\n  // Pacing: how much of the daily budget the last 7d run rate is actually consuming.\n  const dailySpend = spend / 7;\n  const pacing = dailyBudget > 0 ? dailySpend / dailyBudget : 0;\n\n  return { json: {\n    adset_id: r.adset_id || r.id,\n    adset_name: r.adset_name || r.name,\n    campaign_name: r.campaign_name || '',\n    effective_status: r.effective_status || r.status || 'UNKNOWN',\n    spend: Math.round(spend * 100) / 100,\n    revenue: Math.round(revenue * 100) / 100,\n    impressions, clicks, purchases,\n    roas: Math.round(roas * 100) / 100,\n    cpa: cpa === null ? null : Math.round(cpa * 100) / 100,\n    cpm: Math.round(cpm * 100) / 100,\n    ctr: Math.round(ctr * 100) / 100,\n    cvr: Math.round(cvr * 100) / 100,\n    frequency: Math.round(num(r.frequency) * 100) / 100,\n    daily_budget: dailyBudget,\n    lifetime_budget: lifetimeBudget,\n    bid_strategy: r.bid_strategy || 'LOWEST_COST_WITHOUT_CAP',\n    age_days: ageDays,\n    daily_spend: Math.round(dailySpend * 100) / 100,\n    pacing: Math.round(pacing * 100) / 100,\n    target_roas: TARGET_ROAS,\n    breakeven_cpa: Math.round((AOV / TARGET_ROAS) * 100) / 100,\n    evaluated_at: new Date().toISOString()\n  } };\n});\n\n// Only ever consider ad sets that are actually delivering.\nreturn rows.filter(r => r.json.effective_status === 'ACTIVE' || r.json.effective_status === 'UNKNOWN');"}},{"id":"9f060000-49aa-4bbb-8ccc-ddddeeee0006","name":"Enough Data To Act?","type":"n8n-nodes-base.if","typeVersion":2,"position":[780,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.impressions }}","rightValue":4000,"operator":{"type":"number","operation":"gte"}},{"id":"c2","leftValue":"={{ $json.spend }}","rightValue":60,"operator":{"type":"number","operation":"gte"}},{"id":"c3","leftValue":"={{ $json.age_days }}","rightValue":3,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"9f070000-49aa-4bbb-8ccc-ddddeeee0007","name":"Mark As Learning Phase","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1040,520],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"decision","value":"hold","type":"string"},{"id":"a2","name":"rule","value":"min_data_threshold","type":"string"},{"id":"a3","name":"reason","value":"=Below threshold: {{ $json.impressions }} impr / ${{ $json.spend }} spend / {{ $json.age_days }}d old","type":"string"},{"id":"a4","name":"confidence","value":0,"type":"number"},{"id":"a5","name":"applied","value":false,"type":"boolean"}]},"options":{},"includeOtherFields":true}},{"id":"9f080000-49aa-4bbb-8ccc-ddddeeee0008","name":"Score And Apply Safety Rules","type":"n8n-nodes-base.code","typeVersion":2,"position":[1040,300],"parameters":{"jsCode":"// Conservative always-on rule engine. Exactly one decision per ad set:\n//   kill  -> pause it (only when the evidence is overwhelming)\n//   nudge -> +/-20% budget step (never more, never more than once per run)\n//   hold  -> do nothing, but log why\n// Rules are ordered by severity; the first one that fires wins.\n\nconst MAX_STEP = 0.20;        // hard cap on any single budget move\nconst MAX_DAILY_BUDGET = 400; // account guardrail\nconst MIN_DAILY_BUDGET = 10;\nconst MIN_PURCHASES_TO_KILL = 0; // killing is allowed on zero purchases if spend is high enough\n\nconst out = $input.all().map((item) => {\n  const r = { ...item.json };\n  const t = r.target_roas;\n  const killSpend = Math.max(3 * r.breakeven_cpa, 120); // never kill before 3x a breakeven CPA\n\n  // Confidence grows with volume: 100 conversions or 50k impressions == full confidence.\n  const volConf = Math.min(1, r.impressions / 50000);\n  const convConf = Math.min(1, r.purchases / 100);\n  const confidence = Math.round(Math.max(volConf, convConf) * 100) / 100;\n\n  let decision = 'hold';\n  let rule = 'no_rule_fired';\n  let reason = `ROAS ${r.roas} vs target ${t}, nothing decisive`;\n  let newBudget = r.daily_budget;\n\n  if (r.spend >= killSpend && r.purchases <= MIN_PURCHASES_TO_KILL) {\n    decision = 'kill'; rule = 'zero_conversions';\n    reason = `$${r.spend} spent, ${r.purchases} purchases (kill floor $${Math.round(killSpend)})`;\n  } else if (r.spend >= killSpend && r.roas < t * 0.5 && confidence >= 0.4) {\n    decision = 'kill'; rule = 'roas_collapse';\n    reason = `ROAS ${r.roas} is under half of target ${t} on $${r.spend}`;\n  } else if (r.frequency >= 4.5 && r.ctr < 0.7 && r.roas < t) {\n    decision = 'kill'; rule = 'creative_fatigue';\n    reason = `frequency ${r.frequency} with CTR ${r.ctr}% and ROAS ${r.roas}`;\n  } else if (r.roas >= t * 1.5 && r.pacing >= 0.85 && confidence >= 0.3) {\n    decision = 'nudge'; rule = 'scale_winner';\n    newBudget = Math.min(MAX_DAILY_BUDGET, Math.round(r.daily_budget * (1 + MAX_STEP)));\n    reason = `ROAS ${r.roas} >= 1.5x target and pacing at ${Math.round(r.pacing * 100)}%`;\n  } else if (r.roas < t * 0.8 && r.roas >= t * 0.5 && r.purchases >= 3) {\n    decision = 'nudge'; rule = 'trim_underperformer';\n    newBudget = Math.max(MIN_DAILY_BUDGET, Math.round(r.daily_budget * (1 - MAX_STEP)));\n    reason = `ROAS ${r.roas} is 50-80% of target ${t} on ${r.purchases} purchases`;\n  } else if (r.pacing < 0.5 && r.roas >= t) {\n    decision = 'hold'; rule = 'profitable_but_underpacing';\n    reason = `only spending ${Math.round(r.pacing * 100)}% of budget - budget is not the constraint`;\n  }\n\n  // Safety brakes: nothing acts on low confidence, and a nudge that rounds to nothing is a hold.\n  if (decision === 'kill' && confidence < 0.25) {\n    decision = 'hold'; rule = 'kill_blocked_low_confidence';\n    reason = `${rule} would have fired but confidence is ${confidence}`;\n  }\n  if (decision === 'nudge' && Math.abs(newBudget - r.daily_budget) < 1) {\n    decision = 'hold'; rule = 'nudge_too_small';\n    reason = `budget step under $1 on a $${r.daily_budget} budget`;\n  }\n\n  const projectedDailySpend = decision === 'nudge' ? Math.round(newBudget * r.pacing * 100) / 100 : r.daily_spend;\n\n  return { json: { ...r, decision, rule, reason, confidence,\n    new_daily_budget: newBudget,\n    budget_delta: Math.round((newBudget - r.daily_budget) * 100) / 100,\n    projected_daily_spend: projectedDailySpend } };\n});\n\n// Kills first so the Slack digest leads with the loudest changes.\nconst order = { kill: 0, nudge: 1, hold: 2 };\nreturn out.sort((a, b) => order[a.json.decision] - order[b.json.decision]);"}},{"id":"9f090000-49aa-4bbb-8ccc-ddddeeee0009","name":"Dedupe Against Recent Actions","type":"n8n-nodes-base.code","typeVersion":2,"position":[1300,300],"parameters":{"jsCode":"// Set-and-forget only works if it does not thrash. Two guards:\n//  1) cooldown - the same ad set cannot be touched twice inside 48h\n//  2) blast radius - at most 3 kills and 5 nudges per run, whole-account\nconst COOLDOWN_HOURS = 48;\nconst MAX_KILLS = 3;\nconst MAX_NUDGES = 5;\n\nconst store = $getWorkflowStaticData('global');\nstore.lastAction = store.lastAction || {}; // adset_id -> { at, decision }\nconst now = Date.now();\n\n// Drop expired cooldown entries so the store cannot grow forever.\nfor (const [id, rec] of Object.entries(store.lastAction)) {\n  if (now - rec.at > COOLDOWN_HOURS * 3600000) delete store.lastAction[id];\n}\n\nlet kills = 0;\nlet nudges = 0;\n\nconst rows = $input.all().map((item) => {\n  const r = { ...item.json };\n  const prev = store.lastAction[r.adset_id];\n\n  if (r.decision !== 'hold' && prev) {\n    const hoursAgo = Math.round((now - prev.at) / 3600000);\n    r.decision = 'hold';\n    r.rule = 'cooldown_active';\n    r.reason = `already ${prev.decision} ${hoursAgo}h ago, cooldown is ${COOLDOWN_HOURS}h`;\n  }\n\n  if (r.decision === 'kill') {\n    if (kills >= MAX_KILLS) { r.decision = 'hold'; r.rule = 'kill_cap_reached'; r.reason = `already killed ${MAX_KILLS} ad sets this run`; }\n    else kills++;\n  } else if (r.decision === 'nudge') {\n    if (nudges >= MAX_NUDGES) { r.decision = 'hold'; r.rule = 'nudge_cap_reached'; r.reason = `already nudged ${MAX_NUDGES} ad sets this run`; }\n    else nudges++;\n  }\n\n  if (r.decision !== 'hold') store.lastAction[r.adset_id] = { at: now, decision: r.decision };\n\n  r.run_kills = kills;\n  r.run_nudges = nudges;\n  r.applied = r.decision !== 'hold';\n  return { json: r };\n});\n\nreturn rows;"}},{"id":"9f100000-49aa-4bbb-8ccc-ddddeeee0010","name":"Action Required?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1560,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.decision }}","rightValue":"hold","operator":{"type":"string","operation":"notEquals"}}]},"options":{}}},{"id":"9f110000-49aa-4bbb-8ccc-ddddeeee0011","name":"Kill Decision?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1820,140],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.decision }}","rightValue":"kill","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"9f120000-49aa-4bbb-8ccc-ddddeeee0012","name":"Pause Losing Ad Set","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2080,0],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.adset_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ status: \"PAUSED\" }) }}","options":{}}},{"id":"9f130000-49aa-4bbb-8ccc-ddddeeee0013","name":"Apply Budget Nudge","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2080,240],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.adset_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ daily_budget: Math.round($json.new_daily_budget * 100) }) }}","options":{}}},{"id":"9f140000-49aa-4bbb-8ccc-ddddeeee0014","name":"Record Hold Reason","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1820,520],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"applied","value":false,"type":"boolean"},{"id":"a2","name":"action_note","value":"=No change: {{ $json.rule }} — {{ $json.reason }}","type":"string"}]},"options":{},"includeOtherFields":true}},{"id":"9f150000-49aa-4bbb-8ccc-ddddeeee0015","name":"Build Audit Rows","type":"n8n-nodes-base.code","typeVersion":2,"position":[2340,300],"parameters":{"jsCode":"// One flat audit row per evaluation - held ones too, so the log explains inaction.\nconst runId = `run_${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '')}`;\n// Meta write nodes replace $json with the Graph API ack ({success:true}), so recover\n// the evaluated row from the last node that still had it before logging.\nconst rows = $input.all().map((item, i) => {\n  if (item.json && item.json.adset_id) return item.json;\n  try {\n    const src = $('Dedupe Against Recent Actions').itemMatching(i).json;\n    return { ...src, meta_response: item.json };\n  } catch (e) {\n    return item.json || {};\n  }\n});\n\nconst summary = rows.reduce((acc, r) => {\n  acc[r.decision] = (acc[r.decision] || 0) + 1;\n  acc.spend += r.spend || 0;\n  acc.revenue += r.revenue || 0;\n  return acc;\n}, { kill: 0, nudge: 0, hold: 0, spend: 0, revenue: 0 });\n\nconst accountRoas = summary.spend > 0 ? Math.round((summary.revenue / summary.spend) * 100) / 100 : 0;\n\nreturn rows.map(r => ({ json: {\n  run_id: runId,\n  evaluated_at: r.evaluated_at || new Date().toISOString(),\n  adset_id: r.adset_id,\n  adset_name: r.adset_name,\n  campaign_name: r.campaign_name,\n  decision: r.decision,\n  rule: r.rule,\n  reason: r.reason,\n  confidence: r.confidence,\n  applied: r.applied === true,\n  spend: r.spend,\n  roas: r.roas,\n  cpa: r.cpa,\n  ctr: r.ctr,\n  frequency: r.frequency,\n  purchases: r.purchases,\n  old_daily_budget: r.daily_budget,\n  new_daily_budget: r.new_daily_budget != null ? r.new_daily_budget : r.daily_budget,\n  budget_delta: r.budget_delta || 0,\n  run_kills: summary.kill,\n  run_nudges: summary.nudge,\n  run_holds: summary.hold,\n  account_roas_7d: accountRoas\n} }));"}},{"id":"9f160000-49aa-4bbb-8ccc-ddddeeee0016","name":"Append To Decision Log","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2600,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Decision Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"9f170000-49aa-4bbb-8ccc-ddddeeee0017","name":"Anything Actually Changed?","type":"n8n-nodes-base.if","typeVersion":2,"position":[2860,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.applied }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"9f180000-49aa-4bbb-8ccc-ddddeeee0018","name":"Post Change Digest","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[3120,140],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=🤖 {{ $json.decision === \"kill\" ? \"PAUSED\" : \"BUDGET\" }} — {{ $json.adset_name }}\nRule: {{ $json.rule }} ({{ $json.reason }})\nROAS {{ $json.roas }} on ${{ $json.spend }} · budget ${{ $json.old_daily_budget }} → ${{ $json.new_daily_budget }}\nRun: {{ $json.run_kills }} kills / {{ $json.run_nudges }} nudges","otherOptions":{}}},{"id":"9f190000-49aa-4bbb-8ccc-ddddeeee0019","name":"Mark Quiet Run","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[3120,460],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"status","value":"no_action_needed","type":"string"},{"id":"a2","name":"note","value":"=Evaluated {{ $json.adset_name }} — {{ $json.rule }}","type":"string"}]},"options":{},"includeOtherFields":true}},{"id":"9f200000-49aa-4bbb-8ccc-ddddeeee0020","name":"Note: Collect","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-320,-40],"parameters":{"content":"## 1. COLLECT\nEvery 6h pull last-7d ad set insights and the live budget/status objects, then merge them into one row per ad set and normalize Meta's string fields into real numbers.","height":300,"width":460,"color":4}},{"id":"9f210000-49aa-4bbb-8ccc-ddddeeee0021","name":"Note: Decide","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[760,-100],"parameters":{"content":"## 2. DECIDE (safely)\nNothing is touched below the data threshold (4k impressions, $60 spend, 3 days old). The rule engine picks ONE of kill / nudge / hold, budget steps are capped at ±20%, and a 48h cooldown plus per-run caps (3 kills, 5 nudges) stop it thrashing.","height":340,"width":500,"color":3}},{"id":"9f220000-49aa-4bbb-8ccc-ddddeeee0022","name":"Note: Act And Log","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[2300,-40],"parameters":{"content":"## 3. ACT + LOG\nKills pause the ad set, nudges patch daily_budget. EVERY evaluation — including holds — is written to the decision log sheet, so you can always answer \"why did it not do anything?\". Slack only fires on real changes.","height":300,"width":460,"color":5}}],"connections":{"Every 6 Hours":{"main":[[{"node":"Fetch Ad Set Insights","type":"main","index":0},{"node":"Fetch Ad Set Budgets","type":"main","index":0}]]},"Fetch Ad Set Insights":{"main":[[{"node":"Merge Insights With Budgets","type":"main","index":0}]]},"Fetch Ad Set Budgets":{"main":[[{"node":"Merge Insights With Budgets","type":"main","index":1}]]},"Merge Insights With Budgets":{"main":[[{"node":"Normalize Meta Fields","type":"main","index":0}]]},"Normalize Meta Fields":{"main":[[{"node":"Enough Data To Act?","type":"main","index":0}]]},"Enough Data To Act?":{"main":[[{"node":"Score And Apply Safety Rules","type":"main","index":0}],[{"node":"Mark As Learning Phase","type":"main","index":0}]]},"Mark As Learning Phase":{"main":[[{"node":"Build Audit Rows","type":"main","index":0}]]},"Score And Apply Safety Rules":{"main":[[{"node":"Dedupe Against Recent Actions","type":"main","index":0}]]},"Dedupe Against Recent Actions":{"main":[[{"node":"Action Required?","type":"main","index":0}]]},"Action Required?":{"main":[[{"node":"Kill Decision?","type":"main","index":0}],[{"node":"Record Hold Reason","type":"main","index":0}]]},"Kill Decision?":{"main":[[{"node":"Pause Losing Ad Set","type":"main","index":0}],[{"node":"Apply Budget Nudge","type":"main","index":0}]]},"Pause Losing Ad Set":{"main":[[{"node":"Build Audit Rows","type":"main","index":0}]]},"Apply Budget Nudge":{"main":[[{"node":"Build Audit Rows","type":"main","index":0}]]},"Record Hold Reason":{"main":[[{"node":"Build Audit Rows","type":"main","index":0}]]},"Build Audit Rows":{"main":[[{"node":"Append To Decision Log","type":"main","index":0}]]},"Append To Decision Log":{"main":[[{"node":"Anything Actually Changed?","type":"main","index":0}]]},"Anything Actually Changed?":{"main":[[{"node":"Post Change Digest","type":"main","index":0}],[{"node":"Mark Quiet Run","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}