{"name":"Meta ads on autopilot workflow","nodes":[{"id":"11111111-1111-4111-8111-111111111111","name":"Every 4 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-260,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":4}]}}},{"id":"22222222-2222-4222-8222-222222222222","name":"Load Autopilot Config","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[0,300],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"ad_account_id","value":"act_ID","type":"string"},{"id":"a2","name":"target_cpa","value":35,"type":"number"},{"id":"a3","name":"pause_cpa_multiple","value":1.4,"type":"number"},{"id":"a4","name":"scale_cpa_multiple","value":0.75,"type":"number"},{"id":"a5","name":"min_spend_to_judge","value":120,"type":"number"},{"id":"a6","name":"min_purchases_to_scale","value":3,"type":"number"},{"id":"a7","name":"budget_step_pct","value":0.2,"type":"number"},{"id":"a8","name":"max_daily_budget","value":400,"type":"number"},{"id":"a9","name":"spend_cap_ratio","value":0.9,"type":"number"},{"id":"a10","name":"window_days","value":3,"type":"number"}]},"options":{}}},{"id":"31313131-3131-4131-8131-313131313131","name":"Fetch Ad Set Budgets","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[260,300],"parameters":{"url":"=https://graph.facebook.com/v21.0/{{ $json.ad_account_id }}/adsets","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,daily_budget,lifetime_budget,effective_status,campaign{name}"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"33333333-3333-4333-8333-333333333333","name":"Fetch Ad Set Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[520,300],"parameters":{"url":"=https://graph.facebook.com/v21.0/{{ $('Load Autopilot Config').first().json.ad_account_id }}/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"adset"},{"name":"fields","value":"adset_id,adset_name,campaign_name,spend,impressions,clicks,frequency,purchase_roas,actions,action_values"},{"name":"date_preset","value":"last_3d"},{"name":"filtering","value":"[{\"field\":\"spend\",\"operator\":\"GREATER_THAN\",\"value\":1}]"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"44444444-4444-4444-8444-444444444444","name":"Normalize Meta Metrics","type":"n8n-nodes-base.code","typeVersion":2,"position":[780,300],"parameters":{"jsCode":"// Meta Marketing API returns strings + nested action arrays. Flatten to clean numbers.\n// The /insights edge does NOT carry daily_budget or effective_status, so we join them\n// in from the /adsets edge fetched one step earlier, keyed by ad set id.\nconst cfg = $('Load Autopilot Config').first().json;\nconst rows = $input.all().flatMap(i => i.json.data || [i.json]);\n\nconst budgetById = {};\nfor (const a of $('Fetch Ad Set Budgets').all().flatMap(i => i.json.data || [i.json])) {\n  if (!a || !a.id) continue;\n  budgetById[a.id] = a;\n}\n\nconst num = (v) => {\n  const n = typeof v === 'string' ? parseFloat(v) : v;\n  return Number.isFinite(n) ? n : 0;\n};\n// purchase_roas / actions come back as [{action_type, value}]\nconst pick = (arr, type) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find(a => a.action_type === type) || arr[0];\n  return hit ? num(hit.value) : 0;\n};\n\nconst out = [];\nconst seen = new Set();\n\nfor (const r of rows) {\n  // dedupe: the insights edge can repeat an adset across paginated date rows\n  const key = r.adset_id;\n  if (!key || seen.has(key)) continue;\n  seen.add(key);\n\n  const spend = num(r.spend);\n  const impressions = num(r.impressions);\n  const clicks = num(r.clicks);\n  const purchases = pick(r.actions, 'offsite_conversion.fb_pixel_purchase');\n  const revenue = pick(r.action_values, 'offsite_conversion.fb_pixel_purchase');\n  const roas = num(pick(r.purchase_roas, 'omni_purchase')) || (spend > 0 ? revenue / spend : 0);\n  const cpa = purchases > 0 ? spend / purchases : null;\n  const meta = budgetById[r.adset_id] || {};\n  // Meta returns budgets in minor units (cents). Ad sets under CBO have no adset-level\n  // daily_budget - leave them at 0 so the rule engine can never try to scale them.\n  const dailyBudget = num(meta.daily_budget) / 100;\n  const cbo = !!meta.id && !meta.daily_budget && !meta.lifetime_budget;\n\n  out.push({\n    adset_id: r.adset_id,\n    adset_name: r.adset_name || meta.name,\n    campaign_name: r.campaign_name || (meta.campaign && meta.campaign.name) || '',\n    effective_status: meta.effective_status || 'ACTIVE',\n    campaign_budget_optimized: cbo,\n    spend: +spend.toFixed(2),\n    impressions,\n    clicks,\n    ctr: impressions > 0 ? +((clicks / impressions) * 100).toFixed(3) : 0,\n    cpm: impressions > 0 ? +((spend / impressions) * 1000).toFixed(2) : 0,\n    frequency: +num(r.frequency).toFixed(2),\n    purchases,\n    revenue: +revenue.toFixed(2),\n    roas: +roas.toFixed(2),\n    cpa: cpa === null ? null : +cpa.toFixed(2),\n    daily_budget: +dailyBudget.toFixed(2),\n    target_cpa: cfg.target_cpa,\n    max_daily_budget: cfg.max_daily_budget,\n    window_days: cfg.window_days,\n  });\n}\n\nreturn out.map(r => ({ json: r }));"}},{"id":"55555555-5555-4555-8555-555555555555","name":"Apply Autopilot Rules","type":"n8n-nodes-base.code","typeVersion":2,"position":[1040,300],"parameters":{"jsCode":"// ---- AUTOPILOT RULE ENGINE -------------------------------------------------\n// Decide ONE action per ad set: pause / raise_budget / hold.\n// Guardrails first (never act on thin data), then CPA-vs-target rules.\nconst cfg = $('Load Autopilot Config').first().json;\n\nconst TARGET      = Number(cfg.target_cpa);        // e.g. 35\nconst PAUSE_MULT  = Number(cfg.pause_cpa_multiple);// pause above TARGET * 1.4\nconst SCALE_MULT  = Number(cfg.scale_cpa_multiple);// scale below TARGET * 0.75\nconst MIN_SPEND   = Number(cfg.min_spend_to_judge);// statistical floor\nconst MIN_PURCH   = Number(cfg.min_purchases_to_scale);\nconst STEP        = Number(cfg.budget_step_pct);   // 0.20 = +20%\nconst CAP         = Number(cfg.max_daily_budget);\nconst SPEND_CAP_R = Number(cfg.spend_cap_ratio);   // \"budget capped\" if pacing >= 0.9\nconst WINDOW      = Number(cfg.window_days);\n\nconst nowIso = new Date().toISOString();\n\nreturn $input.all().map(item => {\n  const r = { ...item.json };\n\n  const budget = r.daily_budget || 0;\n  const avgDailySpend = WINDOW > 0 ? r.spend / WINDOW : r.spend;\n  // pacing = how much of the daily budget the ad set actually eats.\n  const pacing = budget > 0 ? avgDailySpend / budget : 0;\n  const budgetCapped = pacing >= SPEND_CAP_R;\n\n  // projected next-day spend if we do nothing (pacing carried forward, capped at budget)\n  const projectedSpend = +Math.min(budget || avgDailySpend, avgDailySpend * 1.05).toFixed(2);\n  const projectedCpa = r.cpa;\n\n  // efficiency score: 1.0 = exactly on target CPA, >1 = better than target.\n  // blended with a volume-confidence factor so 1-purchase flukes cannot trigger scaling.\n  const cpaScore = r.cpa && r.cpa > 0 ? TARGET / r.cpa : 0;\n  const confidence = Math.min(1, r.purchases / Math.max(MIN_PURCH, 1));\n  const fatiguePenalty = r.frequency > 3 ? 0.85 : 1;\n  const score = +(cpaScore * (0.6 + 0.4 * confidence) * fatiguePenalty).toFixed(3);\n\n  let action = 'hold';\n  let reason = '';\n  let new_daily_budget = budget;\n\n  if (r.spend < MIN_SPEND) {\n    reason = `only $${r.spend} spent in ${WINDOW}d (floor $${MIN_SPEND}) - still learning`;\n  } else if (r.purchases === 0) {\n    action = 'pause';\n    reason = `$${r.spend} spent, 0 purchases in ${WINDOW}d`;\n  } else if (r.cpa > TARGET * PAUSE_MULT) {\n    action = 'pause';\n    reason = `CPA $${r.cpa} is ${(r.cpa / TARGET).toFixed(2)}x target $${TARGET}`;\n  } else if (r.cpa <= TARGET * SCALE_MULT && budget <= 0) {\n    reason = `efficient (CPA $${r.cpa}) but budget lives on the campaign (CBO) - scale it there`;\n  } else if (r.cpa <= TARGET * SCALE_MULT && r.purchases >= MIN_PURCH && budgetCapped) {\n    action = 'raise_budget';\n    // scale harder when the score is stronger, but never more than 2x the base step\n    const multiplier = Math.min(2, Math.max(1, score));\n    const raw = budget * (1 + STEP * multiplier);\n    new_daily_budget = +Math.min(CAP, raw).toFixed(2);\n    if (new_daily_budget <= budget + 0.5) {\n      action = 'hold';\n      reason = `already at max daily budget $${CAP}`;\n      new_daily_budget = budget;\n    } else {\n      reason = `CPA $${r.cpa} vs target $${TARGET}, pacing ${(pacing * 100).toFixed(0)}% of budget`;\n    }\n  } else if (r.cpa <= TARGET * SCALE_MULT && !budgetCapped) {\n    reason = `efficient (CPA $${r.cpa}) but only pacing ${(pacing * 100).toFixed(0)}% - budget is not the constraint`;\n  } else {\n    reason = `CPA $${r.cpa} within tolerance of target $${TARGET}`;\n  }\n\n  // never touch something that is already off\n  if (r.effective_status !== 'ACTIVE' && action !== 'hold') {\n    action = 'hold';\n    reason = `skipped: status ${r.effective_status}`;\n    new_daily_budget = budget;\n  }\n\n  return {\n    json: {\n      ...r,\n      pacing: +pacing.toFixed(2),\n      budget_capped: budgetCapped,\n      projected_daily_spend: projectedSpend,\n      projected_cpa: projectedCpa,\n      score,\n      action,\n      reason,\n      new_daily_budget,\n      budget_delta: +(new_daily_budget - budget).toFixed(2),\n      new_daily_budget_minor: Math.round(new_daily_budget * 100),\n      decided_at: nowIso,\n    },\n  };\n});"}},{"id":"66666666-6666-4666-8666-666666666666","name":"Only Actionable Ad Sets","type":"n8n-nodes-base.if","typeVersion":2,"position":[1300,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.action }}","rightValue":"hold","operator":{"type":"string","operation":"notEquals"}}]},"options":{}}},{"id":"77777777-7777-4777-8777-777777777777","name":"Log Held Ad Sets","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1560,620],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Holds"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"88888888-8888-4888-8888-888888888888","name":"Is Pause Decision?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1560,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.action }}","rightValue":"pause","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"99999999-9999-4999-8999-999999999999","name":"Pause Losing Ad Set","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1820,100],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.adset_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ status: 'PAUSED' }) }}","options":{}}},{"id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","name":"Is Budget Raise?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1820,460],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.action }}","rightValue":"raise_budget","operator":{"type":"string","operation":"equals"}},{"id":"c2","leftValue":"={{ $json.budget_delta }}","rightValue":0,"operator":{"type":"number","operation":"gt"}}]},"options":{}}},{"id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Raise Ad Set Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2080,460],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.adset_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ daily_budget: $json.new_daily_budget_minor }) }}","options":{}}},{"id":"cccccccc-cccc-4ccc-8ccc-cccccccccccc","name":"Flag Unclassified Decision","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2080,760],"parameters":{"assignments":{"assignments":[{"id":"f1","name":"adset_name","value":"={{ $json.adset_name }}","type":"string"},{"id":"f2","name":"action","value":"manual_review","type":"string"},{"id":"f3","name":"reason","value":"=Rule engine returned \"{{ $json.action }}\" with delta {{ $json.budget_delta }} - needs a human","type":"string"}]},"options":{}}},{"id":"dddddddd-dddd-4ddd-8ddd-dddddddddddd","name":"Alert Human Reviewer","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2340,760],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=🟡 Autopilot needs a human on {{ $json.adset_name }} — {{ $json.reason }}","otherOptions":{}}},{"id":"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee","name":"Merge Applied Changes","type":"n8n-nodes-base.merge","typeVersion":3,"position":[2340,260],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"ffffffff-ffff-4fff-8fff-ffffffffffff","name":"Build Audit Trail Rows","type":"n8n-nodes-base.code","typeVersion":2,"position":[2600,300],"parameters":{"jsCode":"// Build one flat audit row per attempted change + a digest line for Slack.\n// The Graph API POST responses only return {success:true} - no ad set context - so the\n// decision payload is the source of truth and the responses only tell us pass/fail.\nconst decisions = $(\"Apply Autopilot Rules\").all()\n  .map(i => i.json)\n  .filter(d => d.action === \"pause\" || d.action === \"raise_budget\");\n\nconst responses = $input.all().map(i => i.json);\nconst failures = responses.filter(r => r && (r.error || r.success === false));\nconst allApplied = failures.length === 0;\nconst firstError = failures.length\n  ? JSON.stringify(failures[0].error || failures[0]).slice(0, 300)\n  : \"\";\n\nconst rows = decisions.map(d => ({\n  run_at: new Date().toISOString(),\n  adset_id: d.adset_id,\n  adset_name: d.adset_name,\n  campaign_name: d.campaign_name,\n  action: d.action,\n  reason: d.reason,\n  spend_window: d.spend,\n  purchases: d.purchases,\n  cpa: d.cpa,\n  target_cpa: d.target_cpa,\n  roas: d.roas,\n  frequency: d.frequency,\n  pacing: d.pacing,\n  score: d.score,\n  old_daily_budget: d.daily_budget,\n  new_daily_budget: d.new_daily_budget,\n  budget_delta: d.budget_delta,\n  api_applied: allApplied,\n  api_error: allApplied ? \"\" : firstError,\n}));\n\nconst paused = rows.filter(r => r.action === \"pause\");\nconst raised = rows.filter(r => r.action === \"raise_budget\");\nconst extraSpend = raised.reduce((s, r) => s + (r.budget_delta || 0), 0);\nconst savedSpend = paused.reduce((s, r) => s + (r.old_daily_budget || 0), 0);\n\nconst digest = [\n  `Autopilot run: ${rows.length} change(s)`,\n  `paused ${paused.length} (freeing ~$${savedSpend.toFixed(2)}/day)`,\n  `raised ${raised.length} (+$${extraSpend.toFixed(2)}/day)`,\n  failures.length ? `${failures.length} API error(s)` : \"all applied cleanly\",\n].join(\" | \");\n\nif (!rows.length) return [{ json: { digest_line: \"Autopilot run: no changes needed\", action: \"none\", adset_name: \"-\", reason: \"every ad set within tolerance\" } }];\n\nreturn rows.map(r => ({ json: { ...r, digest_line: digest } }));"}},{"id":"10101010-1010-4010-8010-101010101010","name":"Append Autopilot Audit Log","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2860,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Autopilot Audit"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"20202020-2020-4020-8020-202020202020","name":"Post Autopilot Digest","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[3120,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=🤖 {{ $json.digest_line }} — last change: {{ $json.adset_name }} ({{ $json.action }}, {{ $json.reason }})","otherOptions":{}}},{"id":"30303030-3030-4030-8030-303030303030","name":"Sticky Fetch","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-300,20],"parameters":{"content":"## 1. PULL LIVE AD SET METRICS\nEvery 4h: read ad set budgets/status from the /adsets edge, then the last 3 days of /insights. The Code node joins them by ad set id (insights alone has no daily_budget) and flattens the nested `actions` / `purchase_roas` arrays into clean numbers (CPA, ROAS, CTR, CPM, pacing).","height":260,"width":760,"color":4}},{"id":"40404040-4040-4040-8040-404040404040","name":"Sticky Rules","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1000,20],"parameters":{"content":"## 2. AUTOPILOT RULE ENGINE\nOne action per ad set:\n- **pause** — 0 purchases past the spend floor, or CPA > 1.4x target\n- **raise_budget** — CPA <= 0.75x target, >= 3 purchases AND pacing >= 90% of budget (budget is the constraint)\n- **hold** — everything else\nStep size scales with an efficiency score, capped at the max daily budget.","height":260,"width":780,"color":5}},{"id":"50505050-5050-4050-8050-505050505050","name":"Sticky Audit","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[2300,20],"parameters":{"content":"## 3. AUDIT TRAIL\nEvery applied change (plus the Graph API response) is written to a Google Sheet with the metrics that justified it, then summarised into Slack. Holds are logged separately so nothing is silently ignored.","height":220,"width":700,"color":6}}],"connections":{"Every 4 Hours":{"main":[[{"node":"Load Autopilot Config","type":"main","index":0}]]},"Load Autopilot Config":{"main":[[{"node":"Fetch Ad Set Budgets","type":"main","index":0}]]},"Fetch Ad Set Insights":{"main":[[{"node":"Normalize Meta Metrics","type":"main","index":0}]]},"Normalize Meta Metrics":{"main":[[{"node":"Apply Autopilot Rules","type":"main","index":0}]]},"Apply Autopilot Rules":{"main":[[{"node":"Only Actionable Ad Sets","type":"main","index":0}]]},"Only Actionable Ad Sets":{"main":[[{"node":"Is Pause Decision?","type":"main","index":0}],[{"node":"Log Held Ad Sets","type":"main","index":0}]]},"Is Pause Decision?":{"main":[[{"node":"Pause Losing Ad Set","type":"main","index":0}],[{"node":"Is Budget Raise?","type":"main","index":0}]]},"Pause Losing Ad Set":{"main":[[{"node":"Merge Applied Changes","type":"main","index":0}]]},"Is Budget Raise?":{"main":[[{"node":"Raise Ad Set Budget","type":"main","index":0}],[{"node":"Flag Unclassified Decision","type":"main","index":0}]]},"Raise Ad Set Budget":{"main":[[{"node":"Merge Applied Changes","type":"main","index":1}]]},"Flag Unclassified Decision":{"main":[[{"node":"Alert Human Reviewer","type":"main","index":0}]]},"Merge Applied Changes":{"main":[[{"node":"Build Audit Trail Rows","type":"main","index":0}]]},"Build Audit Trail Rows":{"main":[[{"node":"Append Autopilot Audit Log","type":"main","index":0}]]},"Append Autopilot Audit Log":{"main":[[{"node":"Post Autopilot Digest","type":"main","index":0}]]},"Fetch Ad Set Budgets":{"main":[[{"node":"Fetch Ad Set Insights","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}