{"name":"Meta scale workflow","nodes":[{"id":"a1b2c3d4-0001-4222-8333-444455556600","name":"Every 6 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-220,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":6}]}}},{"id":"a1b2c3d4-0002-4222-8333-444455556600","name":"Fetch Active Campaigns","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[40,300],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/campaigns","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,status,effective_status,daily_budget,lifetime_budget,bid_strategy"},{"name":"effective_status","value":"[\"ACTIVE\"]"},{"name":"limit","value":"100"}]},"options":{}}},{"id":"a1b2c3d4-0003-4222-8333-444455556600","name":"Fetch 7d Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,300],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"campaign"},{"name":"fields","value":"campaign_id,campaign_name,spend,impressions,clicks,ctr,cpm,frequency,purchase_roas,actions,action_values"},{"name":"date_preset","value":"last_7d"},{"name":"limit","value":"100"}]},"options":{}}},{"id":"a1b2c3d4-0004-4222-8333-444455556600","name":"Merge Budgets With Insights","type":"n8n-nodes-base.merge","typeVersion":3,"position":[560,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"a1b2c3d4-0005-4222-8333-444455556600","name":"Normalize Meta Metrics","type":"n8n-nodes-base.code","typeVersion":2,"position":[820,300],"parameters":{"jsCode":"// Meta Marketing API returns purchase_roas / actions as arrays of {action_type, value}.\n// Flatten one campaign row per item into clean numbers.\nconst pick = (arr, type, key) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find((a) => (a.action_type || '').includes(type));\n  return hit ? Number(hit[key || 'value']) || 0 : 0;\n};\n\nconst rows = $input.all().map((item) => {\n  const r = item.json;\n  const spend = Number(r.spend) || 0;\n  const impressions = Number(r.impressions) || 0;\n  const clicks = Number(r.clicks) || 0;\n\n  const roas = Array.isArray(r.purchase_roas)\n    ? Number(r.purchase_roas[0]?.value) || 0\n    : Number(r.purchase_roas) || 0;\n\n  const purchases = pick(r.actions, 'purchase');\n  const revenue = pick(r.action_values, 'purchase') || roas * spend;\n\n  // daily_budget / lifetime_budget come back in minor units (cents)\n  const dailyBudget = (Number(r.daily_budget) || 0) / 100;\n  const lifetimeBudget = (Number(r.lifetime_budget) || 0) / 100;\n\n  return {\n    json: {\n      campaign_id: String(r.campaign_id || r.id || ''),\n      campaign_name: r.campaign_name || r.name || 'unknown',\n      status: r.effective_status || r.status || 'ACTIVE',\n      spend,\n      impressions,\n      clicks,\n      ctr: Number(r.ctr) || (impressions ? (clicks / impressions) * 100 : 0),\n      cpm: Number(r.cpm) || (impressions ? (spend / impressions) * 1000 : 0),\n      frequency: Number(r.frequency) || 0,\n      purchases,\n      revenue,\n      roas: roas || (spend ? revenue / spend : 0),\n      cpa: purchases ? spend / purchases : 0,\n      daily_budget: dailyBudget,\n      lifetime_budget: lifetimeBudget,\n      date_start: r.date_start || null,\n      date_stop: r.date_stop || null,\n    },\n  };\n});\n\nreturn rows;"}},{"id":"a1b2c3d4-0006-4222-8333-444455556600","name":"Score ROAS And Spend Pace","type":"n8n-nodes-base.code","typeVersion":2,"position":[1080,300],"parameters":{"jsCode":"// ---- Strategy config -------------------------------------------------------\nconst TARGET_ROAS = 2.0;        // break-even + margin for this account\nconst SCALE_ROAS = 2.5;         // only scale clear winners\nconst KILL_ROAS = 1.2;          // below this the campaign burns cash\nconst MIN_SPEND = 75;           // learning-phase floor: ignore thin data\nconst MIN_PURCHASES = 5;        // statistical floor for a scale decision\nconst FREQ_CEILING = 3.2;       // above this the audience is fatigued\nconst MAX_DAILY_BUDGET = 500;   // account guardrail\nconst SCALE_STEP = 0.20;        // +20% per scale event\nconst LOOKBACK_DAYS = 7;\nconst MONTHLY_TARGET = 60000;   // account spend pace target\n\nconst rows = $input.all().map((i) => i.json);\n\n// ---- Dedupe: Meta can return one row per (campaign, day). Keep the newest,\n// and sum spend/revenue so the scoring sees the whole lookback window. -------\nconst byId = new Map();\nfor (const r of rows) {\n  const prev = byId.get(r.campaign_id);\n  if (!prev) { byId.set(r.campaign_id, { ...r }); continue; }\n  prev.spend += r.spend;\n  prev.revenue += r.revenue;\n  prev.purchases += r.purchases;\n  prev.impressions += r.impressions;\n  prev.clicks += r.clicks;\n  if ((r.date_stop || '') > (prev.date_stop || '')) {\n    prev.date_stop = r.date_stop;\n    prev.frequency = r.frequency;\n    prev.daily_budget = r.daily_budget;\n  }\n  prev.roas = prev.spend ? prev.revenue / prev.spend : 0;\n  prev.cpa = prev.purchases ? prev.spend / prev.purchases : 0;\n}\nconst campaigns = [...byId.values()];\n\n// ---- Spend pace: where does this account land by end of month? -------------\nconst now = new Date();\nconst daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();\nconst dayOfMonth = now.getDate();\nconst accountDailySpend = campaigns.reduce((s, c) => s + c.spend, 0) / LOOKBACK_DAYS;\nconst projectedMonthSpend = accountDailySpend * daysInMonth;\nconst pace = MONTHLY_TARGET ? projectedMonthSpend / MONTHLY_TARGET : 1;\nconst headroom = Math.max(0, MONTHLY_TARGET - projectedMonthSpend);\n\n// ---- Score each campaign 0-100 --------------------------------------------\n// 55% efficiency (ROAS vs target), 25% volume (purchases), 20% health\n// (frequency + CTR). Pace acts as a multiplier, not a gate.\nconst clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));\n\nconst scored = campaigns.map((c) => {\n  const efficiency = clamp(c.roas / TARGET_ROAS, 0, 2) / 2;          // 0..1\n  const volume = clamp(c.purchases / 25, 0, 1);                       // 0..1\n  const fatigue = clamp((c.frequency - 1.5) / (FREQ_CEILING - 1.5), 0, 1);\n  const ctrHealth = clamp(c.ctr / 1.5, 0, 1);\n  const health = (1 - fatigue) * 0.6 + ctrHealth * 0.4;\n\n  let score = (efficiency * 55 + volume * 25 + health * 20);\n  if (pace > 1.15) score *= 0.85;   // already overspending -> scale slower\n  if (pace < 0.80) score *= 1.10;   // underspending -> lean in\n  score = Math.round(clamp(score, 0, 100));\n\n  const enoughData = c.spend >= MIN_SPEND && c.purchases >= MIN_PURCHASES;\n  const fatigued = c.frequency >= FREQ_CEILING;\n  const atCap = c.daily_budget >= MAX_DAILY_BUDGET;\n\n  let decision = 'hold';\n  let reason = 'Inside target band - leave it alone.';\n\n  if (!enoughData) {\n    decision = 'hold';\n    reason = `Not enough signal (spend $${c.spend.toFixed(0)}, ${c.purchases} purchases).`;\n  } else if (c.roas >= SCALE_ROAS && !fatigued && !atCap && headroom > 0) {\n    decision = 'scale';\n    reason = `ROAS ${c.roas.toFixed(2)} >= ${SCALE_ROAS} at freq ${c.frequency.toFixed(2)} - push +${SCALE_STEP * 100}%.`;\n  } else if (c.roas >= SCALE_ROAS && fatigued) {\n    decision = 'laggard';\n    reason = `Profitable (ROAS ${c.roas.toFixed(2)}) but frequency ${c.frequency.toFixed(2)} - needs new creative, not more budget.`;\n  } else if (c.roas < KILL_ROAS) {\n    decision = 'laggard';\n    reason = `ROAS ${c.roas.toFixed(2)} under kill line ${KILL_ROAS} on $${c.spend.toFixed(0)} spend.`;\n  } else if (c.roas < TARGET_ROAS) {\n    decision = 'laggard';\n    reason = `ROAS ${c.roas.toFixed(2)} below target ${TARGET_ROAS} - watch or cut.`;\n  } else if (atCap) {\n    decision = 'hold';\n    reason = `At the $${MAX_DAILY_BUDGET} daily guardrail - manual review to go higher.`;\n  }\n\n  // Never scale past the monthly headroom left in the account.\n  const rawNext = c.daily_budget * (1 + SCALE_STEP);\n  const capped = Math.min(rawNext, MAX_DAILY_BUDGET, c.daily_budget + headroom / Math.max(1, daysInMonth - dayOfMonth));\n  const nextBudget = decision === 'scale' ? Math.round(capped) : Math.round(c.daily_budget);\n\n  return {\n    json: {\n      ...c,\n      score,\n      decision,\n      reason,\n      target_roas: TARGET_ROAS,\n      next_daily_budget: nextBudget,\n      budget_delta: nextBudget - Math.round(c.daily_budget),\n      fatigued,\n      at_cap: atCap,\n      account_pace: Number(pace.toFixed(3)),\n      projected_month_spend: Math.round(projectedMonthSpend),\n      monthly_headroom: Math.round(headroom),\n      decided_at: now.toISOString(),\n    },\n  };\n});\n\n// Highest score first so the loop scales the best campaigns while headroom lasts.\nreturn scored.sort((a, b) => b.json.score - a.json.score);"}},{"id":"a1b2c3d4-0007-4222-8333-444455556600","name":"Drop Thin-Data Campaigns","type":"n8n-nodes-base.filter","typeVersion":2,"position":[1340,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.spend }}","rightValue":25,"operator":{"type":"number","operation":"gte"}},{"id":"f2","leftValue":"={{ $json.status }}","rightValue":"ACTIVE","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"a1b2c3d4-0008-4222-8333-444455556600","name":"Loop Over Campaigns","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[1600,300],"parameters":{"batchSize":1,"options":{}}},{"id":"a1b2c3d4-0009-4222-8333-444455556600","name":"Is A Scale Winner?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1860,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.decision }}","rightValue":"scale","operator":{"type":"string","operation":"equals"}},{"id":"c2","leftValue":"={{ $json.budget_delta }}","rightValue":0,"operator":{"type":"number","operation":"gt"}}]},"options":{}}},{"id":"a1b2c3d4-0010-4222-8333-444455556600","name":"Compute +20% Budget","type":"n8n-nodes-base.code","typeVersion":2,"position":[2120,80],"parameters":{"jsCode":"// Meta wants budgets in minor units (cents) as a string.\nconst c = $json;\nconst newDaily = Math.max(1, Math.round(c.next_daily_budget));\nreturn [{\n  json: {\n    ...c,\n    new_daily_budget: newDaily,\n    daily_budget_minor: String(newDaily * 100),\n    action: 'scale',\n    action_note: `${c.campaign_name}: $${Math.round(c.daily_budget)} -> $${newDaily}/day (+${c.budget_delta})`,\n  },\n}];"}},{"id":"a1b2c3d4-0011-4222-8333-444455556600","name":"Raise Campaign Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2380,80],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.campaign_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ daily_budget: $json.daily_budget_minor, status: \"ACTIVE\" }) }}","options":{}}},{"id":"a1b2c3d4-0012-4222-8333-444455556600","name":"Post Scale Win To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2640,80],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-scaling","mode":"name"},"text":"=📈 Scaled *{{ $json.campaign_name }}* — ROAS {{ $json.roas }} (score {{ $json.score }})\nBudget ${{ $json.daily_budget }} → ${{ $json.new_daily_budget }}/day\n_{{ $json.reason }}_","otherOptions":{}}},{"id":"a1b2c3d4-0013-4222-8333-444455556600","name":"Is A Laggard?","type":"n8n-nodes-base.if","typeVersion":2,"position":[2120,520],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"l1","leftValue":"={{ $json.decision }}","rightValue":"laggard","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"a1b2c3d4-0014-4222-8333-444455556600","name":"Build Laggard Alert","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2380,420],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"alert_severity","value":"={{ $json.roas < 1.2 ? \"critical\" : \"watch\" }}","type":"string"},{"id":"a2","name":"suggested_action","value":"={{ $json.fatigued ? \"refresh creative\" : ($json.roas < 1.2 ? \"pause or cut budget 30%\" : \"hold and re-check in 24h\") }}","type":"string"},{"id":"a3","name":"wasted_spend","value":"={{ Math.round($json.spend - ($json.revenue / $json.target_roas)) }}","type":"number"}]},"options":{}}},{"id":"a1b2c3d4-0015-4222-8333-444455556600","name":"Alert Laggards In Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2640,420],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=🚨 [{{ $json.alert_severity }}] *{{ $json.campaign_name }}* — ROAS {{ $json.roas }} vs target {{ $json.target_roas }}\nSpend ${{ $json.spend }} · CPA ${{ $json.cpa }} · freq {{ $json.frequency }}\nAction: *{{ $json.suggested_action }}* ({{ $json.reason }})","otherOptions":{}}},{"id":"a1b2c3d4-0016-4222-8333-444455556600","name":"Mark As Hold","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2380,680],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"decision","value":"hold","type":"string"},{"id":"h2","name":"suggested_action","value":"no change - inside target band","type":"string"}]},"options":{}}},{"id":"a1b2c3d4-0017-4222-8333-444455556600","name":"Build Decision Log","type":"n8n-nodes-base.code","typeVersion":2,"position":[2120,-160],"parameters":{"jsCode":"// Every campaign that passed through the loop lands here. Flatten to one tidy\n// audit row per campaign_id (last write wins) for the Sheets log.\nconst seen = new Map();\nfor (const item of $input.all()) {\n  const r = item.json || {};\n  if (!r.campaign_id) continue;\n  seen.set(r.campaign_id, r);\n}\n\nconst rows = [...seen.values()].map((r) => ({\n  json: {\n    run_at: new Date().toISOString(),\n    campaign_id: r.campaign_id,\n    campaign_name: r.campaign_name,\n    decision: r.decision,\n    reason: r.reason,\n    score: r.score,\n    roas: Number((r.roas || 0).toFixed(2)),\n    target_roas: r.target_roas,\n    spend: Number((r.spend || 0).toFixed(2)),\n    revenue: Number((r.revenue || 0).toFixed(2)),\n    purchases: r.purchases,\n    cpa: Number((r.cpa || 0).toFixed(2)),\n    ctr: Number((r.ctr || 0).toFixed(3)),\n    frequency: Number((r.frequency || 0).toFixed(2)),\n    old_daily_budget: Math.round(r.daily_budget || 0),\n    new_daily_budget: Math.round(r.new_daily_budget || r.next_daily_budget || r.daily_budget || 0),\n    budget_delta: r.budget_delta || 0,\n    account_pace: r.account_pace,\n    projected_month_spend: r.projected_month_spend,\n  },\n}));\n\n// Newest decisions first, biggest budget moves at the top.\nreturn rows.sort((a, b) => Math.abs(b.json.budget_delta) - Math.abs(a.json.budget_delta));"}},{"id":"a1b2c3d4-0018-4222-8333-444455556600","name":"Append Decisions To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2380,-160],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Scale Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"a1b2c3d4-0019-4222-8333-444455556600","name":"Note Pull","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[0,80],"parameters":{"content":"## 1. PULL\nEvery 6h: campaign budgets + last_7d insights from the Meta Marketing API, merged into one row per campaign.","height":300,"width":700,"color":4}},{"id":"a1b2c3d4-0020-4222-8333-444455556600","name":"Note Score","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[780,480],"parameters":{"content":"## 2. SCORE\nDedupe by campaign_id, project month-end spend vs the $60k target, then score 0-100:\n55% ROAS efficiency · 25% purchase volume · 20% frequency/CTR health.\nDecision: scale / laggard / hold.","height":320,"width":700,"color":5}},{"id":"a1b2c3d4-0021-4222-8333-444455556600","name":"Note Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1820,-340],"parameters":{"content":"## 3. ACT + LOG\nWinners get +20% daily budget (capped by the account guardrail and the remaining monthly headroom).\nLaggards get a Slack alert with a suggested action. Everything is appended to the Sheets audit log.","height":300,"width":780,"color":3}}],"connections":{"Every 6 Hours":{"main":[[{"node":"Fetch Active Campaigns","type":"main","index":0},{"node":"Fetch 7d Insights","type":"main","index":0}]]},"Fetch Active Campaigns":{"main":[[{"node":"Merge Budgets With Insights","type":"main","index":0}]]},"Fetch 7d Insights":{"main":[[{"node":"Merge Budgets With Insights","type":"main","index":1}]]},"Merge Budgets With Insights":{"main":[[{"node":"Normalize Meta Metrics","type":"main","index":0}]]},"Normalize Meta Metrics":{"main":[[{"node":"Score ROAS And Spend Pace","type":"main","index":0}]]},"Score ROAS And Spend Pace":{"main":[[{"node":"Drop Thin-Data Campaigns","type":"main","index":0}]]},"Drop Thin-Data Campaigns":{"main":[[{"node":"Loop Over Campaigns","type":"main","index":0}]]},"Loop Over Campaigns":{"main":[[{"node":"Build Decision Log","type":"main","index":0}],[{"node":"Is A Scale Winner?","type":"main","index":0}]]},"Is A Scale Winner?":{"main":[[{"node":"Compute +20% Budget","type":"main","index":0}],[{"node":"Is A Laggard?","type":"main","index":0}]]},"Compute +20% Budget":{"main":[[{"node":"Raise Campaign Budget","type":"main","index":0}]]},"Raise Campaign Budget":{"main":[[{"node":"Post Scale Win To Slack","type":"main","index":0}]]},"Post Scale Win To Slack":{"main":[[{"node":"Loop Over Campaigns","type":"main","index":0}]]},"Is A Laggard?":{"main":[[{"node":"Build Laggard Alert","type":"main","index":0}],[{"node":"Mark As Hold","type":"main","index":0}]]},"Build Laggard Alert":{"main":[[{"node":"Alert Laggards In Slack","type":"main","index":0}]]},"Alert Laggards In Slack":{"main":[[{"node":"Loop Over Campaigns","type":"main","index":0}]]},"Mark As Hold":{"main":[[{"node":"Loop Over Campaigns","type":"main","index":0}]]},"Build Decision Log":{"main":[[{"node":"Append Decisions To Sheet","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}