{"name":"CBO scaling workflow","nodes":[{"id":"cb010000-1111-4222-8333-444455556601","name":"Every 3 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-240,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":3}]}}},{"id":"cb020000-1111-4222-8333-444455556602","name":"Fetch CBO Campaigns","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[20,300],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/campaigns","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,status,effective_status,bid_strategy,daily_budget,lifetime_budget,updated_time,insights.date_preset(today){spend}"},{"name":"effective_status","value":"[\"ACTIVE\"]"},{"name":"limit","value":"100"}]},"options":{}}},{"id":"cb030000-1111-4222-8333-444455556603","name":"Keep Real CBO Campaigns","type":"n8n-nodes-base.code","typeVersion":2,"position":[280,300],"parameters":{"jsCode":"// Keep only true CBO campaigns: campaign-level budget set (Advantage Campaign Budget),\n// active delivery, and enough spend history to trust the distribution.\nconst MIN_CAMPAIGN_SPEND = 50;      // USD in the lookback window\nconst campaigns = [];\n\nfor (const item of $input.all()) {\n  const rows = item.json.data || [item.json];\n  for (const c of rows) {\n    const dailyCents = Number(c.daily_budget || 0);\n    const lifetimeCents = Number(c.lifetime_budget || 0);\n    const isCbo = dailyCents > 0 || lifetimeCents > 0;   // adset budgets => campaign budget is 0\n    if (!isCbo) continue;\n    if ((c.effective_status || c.status) !== 'ACTIVE') continue;\n\n    const insight = (c.insights && c.insights.data && c.insights.data[0]) || {};\n    const spend = Number(insight.spend || 0);\n    if (spend < MIN_CAMPAIGN_SPEND) continue;\n\n    // hours since the last edit - used later to avoid stacking budget changes\n    const updated = c.updated_time ? new Date(c.updated_time).getTime() : 0;\n    const hoursSinceEdit = updated ? (Date.now() - updated) / 36e5 : 999;\n\n    campaigns.push({\n      campaign_id: c.id,\n      campaign_name: c.name,\n      bid_strategy: c.bid_strategy || 'LOWEST_COST_WITHOUT_CAP',\n      daily_budget_usd: dailyCents / 100,\n      lifetime_budget_usd: lifetimeCents / 100,\n      campaign_spend: Number(spend.toFixed(2)),\n      hours_since_edit: Number(hoursSinceEdit.toFixed(1)),\n    });\n  }\n}\n\n// biggest budgets first - that is where a starved winner costs the most money\ncampaigns.sort((a, b) => b.daily_budget_usd - a.daily_budget_usd);\nreturn campaigns.map((c) => ({ json: c }));"}},{"id":"cb040000-1111-4222-8333-444455556604","name":"Loop Over CBO Campaigns","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[540,300],"parameters":{"batchSize":1,"options":{}}},{"id":"cb050000-1111-4222-8333-444455556605","name":"Fetch Ad Set Spend Split","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[800,500],"parameters":{"url":"=https://graph.facebook.com/v21.0/{{ $json.campaign_id }}/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"adset"},{"name":"fields","value":"adset_id,adset_name,spend,impressions,frequency,cpm,ctr,purchase_roas,actions"},{"name":"date_preset","value":"today"},{"name":"limit","value":"50"}]},"options":{}}},{"id":"cb060000-1111-4222-8333-444455556606","name":"Detect Budget Starvation","type":"n8n-nodes-base.code","typeVersion":2,"position":[1060,500],"parameters":{"jsCode":"// ---- CBO budget-starvation detector -------------------------------------\n// A CBO campaign is \"starved\" when Meta has already concentrated delivery into\n// one ad set, that ad set is profitable, and the CAMPAIGN budget (not the\n// auction) is what is stopping it from spending more.\nconst camp = $('Loop Over CBO Campaigns').first().json;\n\nconst TARGET_ROAS       = 1.8;   // breakeven + margin\nconst TOP_SHARE_MIN     = 0.50;  // winner must own >=50% of campaign spend\nconst PACING_CAP_MIN    = 0.92;  // campaign must be pacing into its own budget\nconst MIN_PURCHASES     = 5;     // per ad set, for signal\nconst FREQ_CEILING      = 3.2;   // don't pour budget into a fried audience\nconst COOLDOWN_HOURS    = 12;    // don't stack edits\n\nconst rows = [];\nfor (const item of $input.all()) {\n  for (const r of (item.json.data || [item.json])) rows.push(r);\n}\n\nconst purchasesOf = (r) => {\n  const a = (r.actions || []).find((x) => x.action_type === 'purchase' || x.action_type === 'omni_purchase');\n  return a ? Number(a.value) : 0;\n};\nconst roasOf = (r) => {\n  const v = Array.isArray(r.purchase_roas) ? r.purchase_roas[0] : r.purchase_roas;\n  return v ? Number(v.value || v) : 0;\n};\n\nconst adsets = rows.map((r) => {\n  const spend = Number(r.spend || 0);\n  const purchases = purchasesOf(r);\n  const roas = roasOf(r);\n  return {\n    adset_id: r.adset_id,\n    adset_name: r.adset_name,\n    spend: Number(spend.toFixed(2)),\n    impressions: Number(r.impressions || 0),\n    frequency: Number(Number(r.frequency || 0).toFixed(2)),\n    cpm: Number(Number(r.cpm || 0).toFixed(2)),\n    ctr: Number(Number(r.ctr || 0).toFixed(3)),\n    purchases,\n    revenue: Number((spend * roas).toFixed(2)),\n    purchase_roas: Number(roas.toFixed(2)),\n    // confidence-weighted efficiency: ROAS discounted until volume proves it\n    score: Number((roas * (purchases / (purchases + 4))).toFixed(3)),\n  };\n});\n\nconst totalSpend = adsets.reduce((s, a) => s + a.spend, 0);\nconst totalRev = adsets.reduce((s, a) => s + a.revenue, 0);\nconst blendedRoas = totalSpend ? totalRev / totalSpend : 0;\n\n// spend distribution: share + Herfindahl concentration index (1 = one ad set eats all)\nadsets.forEach((a) => { a.spend_share = totalSpend ? Number((a.spend / totalSpend).toFixed(3)) : 0; });\nconst hhi = adsets.reduce((s, a) => s + a.spend_share * a.spend_share, 0);\n\nadsets.sort((a, b) => b.score - a.score);\nconst top = adsets[0] || { spend_share: 0, purchase_roas: 0, purchases: 0, frequency: 0 };\n\n// pacing: how hard the campaign is pushing against its own daily budget.\n// The insights window is today so far - project it out to a full day.\nconst hoursElapsed = Math.max(1, new Date().getHours() + new Date().getMinutes() / 60);\nconst projectedDailySpend = (totalSpend / hoursElapsed) * 24;\nconst budget = camp.daily_budget_usd || (camp.lifetime_budget_usd / 7);\nconst capUtilisation = budget ? projectedDailySpend / budget : 0;\n\n// starved = concentrated + profitable + pressed against the cap + not fatigued + off cooldown\nconst reasons = [];\nif (top.spend_share < TOP_SHARE_MIN) reasons.push('spend not concentrated (' + Math.round(top.spend_share * 100) + '% top share)');\nif (capUtilisation < PACING_CAP_MIN) reasons.push('budget not capped (pacing ' + Math.round(capUtilisation * 100) + '%)');\nif (top.purchase_roas < TARGET_ROAS) reasons.push('top ad set ROAS ' + top.purchase_roas + ' < ' + TARGET_ROAS);\nif (top.purchases < MIN_PURCHASES) reasons.push('only ' + top.purchases + ' purchases on top ad set');\nif (top.frequency > FREQ_CEILING) reasons.push('frequency ' + top.frequency + ' above ceiling');\nif (camp.hours_since_edit < COOLDOWN_HOURS) reasons.push('edited ' + camp.hours_since_edit + 'h ago (cooldown)');\n\nreturn [{\n  json: {\n    campaign_id: camp.campaign_id,\n    campaign_name: camp.campaign_name,\n    bid_strategy: camp.bid_strategy,\n    current_budget: Number(budget.toFixed(2)),\n    total_spend: Number(totalSpend.toFixed(2)),\n    projected_daily_spend: Number(projectedDailySpend.toFixed(2)),\n    cap_utilisation: Number(capUtilisation.toFixed(3)),\n    blended_roas: Number(blendedRoas.toFixed(2)),\n    concentration_hhi: Number(hhi.toFixed(3)),\n    active_adsets: adsets.length,\n    top_adset_id: top.adset_id,\n    top_adset_name: top.adset_name,\n    top_adset_share: top.spend_share,\n    top_adset_roas: top.purchase_roas,\n    top_adset_purchases: top.purchases,\n    top_adset_frequency: top.frequency,\n    starved: reasons.length === 0,\n    block_reasons: reasons,\n    adsets,\n  },\n}];"}},{"id":"cb070000-1111-4222-8333-444455556607","name":"Winner Starved By Campaign Cap?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1320,500],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.starved }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"cb080000-1111-4222-8333-444455556608","name":"Size Budget Increase","type":"n8n-nodes-base.code","typeVersion":2,"position":[1580,380],"parameters":{"jsCode":"// ---- rebalance: size the campaign budget increase --------------------------\n// The winner is capped, so we raise the CBO budget instead of touching ad sets\n// (editing ad set budgets in a CBO campaign is what breaks the optimiser).\nconst MAX_STEP        = 0.20;   // never more than +20% in one move (learning phase)\nconst MIN_STEP        = 0.08;\nconst ABSOLUTE_CEILING = 500;   // hard daily budget ceiling for this account\nconst TARGET_ROAS     = 1.8;\n\nconst out = [];\nfor (const item of $input.all()) {\n  const d = item.json;\n\n  // headroom = how far above target the winner is; more headroom => bigger step\n  const headroom = Math.max(0, d.top_adset_roas / TARGET_ROAS - 1);\n  // concentration damps the step: if one ad set owns 95% of spend, raising the\n  // budget mostly feeds that one audience, so move slower.\n  const damp = 1 - Math.min(0.4, Math.max(0, d.concentration_hhi - 0.5));\n  let step = Math.min(MAX_STEP, Math.max(MIN_STEP, headroom * 0.5)) * damp;\n\n  // pressure bonus: pacing well over the cap means real demand is being refused\n  if (d.cap_utilisation > 1.05) step = Math.min(MAX_STEP, step + 0.03);\n\n  const rawBudget = d.current_budget * (1 + step);\n  const newBudget = Math.round(Math.min(ABSOLUTE_CEILING, rawBudget));\n  const applied = (newBudget - d.current_budget) / d.current_budget;\n\n  out.push({\n    json: {\n      ...d,\n      step_pct: Number((step * 100).toFixed(1)),\n      applied_pct: Number((applied * 100).toFixed(1)),\n      new_budget: newBudget,\n      new_budget_cents: Math.round(newBudget * 100),\n      hit_ceiling: rawBudget > ABSOLUTE_CEILING,\n      projected_extra_revenue: Number(((newBudget - d.current_budget) * d.top_adset_roas).toFixed(2)),\n      decision: 'RAISE_CBO_BUDGET',\n    },\n  });\n}\nreturn out;"}},{"id":"cb090000-1111-4222-8333-444455556609","name":"New Budget Under Ceiling?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1840,380],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.hit_ceiling }}","rightValue":"","operator":{"type":"boolean","operation":"false","singleValue":true}},{"id":"c2","leftValue":"={{ $json.applied_pct }}","rightValue":2,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"cb0100000-1111-4222-8333-444455556610","name":"Raise CBO Daily Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2100,240],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.campaign_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ daily_budget: $json.new_budget_cents, status: \"ACTIVE\" }) }}","options":{}}},{"id":"cb0110000-1111-4222-8333-444455556611","name":"Build Rebalance Log Row","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2360,240],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"campaign","value":"={{ $('Size Budget Increase').item.json.campaign_name }}","type":"string"},{"id":"a2","name":"old_budget","value":"={{ $('Size Budget Increase').item.json.current_budget }}","type":"number"},{"id":"a3","name":"new_budget","value":"={{ $('Size Budget Increase').item.json.new_budget }}","type":"number"},{"id":"a4","name":"applied_pct","value":"={{ $('Size Budget Increase').item.json.applied_pct }}","type":"number"},{"id":"a5","name":"top_adset","value":"={{ $('Size Budget Increase').item.json.top_adset_name }}","type":"string"},{"id":"a6","name":"top_adset_roas","value":"={{ $('Size Budget Increase').item.json.top_adset_roas }}","type":"number"},{"id":"a7","name":"top_adset_share","value":"={{ $('Size Budget Increase').item.json.top_adset_share }}","type":"number"},{"id":"a8","name":"cap_utilisation","value":"={{ $('Size Budget Increase').item.json.cap_utilisation }}","type":"number"},{"id":"a9","name":"logged_at","value":"={{ $now.format('yyyy-LL-dd HH:mm') }}","type":"string"}]},"options":{}}},{"id":"cb0120000-1111-4222-8333-444455556612","name":"Append To Budget Ledger","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2620,240],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"CBO Budget Ledger"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"cb0130000-1111-4222-8333-444455556613","name":"Alert Account Budget Ceiling","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2360,500],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=🧱 {{ $json.campaign_name }} wants ${{ $json.new_budget }}/day but hits the $500 account ceiling. Top ad set {{ $json.top_adset_name }} at {{ $json.top_adset_roas }}x is capped — approve a manual raise or duplicate the campaign.","otherOptions":{}}},{"id":"cb0140000-1111-4222-8333-444455556614","name":"Mark Campaign Balanced","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1580,660],"parameters":{"assignments":{"assignments":[{"id":"b1","name":"decision","value":"HOLD","type":"string"},{"id":"b2","name":"hold_reason","value":"={{ $json.block_reasons.join(\"; \") }}","type":"string"}]},"options":{}}},{"id":"cb0150000-1111-4222-8333-444455556615","name":"Summarise Scaling Run","type":"n8n-nodes-base.code","typeVersion":2,"position":[800,100],"parameters":{"jsCode":"// Roll every campaign decision from this run into one Slack digest.\nconst runs = $('Loop Over CBO Campaigns').all().map((i) => i.json);\nlet raised = 0, held = 0, extra = 0, spend = 0;\nconst lines = [];\n\nfor (const r of runs) {\n  spend += Number(r.total_spend || 0);\n  if (r.decision === 'RAISE_CBO_BUDGET') {\n    raised++;\n    extra += Number(r.projected_extra_revenue || 0);\n    lines.push('⬆️ ' + r.campaign_name + ' $' + r.current_budget + ' → $' + r.new_budget +\n      ' (+' + r.applied_pct + '%) · top ' + r.top_adset_name + ' ' + r.top_adset_roas + 'x @ ' +\n      Math.round(r.top_adset_share * 100) + '% share');\n  } else {\n    held++;\n    lines.push('• ' + r.campaign_name + ' held — ' + ((r.block_reasons || []).join('; ') || 'balanced'));\n  }\n}\n\nconst text = '*CBO scaling run* — ' + runs.length + ' campaigns · $' + spend.toFixed(0) +\n  ' spend\\n' + raised + ' budget raises, ' + held + ' held · projected +$' + extra.toFixed(0) +\n  ' revenue\\n' + lines.slice(0, 15).join('\\n');\n\nreturn [{ json: { campaigns: runs.length, raised, held, projected_extra_revenue: Number(extra.toFixed(2)), text } }];"}},{"id":"cb0160000-1111-4222-8333-444455556616","name":"Post Scaling Digest","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1060,100],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"={{ $json.text }}","otherOptions":{}}},{"id":"cb0170000-1111-4222-8333-444455556617","name":"Note Read","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-40,100],"parameters":{"content":"## 1 · READ CBO SPEND\nPull active campaigns that carry a CAMPAIGN-level budget (real CBO), then pull the ad-set level spend split for each one.","height":260,"width":400,"color":4}},{"id":"cb0180000-1111-4222-8333-444455556618","name":"Note Detect","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1000,740],"parameters":{"content":"## 2 · DETECT STARVATION\nStarved = top ad set owns >=50% of spend, is above 1.8 ROAS with >=5 purchases, campaign is pacing >=92% of its budget, frequency under 3.2, and no edit in the last 12h.","height":300,"width":420,"color":5}},{"id":"cb0190000-1111-4222-8333-444455556619","name":"Note Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[2040,-20],"parameters":{"content":"## 3 · REBALANCE\nRaise the CAMPAIGN budget (never the ad set budget in CBO). Step = 8-20%, scaled by ROAS headroom and damped by spend concentration. Ledger every change, escalate at the account ceiling.","height":300,"width":420,"color":3}}],"connections":{"Every 3 Hours":{"main":[[{"node":"Fetch CBO Campaigns","type":"main","index":0}]]},"Fetch CBO Campaigns":{"main":[[{"node":"Keep Real CBO Campaigns","type":"main","index":0}]]},"Keep Real CBO Campaigns":{"main":[[{"node":"Loop Over CBO Campaigns","type":"main","index":0}]]},"Loop Over CBO Campaigns":{"main":[[{"node":"Summarise Scaling Run","type":"main","index":0}],[{"node":"Fetch Ad Set Spend Split","type":"main","index":0}]]},"Fetch Ad Set Spend Split":{"main":[[{"node":"Detect Budget Starvation","type":"main","index":0}]]},"Detect Budget Starvation":{"main":[[{"node":"Winner Starved By Campaign Cap?","type":"main","index":0}]]},"Winner Starved By Campaign Cap?":{"main":[[{"node":"Size Budget Increase","type":"main","index":0}],[{"node":"Mark Campaign Balanced","type":"main","index":0}]]},"Size Budget Increase":{"main":[[{"node":"New Budget Under Ceiling?","type":"main","index":0}]]},"New Budget Under Ceiling?":{"main":[[{"node":"Raise CBO Daily Budget","type":"main","index":0}],[{"node":"Alert Account Budget Ceiling","type":"main","index":0}]]},"Raise CBO Daily Budget":{"main":[[{"node":"Build Rebalance Log Row","type":"main","index":0}]]},"Build Rebalance Log Row":{"main":[[{"node":"Append To Budget Ledger","type":"main","index":0}]]},"Append To Budget Ledger":{"main":[[{"node":"Loop Over CBO Campaigns","type":"main","index":0}]]},"Alert Account Budget Ceiling":{"main":[[{"node":"Loop Over CBO Campaigns","type":"main","index":0}]]},"Mark Campaign Balanced":{"main":[[{"node":"Loop Over CBO Campaigns","type":"main","index":0}]]},"Summarise Scaling Run":{"main":[[{"node":"Post Scaling Digest","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}