{"name":"Full Facebook automation workflow","nodes":[{"id":"f2b0a1c0-0001-4a11-8b22-c0ffee000001","name":"Daily 06:00 Trigger","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-360,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}}},{"id":"f2b0a1c0-0002-4a11-8b22-c0ffee000002","name":"Fetch Account Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-100,300],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"spend,impressions,clicks,ctr,cpm,frequency,purchase_roas,actions,date_start,date_stop"},{"name":"date_preset","value":"this_month"},{"name":"level","value":"account"}]},"options":{}}},{"id":"f2b0a1c0-0003-4a11-8b22-c0ffee000003","name":"Fetch Campaign Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[160,140],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"campaign_id,campaign_name,spend,impressions,clicks,ctr,cpm,frequency,purchase_roas,actions"},{"name":"date_preset","value":"last_7d"},{"name":"level","value":"campaign"},{"name":"filtering","value":"[{\"field\":\"campaign.effective_status\",\"operator\":\"IN\",\"value\":[\"ACTIVE\"]}]"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"f2b0a1c0-0004-4a11-8b22-c0ffee000004","name":"Fetch Ad Set Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[160,460],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"adset_id,adset_name,campaign_id,campaign_name,spend,impressions,clicks,ctr,cpm,frequency,purchase_roas,actions,date_start,date_stop"},{"name":"date_preset","value":"last_7d"},{"name":"level","value":"adset"},{"name":"filtering","value":"[{\"field\":\"adset.effective_status\",\"operator\":\"IN\",\"value\":[\"ACTIVE\"]}]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"f2b0a1c0-0020-4a11-8b22-c0ffee000020","name":"Fetch Ad Set Budgets","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[160,620],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/adsets","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,daily_budget,lifetime_budget,effective_status"},{"name":"effective_status","value":"[\"ACTIVE\"]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"f2b0a1c0-0005-4a11-8b22-c0ffee000005","name":"Merge Campaign + Ad Set Rows","type":"n8n-nodes-base.merge","typeVersion":3,"position":[420,300],"parameters":{"mode":"append","numberInputs":3,"options":{}}},{"id":"f2b0a1c0-0006-4a11-8b22-c0ffee000006","name":"Score Ad Set Health","type":"n8n-nodes-base.code","typeVersion":2,"position":[680,300],"parameters":{"jsCode":"// ── Blend account + campaign + ad-set insights, then score every ad set ──\n// Meta returns purchase_roas / actions as arrays of {action_type, value}.\nconst pick = (arr, type, field) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find(a => a.action_type === type);\n  return hit ? Number(hit[field != null ? field : 'value']) || 0 : 0;\n};\nconst num = v => (v === undefined || v === null || v === '' ? 0 : Number(v) || 0);\n\nconst rows = $input.all().map(i => i.json);\n\n// The merged stream carries three shapes; split them by which keys are present.\n// Budget rows come from /adsets (entity read) - insights never returns budgets.\nconst budgetRows   = rows.filter(r => r.id && r.daily_budget !== undefined && !r.adset_id);\nconst campaignRows = rows.filter(r => r.campaign_id && !r.adset_id && !r.id);\nconst adsetRows    = rows.filter(r => r.adset_id);\n\n// adset_id -> current daily budget in major units (Meta returns minor units).\nconst budgets = new Map();\nfor (const b of budgetRows) {\n  budgets.set(String(b.id), {\n    daily: Number(b.daily_budget) / 100 || 0,\n    lifetime: Number(b.lifetime_budget) / 100 || 0,\n  });\n}\n\n// Dedupe ad sets (the API can page the same row twice) keeping the freshest.\nconst byAdset = new Map();\nfor (const r of adsetRows) {\n  const prev = byAdset.get(r.adset_id);\n  if (!prev || String(r.date_stop) > String(prev.date_stop)) byAdset.set(r.adset_id, r);\n}\n\n// Campaign-level context: roll spend + revenue up so we can compare an ad set\n// to its own campaign instead of to a global average.\nconst campaign = new Map();\nfor (const c of campaignRows) {\n  const spend = num(c.spend);\n  const roas  = pick(c.purchase_roas, 'omni_purchase');\n  campaign.set(c.campaign_id, {\n    name: c.campaign_name,\n    spend,\n    revenue: spend * roas,\n    roas,\n  });\n}\n\n// Account level: pacing. How much of the monthly cap have we burned vs. burned-through-time?\nconst acct = $('Fetch Account Insights').first().json;\nconst MONTHLY_CAP = 60000;\nconst now = new Date(`${acct.date_stop || $now.toISODate()}T12:00:00Z`);\nconst dayOfMonth = now.getUTCDate();\nconst daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate();\nconst mtdSpend = num(acct.spend);\nconst projectedSpend = (mtdSpend / dayOfMonth) * daysInMonth;\nconst pacing = projectedSpend / MONTHLY_CAP;          // 1.0 = exactly on cap\nconst pacingHot = pacing > 1.05;                       // overspending -> no scaling today\nconst accountRoas = pick(acct.purchase_roas, 'omni_purchase');\n\n// Target ROAS is the account's own blended ROAS, floored so a bad day can't\n// make garbage look good.\nconst TARGET_ROAS = Math.max(1.8, accountRoas * 0.9);\nconst MIN_SPEND   = 50;    // below this the numbers are noise\nconst MIN_IMPR    = 2000;\n\nconst out = [];\nfor (const a of byAdset.values()) {\n  const spend       = num(a.spend);\n  const impressions = num(a.impressions);\n  const clicks      = num(a.clicks);\n  const frequency   = num(a.frequency);\n  const ctr         = num(a.ctr);\n  const cpm         = num(a.cpm);\n  const purchases   = pick(a.actions, 'omni_purchase');\n  const roas        = pick(a.purchase_roas, 'omni_purchase');\n  const revenue     = spend * roas;\n  const cpa         = purchases ? spend / purchases : null;\n  const parent      = campaign.get(a.campaign_id) || { roas: accountRoas, name: a.campaign_name };\n  const budget      = budgets.get(String(a.adset_id)) || { daily: 0, lifetime: 0 };\n\n  // ── health score 0-100 ──────────────────────────────────────────────\n  // efficiency (0-50): ROAS vs target, capped at 2x target\n  const efficiency = Math.min(roas / TARGET_ROAS, 2) * 25;\n  // relative (0-20): does it beat the campaign it lives in?\n  const relative = parent.roas > 0\n    ? Math.max(0, Math.min(roas / parent.roas, 2)) * 10\n    : 10;\n  // fatigue penalty (0 to -25): frequency above 2.2 hurts, 4+ is dead\n  const fatigue = -Math.max(0, Math.min(frequency - 2.2, 1.8)) * (25 / 1.8);\n  // creative pull (0-20): CTR relative to a 1.2% benchmark\n  const pull = Math.min(ctr / 1.2, 2) * 10;\n  // volume confidence (0-10): more spend = more trust in the numbers\n  const confidence = Math.min(spend / 300, 1) * 10;\n\n  const health = Math.round(\n    Math.max(0, Math.min(100, efficiency + relative + fatigue + pull + confidence))\n  );\n\n  // ── decision ────────────────────────────────────────────────────────\n  const thin = spend < MIN_SPEND || impressions < MIN_IMPR;\n  let action = 'watch', reason = '';\n  if (thin) {\n    action = 'learning';\n    reason = `only $${spend.toFixed(0)} / ${impressions} impr — still learning`;\n  } else if (roas === 0 && spend > MIN_SPEND * 2) {\n    action = 'pause';\n    reason = `$${spend.toFixed(0)} spent, zero purchases`;\n  } else if (health < 35) {\n    action = 'pause';\n    reason = `health ${health} (roas ${roas.toFixed(2)} vs target ${TARGET_ROAS.toFixed(2)}, freq ${frequency.toFixed(2)})`;\n  } else if (health >= 70 && roas >= TARGET_ROAS * 1.15 && frequency < 3 && !pacingHot && budget.daily > 0) {\n    action = 'scale';\n    reason = `health ${health}, roas ${roas.toFixed(2)}, freq ${frequency.toFixed(2)} — room to push`;\n  } else if (health >= 70 && pacingHot) {\n    action = 'watch';\n    reason = `winner but account pacing ${(pacing * 100).toFixed(0)}% of cap — holding budget`;\n  } else if (health >= 70 && budget.daily <= 0) {\n    action = 'watch';\n    reason = `winner but no ad-set daily budget (CBO or lifetime) — scale at campaign level`;\n  } else {\n    action = 'watch';\n    reason = `health ${health} — inside the band, leave it alone`;\n  }\n\n  out.push({\n    json: {\n      adset_id: a.adset_id,\n      adset_name: a.adset_name,\n      campaign_id: a.campaign_id,\n      campaign_name: parent.name || a.campaign_name,\n      spend: Number(spend.toFixed(2)),\n      revenue: Number(revenue.toFixed(2)),\n      impressions, clicks, frequency, ctr, cpm,\n      purchases,\n      cpa: cpa === null ? null : Number(cpa.toFixed(2)),\n      roas: Number(roas.toFixed(2)),\n      campaign_roas: Number(parent.roas.toFixed(2)),\n      daily_budget: Number(budget.daily.toFixed(2)),\n      target_roas: Number(TARGET_ROAS.toFixed(2)),\n      health,\n      action,\n      reason,\n      account_pacing: Number(pacing.toFixed(3)),\n      projected_month_spend: Math.round(projectedSpend),\n      monthly_cap: MONTHLY_CAP,\n      checked_at: $now.toISO(),\n    },\n  });\n}\n\n// Worst first — the digest reads top-down as a to-do list.\nout.sort((a, b) => a.json.health - b.json.health);\nreturn out;"}},{"id":"f2b0a1c0-0007-4a11-8b22-c0ffee000007","name":"Drop Non-Actionable Rows","type":"n8n-nodes-base.filter","typeVersion":2,"position":[940,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.action }}","rightValue":"learning","operator":{"type":"string","operation":"notEquals"}}]},"options":{}}},{"id":"f2b0a1c0-0008-4a11-8b22-c0ffee000008","name":"Kill Candidate?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1200,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"k1","leftValue":"={{ $json.action }}","rightValue":"pause","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"f2b0a1c0-0009-4a11-8b22-c0ffee000009","name":"Pause Ad Set","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1460,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":"f2b0a1c0-0010-4a11-8b22-c0ffee000010","name":"Scale Candidate?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1460,360],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"s1","leftValue":"={{ $json.action }}","rightValue":"scale","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"f2b0a1c0-0011-4a11-8b22-c0ffee000011","name":"Compute Budget Step-Up","type":"n8n-nodes-base.code","typeVersion":2,"position":[1720,240],"parameters":{"jsCode":"// ── Budget step-up for winners: never more than +25% a day ──────────────\n// Meta resets learning if you jump budget too hard, so we step, we don't leap.\nreturn $input.all().map(item => {\n  const r = item.json;\n  const current = Number(r.daily_budget) || 0;\n  const headroom = Math.max(0, r.monthly_cap - r.projected_month_spend);\n\n  // Confidence in the step scales with how far past target the ROAS is.\n  const beat = r.target_roas > 0 ? r.roas / r.target_roas : 1;\n  let pct = 0.10;\n  if (beat >= 1.5) pct = 0.25;\n  else if (beat >= 1.3) pct = 0.20;\n  else if (beat >= 1.15) pct = 0.15;\n\n  // Fatigue brake: a frequency creeping up means the audience is thinning.\n  if (Number(r.frequency) > 2.4) pct = pct / 2;\n\n  // A step under $5 is noise to Meta's delivery, so round the raise up to $5.\n  let next = Math.max(current * (1 + pct), current + 5);\n  // Then let the remaining monthly headroom have the final say: no single ad set\n  // gets to eat the cap. Never below today's budget - scaling can't be a cut.\n  const maxDaily = headroom > 0 ? current + headroom / 7 : current;\n  next = Math.round(Math.max(current, Math.min(next, maxDaily)));\n\n  return {\n    json: {\n      ...r,\n      old_daily_budget: current,\n      new_daily_budget: next,\n      budget_step_pct: Math.round(((next / (current || next)) - 1) * 100),\n      new_daily_budget_minor: Math.round(next * 100), // Meta wants cents\n    },\n  };\n});"}},{"id":"f2b0a1c0-0012-4a11-8b22-c0ffee000012","name":"Raise Ad Set Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1980,240],"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":"f2b0a1c0-0013-4a11-8b22-c0ffee000013","name":"Tag Watchlist Ad Set","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1720,540],"parameters":{"assignments":{"assignments":[{"id":"w1","name":"action_taken","value":"none","type":"string"},{"id":"w2","name":"note","value":"={{ $json.reason }}","type":"string"},{"id":"w3","name":"review_on","value":"={{ $now.plus(3, 'days').format('yyyy-LL-dd') }}","type":"string"}]},"options":{}}},{"id":"f2b0a1c0-0014-4a11-8b22-c0ffee000014","name":"Log Actions to Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2240,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"meta_daily_log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"f2b0a1c0-0015-4a11-8b22-c0ffee000015","name":"Build Daily Digest","type":"n8n-nodes-base.code","typeVersion":2,"position":[2500,300],"parameters":{"jsCode":"// ── Roll every acted-on ad set into one HTML digest ─────────────────────\nconst rows = $input.all().map(i => i.json);\nif (!rows.length) {\n  return [{ json: { html: '<p>No ad sets met the volume threshold today.</p>', subject_stat: 'no data' } }];\n}\n\nconst money = n => '$' + (Number(n) || 0).toLocaleString('en-US', { maximumFractionDigits: 0 });\nconst group = k => rows.filter(r => r.action === k);\n\nconst paused  = group('pause');\nconst scaled  = group('scale');\nconst watched = group('watch');\nconst learn   = group('learning');\n\nconst spend   = rows.reduce((s, r) => s + (Number(r.spend) || 0), 0);\nconst revenue = rows.reduce((s, r) => s + (Number(r.revenue) || 0), 0);\nconst blended = spend ? revenue / spend : 0;\nconst savedPerDay = paused.reduce((s, r) => s + (Number(r.daily_budget) || 0), 0);\nconst addedPerDay = scaled.reduce(\n  (s, r) => s + ((Number(r.new_daily_budget) || 0) - (Number(r.old_daily_budget) || 0)), 0\n);\nconst first = rows[0];\n\nconst table = (title, list, extra) => {\n  if (!list.length) return '';\n  const body = list.map(r => `<tr>\n      <td>${r.adset_name}</td>\n      <td>${r.campaign_name}</td>\n      <td align=\"right\">${money(r.spend)}</td>\n      <td align=\"right\">${(Number(r.roas)).toFixed(2)}x</td>\n      <td align=\"right\">${(Number(r.frequency)).toFixed(2)}</td>\n      <td align=\"right\"><b>${r.health}</b></td>\n      <td>${extra ? extra(r) : r.reason}</td>\n    </tr>`).join('');\n  return `<h3>${title} (${list.length})</h3>\n    <table cellpadding=\"6\" cellspacing=\"0\" border=\"1\" style=\"border-collapse:collapse;font:13px/1.4 -apple-system,Arial\">\n      <tr><th align=\"left\">Ad set</th><th align=\"left\">Campaign</th><th>Spend</th><th>ROAS</th><th>Freq</th><th>Health</th><th align=\"left\">Why</th></tr>\n      ${body}\n    </table>`;\n};\n\nconst html = `<div style=\"font:14px/1.5 -apple-system,Arial;color:#111\">\n  <h2>Meta daily ops — ${$now.format('yyyy-LL-dd')}</h2>\n  <p>\n    Spend <b>${money(spend)}</b> · Revenue <b>${money(revenue)}</b> ·\n    Blended ROAS <b>${blended.toFixed(2)}x</b> · Target <b>${Number(first.target_roas).toFixed(2)}x</b><br>\n    Month projection <b>${money(first.projected_month_spend)}</b> of ${money(first.monthly_cap)} cap\n    (<b>${(Number(first.account_pacing) * 100).toFixed(0)}%</b> pacing)\n  </p>\n  <p>\n    Paused <b>${paused.length}</b> ad sets → ${money(savedPerDay)}/day stopped.<br>\n    Scaled <b>${scaled.length}</b> ad sets → ${money(addedPerDay)}/day added.<br>\n    Left alone: ${watched.length} · Still learning: ${learn.length}\n  </p>\n  ${table('Paused', paused)}\n  ${table('Scaled', scaled, r => `${money(r.old_daily_budget)} → ${money(r.new_daily_budget)}/day (+${r.budget_step_pct}%)`)}\n  ${table('Watchlist', watched)}\n  ${table('Still learning', learn)}\n</div>`;\n\nreturn [{ json: {\n  html,\n  subject_stat: `${paused.length} paused / ${scaled.length} scaled / ${blended.toFixed(2)}x`,\n  spend: Number(spend.toFixed(2)),\n  revenue: Number(revenue.toFixed(2)),\n  blended_roas: Number(blended.toFixed(2)),\n} }];"}},{"id":"f2b0a1c0-0016-4a11-8b22-c0ffee000016","name":"Email Daily Digest","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[2760,300],"parameters":{"sendTo":"media-buying@brand.com","subject":"=Meta daily ops {{ $now.format('yyyy-LL-dd') }} — {{ $json.subject_stat }}","message":"={{ $json.html }}","options":{}}},{"id":"f2b0a1c0-0017-4a11-8b22-c0ffee000017","name":"Note Fetch","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-380,-120],"parameters":{"content":"## 1. PULL\nOne daily pull of Meta insights at three levels.\nAccount = pacing context (month to date), campaign = the\nbenchmark each ad set is judged against, ad set = the unit\nwe actually act on. All filtered to ACTIVE only.\n\nThe 4th call reads the ad set ENTITIES, because /insights\nnever returns daily_budget — without it there is nothing to\nstep up.","height":320,"width":700,"color":4}},{"id":"f2b0a1c0-0018-4a11-8b22-c0ffee000018","name":"Note Decide","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[660,-120],"parameters":{"content":"## 2. SCORE + DECIDE\nHealth 0-100 = efficiency (ROAS vs target) + beating its own\ncampaign + creative pull (CTR) + volume confidence, minus a\nfrequency-fatigue penalty. <35 = pause, >=70 with headroom =\nscale, everything else is left alone. Ad sets under $50 /\n2k impressions are ruled \"learning\" and never touched.","height":320,"width":700,"color":3}},{"id":"f2b0a1c0-0019-4a11-8b22-c0ffee000019","name":"Note Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1700,-120],"parameters":{"content":"## 3. ACT + REPORT\nPause writes status=PAUSED. Scale steps the daily budget by\n10-25% (halved if frequency > 2.4, capped by the remaining\nmonthly headroom) so Meta does not reset learning. Every\nrow lands in the sheet, then the digest email shows what was\npaused, what was scaled, and the blended ROAS.","height":320,"width":700,"color":5}}],"connections":{"Daily 06:00 Trigger":{"main":[[{"node":"Fetch Account Insights","type":"main","index":0}]]},"Fetch Account Insights":{"main":[[{"node":"Fetch Campaign Insights","type":"main","index":0},{"node":"Fetch Ad Set Insights","type":"main","index":0},{"node":"Fetch Ad Set Budgets","type":"main","index":0}]]},"Fetch Campaign Insights":{"main":[[{"node":"Merge Campaign + Ad Set Rows","type":"main","index":0}]]},"Fetch Ad Set Insights":{"main":[[{"node":"Merge Campaign + Ad Set Rows","type":"main","index":1}]]},"Fetch Ad Set Budgets":{"main":[[{"node":"Merge Campaign + Ad Set Rows","type":"main","index":2}]]},"Merge Campaign + Ad Set Rows":{"main":[[{"node":"Score Ad Set Health","type":"main","index":0}]]},"Score Ad Set Health":{"main":[[{"node":"Drop Non-Actionable Rows","type":"main","index":0}]]},"Drop Non-Actionable Rows":{"main":[[{"node":"Kill Candidate?","type":"main","index":0}]]},"Kill Candidate?":{"main":[[{"node":"Pause Ad Set","type":"main","index":0}],[{"node":"Scale Candidate?","type":"main","index":0}]]},"Pause Ad Set":{"main":[[{"node":"Log Actions to Sheet","type":"main","index":0}]]},"Scale Candidate?":{"main":[[{"node":"Compute Budget Step-Up","type":"main","index":0}],[{"node":"Tag Watchlist Ad Set","type":"main","index":0}]]},"Compute Budget Step-Up":{"main":[[{"node":"Raise Ad Set Budget","type":"main","index":0}]]},"Raise Ad Set Budget":{"main":[[{"node":"Log Actions to Sheet","type":"main","index":0}]]},"Tag Watchlist Ad Set":{"main":[[{"node":"Log Actions to Sheet","type":"main","index":0}]]},"Log Actions to Sheet":{"main":[[{"node":"Build Daily Digest","type":"main","index":0}]]},"Build Daily Digest":{"main":[[{"node":"Email Daily Digest","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}