{"name":"Email + ads combo workflow","nodes":[{"id":"11111111-1111-4111-8111-111111111111","name":"Daily Audience Sync","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-320,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}}},{"id":"11111111-1111-4111-8111-111111111112","name":"Fetch Klaviyo Engagement Segments","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-60,140],"parameters":{"url":"https://a.klaviyo.com/api/segments/KLAVIYO_SEGMENT_ID/profiles","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields[profile]","value":"email,first_name,country,opens_30d,clicks_30d,sends_30d,last_click_at,product_views_30d,add_to_carts_30d,started_checkouts_30d"},{"name":"page[size]","value":"100"}]},"options":{}}},{"id":"11111111-1111-4111-8111-111111111113","name":"Fetch Purchasers Last 30 Days","type":"n8n-nodes-base.postgres","typeVersion":2.5,"position":[-60,460],"parameters":{"operation":"executeQuery","query":"select lower(email) as email, max(created_at) as last_order_at, sum(total_price) as lifetime_revenue, count(*) as orders_30d from orders where created_at > now() - interval '30 days' and financial_status = 'paid' group by 1","options":{}}},{"id":"11111111-1111-4111-8111-111111111114","name":"Merge Email And Purchase Data","type":"n8n-nodes-base.merge","typeVersion":3,"position":[200,300],"parameters":{"mode":"combine","combineBy":"combineByFields","fieldsToMatchString":"email","joinMode":"enrichInput1","options":{}}},{"id":"11111111-1111-4111-8111-111111111115","name":"Score Intent And Flag Suppression","type":"n8n-nodes-base.code","typeVersion":2,"position":[460,300],"parameters":{"jsCode":"// Blend Klaviyo email engagement with the purchase table, score buying intent,\n// and decide who is suppressed from paid vs. pushed into retargeting.\nconst rows = $input.all().map(i => i.json);\n\nconst NOW = Date.now();\nconst DAY = 86400000;\nconst SUPPRESS_WINDOW_DAYS = 30;   // recent purchasers never see prospecting/retargeting\nconst HIGH_INTENT_SCORE = 55;      // >= goes to the Meta retargeting audience\nconst MIN_EMAIL_TOUCHES = 2;       // need real engagement, not one accidental open\n\n// --- dedupe on hashed email, keep the richest record -------------------------\nconst byKey = new Map();\nfor (const r of rows) {\n  const email = String(r.email || r.profile_email || '').trim().toLowerCase();\n  if (!email || !email.includes('@')) continue;\n  const prev = byKey.get(email);\n  const merged = Object.assign({}, prev || {}, r, { email });\n  // keep the most recent purchase date seen across sources\n  const a = prev && prev.last_order_at ? Date.parse(prev.last_order_at) : 0;\n  const b = r.last_order_at ? Date.parse(r.last_order_at) : 0;\n  if (a > b) merged.last_order_at = prev.last_order_at;\n  byKey.set(email, merged);\n}\n\nconst out = [];\nfor (const p of byKey.values()) {\n  const opens = Number(p.opens_30d || 0);\n  const clicks = Number(p.clicks_30d || 0);\n  const sends = Number(p.sends_30d || 0) || 1;\n  const carts = Number(p.add_to_carts_30d || 0);\n  const checkouts = Number(p.started_checkouts_30d || 0);\n  const views = Number(p.product_views_30d || 0);\n  const revenue = Number(p.lifetime_revenue || 0);\n\n  const openRate = opens / sends;\n  const clickRate = clicks / sends;\n\n  const daysSinceOrder = p.last_order_at\n    ? Math.floor((NOW - Date.parse(p.last_order_at)) / DAY)\n    : null;\n  const daysSinceClick = p.last_click_at\n    ? Math.floor((NOW - Date.parse(p.last_click_at)) / DAY)\n    : 999;\n\n  // --- intent score (0-100) -------------------------------------------------\n  // email engagement is the cheap signal, on-site behaviour is the strong one\n  let score = 0;\n  score += Math.min(clickRate * 100, 25);          // up to 25 : click rate\n  score += Math.min(openRate * 25, 10);            // up to 10 : open rate\n  score += Math.min(views * 2, 12);                // up to 12 : browsing\n  score += Math.min(carts * 8, 24);                // up to 24 : add to cart\n  score += checkouts > 0 ? 20 : 0;                 //       20 : abandoned checkout\n  score += revenue > 0 ? 5 : 0;                    //        5 : has bought before\n  // recency decay: an old clicker is worth less than a fresh one\n  score = score * (daysSinceClick <= 7 ? 1 : daysSinceClick <= 21 ? 0.8 : 0.55);\n  score = Math.round(Math.max(0, Math.min(100, score)));\n\n  const touches = opens + clicks + carts + checkouts;\n  const isRecentPurchaser = daysSinceOrder !== null && daysSinceOrder <= SUPPRESS_WINDOW_DAYS;\n  const isHighIntent = !isRecentPurchaser && score >= HIGH_INTENT_SCORE && touches >= MIN_EMAIL_TOUCHES;\n\n  const segment = isRecentPurchaser ? 'suppress_recent_purchaser'\n    : isHighIntent ? 'retarget_high_intent'\n    : score >= 30 ? 'nurture_warm'\n    : 'nurture_cold';\n\n  out.push({\n    json: {\n      email: p.email,\n      // Meta wants normalised + sha256; hashing happens in the payload node\n      first_name: (p.first_name || '').trim().toLowerCase(),\n      country: (p.country || 'us').toLowerCase(),\n      intent_score: score,\n      segment,\n      is_high_intent: isHighIntent,\n      is_recent_purchaser: isRecentPurchaser,\n      days_since_order: daysSinceOrder,\n      days_since_click: daysSinceClick,\n      opens_30d: opens,\n      clicks_30d: clicks,\n      click_rate: Number(clickRate.toFixed(4)),\n      add_to_carts_30d: carts,\n      started_checkouts_30d: checkouts,\n      lifetime_revenue: revenue,\n      email_list_id: p.list_id || 'KLAVIYO_LIST_ID',\n    },\n  });\n}\n\n// highest intent first so batched pushes prioritise the good stuff\nout.sort((a, b) => b.json.intent_score - a.json.intent_score);\nreturn out;"}},{"id":"11111111-1111-4111-8111-111111111116","name":"High Intent Non Buyer?","type":"n8n-nodes-base.if","typeVersion":2,"position":[720,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.is_high_intent }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"11111111-1111-4111-8111-111111111117","name":"Build Meta Audience Payload","type":"n8n-nodes-base.code","typeVersion":2,"position":[980,100],"parameters":{"jsCode":"// Build the Meta Custom Audience payload for high-intent non-buyers.\n// Meta wants lowercased/trimmed values; the sha256 step is done by the\n// hashing proxy behind AUDIENCE_ENDPOINT, so we only normalise + chunk here.\nconst people = $input.all().map(i => i.json);\n\nconst norm = (v) => String(v || '').trim().toLowerCase();\nconst seen = new Set();\nconst data = [];\nfor (const p of people) {\n  const e = norm(p.email);\n  if (!e || seen.has(e)) continue;\n  seen.add(e);\n  data.push([e, norm(p.first_name), norm(p.country)]);\n}\n\n// Meta caps a single users/POST at 10k rows\nconst CHUNK = 10000;\nconst chunks = [];\nfor (let i = 0; i < data.length; i += CHUNK) chunks.push(data.slice(i, i + CHUNK));\n\nreturn chunks.map((rows, idx) => ({\n  json: {\n    audience_name: 'EMAIL_HIGH_INTENT_NON_BUYERS_30D',\n    chunk_index: idx,\n    chunk_total: chunks.length,\n    payload: {\n      schema: ['EMAIL', 'FN', 'COUNTRY'],\n      data: rows,\n    },\n    row_count: rows.length,\n  },\n}));"}},{"id":"11111111-1111-4111-8111-111111111118","name":"Push Retargeting Custom Audience","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1240,100],"parameters":{"url":"https://graph.facebook.com/v21.0/AUDIENCE_ID/users","method":"POST","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ payload: $json.payload, session: { session_id: 1001 + $json.chunk_index, batch_seq: $json.chunk_index + 1, last_batch_flag: $json.chunk_index + 1 === $json.chunk_total } }) }}","options":{}}},{"id":"11111111-1111-4111-8111-111111111119","name":"Project Reach And Pacing","type":"n8n-nodes-base.code","typeVersion":2,"position":[1500,100],"parameters":{"jsCode":"// After the audience push, project whether this retargeting pool can actually\n// carry the planned budget without burning frequency.\nconst items = $input.all().map(i => i.json);\nconst people = $('Score Intent And Flag Suppression').all().map(i => i.json);\n\nconst audience = people.filter(p => p.is_high_intent);\nconst size = audience.length;\n\nconst META_MIN_AUDIENCE = 1000;         // below this Meta will not deliver well\nconst DAILY_BUDGET = 250;               // USD/day planned for the retargeting ad set\nconst TARGET_CPM = 18.5;                // observed retargeting CPM\nconst MATCH_RATE = 0.68;                // typical email -> Meta match rate\nconst MAX_FREQUENCY_7D = 3.2;           // fatigue ceiling\n\nconst matched = Math.round(size * MATCH_RATE);\nconst dailyImpressions = (DAILY_BUDGET / TARGET_CPM) * 1000;\nconst projectedFrequency7d = matched > 0 ? (dailyImpressions * 7) / matched : 0;\n\n// budget that keeps frequency under the ceiling\nconst safeDailyBudget = matched > 0\n  ? Math.round(((matched * MAX_FREQUENCY_7D) / 7 / 1000) * TARGET_CPM)\n  : 0;\n\nconst avgScore = size ? audience.reduce((s, p) => s + p.intent_score, 0) / size : 0;\nconst checkoutAbandoners = audience.filter(p => p.started_checkouts_30d > 0).length;\n\nreturn [{\n  json: {\n    audience_name: 'EMAIL_HIGH_INTENT_NON_BUYERS_30D',\n    audience_id: items[0] && items[0].audience_id ? items[0].audience_id : 'AUDIENCE_ID',\n    raw_size: size,\n    matched_size: matched,\n    meta_minimum: META_MIN_AUDIENCE,\n    avg_intent_score: Number(avgScore.toFixed(1)),\n    checkout_abandoners: checkoutAbandoners,\n    planned_daily_budget: DAILY_BUDGET,\n    safe_daily_budget: Math.min(DAILY_BUDGET, safeDailyBudget),\n    projected_frequency_7d: Number(projectedFrequency7d.toFixed(2)),\n    frequency_ceiling: MAX_FREQUENCY_7D,\n    pacing_verdict: projectedFrequency7d > MAX_FREQUENCY_7D ? 'throttle' : 'ok',\n  },\n}];"}},{"id":"11111111-1111-4111-8111-11111111111a","name":"Audience Above Meta Minimum?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1760,100],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c2","leftValue":"={{ $json.matched_size }}","rightValue":1000,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"11111111-1111-4111-8111-11111111111b","name":"Activate Retargeting Ad Set","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2020,-80],"parameters":{"url":"https://graph.facebook.com/v21.0/AD_SET_ID","method":"POST","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ status: \"ACTIVE\", daily_budget: $json.safe_daily_budget * 100, targeting: { custom_audiences: [{ id: $json.audience_id }] } }) }}","options":{}}},{"id":"11111111-1111-4111-8111-11111111111c","name":"Alert Retargeting Launched","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2280,-80],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=🎯 Retargeting live — {{ $('Project Reach And Pacing').first().json.matched_size }} matched high-intent non-buyers, budget ${{ $('Project Reach And Pacing').first().json.safe_daily_budget }}/day, projected 7d frequency {{ $('Project Reach And Pacing').first().json.projected_frequency_7d }} ({{ $('Project Reach And Pacing').first().json.pacing_verdict }})","otherOptions":{}}},{"id":"11111111-1111-4111-8111-11111111111d","name":"Hold Audience Until It Fills","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2020,280],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"status","value":"holding_below_minimum","type":"string"},{"id":"h2","name":"audience_name","value":"={{ $json.audience_name }}","type":"string"},{"id":"h3","name":"matched_size","value":"={{ $json.matched_size }}","type":"number"},{"id":"h4","name":"needed","value":"={{ $json.meta_minimum - $json.matched_size }}","type":"number"}]},"options":{}}},{"id":"11111111-1111-4111-8111-11111111111e","name":"Log Audience State","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2280,280],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Audience Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"11111111-1111-4111-8111-11111111111f","name":"Purchased In Last 30 Days?","type":"n8n-nodes-base.if","typeVersion":2,"position":[980,520],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c3","leftValue":"={{ $json.is_recent_purchaser }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"11111111-1111-4111-8111-111111111120","name":"Push Purchaser Suppression List","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1240,660],"parameters":{"url":"https://graph.facebook.com/v21.0/SUPPRESSION_AUDIENCE_ID/users","method":"POST","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ payload: { schema: [\"EMAIL\"], data: [[$json.email]] }, session: { session_id: 2002, batch_seq: 1, last_batch_flag: true } } ) }}","options":{}}},{"id":"11111111-1111-4111-8111-111111111121","name":"Log Suppression Sync","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1500,660],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Suppression Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"11111111-1111-4111-8111-111111111122","name":"Tag Email Only Nurture Cohort","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1240,400],"parameters":{"assignments":{"assignments":[{"id":"n1","name":"status","value":"email_only_no_paid","type":"string"},{"id":"n2","name":"audience_name","value":"={{ $json.segment }}","type":"string"},{"id":"n3","name":"matched_size","value":"={{ $json.intent_score }}","type":"number"},{"id":"n4","name":"needed","value":0,"type":"number"}]},"options":{}}},{"id":"11111111-1111-4111-8111-111111111123","name":"Build Overlap Report","type":"n8n-nodes-base.code","typeVersion":2,"position":[2540,-80],"parameters":{"jsCode":"// Overlap report: how much of the email file is being spent against, and how\n// much paid budget the suppression list is saving.\nconst people = $('Score Intent And Flag Suppression').all().map(i => i.json);\nconst pacing = $('Project Reach And Pacing').first().json;\n\nconst total = people.length || 1;\nconst counts = { suppress_recent_purchaser: 0, retarget_high_intent: 0, nurture_warm: 0, nurture_cold: 0 };\nfor (const p of people) counts[p.segment] = (counts[p.segment] || 0) + 1;\n\nconst pct = (n) => Number(((n / total) * 100).toFixed(1));\n\n// wasted spend avoided = suppressed people x historical retargeting cost per reach\nconst COST_PER_REACHED_USER_7D = 0.31;\nconst savedSpend = Number((counts.suppress_recent_purchaser * 0.68 * COST_PER_REACHED_USER_7D).toFixed(2));\n\nconst engagedNonBuyers = people.filter(p => !p.is_recent_purchaser && p.clicks_30d > 0);\nconst emailPaidOverlapPct = pct(counts.retarget_high_intent);\n\nconst rows = [\n  ['Total profiles processed', total],\n  ['Suppressed (bought <= 30d)', counts.suppress_recent_purchaser + ' (' + pct(counts.suppress_recent_purchaser) + '%)'],\n  ['Pushed to retargeting', counts.retarget_high_intent + ' (' + emailPaidOverlapPct + '%)'],\n  ['Warm nurture (email only)', counts.nurture_warm + ' (' + pct(counts.nurture_warm) + '%)'],\n  ['Cold nurture (email only)', counts.nurture_cold + ' (' + pct(counts.nurture_cold) + '%)'],\n  ['Engaged non-buyers', engagedNonBuyers.length],\n  ['Matched audience size', pacing.matched_size],\n  ['Avg intent score', pacing.avg_intent_score],\n  ['Projected 7d frequency', pacing.projected_frequency_7d + ' / ' + pacing.frequency_ceiling],\n  ['Safe daily budget', '$' + pacing.safe_daily_budget],\n  ['Est. spend saved by suppression', '$' + savedSpend],\n];\n\nconst html = '<h2>Email x Paid overlap report</h2><table cellpadding=\"6\" border=\"1\" style=\"border-collapse:collapse\">'\n  + rows.map(r => '<tr><td>' + r[0] + '</td><td><b>' + r[1] + '</b></td></tr>').join('')\n  + '</table><p>Pacing verdict: <b>' + pacing.pacing_verdict + '</b></p>';\n\nreturn [{\n  json: {\n    html,\n    total_profiles: total,\n    suppressed: counts.suppress_recent_purchaser,\n    retargeted: counts.retarget_high_intent,\n    nurture_warm: counts.nurture_warm,\n    nurture_cold: counts.nurture_cold,\n    email_paid_overlap_pct: emailPaidOverlapPct,\n    est_spend_saved: savedSpend,\n    pacing_verdict: pacing.pacing_verdict,\n  },\n}];"}},{"id":"11111111-1111-4111-8111-111111111124","name":"Email Overlap Report","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[2800,-80],"parameters":{"sendTo":"growth@brand.com","subject":"=Email x Paid overlap {{ $now.format('yyyy-LL-dd') }} — {{ $json.retargeted }} retargeted / {{ $json.suppressed }} suppressed","message":"={{ $json.html }}","options":{}}},{"id":"11111111-1111-4111-8111-1111111111a1","name":"Note Sources","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-360,-40],"parameters":{"content":"## 1. PULL BOTH SIDES\nKlaviyo engagement segment + paid orders from Postgres, merged on email so one profile carries email behaviour AND purchase recency.","height":300,"width":420,"color":4}},{"id":"11111111-1111-4111-8111-1111111111a2","name":"Note Scoring","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[440,-180],"parameters":{"content":"## 2. SCORE + ROUTE\nIntent score 0-100 from click rate, product views, ATC and abandoned checkout, decayed by click recency.\n\nBought <=30d -> suppression audience.\nScore >=55 + 2 touches -> retargeting.\nEverything else stays email-only.","height":380,"width":440,"color":3}},{"id":"11111111-1111-4111-8111-1111111111a3","name":"Note Pacing","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1720,-420],"parameters":{"content":"## 3. PACE + REPORT\nMatch rate 68%, CPM $18.50, frequency ceiling 3.2/7d. Budget is capped to whatever keeps frequency under the ceiling. Under 1,000 matched the audience is held, not launched.\n\nFinal report shows email/paid overlap % and spend saved by suppression.","height":400,"width":460,"color":5}}],"connections":{"Daily Audience Sync":{"main":[[{"node":"Fetch Klaviyo Engagement Segments","type":"main","index":0},{"node":"Fetch Purchasers Last 30 Days","type":"main","index":0}]]},"Fetch Klaviyo Engagement Segments":{"main":[[{"node":"Merge Email And Purchase Data","type":"main","index":0}]]},"Fetch Purchasers Last 30 Days":{"main":[[{"node":"Merge Email And Purchase Data","type":"main","index":1}]]},"Merge Email And Purchase Data":{"main":[[{"node":"Score Intent And Flag Suppression","type":"main","index":0}]]},"Score Intent And Flag Suppression":{"main":[[{"node":"High Intent Non Buyer?","type":"main","index":0}]]},"High Intent Non Buyer?":{"main":[[{"node":"Build Meta Audience Payload","type":"main","index":0}],[{"node":"Purchased In Last 30 Days?","type":"main","index":0}]]},"Build Meta Audience Payload":{"main":[[{"node":"Push Retargeting Custom Audience","type":"main","index":0}]]},"Push Retargeting Custom Audience":{"main":[[{"node":"Project Reach And Pacing","type":"main","index":0}]]},"Project Reach And Pacing":{"main":[[{"node":"Audience Above Meta Minimum?","type":"main","index":0}]]},"Audience Above Meta Minimum?":{"main":[[{"node":"Activate Retargeting Ad Set","type":"main","index":0}],[{"node":"Hold Audience Until It Fills","type":"main","index":0}]]},"Activate Retargeting Ad Set":{"main":[[{"node":"Alert Retargeting Launched","type":"main","index":0}]]},"Alert Retargeting Launched":{"main":[[{"node":"Build Overlap Report","type":"main","index":0}]]},"Hold Audience Until It Fills":{"main":[[{"node":"Log Audience State","type":"main","index":0}]]},"Purchased In Last 30 Days?":{"main":[[{"node":"Push Purchaser Suppression List","type":"main","index":0}],[{"node":"Tag Email Only Nurture Cohort","type":"main","index":0}]]},"Push Purchaser Suppression List":{"main":[[{"node":"Log Suppression Sync","type":"main","index":0}]]},"Tag Email Only Nurture Cohort":{"main":[[{"node":"Log Audience State","type":"main","index":0}]]},"Build Overlap Report":{"main":[[{"node":"Email Overlap Report","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}