{"name":"Lookalike audience workflow","nodes":[{"id":"bb000001-1111-4222-8333-444455556601","name":"Weekly Audience Refresh","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-460,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":7}]}}},{"id":"bb000002-1111-4222-8333-444455556602","name":"Query High LTV Purchasers","type":"n8n-nodes-base.postgres","typeVersion":2.5,"position":[-220,300],"parameters":{"operation":"executeQuery","query":"select c.id as customer_id, c.email, c.phone, c.first_name, c.last_name, c.city, c.country_code, c.created_at as first_order_at, o.id as order_id, o.total_price as order_total, o.refunded_amount, o.created_at as order_created_at, o.top_category, count(o.id) over (partition by c.id) as order_count, sum(o.total_price) over (partition by c.id) as lifetime_revenue, max(o.created_at) over (partition by c.id) as last_order_at from customers c join orders o on o.customer_id = c.id where o.financial_status = 'paid' and o.created_at > now() - interval '540 days' and c.marketing_opt_in = true order by lifetime_revenue desc limit 50000","options":{}}},{"id":"bb000003-1111-4222-8333-444455556603","name":"Score And Cut Seed List","type":"n8n-nodes-base.code","typeVersion":2,"position":[20,300],"parameters":{"jsCode":"// ---- Seed-audience config -------------------------------------------------\n// We are NOT feeding Meta \"everyone who bought once\". A lookalike is only as\n// good as its seed, so we rank customers by predicted LTV and keep the top slice.\nconst CFG = {\n  minSeedSize: 1000,        // Meta's hard floor for a usable lookalike source\n  targetSeedSize: 8000,     // sweet spot: big enough to model, tight enough to stay premium\n  topPercentile: 0.20,      // keep the top 20% of customers by LTV score\n  recencyHalfLifeDays: 180, // an order from 180d ago counts half as much as today's\n  refundPenalty: 0.5,       // a refunded order costs half its value in trust\n  minOrders: 1\n};\n\nconst num = (v) => {\n  const n = typeof v === 'string' ? parseFloat(v) : v;\n  return Number.isFinite(n) ? n : 0;\n};\n\nconst daysAgo = (iso) => {\n  if (!iso) return 9999;\n  const t = new Date(iso).getTime();\n  if (!Number.isFinite(t)) return 9999;\n  return Math.max(0, (Date.now() - t) / 86400000);\n};\n\n// Meta requires SHA-256 lowercase-trimmed PII. n8n's sandbox has no crypto\n// module, so we normalise here and let the hashing step downstream do the digest.\nconst norm = (v) => String(v == null ? '' : v).trim().toLowerCase();\nconst normPhone = (v) => {\n  const digits = String(v == null ? '' : v).replace(/[^0-9]/g, '');\n  return digits.length >= 10 ? digits : '';\n};\n\nconst rows = $input.all().map((i) => i.json);\n\n// ---- 1. collapse to one record per customer -------------------------------\n// The query joins orders, so a 5-order customer arrives as 5 rows.\nconst byCustomer = new Map();\nfor (const r of rows) {\n  const key = norm(r.email) || String(r.customer_id || '');\n  if (!key) continue;\n  const prev = byCustomer.get(key);\n  if (!prev) {\n    byCustomer.set(key, {\n      customer_id: r.customer_id,\n      email: norm(r.email),\n      phone: normPhone(r.phone),\n      first_name: norm(r.first_name),\n      last_name: norm(r.last_name),\n      city: norm(r.city),\n      country: norm(r.country_code) || 'us',\n      orders: num(r.order_count) || 1,\n      revenue: num(r.lifetime_revenue) || num(r.order_total),\n      refunded: num(r.refunded_amount),\n      last_order_at: r.last_order_at || r.order_created_at,\n      first_order_at: r.first_order_at || r.order_created_at,\n      top_category: r.top_category || 'unknown'\n    });\n    continue;\n  }\n  prev.orders += 1;\n  prev.revenue += num(r.order_total);\n  prev.refunded += num(r.refunded_amount);\n  if (daysAgo(r.order_created_at) < daysAgo(prev.last_order_at)) prev.last_order_at = r.order_created_at;\n}\n\n// ---- 2. LTV score ---------------------------------------------------------\n// net revenue, decayed by recency, boosted by repeat behaviour.\nconst scored = [];\nfor (const c of byCustomer.values()) {\n  if (!c.email && !c.phone) continue;          // unmatchable, would just dilute the seed\n  if (c.orders < CFG.minOrders) continue;\n\n  const net = Math.max(0, c.revenue - c.refunded * CFG.refundPenalty);\n  if (net <= 0) continue;\n\n  const recency = Math.pow(0.5, daysAgo(c.last_order_at) / CFG.recencyHalfLifeDays);\n  const repeatBoost = 1 + Math.log2(Math.max(1, c.orders));   // 1 order = 1.0, 4 orders = 3.0\n  const aov = net / Math.max(1, c.orders);\n\n  // tenure: customers who kept buying over months are better seeds than one big spike\n  const tenureDays = Math.max(0, daysAgo(c.first_order_at) - daysAgo(c.last_order_at));\n  const tenureBoost = 1 + Math.min(0.5, tenureDays / 730);\n\n  const ltvScore = net * recency * repeatBoost * tenureBoost;\n\n  scored.push({\n    ...c,\n    net_revenue: Math.round(net * 100) / 100,\n    aov: Math.round(aov * 100) / 100,\n    recency_weight: Math.round(recency * 1000) / 1000,\n    ltv_score: Math.round(ltvScore * 100) / 100\n  });\n}\n\nscored.sort((a, b) => b.ltv_score - a.ltv_score);\n\n// ---- 3. cut the seed ------------------------------------------------------\nconst byPercentile = Math.ceil(scored.length * CFG.topPercentile);\nconst cut = Math.min(CFG.targetSeedSize, Math.max(CFG.minSeedSize, byPercentile));\nconst seed = scored.slice(0, cut);\n\nconst sum = (arr, f) => arr.reduce((s, x) => s + f(x), 0);\nconst seedRevenue = sum(seed, (c) => c.net_revenue);\nconst allRevenue = sum(scored, (c) => c.net_revenue) || 1;\n\n// Meta's /users endpoint takes a schema + positional rows, not objects.\nconst schema = ['EMAIL', 'PHONE', 'FN', 'LN', 'CT', 'COUNTRY'];\nconst data = seed.map((c) => [c.email, c.phone, c.first_name, c.last_name, c.city, c.country]);\n\nreturn [{\n  json: {\n    seed_size: seed.length,\n    candidate_pool: scored.length,\n    seed_ready: seed.length >= CFG.minSeedSize,\n    min_seed_size: CFG.minSeedSize,\n    cutoff_ltv_score: seed.length ? seed[seed.length - 1].ltv_score : 0,\n    seed_avg_ltv: seed.length ? Math.round((seedRevenue / seed.length) * 100) / 100 : 0,\n    pool_avg_ltv: Math.round((allRevenue / scored.length) * 100) / 100,\n    seed_revenue_share: Math.round((seedRevenue / allRevenue) * 1000) / 10,\n    audience_name: 'HighLTV Seed ' + new Date().toISOString().slice(0, 10),\n    schema,\n    data,\n    built_at: new Date().toISOString()\n  }\n}];"}},{"id":"bb000004-1111-4222-8333-444455556604","name":"Seed Big Enough To Model?","type":"n8n-nodes-base.if","typeVersion":2,"position":[260,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"s1","leftValue":"={{ $json.seed_size }}","rightValue":1000,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"bb000005-1111-4222-8333-444455556605","name":"Warn Seed Too Small","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[500,560],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=⚠️ Lookalike build skipped — only {{ $json.seed_size }} matchable high-LTV customers (Meta needs 1,000+).\nCandidate pool was {{ $json.candidate_pool }}. Loosen the LTV cut or widen the 540-day window before retrying.","otherOptions":{}}},{"id":"bb000006-1111-4222-8333-444455556606","name":"Log Skipped Run","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[740,560],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Skipped Runs"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000007-1111-4222-8333-444455556607","name":"Create Custom Audience","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[500,300],"parameters":{"method":"POST","url":"https://graph.facebook.com/v21.0/act_ID/customaudiences","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ name: $json.audience_name, subtype: 'CUSTOM', description: 'Top ' + $json.seed_size + ' customers by decayed LTV score (cutoff ' + $json.cutoff_ltv_score + ')', customer_file_source: 'USER_PROVIDED_ONLY' }) }}","options":{}}},{"id":"bb000008-1111-4222-8333-444455556608","name":"Upload Hashed Seed Users","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[740,300],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.id }}/users","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ payload: { schema: $('Score And Cut Seed List').first().json.schema, data: $('Score And Cut Seed List').first().json.data } }) }}","options":{}}},{"id":"bb000009-1111-4222-8333-444455556609","name":"Wait For Audience To Populate","type":"n8n-nodes-base.wait","typeVersion":1.1,"position":[980,300],"parameters":{"amount":2,"unit":"hours"}},{"id":"bb00000a-1111-4222-8333-44445555660a","name":"Read Source Audience Size","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1220,300],"parameters":{"url":"=https://graph.facebook.com/v21.0/{{ $('Create Custom Audience').first().json.id }}","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,approximate_count_lower_bound,approximate_count_upper_bound,delivery_status,operation_status"}]},"options":{}}},{"id":"bb00000b-1111-4222-8333-44445555660b","name":"Match Rate Good Enough?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1460,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"m1","leftValue":"={{ $json.approximate_count_lower_bound }}","rightValue":1000,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"bb00000c-1111-4222-8333-44445555660c","name":"Alert Poor Match Rate","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1700,560],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=⚠️ Custom audience {{ $json.id }} only matched {{ $json.approximate_count_lower_bound }} of {{ $('Score And Cut Seed List').first().json.seed_size }} uploaded rows.\nStatus: {{ $json.delivery_status.description }}. Lookalike creation halted — check email normalisation and phone country codes.","otherOptions":{}}},{"id":"bb00000d-1111-4222-8333-44445555660d","name":"Build Lookalike Ladder Specs","type":"n8n-nodes-base.code","typeVersion":2,"position":[1700,300],"parameters":{"jsCode":"// ---- Lookalike ladder -----------------------------------------------------\n// One seed, several ratios. 1% is the closest match (best CPA, smallest reach),\n// 10% is the widest (cheapest CPMs, most dilution). We build the whole ladder so\n// the buyer can start tight and expand once the 1% saturates.\nconst LADDER = [\n  { ratio: 0.01, role: 'prospecting-tight',  expected_cpa_index: 1.00, bid_note: 'launch here' },\n  { ratio: 0.02, role: 'prospecting-tight',  expected_cpa_index: 1.08, bid_note: 'first expansion' },\n  { ratio: 0.03, role: 'prospecting-mid',    expected_cpa_index: 1.15, bid_note: 'scale once 1-2% saturates' },\n  { ratio: 0.05, role: 'prospecting-broad',  expected_cpa_index: 1.28, bid_note: 'volume play' },\n  { ratio: 0.10, role: 'prospecting-broad',  expected_cpa_index: 1.45, bid_note: 'only with strong creative' }\n];\n\n// Meta's custom_ratio spec models against ONE country at a time. Keep the reach\n// model and the spec on the same country or every rung looks under-delivered.\nconst TARGET_COUNTRY = 'US';\n\nconst src = $input.first().json;\nconst seedId = String(src.audience_id || src.id || '');\nconst seedSize = Number(src.approximate_count_lower_bound || src.seed_size || 0);\n\nif (!seedId) {\n  throw new Error('No source audience id came back from Meta - cannot build lookalikes.');\n}\n\n// Rough reach model: a lookalike at ratio r covers r x the addressable Meta\n// population of the target country. Sanity-check only, not a delivery promise.\nconst POP = { US: 240000000, CA: 30000000, GB: 52000000, AU: 19000000 };\nconst addressable = POP[TARGET_COUNTRY] || 0;\n\nreturn LADDER.map((rung) => {\n  const pct = Math.round(rung.ratio * 100);\n  return {\n    json: {\n      source_audience_id: seedId,\n      seed_size: seedSize,\n      ratio: rung.ratio,\n      ratio_pct: pct,\n      role: rung.role,\n      bid_note: rung.bid_note,\n      expected_cpa_index: rung.expected_cpa_index,\n      country: TARGET_COUNTRY,\n      projected_reach: Math.round(addressable * rung.ratio),\n      audience_name: 'LAL ' + pct + '% - ' + (src.audience_name || 'HighLTV Seed'),\n      lookalike_spec: JSON.stringify({\n        type: 'custom_ratio',\n        ratio: rung.ratio,\n        country: TARGET_COUNTRY,\n        origin: [{ id: seedId }]\n      })\n    }\n  };\n});"}},{"id":"bb00000e-1111-4222-8333-44445555660e","name":"Loop Over Ratios","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[1940,300],"parameters":{"batchSize":1,"options":{}}},{"id":"bb00000f-1111-4222-8333-44445555660f","name":"Create Lookalike Audience","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2180,480],"parameters":{"method":"POST","url":"https://graph.facebook.com/v21.0/act_ID/customaudiences","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ name: $json.audience_name, subtype: 'LOOKALIKE', origin_audience_id: $json.source_audience_id, lookalike_spec: $json.lookalike_spec }) }}","options":{}}},{"id":"bb000010-1111-4222-8333-444455556610","name":"Read Lookalike Size","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2420,480],"parameters":{"url":"=https://graph.facebook.com/v21.0/{{ $json.id }}","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,approximate_count_lower_bound,delivery_status,operation_status"}]},"options":{}}},{"id":"bb000011-1111-4222-8333-444455556611","name":"Record Lookalike Result","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2660,480],"parameters":{"assignments":{"assignments":[{"id":"r1","name":"lookalike_id","value":"={{ $json.id }}","type":"string"},{"id":"r2","name":"audience_name","value":"={{ $('Loop Over Ratios').item.json.audience_name }}","type":"string"},{"id":"r3","name":"ratio_pct","value":"={{ $('Loop Over Ratios').item.json.ratio_pct }}","type":"number"},{"id":"r4","name":"role","value":"={{ $('Loop Over Ratios').item.json.role }}","type":"string"},{"id":"r5","name":"bid_note","value":"={{ $('Loop Over Ratios').item.json.bid_note }}","type":"string"},{"id":"r6","name":"source_audience_id","value":"={{ $('Loop Over Ratios').item.json.source_audience_id }}","type":"string"},{"id":"r7","name":"seed_size","value":"={{ $('Loop Over Ratios').item.json.seed_size }}","type":"number"},{"id":"r8","name":"projected_reach","value":"={{ $('Loop Over Ratios').item.json.projected_reach }}","type":"number"},{"id":"r9","name":"actual_size","value":"={{ $json.approximate_count_lower_bound }}","type":"number"},{"id":"r10","name":"operation_status","value":"={{ $json.operation_status.description }}","type":"string"},{"id":"r11","name":"created_at","value":"={{ $now.toISO() }}","type":"string"}]},"options":{}}},{"id":"bb000012-1111-4222-8333-444455556612","name":"Log Audience Sizes","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2900,480],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Lookalike Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000013-1111-4222-8333-444455556613","name":"Summarise Ladder","type":"n8n-nodes-base.code","typeVersion":2,"position":[2180,300],"parameters":{"jsCode":"// ---- Roll the ladder up into one report -----------------------------------\nconst rows = $input.all().map((i) => i.json).filter((r) => r.ratio_pct);\n\nconst fmt = (n) => Number(n || 0).toLocaleString('en-US');\n\nrows.sort((a, b) => a.ratio_pct - b.ratio_pct);\n\nconst built = rows.filter((r) => r.lookalike_id);\nconst failed = rows.filter((r) => !r.lookalike_id);\n\nconst lines = rows.map((r) => {\n  const size = Number(r.actual_size || 0);\n  const delta = r.projected_reach ? Math.round(((size - r.projected_reach) / r.projected_reach) * 100) : 0;\n  const flag = !r.lookalike_id ? '❌ failed'\n    : size < 50000 ? '⚠️ thin'\n    : delta < -40 ? '⚠️ ' + delta + '% under projection'\n    : '✅';\n  return '• *' + r.ratio_pct + '%* (' + r.role + ') — ' + fmt(size) + ' people · ' +\n    'projected ' + fmt(r.projected_reach) + ' · ' + flag + '\\n     ↳ ' + r.bid_note;\n});\n\nconst totalReach = built.reduce((s, r) => s + Number(r.actual_size || 0), 0);\nconst seedSize = rows.length ? Number(rows[0].seed_size || 0) : 0;\n\nconst slack_text = [\n  '👥 *Lookalike ladder built from ' + fmt(seedSize) + ' high-LTV purchasers*',\n  built.length + ' of ' + rows.length + ' ratios created · ' + fmt(totalReach) + ' total addressable',\n  failed.length ? '❌ ' + failed.length + ' failed to create - check the audience is still populating' : '',\n  '',\n  ...lines,\n  '',\n  'Start on the 1%, expand to 2-3% only when frequency climbs past 2.5.'\n].filter(Boolean).join('\\n');\n\nreturn [{ json: { slack_text, built_count: built.length, failed_count: failed.length, total_reach: totalReach, seed_size: seedSize } }];"}},{"id":"bb000014-1111-4222-8333-444455556614","name":"Post Ladder To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2420,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"bb000015-1111-4222-8333-444455556615","name":"Note Seed","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-500,0],"parameters":{"content":"## 1. BUILD THE SEED\nPull 540 days of paid orders, collapse to one row per customer, then score by net revenue x recency decay (180d half-life) x repeat boost. Keep the top 20% (capped at 8k) — a tight, premium seed beats \"everyone who ever bought\".","height":280,"width":440,"color":4}},{"id":"bb000016-1111-4222-8333-444455556616","name":"Note Upload","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[480,0],"parameters":{"content":"## 2. PUSH AS CUSTOM AUDIENCE\nCreate the CUSTOM audience, POST the normalised rows to /users with a positional schema, then wait 2h for Meta to match. If the matched count comes back under 1,000 we stop and alert instead of seeding a garbage lookalike.","height":280,"width":440,"color":3}},{"id":"bb000017-1111-4222-8333-444455556617","name":"Note Ladder","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1920,0],"parameters":{"content":"## 3. LOOKALIKE LADDER + LOG\nCreate 1 / 2 / 3 / 5 / 10% lookalikes off the same seed, read each one back for its real size, log every ratio to the sheet, and post the ladder to Slack with under-projection flags. Launch on 1%, expand when frequency passes 2.5.","height":280,"width":440,"color":5}}],"connections":{"Weekly Audience Refresh":{"main":[[{"node":"Query High LTV Purchasers","type":"main","index":0}]]},"Query High LTV Purchasers":{"main":[[{"node":"Score And Cut Seed List","type":"main","index":0}]]},"Score And Cut Seed List":{"main":[[{"node":"Seed Big Enough To Model?","type":"main","index":0}]]},"Seed Big Enough To Model?":{"main":[[{"node":"Create Custom Audience","type":"main","index":0}],[{"node":"Warn Seed Too Small","type":"main","index":0}]]},"Warn Seed Too Small":{"main":[[{"node":"Log Skipped Run","type":"main","index":0}]]},"Create Custom Audience":{"main":[[{"node":"Upload Hashed Seed Users","type":"main","index":0}]]},"Upload Hashed Seed Users":{"main":[[{"node":"Wait For Audience To Populate","type":"main","index":0}]]},"Wait For Audience To Populate":{"main":[[{"node":"Read Source Audience Size","type":"main","index":0}]]},"Read Source Audience Size":{"main":[[{"node":"Match Rate Good Enough?","type":"main","index":0}]]},"Match Rate Good Enough?":{"main":[[{"node":"Build Lookalike Ladder Specs","type":"main","index":0}],[{"node":"Alert Poor Match Rate","type":"main","index":0}]]},"Alert Poor Match Rate":{"main":[[{"node":"Log Skipped Run","type":"main","index":0}]]},"Build Lookalike Ladder Specs":{"main":[[{"node":"Loop Over Ratios","type":"main","index":0}]]},"Loop Over Ratios":{"main":[[{"node":"Summarise Ladder","type":"main","index":0}],[{"node":"Create Lookalike Audience","type":"main","index":0}]]},"Create Lookalike Audience":{"main":[[{"node":"Read Lookalike Size","type":"main","index":0}]]},"Read Lookalike Size":{"main":[[{"node":"Record Lookalike Result","type":"main","index":0}]]},"Record Lookalike Result":{"main":[[{"node":"Log Audience Sizes","type":"main","index":0}]]},"Log Audience Sizes":{"main":[[{"node":"Loop Over Ratios","type":"main","index":0}]]},"Summarise Ladder":{"main":[[{"node":"Post Ladder To Slack","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}