{"name":"Retargeting on autopilot workflow","nodes":[{"id":"cc000001-1111-4222-8333-444455556601","name":"Every 3 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-820,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":3}]}}},{"id":"cc000002-1111-4222-8333-444455556602","name":"Fetch Site Engagement Events","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-580,160],"parameters":{"url":"https://api.shop-events.internal/v1/events","sendQuery":true,"queryParameters":{"parameters":[{"name":"event_name","value":"page_view,view_content,add_to_cart"},{"name":"since","value":"={{ $now.minus({ days: 14 }).toISO() }}"},{"name":"fields","value":"event_name,event_time,email,phone,external_id,value,currency,product_id,engagement_time_seconds"},{"name":"limit","value":"5000"}]},"options":{}}},{"id":"cc000003-1111-4222-8333-444455556603","name":"Fetch Purchase Events","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-580,440],"parameters":{"url":"https://STORE.myshopify.com/admin/api/2024-10/orders.json","sendQuery":true,"queryParameters":{"parameters":[{"name":"status","value":"any"},{"name":"financial_status","value":"paid"},{"name":"created_at_min","value":"={{ $now.minus({ days: 180 }).toISO() }}"},{"name":"fields","value":"id,email,phone,customer,total_price,currency,created_at,financial_status"},{"name":"limit","value":"250"}]},"options":{}}},{"id":"cc000004-1111-4222-8333-444455556604","name":"Merge Event Streams","type":"n8n-nodes-base.merge","typeVersion":3,"position":[-340,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"cc000005-1111-4222-8333-444455556605","name":"Bucket Events Into Audiences","type":"n8n-nodes-base.code","typeVersion":2,"position":[-100,300],"parameters":{"jsCode":"// ===========================================================================\n// EVENT -> AUDIENCE BUCKETING\n// Input: two merged streams — site events (page_view / view_content / add_to_cart)\n// and purchase events from the store. Output: one item per audience bucket,\n// each carrying the normalized member list we will PUSH to Meta.\n// ===========================================================================\nconst CFG = {\n  // how far back each bucket looks (retargeting decays fast on ecom)\n  windows: { viewers: 14, add_to_cart: 7, purchasers: 180 },\n  // audience ids created once in Ads Manager, referenced forever after\n  audiences: {\n    viewers:      { id: 'AUDIENCE_ID_VIEWERS_14D',  label: 'Site viewers 14d' },\n    add_to_cart:  { id: 'AUDIENCE_ID_ATC_7D',       label: 'Add to cart 7d, no purchase' },\n    purchasers:   { id: 'AUDIENCE_ID_BUYERS_180D',  label: 'Purchasers 180d' }\n  },\n  minViewSeconds: 8,      // a 2-second bounce is not intent\n  minCartValue: 15        // ignore accidental micro-carts\n};\n\nconst DAY = 24 * 60 * 60 * 1000;\nconst now = Date.now();\nconst num = (v) => { const n = typeof v === 'string' ? parseFloat(v) : v; return Number.isFinite(n) ? n : 0; };\n\n// Meta matches on normalized (lowercased, trimmed) values; digits only for phone.\nconst normEmail = (e) => (typeof e === 'string' ? e.trim().toLowerCase() : '');\nconst normPhone = (p) => (typeof p === 'string' ? p.replace(/[^0-9]/g, '') : '');\n\nconst ts = (r) => {\n  const raw = r.event_time || r.occurred_at || r.created_at || r.processed_at;\n  const t = typeof raw === 'number' ? (raw < 1e12 ? raw * 1000 : raw) : Date.parse(raw || '');\n  return Number.isFinite(t) ? t : now;\n};\n\nconst rows = $input.all().map((i) => i.json);\n\n// ---- 1. collapse every event down to one record per PERSON ----------------\n// Same shopper fires dozens of events; the audience API only wants them once,\n// but we keep the strongest signal and the freshest timestamp.\nconst people = new Map();\n\nfor (const r of rows) {\n  const email = normEmail(r.email || r.customer_email || (r.customer && r.customer.email));\n  const phone = normPhone(r.phone || r.customer_phone || (r.customer && r.customer.phone));\n  const extId = String(r.external_id || r.customer_id || r.client_user_id || '');\n  const key = email || phone || extId;\n  if (!key) continue; // unmatchable traffic — nothing to sync\n\n  const name = String(r.event_name || r.event || r.type || '').toLowerCase();\n  const value = num(r.value || r.total_price || r.cart_value || r.line_total);\n  const seconds = num(r.engagement_time_seconds || r.time_on_page);\n  const when = ts(r);\n\n  // rank the event: purchase (3) beats add_to_cart (2) beats a real view (1)\n  let rank = 0;\n  if (name.includes('purchase') || name === 'checkout_completed' || r.financial_status === 'paid') rank = 3;\n  else if (name.includes('add_to_cart') || name === 'cart_add') rank = value >= CFG.minCartValue ? 2 : 0;\n  else if (name.includes('view') || name === 'page_view') rank = seconds >= CFG.minViewSeconds ? 1 : 0;\n  if (rank === 0) continue; // bounce / junk event\n\n  const prev = people.get(key);\n  if (!prev) {\n    people.set(key, {\n      key, email, phone, external_id: extId,\n      rank, last_seen: when, first_seen: when,\n      revenue: rank === 3 ? value : 0,\n      cart_value: rank === 2 ? value : 0,\n      events: 1\n    });\n  } else {\n    prev.events += 1;\n    prev.email = prev.email || email;\n    prev.phone = prev.phone || phone;\n    prev.external_id = prev.external_id || extId;\n    prev.first_seen = Math.min(prev.first_seen, when);\n    if (rank === 3) prev.revenue += value;\n    if (rank === 2) prev.cart_value = Math.max(prev.cart_value, value);\n    if (rank > prev.rank) { prev.rank = rank; prev.last_seen = when; }\n    else if (rank === prev.rank) prev.last_seen = Math.max(prev.last_seen, when);\n  }\n}\n\n// ---- 2. assign each person to exactly ONE bucket -------------------------\n// Mutually exclusive on purpose: a buyer must NOT keep seeing the \"you left\n// something in your cart\" ad, and a carter should not sit in the cold viewer pool.\nconst buckets = { viewers: [], add_to_cart: [], purchasers: [] };\nconst suppress = { viewers: [], add_to_cart: [] }; // people to REMOVE from upper funnel\n\nfor (const p of people.values()) {\n  const ageDays = (now - p.last_seen) / DAY;\n  const member = {\n    email: p.email || null,\n    phone: p.phone || null,\n    external_id: p.external_id || null,\n    age_days: Math.round(ageDays * 10) / 10,\n    value: Math.round((p.revenue || p.cart_value) * 100) / 100\n  };\n\n  if (p.rank === 3) {\n    if (ageDays <= CFG.windows.purchasers) buckets.purchasers.push(member);\n    // a purchase invalidates their membership everywhere upstream\n    suppress.viewers.push(member);\n    suppress.add_to_cart.push(member);\n  } else if (p.rank === 2) {\n    if (ageDays <= CFG.windows.add_to_cart) buckets.add_to_cart.push(member);\n    suppress.viewers.push(member); // carters get the cart ad, not the cold view ad\n  } else if (ageDays <= CFG.windows.viewers) {\n    buckets.viewers.push(member);\n  }\n}\n\n// ---- 3. emit one item per bucket -----------------------------------------\n// Highest intent first so the loop touches purchasers (exclusion list) before\n// it inflates the viewer pool — order matters when the ad sets read each other.\nconst order = ['purchasers', 'add_to_cart', 'viewers'];\nreturn order.map((name) => {\n  const members = buckets[name];\n  const totalValue = members.reduce((s, m) => s + num(m.value), 0);\n  return {\n    json: {\n      bucket: name,\n      audience_id: CFG.audiences[name].id,\n      audience_label: CFG.audiences[name].label,\n      window_days: CFG.windows[name],\n      size: members.length,\n      total_value: Math.round(totalValue * 100) / 100,\n      avg_value: members.length ? Math.round((totalValue / members.length) * 100) / 100 : 0,\n      members,\n      suppress_from: name === 'purchasers' ? ['AUDIENCE_ID_VIEWERS_14D', 'AUDIENCE_ID_ATC_7D']\n        : name === 'add_to_cart' ? ['AUDIENCE_ID_VIEWERS_14D'] : [],\n      suppress_members: name === 'purchasers' ? suppress.add_to_cart\n        : name === 'add_to_cart' ? suppress.viewers : [],\n      synced_at: new Date().toISOString()\n    }\n  };\n});"}},{"id":"cc000006-1111-4222-8333-444455556606","name":"Loop Over Audience Buckets","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[140,300],"parameters":{"batchSize":1,"options":{}}},{"id":"cc000007-1111-4222-8333-444455556607","name":"Push Members To Custom Audience","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[380,560],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.audience_id }}/users","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ payload: { schema: [\"EMAIL\", \"PHONE\", \"EXTERN_ID\"], is_raw: true, data: ($json.members || []).map(m => [m.email || \"\", m.phone || \"\", m.external_id || \"\"]) } }) }}","options":{}}},{"id":"cc000008-1111-4222-8333-444455556608","name":"Prune Buyers From Upper Funnel","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[620,560],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $('Loop Over Audience Buckets').item.json.suppress_from[0] || $('Loop Over Audience Buckets').item.json.audience_id }}/users","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ method: \"delete\", payload: { schema: [\"EMAIL\", \"PHONE\", \"EXTERN_ID\"], is_raw: true, data: ($('Loop Over Audience Buckets').item.json.suppress_members || []).map(m => [m.email || \"\", m.phone || \"\", m.external_id || \"\"]) } }) }}","options":{}}},{"id":"cc000009-1111-4222-8333-444455556609","name":"Fetch Refreshed Audience Sizes","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[380,300],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/customaudiences","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,approximate_count_lower_bound,delivery_status,operation_status,time_updated"},{"name":"limit","value":"100"}]},"options":{}}},{"id":"cc000010-1111-4222-8333-444455556610","name":"Fetch Meta Retargeting Spend","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[620,160],"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,reach,frequency,actions,action_values,purchase_roas,daily_budget"},{"name":"filtering","value":"[{\"field\":\"campaign.name\",\"operator\":\"CONTAIN\",\"value\":\"RT |\"}]"},{"name":"date_preset","value":"last_7d"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"cc000011-1111-4222-8333-444455556611","name":"Fetch Google Remarketing Cost","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[620,440],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/googleAds:searchStream","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ query: \"SELECT campaign.name, metrics.cost_micros, metrics.conversions, metrics.conversions_value FROM campaign WHERE campaign.advertising_channel_type IN (DISPLAY, VIDEO) AND campaign.name LIKE 'RMKT |%' AND segments.date DURING LAST_7_DAYS\" }) }}","options":{}}},{"id":"cc000012-1111-4222-8333-444455556612","name":"Merge Sizes With Spend","type":"n8n-nodes-base.merge","typeVersion":3,"position":[860,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"cc000013-1111-4222-8333-444455556613","name":"Plan Budget By Pool Size","type":"n8n-nodes-base.code","typeVersion":2,"position":[1100,300],"parameters":{"jsCode":"// ===========================================================================\n// BUDGET PLAN — size the retargeting spend to the size of each pool.\n// You cannot spend $200/day against 900 people without frequency exploding,\n// and you are leaving money on the table if a 40k pool sits on $30/day.\n// ===========================================================================\nconst CFG = {\n  targetTouchesPerWeek: 3.0,   // impressions per person per week we're happy with\n  estCpm: 14,                  // account-level retargeting CPM\n  freqCeiling: 4.5,            // above this the pool is burnt, pull budget back\n  breakevenRoas: 1.8,\n  scaleRoas: 3.0,              // above this we push budget up\n  maxDailyChangePct: 0.25,     // never move a budget more than 25% in one run\n  minPoolSize: 500,            // below this Meta delivery gets erratic\n  floorDaily: 10,\n  ceilingDaily: 400\n};\n\nconst num = (v) => { const n = typeof v === 'string' ? parseFloat(v) : v; return Number.isFinite(n) ? n : 0; };\nconst pick = (arr, type) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find((a) => a.action_type === type);\n  return hit ? num(hit.value) : 0;\n};\n\nconst rows = $input.all().map((i) => i.json);\n\n// The merge is positional, so rebuild each side by its own key instead of\n// trusting that row N of sizes lines up with row N of spend.\nconst sizeByAudience = new Map();   // audience_id -> approximate_count\nconst metaByAdset = new Map();      // adset_name  -> insights\nconst googleByCampaign = new Map(); // campaign    -> cost_micros\n\nfor (const r of rows) {\n  const audId = r.audience_id || r.id;\n  if (audId && (r.approximate_count_lower_bound !== undefined || r.approximate_count !== undefined)) {\n    sizeByAudience.set(String(audId), num(\n      r.approximate_count !== undefined ? r.approximate_count : r.approximate_count_lower_bound\n    ));\n  }\n  if (r.adset_id || r.adset_name) {\n    metaByAdset.set(String(r.adset_name || r.adset_id), {\n      adset_id: r.adset_id || null,\n      spend: num(r.spend),\n      impressions: num(r.impressions),\n      frequency: num(r.frequency),\n      reach: num(r.reach),\n      daily_budget: num(r.daily_budget) / 100, // Meta returns minor units\n      purchases: pick(r.actions, 'purchase') + pick(r.actions, 'omni_purchase'),\n      roas: Array.isArray(r.purchase_roas) ? pick(r.purchase_roas, 'purchase') : num(r.purchase_roas)\n    });\n  }\n  if (r.cost_micros !== undefined || (r.metrics && r.metrics.cost_micros !== undefined)) {\n    const m = r.metrics || r;\n    googleByCampaign.set(String(r.campaign_name || (r.campaign && r.campaign.name) || 'google-rmkt'), {\n      cost: num(m.cost_micros) / 1e6,\n      conversions: num(m.conversions),\n      conversions_value: num(m.conversions_value)\n    });\n  }\n}\n\n// Map each bucket to the ad set that actually serves it.\nconst ROUTES = {\n  purchasers:  { adset: 'RT | Buyers 180d | Cross-sell',  adset_id: 'ADSET_ID_BUYERS' },\n  add_to_cart: { adset: 'RT | ATC 7d | Cart abandon',     adset_id: 'ADSET_ID_ATC' },\n  viewers:     { adset: 'RT | Viewers 14d | Prospect',    adset_id: 'ADSET_ID_VIEWERS' }\n};\n\n// The bucket definitions do NOT survive the loop (the loop's done-output carries\n// the Graph API responses, not our rows), so read them straight from the source\n// node and fall back to anything bucket-shaped that did come through the merge.\nlet buckets = [];\ntry {\n  buckets = $('Bucket Events Into Audiences').all().map((i) => i.json).filter((r) => r.bucket && r.audience_id);\n} catch (e) {\n  buckets = [];\n}\nif (!buckets.length) buckets = rows.filter((r) => r.bucket && r.audience_id);\nconst out = [];\n\nfor (const b of buckets) {\n  const route = ROUTES[b.bucket] || { adset: b.bucket, adset_id: null };\n  const perf = metaByAdset.get(route.adset) || {};\n  const google = googleByCampaign.get('RMKT | ' + b.bucket) || {};\n\n  // live size from Meta beats our own count — Meta drops unmatched members\n  const liveSize = sizeByAudience.get(String(b.audience_id));\n  const poolSize = liveSize && liveSize > 0 ? liveSize : num(b.size);\n\n  const current = num(perf.daily_budget) || CFG.floorDaily;\n  const roas = num(perf.roas);\n  const frequency = num(perf.frequency);\n  const blendedSpend = num(perf.spend) + num(google.cost); // Meta + Google Ads\n\n  // 1. what the pool can absorb: touches * people * CPM / 1000, spread over 7 days\n  const absorbable = (poolSize * CFG.targetTouchesPerWeek * CFG.estCpm) / 1000 / 7;\n\n  // 2. modulate by how the money is actually performing\n  let multiplier = 1;\n  const reasons = [];\n  if (roas >= CFG.scaleRoas) { multiplier *= 1.25; reasons.push('ROAS ' + roas.toFixed(2) + ' above scale line'); }\n  else if (roas > 0 && roas < CFG.breakevenRoas) { multiplier *= 0.7; reasons.push('ROAS ' + roas.toFixed(2) + ' under breakeven ' + CFG.breakevenRoas); }\n  if (frequency > CFG.freqCeiling) { multiplier *= 0.65; reasons.push('frequency ' + frequency.toFixed(2) + ' — pool is burnt'); }\n  if (poolSize < CFG.minPoolSize) { multiplier *= 0.5; reasons.push('pool only ' + poolSize + ' people'); }\n\n  // 3. target, then clamp so we never shock the learning phase\n  let target = absorbable * multiplier;\n  const maxUp = current * (1 + CFG.maxDailyChangePct);\n  const maxDown = current * (1 - CFG.maxDailyChangePct);\n  target = Math.min(Math.max(target, maxDown), maxUp);\n  target = Math.min(Math.max(target, CFG.floorDaily), CFG.ceilingDaily);\n  const newDaily = Math.round(target);\n\n  const healthy = poolSize >= CFG.minPoolSize && frequency <= CFG.freqCeiling &&\n    (roas === 0 || roas >= CFG.breakevenRoas);\n\n  out.push({\n    json: {\n      bucket: b.bucket,\n      audience_id: b.audience_id,\n      audience_label: b.audience_label,\n      adset_id: route.adset_id,\n      adset_name: route.adset,\n      pool_size: poolSize,\n      synced_members: num(b.size),\n      window_days: num(b.window_days),\n      absorbable_daily: Math.round(absorbable * 100) / 100,\n      current_daily_budget: Math.round(current * 100) / 100,\n      new_daily_budget: newDaily,\n      budget_delta: Math.round((newDaily - current) * 100) / 100,\n      budget_delta_pct: current ? Math.round(((newDaily - current) / current) * 1000) / 10 : 0,\n      purchase_roas: Math.round(roas * 100) / 100,\n      frequency: Math.round(frequency * 100) / 100,\n      meta_spend: Math.round(num(perf.spend) * 100) / 100,\n      google_cost: Math.round(num(google.cost) * 100) / 100,\n      blended_spend: Math.round(blendedSpend * 100) / 100,\n      scale_ok: healthy && newDaily >= current,\n      decision: healthy && newDaily >= current ? 'scale' : 'pull_back',\n      reason: reasons.join(' + ') || 'pool and performance both inside guardrails',\n      planned_at: new Date().toISOString()\n    }\n  });\n}\n\n// biggest movers first so the Slack digest reads top-down\nout.sort((a, b) => Math.abs(b.json.budget_delta) - Math.abs(a.json.budget_delta));\nreturn out;"}},{"id":"cc000014-1111-4222-8333-444455556614","name":"Pool Healthy Enough To Scale?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1340,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"p1","leftValue":"={{ $json.scale_ok }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}},{"id":"p2","leftValue":"={{ $json.pool_size }}","rightValue":500,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"cc000015-1111-4222-8333-444455556615","name":"Raise Retargeting Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1580,140],"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), status: \"ACTIVE\" }) }}","options":{}}},{"id":"cc000016-1111-4222-8333-444455556616","name":"Pull Back Burnt Audience","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1580,460],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.adset_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ daily_budget: Math.round(Math.max($json.new_daily_budget, 10) * 100), status: $json.pool_size < 200 ? \"PAUSED\" : \"ACTIVE\" }) }}","options":{}}},{"id":"cc000017-1111-4222-8333-444455556617","name":"Log Budget Moves","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1820,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Retargeting Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"cc000018-1111-4222-8333-444455556618","name":"Build Retargeting Digest","type":"n8n-nodes-base.code","typeVersion":2,"position":[2060,300],"parameters":{"jsCode":"// One digest per run: what got synced, what moved, and what to look at.\nconst rows = $input.all().map((i) => i.json);\nif (!rows.length) {\n  return [{ json: { slack_text: 'Retargeting sync ran but produced no bucket rows — check the event source.', moved: 0 } }];\n}\nconst money = (n) => '$' + Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 0 });\n\nconst totalPool = rows.reduce((s, r) => s + Number(r.pool_size || 0), 0);\nconst oldSpend = rows.reduce((s, r) => s + Number(r.current_daily_budget || 0), 0);\nconst newSpend = rows.reduce((s, r) => s + Number(r.new_daily_budget || 0), 0);\nconst scaled = rows.filter((r) => r.decision === 'scale');\nconst pulled = rows.filter((r) => r.decision !== 'scale');\n\nconst lines = rows.map((r) => {\n  const arrow = Number(r.budget_delta) > 0 ? 'UP' : Number(r.budget_delta) < 0 ? 'DOWN' : 'FLAT';\n  return '• *' + r.audience_label + '* — ' + Number(r.pool_size).toLocaleString('en-US') + ' people, ' +\n    Number(r.synced_members).toLocaleString('en-US') + ' synced this run\\n' +\n    '   ' + arrow + ' ' + money(r.current_daily_budget) + ' -> ' + money(r.new_daily_budget) +\n    ' /day (' + r.budget_delta_pct + '%) · ROAS ' + r.purchase_roas + ' · freq ' + r.frequency +\n    ' · blended ' + money(r.blended_spend) + '\\n' +\n    '   why: ' + r.reason;\n});\n\nconst slack_text = [\n  'Retargeting autopilot — ' + rows.length + ' buckets refreshed',\n  totalPool.toLocaleString('en-US') + ' people in the retargeting pools · daily budget ' +\n    money(oldSpend) + ' -> ' + money(newSpend),\n  scaled.length + ' scaled · ' + pulled.length + ' pulled back',\n  '',\n  ...lines\n].join('\\n');\n\nreturn [{ json: {\n  buckets: rows.length,\n  total_pool: totalPool,\n  old_daily_total: Math.round(oldSpend),\n  new_daily_total: Math.round(newSpend),\n  scaled_count: scaled.length,\n  pulled_count: pulled.length,\n  slack_text\n} }];"}},{"id":"cc000019-1111-4222-8333-444455556619","name":"Post Digest To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2300,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"cc000020-1111-4222-8333-444455556620","name":"Note Collect","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-860,-40],"parameters":{"content":"## 1. COLLECT THE SIGNAL\nLast 14 days of site engagement (view_content / add_to_cart, with engagement_time_seconds so bounces are dropped) merged with 180 days of paid orders. Everything is keyed by email / phone / external_id.","height":280,"width":440,"color":4}},{"id":"cc000021-1111-4222-8333-444455556621","name":"Note Buckets","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-120,-40],"parameters":{"content":"## 2. MUTUALLY EXCLUSIVE BUCKETS\nOne record per person, highest intent wins: purchaser > add_to_cart > viewer. Buyers are pushed to the buyers audience AND deleted from viewers + ATC, so nobody who already bought keeps seeing the cart-abandon ad.","height":280,"width":440,"color":3}},{"id":"cc000022-1111-4222-8333-444455556622","name":"Note Budget","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1080,660],"parameters":{"content":"## 3. BUDGET FOLLOWS POOL SIZE\nAbsorbable spend = pool x 3 touches/week x $14 CPM / 1000 / 7, then modulated by purchase_roas and frequency and clamped to +/-25% per run. Healthy pools scale; burnt or thin pools get pulled back (under 200 people the ad set pauses).","height":280,"width":460,"color":5}}],"connections":{"Every 3 Hours":{"main":[[{"node":"Fetch Site Engagement Events","type":"main","index":0},{"node":"Fetch Purchase Events","type":"main","index":0}]]},"Fetch Site Engagement Events":{"main":[[{"node":"Merge Event Streams","type":"main","index":0}]]},"Fetch Purchase Events":{"main":[[{"node":"Merge Event Streams","type":"main","index":1}]]},"Merge Event Streams":{"main":[[{"node":"Bucket Events Into Audiences","type":"main","index":0}]]},"Bucket Events Into Audiences":{"main":[[{"node":"Loop Over Audience Buckets","type":"main","index":0}]]},"Loop Over Audience Buckets":{"main":[[{"node":"Fetch Refreshed Audience Sizes","type":"main","index":0}],[{"node":"Push Members To Custom Audience","type":"main","index":0}]]},"Push Members To Custom Audience":{"main":[[{"node":"Prune Buyers From Upper Funnel","type":"main","index":0}]]},"Prune Buyers From Upper Funnel":{"main":[[{"node":"Loop Over Audience Buckets","type":"main","index":0}]]},"Fetch Refreshed Audience Sizes":{"main":[[{"node":"Fetch Meta Retargeting Spend","type":"main","index":0},{"node":"Fetch Google Remarketing Cost","type":"main","index":0}]]},"Fetch Meta Retargeting Spend":{"main":[[{"node":"Merge Sizes With Spend","type":"main","index":0}]]},"Fetch Google Remarketing Cost":{"main":[[{"node":"Merge Sizes With Spend","type":"main","index":1}]]},"Merge Sizes With Spend":{"main":[[{"node":"Plan Budget By Pool Size","type":"main","index":0}]]},"Plan Budget By Pool Size":{"main":[[{"node":"Pool Healthy Enough To Scale?","type":"main","index":0}]]},"Pool Healthy Enough To Scale?":{"main":[[{"node":"Raise Retargeting Budget","type":"main","index":0}],[{"node":"Pull Back Burnt Audience","type":"main","index":0}]]},"Raise Retargeting Budget":{"main":[[{"node":"Log Budget Moves","type":"main","index":0}]]},"Pull Back Burnt Audience":{"main":[[{"node":"Log Budget Moves","type":"main","index":0}]]},"Log Budget Moves":{"main":[[{"node":"Build Retargeting Digest","type":"main","index":0}]]},"Build Retargeting Digest":{"main":[[{"node":"Post Digest To Slack","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}