{"name":"Facebook ads testing workflow","nodes":[{"id":"f6a0d001-1111-4222-8333-444455556601","name":"Daily Test Review","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-160,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":24}]}}},{"id":"f6a0d002-1111-4222-8333-444455556602","name":"Read Active Test Registry","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[100,300],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"active_tests"},"options":{}}},{"id":"f6a0d003-1111-4222-8333-444455556603","name":"Fetch Variant Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[360,300],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"ad"},{"name":"fields","value":"ad_id,ad_name,adset_id,impressions,clicks,spend,frequency,purchase_roas,actions"},{"name":"filtering","value":"=[{\"field\":\"adset.id\",\"operator\":\"IN\",\"value\":[\"{{ $json.adset_id }}\"]}]"},{"name":"date_preset","value":"last_14d"},{"name":"limit","value":"100"}]},"options":{}}},{"id":"f6a0d004-1111-4222-8333-444455556604","name":"Merge Registry With Insights","type":"n8n-nodes-base.merge","typeVersion":3,"position":[620,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"f6a0d005-1111-4222-8333-444455556605","name":"Score Variant Confidence","type":"n8n-nodes-base.code","typeVersion":2,"position":[880,300],"parameters":{"jsCode":"// ── Creative test scoring ────────────────────────────────────────────────\n// Input: one item per ad variant, merged from the test registry sheet\n// (test_id, variant_role, launched_at, min_sample) and Meta insights\n// (ad_id, ad_name, impressions, spend, purchase_roas, actions[], frequency).\n// Output: one item per variant carrying decision maths + one test-level roll-up.\n\nconst MIN_IMPRESSIONS = 4000;   // per variant before we trust anything\nconst MIN_CONVERSIONS = 25;     // per variant, guards against 1-purchase flukes\nconst CONFIDENCE_GATE = 0.95;   // two-proportion z-test vs the control variant\nconst MIN_LIFT        = 0.10;   // 10% relative lift, below this it is noise\nconst MAX_FREQUENCY   = 3.2;    // fatigue ceiling\nconst MAX_TEST_DAYS   = 14;     // hard runway\n\n// Normal CDF (Abramowitz & Stegun 7.1.26 erf approximation).\nfunction normalCdf(z) {\n  const s = z < 0 ? -1 : 1, x = Math.abs(z) / Math.SQRT2;\n  const t = 1 / (1 + 0.3275911 * x);\n  const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);\n  return 0.5 * (1 + s * y);\n}\n\n// Dedupe: Meta can return the same ad_id across breakdown rows — sum them.\nconst merged = new Map();\nfor (const item of $input.all()) {\n  const r = item.json;\n  const adId = String(r.ad_id || r.id || '');\n  if (!adId) continue;\n  const purchases = Array.isArray(r.actions)\n    ? Number((r.actions.find(a => a.action_type === 'purchase') || {}).value || 0)\n    : Number(r.purchases || 0);\n  const roas = Array.isArray(r.purchase_roas)\n    ? Number((r.purchase_roas[0] || {}).value || 0)\n    : Number(r.purchase_roas || 0);\n  const prev = merged.get(adId) || {\n    test_id: r.test_id, ad_id: adId, ad_name: r.ad_name, adset_id: r.adset_id,\n    variant_role: (r.variant_role || 'challenger').toLowerCase(),\n    launched_at: r.launched_at, cycle: Number(r.cycle || 0), impressions: 0, clicks: 0, spend: 0,\n    conversions: 0, revenue: 0, frequency: 0\n  };\n  prev.impressions += Number(r.impressions || 0);\n  prev.clicks      += Number(r.clicks || 0);\n  prev.spend       += Number(r.spend || 0);\n  prev.conversions += purchases;\n  prev.revenue     += roas * Number(r.spend || 0);\n  prev.frequency    = Math.max(prev.frequency, Number(r.frequency || 0));\n  merged.set(adId, prev);\n}\n\nconst variants = [...merged.values()].map(v => {\n  const cvr = v.impressions ? v.conversions / v.impressions : 0;\n  return {\n    ...v,\n    cvr,\n    ctr:  v.impressions ? v.clicks / v.impressions : 0,\n    cpa:  v.conversions ? v.spend / v.conversions : null,\n    roas: v.spend ? v.revenue / v.spend : 0,\n    days_live: v.launched_at\n      ? Math.max(1, Math.round((Date.now() - new Date(v.launched_at).getTime()) / 86400000))\n      : 1\n  };\n});\n\n// Control = the variant flagged \"control\", else the one with most impressions.\nconst control = variants.find(v => v.variant_role === 'control')\n  || variants.slice().sort((a, b) => b.impressions - a.impressions)[0];\nif (!control) return [];\n\nconst scored = variants.map(v => {\n  const isControl = v.ad_id === control.ad_id;\n  const lift = control.cvr > 0 ? (v.cvr - control.cvr) / control.cvr : 0;\n\n  // Two-proportion z-test on conversion rate against the control.\n  const n1 = v.impressions, n2 = control.impressions;\n  const pooled = (n1 + n2) ? (v.conversions + control.conversions) / (n1 + n2) : 0;\n  const se = (n1 && n2 && pooled > 0 && pooled < 1)\n    ? Math.sqrt(pooled * (1 - pooled) * (1 / n1 + 1 / n2)) : 0;\n  const z = se > 0 ? (v.cvr - control.cvr) / se : 0;\n  const confidence = isControl ? 0 : normalCdf(Math.abs(z)) * 2 - 1; // two-tailed\n\n  const enoughData = v.impressions >= MIN_IMPRESSIONS && v.conversions >= MIN_CONVERSIONS;\n  const fatigued   = v.frequency >= MAX_FREQUENCY;\n\n  // Pacing: how many more days at the current impression rate to hit sample size.\n  const impPerDay = v.impressions / v.days_live;\n  const daysToSample = impPerDay > 0\n    ? Math.max(0, Math.ceil((MIN_IMPRESSIONS - v.impressions) / impPerDay)) : 99;\n\n  let decision = 'watch';\n  if (isControl) decision = 'control';\n  else if (enoughData && confidence >= CONFIDENCE_GATE && lift >= MIN_LIFT) decision = 'promote';\n  else if (enoughData && confidence >= CONFIDENCE_GATE && lift <= -MIN_LIFT) decision = 'kill';\n  else if (fatigued && v.roas < control.roas) decision = 'kill';\n  else if (v.days_live >= MAX_TEST_DAYS) decision = 'kill';\n\n  return {\n    ...v,\n    is_control: isControl,\n    lift_vs_control: Number(lift.toFixed(4)),\n    z_score: Number(z.toFixed(3)),\n    confidence: Number(confidence.toFixed(4)),\n    enough_data: enoughData,\n    fatigued,\n    impressions_per_day: Math.round(impPerDay),\n    days_to_sample: daysToSample,\n    decision\n  };\n});\n\n// Test-level roll-up stamped on every row so the IF can branch once per test.\nconst winner = scored\n  .filter(v => v.decision === 'promote')\n  .sort((a, b) => b.confidence - a.confidence || b.roas - a.roas)[0] || null;\nconst conclusive = Boolean(winner);\nconst testId = control.test_id || 'test_unknown';\n\nreturn scored.map(v => ({ json: {\n  ...v,\n  test_id: testId,\n  test_conclusive: conclusive,\n  winner_ad_id: winner ? winner.ad_id : null,\n  winner_ad_name: winner ? winner.ad_name : null,\n  winner_confidence: winner ? winner.confidence : 0,\n  variants_in_test: scored.length,\n  decided_at: new Date().toISOString()\n} }));"}},{"id":"f6a0d006-1111-4222-8333-444455556606","name":"Test Conclusive?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1140,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.test_conclusive }}","rightValue":"","operator":{"type":"boolean","operation":"true"}},{"id":"c2","leftValue":"={{ $json.winner_confidence }}","rightValue":0.95,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"f6a0d007-1111-4222-8333-444455556607","name":"Keep Winning Variants","type":"n8n-nodes-base.filter","typeVersion":2,"position":[1400,80],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"w1","leftValue":"={{ $json.decision }}","rightValue":"promote","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"f6a0d008-1111-4222-8333-444455556608","name":"Scale Winner Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1660,80],"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.spend / Math.max($json.days_live,1)) * 150), status: 'ACTIVE' }) }}","options":{}}},{"id":"f6a0d009-1111-4222-8333-444455556609","name":"Open Next Test Slot","type":"n8n-nodes-base.code","typeVersion":2,"position":[1920,80],"parameters":{"jsCode":"// ── Open the next test slot ──────────────────────────────────────────────\n// The winner becomes the new control; we queue the next challenger from the\n// backlog and size its budget off the winner's proven CPA.\nconst rows = $input.all().map(i => i.json);\nconst winner = rows.sort((a, b) => b.confidence - a.confidence)[0];\nif (!winner) return [];\n\nconst BACKLOG = ['ugc_testimonial_v4', 'static_offer_grid', 'founder_talking_head', 'ugc_unboxing_v2'];\nconst cycle = Number(winner.cycle || 0) + 1;\nconst nextConcept = BACKLOG[cycle % BACKLOG.length];\n\n// Budget the next test so it can reach the 25-conversion floor in 7 days.\nconst provenCpa = winner.cpa && winner.cpa > 0 ? winner.cpa : 40;\nconst dailyBudget = Math.max(25, Math.ceil((provenCpa * 25) / 7 / 5) * 5);\n\nreturn [{ json: {\n  test_id: 'test_' + String(Date.now()).slice(-8),\n  previous_test_id: winner.test_id,\n  new_control_ad_id: winner.ad_id,\n  new_control_ad_name: winner.ad_name,\n  new_control_cpa: Number(provenCpa.toFixed(2)),\n  new_control_roas: Number(winner.roas.toFixed(2)),\n  next_challenger_concept: nextConcept,\n  daily_budget: dailyBudget,\n  min_sample_impressions: 4000,\n  cycle,\n  status: 'queued',\n  opened_at: new Date().toISOString()\n} }];"}},{"id":"f6a0d010-1111-4222-8333-444455556610","name":"Queue Next Challenger","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2180,80],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"test_queue"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"f6a0d011-1111-4222-8333-444455556611","name":"Keep Losing Variants","type":"n8n-nodes-base.filter","typeVersion":2,"position":[1400,240],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"l1","leftValue":"={{ $json.decision }}","rightValue":"kill","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"f6a0d012-1111-4222-8333-444455556612","name":"Pause Losing Creatives","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1660,240],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.ad_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ status: 'PAUSED' }) }}","options":{}}},{"id":"f6a0d013-1111-4222-8333-444455556613","name":"Project Days To Significance","type":"n8n-nodes-base.code","typeVersion":2,"position":[1400,520],"parameters":{"jsCode":"// ── Inconclusive test: project runway ────────────────────────────────────\n// No significant winner yet. Work out whether the test can still finish\n// inside its remaining budget/time, or whether it should be cut for stalling.\nconst rows = $input.all().map(i => i.json);\nconst challengers = rows.filter(r => !r.is_control);\nif (!challengers.length) return [];\n\nconst MAX_TEST_DAYS = 14;\nconst bestSoFar = challengers.sort((a, b) => b.confidence - a.confidence)[0];\nconst daysLive = Math.max(...rows.map(r => r.days_live || 1));\nconst daysLeft = Math.max(0, MAX_TEST_DAYS - daysLive);\nconst worstPacing = Math.max(...challengers.map(r => r.days_to_sample || 0));\nconst spendToDate = rows.reduce((s, r) => s + (r.spend || 0), 0);\nconst burnPerDay = spendToDate / daysLive;\n\n// Stall = cannot collect the sample inside the runway, or flat after a week.\nconst cannotFinish = worstPacing > daysLeft;\nconst flatAfterWeek = daysLive >= 7 && Math.abs(bestSoFar.lift_vs_control) < 0.05;\nconst stalled = cannotFinish || flatAfterWeek;\n\nreturn [{ json: {\n  test_id: bestSoFar.test_id,\n  leading_ad_id: bestSoFar.ad_id,\n  leading_ad_name: bestSoFar.ad_name,\n  leading_confidence: bestSoFar.confidence,\n  leading_lift: bestSoFar.lift_vs_control,\n  days_live: daysLive,\n  days_left: daysLeft,\n  days_to_sample: worstPacing,\n  spend_to_date: Number(spendToDate.toFixed(2)),\n  projected_extra_spend: Number((burnPerDay * Math.min(worstPacing, daysLeft)).toFixed(2)),\n  cannot_finish: cannotFinish,\n  flat_after_week: flatAfterWeek,\n  stalled,\n  reason: cannotFinish ? 'sample unreachable in runway'\n        : flatAfterWeek ? 'no separation after 7 days'\n        : 'still collecting data'\n} }];"}},{"id":"f6a0d014-1111-4222-8333-444455556614","name":"Test Stalled?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1660,520],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"s1","leftValue":"={{ $json.stalled }}","rightValue":"","operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"f6a0d015-1111-4222-8333-444455556615","name":"Kill Stalled Test","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1920,660],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.leading_ad_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ status: 'PAUSED' }) }}","options":{}}},{"id":"f6a0d016-1111-4222-8333-444455556616","name":"Alert Stalled Test","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2180,660],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-testing","mode":"name"},"text":"=🛑 Test {{ $json.test_id }} cut after {{ $json.days_live }}d — {{ $json.reason }}. Best challenger {{ $json.leading_ad_name }} at {{ $json.leading_confidence }} confidence, ${{ $json.spend_to_date }} spent.","otherOptions":{}}},{"id":"f6a0d017-1111-4222-8333-444455556617","name":"Let Test Keep Running","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1920,400],"parameters":{"assignments":{"assignments":[{"id":"k1","name":"test_id","value":"={{ $json.test_id }}","type":"string"},{"id":"k2","name":"action","value":"continue","type":"string"},{"id":"k3","name":"days_to_sample","value":"={{ $json.days_to_sample }}","type":"number"},{"id":"k4","name":"projected_extra_spend","value":"={{ $json.projected_extra_spend }}","type":"number"},{"id":"k5","name":"note","value":"={{ $json.reason }}","type":"string"}]},"options":{}}},{"id":"f6a0d018-1111-4222-8333-444455556618","name":"Log Test Decisions","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2440,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2","mode":"list","cachedResultName":"decision_log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"f6a0d019-1111-4222-8333-444455556619","name":"Post Test Cycle Summary","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2700,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-testing","mode":"name"},"text":"=✅ Creative test cycle updated — see decision_log for winners promoted, losers paused and the next slot queued.","otherOptions":{}}},{"id":"f6a0d020-1111-4222-8333-444455556620","name":"Note Collect","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-220,60],"parameters":{"content":"## 1. COLLECT\nPull the active creative tests from the registry sheet, then fetch last-14d ad-level insights for their ad sets and merge the two by position.","height":320,"width":420,"color":4}},{"id":"f6a0d021-1111-4222-8333-444455556621","name":"Note Decide","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[840,40],"parameters":{"content":"## 2. DECIDE\nDedupe rows per ad_id, compute CVR / CPA / ROAS, then run a two-proportion z-test against the control. Promote at ≥95% confidence with ≥10% lift, kill on negative lift, fatigue (freq ≥ 3.2) or a 14-day runway.","height":220,"width":260,"color":3}},{"id":"f6a0d022-1111-4222-8333-444455556622","name":"Note Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1620,-120],"parameters":{"content":"## 3. ACT\nWinner: scale budget to ~150% of proven daily spend, promote it to control and queue the next challenger concept with a budget sized to hit 25 conversions in 7 days.\nLosers: pause. Inconclusive: project days-to-sample and cut if the test cannot finish in its runway.","height":180,"width":720,"color":5}}],"connections":{"Daily Test Review":{"main":[[{"node":"Read Active Test Registry","type":"main","index":0}]]},"Read Active Test Registry":{"main":[[{"node":"Fetch Variant Insights","type":"main","index":0},{"node":"Merge Registry With Insights","type":"main","index":0}]]},"Fetch Variant Insights":{"main":[[{"node":"Merge Registry With Insights","type":"main","index":1}]]},"Merge Registry With Insights":{"main":[[{"node":"Score Variant Confidence","type":"main","index":0}]]},"Score Variant Confidence":{"main":[[{"node":"Test Conclusive?","type":"main","index":0}]]},"Test Conclusive?":{"main":[[{"node":"Keep Winning Variants","type":"main","index":0},{"node":"Keep Losing Variants","type":"main","index":0}],[{"node":"Project Days To Significance","type":"main","index":0}]]},"Keep Winning Variants":{"main":[[{"node":"Scale Winner Budget","type":"main","index":0}]]},"Scale Winner Budget":{"main":[[{"node":"Open Next Test Slot","type":"main","index":0}]]},"Open Next Test Slot":{"main":[[{"node":"Queue Next Challenger","type":"main","index":0}]]},"Queue Next Challenger":{"main":[[{"node":"Log Test Decisions","type":"main","index":0}]]},"Keep Losing Variants":{"main":[[{"node":"Pause Losing Creatives","type":"main","index":0}]]},"Pause Losing Creatives":{"main":[[{"node":"Log Test Decisions","type":"main","index":0}]]},"Project Days To Significance":{"main":[[{"node":"Test Stalled?","type":"main","index":0}]]},"Test Stalled?":{"main":[[{"node":"Kill Stalled Test","type":"main","index":0}],[{"node":"Let Test Keep Running","type":"main","index":0}]]},"Kill Stalled Test":{"main":[[{"node":"Alert Stalled Test","type":"main","index":0}]]},"Alert Stalled Test":{"main":[[{"node":"Log Test Decisions","type":"main","index":0}]]},"Let Test Keep Running":{"main":[[{"node":"Log Test Decisions","type":"main","index":0}]]},"Log Test Decisions":{"main":[[{"node":"Post Test Cycle Summary","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}