{"name":"Winning creative finder workflow","nodes":[{"id":"7f1a2b3c-1001-4a01-8b01-aa0000000001","name":"Every Morning 7am","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-380,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":24}]}}},{"id":"7f1a2b3c-1002-4a01-8b01-aa0000000002","name":"Fetch Active Ads","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-120,300],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/ads","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,adset_name,campaign_name,creative{id,image_hash,video_id,thumbnail_url,body}"},{"name":"effective_status","value":"[\"ACTIVE\"]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"7f1a2b3c-1003-4a01-8b01-aa0000000003","name":"Fetch 30d Ad Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[140,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_name,campaign_name,spend,impressions,clicks,inline_link_clicks,frequency,purchase_roas,actions,action_values,video_play_actions,video_thruplay_watched_actions,video_p75_watched_actions"},{"name":"date_preset","value":"last_30d"},{"name":"filtering","value":"[{\"field\":\"spend\",\"operator\":\"GREATER_THAN\",\"value\":150}]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"7f1a2b3c-1004-4a01-8b01-aa0000000004","name":"Join Creative Meta With Insights","type":"n8n-nodes-base.merge","typeVersion":3,"position":[400,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"7f1a2b3c-1005-4a01-8b01-aa0000000005","name":"Normalize Creative Metrics","type":"n8n-nodes-base.code","typeVersion":2,"position":[660,300],"parameters":{"jsCode":"// Flatten Meta ad + insight rows into one clean creative record per ad.\n// Meta returns action-type arrays; we pull the fields a media buyer actually judges on.\nconst num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };\nconst pick = (arr, type) => num((arr || []).find((a) => a.action_type === type)?.value);\n\nconst rows = $input.all().map((i) => i.json);\nconst out = [];\n\nfor (const r of rows) {\n  const impressions = num(r.impressions);\n  if (impressions <= 0) continue;\n\n  const spend = num(r.spend);\n  const clicks = num(r.inline_link_clicks || r.clicks);\n  const roas = num((r.purchase_roas || [])[0]?.value);\n  const purchases = pick(r.actions, 'omni_purchase') || pick(r.actions, 'purchase');\n  const revenue = num((r.action_values || []).find((a) => a.action_type === 'omni_purchase')?.value)\n    || spend * roas;\n\n  // Video engagement funnel (0 for static creatives - handled by the fallback below).\n  const v3 = pick(r.video_play_actions, 'video_view') || num(r.video_3_sec_watched_actions);\n  const thruplay = pick(r.video_thruplay_watched_actions, 'video_view');\n  const p75 = num((r.video_p75_watched_actions || [])[0]?.value);\n\n  const ctr = (clicks / impressions) * 100;                 // link CTR %\n  const hookRate = v3 > 0 ? (v3 / impressions) * 100 : ctr * 3;   // static proxy: CTR-derived\n  const thumbstop = thruplay > 0 ? (thruplay / Math.max(v3, 1)) * 100\n                                 : (p75 > 0 ? (p75 / Math.max(v3, 1)) * 100 : hookRate * 0.4);\n\n  const days = Math.max(num(r.days_active) || 7, 1);\n\n  out.push({\n    json: {\n      ad_id: r.ad_id,\n      ad_name: r.ad_name,\n      adset_name: r.adset_name,\n      campaign_name: r.campaign_name,\n      creative_id: r.creative?.id || r.creative_id || r.ad_id,\n      // Dedupe key: the same asset re-uploaded across ad sets must collapse to one winner.\n      asset_key: String(r.creative?.image_hash || r.creative?.video_id || r.creative_id || r.ad_id),\n      thumbnail_url: r.creative?.thumbnail_url || '',\n      body_copy: (r.creative?.body || '').slice(0, 240),\n      spend: Math.round(spend * 100) / 100,\n      revenue: Math.round(revenue * 100) / 100,\n      impressions,\n      clicks,\n      purchases,\n      frequency: num(r.frequency),\n      cpa: purchases > 0 ? Math.round((spend / purchases) * 100) / 100 : null,\n      purchase_roas: Math.round(roas * 1000) / 1000,\n      ctr: Math.round(ctr * 1000) / 1000,\n      hook_rate: Math.round(hookRate * 1000) / 1000,\n      thumbstop_rate: Math.round(thumbstop * 1000) / 1000,\n      days_active: days,\n      daily_spend: Math.round((spend / days) * 100) / 100,\n    },\n  });\n}\n\nreturn out;"}},{"id":"7f1a2b3c-1006-4a01-8b01-aa0000000006","name":"Drop Statistically Thin Ads","type":"n8n-nodes-base.filter","typeVersion":2,"position":[920,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.impressions }}","rightValue":4000,"operator":{"type":"number","operation":"gte"}},{"id":"f2","leftValue":"={{ $json.spend }}","rightValue":150,"operator":{"type":"number","operation":"gte"}},{"id":"f3","leftValue":"={{ $json.days_active }}","rightValue":3,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"7f1a2b3c-1007-4a01-8b01-aa0000000007","name":"Score Creatives (ROAS/CTR/Hook/Thumbstop)","type":"n8n-nodes-base.code","typeVersion":2,"position":[1180,300],"parameters":{"jsCode":"// Composite winner score.\n// Raw metrics live on wildly different scales (ROAS ~0-8, CTR ~0-4%, hook ~0-40%), so each\n// metric is converted to a percentile rank inside THIS scan's cohort, then weighted.\n// Percentile ranking also makes the score self-calibrating: a 2.1 ROAS is a winner in a\n// cold-traffic account and a loser in a retargeting account, and this respects that.\nconst WEIGHTS = { roas: 0.45, ctr: 0.20, hook: 0.20, thumbstop: 0.15 };\nconst FREQ_FATIGUE = 3.2;   // above this, the creative is winning on reach not on craft\nconst MIN_ROAS_FLOOR = 1.0; // never call something a winner if it lost money\n\nlet rows = $input.all().map((i) => i.json);\n\n// --- dedupe: same asset running in several ad sets -> merge into one weighted record ---\nconst byAsset = new Map();\nfor (const r of rows) {\n  const prev = byAsset.get(r.asset_key);\n  if (!prev) { byAsset.set(r.asset_key, { ...r, duplicate_placements: 1 }); continue; }\n  const s = prev.spend + r.spend;\n  const w = (a, b) => (s > 0 ? (a * prev.spend + b * r.spend) / s : (a + b) / 2);\n  byAsset.set(r.asset_key, {\n    ...prev,\n    ad_name: prev.spend >= r.spend ? prev.ad_name : r.ad_name,\n    duplicate_placements: prev.duplicate_placements + 1,\n    spend: Math.round(s * 100) / 100,\n    revenue: Math.round((prev.revenue + r.revenue) * 100) / 100,\n    impressions: prev.impressions + r.impressions,\n    clicks: prev.clicks + r.clicks,\n    purchases: prev.purchases + r.purchases,\n    days_active: Math.max(prev.days_active, r.days_active),\n    frequency: w(prev.frequency, r.frequency),\n    ctr: w(prev.ctr, r.ctr),\n    hook_rate: w(prev.hook_rate, r.hook_rate),\n    thumbstop_rate: w(prev.thumbstop_rate, r.thumbstop_rate),\n    purchase_roas: s > 0 ? (prev.revenue + r.revenue) / s : 0,\n    daily_spend: prev.daily_spend + r.daily_spend,\n  });\n}\nrows = [...byAsset.values()];\nif (!rows.length) return [];\n\n// --- percentile rank helper (ties share the same rank) ---\nconst rank = (key) => {\n  const sorted = rows.map((r) => Number(r[key]) || 0).sort((a, b) => a - b);\n  return (v) => {\n    const x = Number(v) || 0;\n    let lo = 0, hi = sorted.length;\n    while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] < x) lo = mid + 1; else hi = mid; }\n    return sorted.length > 1 ? lo / (sorted.length - 1) : 1;\n  };\n};\nconst pRoas = rank('purchase_roas');\nconst pCtr = rank('ctr');\nconst pHook = rank('hook_rate');\nconst pThumb = rank('thumbstop_rate');\n\nconst scored = rows.map((r) => {\n  const parts = {\n    roas: pRoas(r.purchase_roas),\n    ctr: pCtr(r.ctr),\n    hook: pHook(r.hook_rate),\n    thumbstop: pThumb(r.thumbstop_rate),\n  };\n  let score = 100 * (parts.roas * WEIGHTS.roas + parts.ctr * WEIGHTS.ctr\n    + parts.hook * WEIGHTS.hook + parts.thumbstop * WEIGHTS.thumbstop);\n\n  // Fatigue haircut: a saturated creative is a fading winner, not a scalable one.\n  const fatigue = r.frequency > FREQ_FATIGUE ? Math.min(0.25, (r.frequency - FREQ_FATIGUE) * 0.06) : 0;\n  score *= (1 - fatigue);\n  // Confidence haircut: thin data (few purchases) gets pulled toward the middle.\n  const confidence = Math.min(1, r.purchases / 10);\n  score = score * (0.7 + 0.3 * confidence);\n  if (r.purchase_roas < MIN_ROAS_FLOOR) score *= 0.5;\n\n  // Pacing projection: what this creative is worth over the next 30 days if we hold pace.\n  const projected_spend_30d = Math.round(r.daily_spend * 30 * 100) / 100;\n  const projected_revenue_30d = Math.round(projected_spend_30d * r.purchase_roas * 100) / 100;\n\n  return {\n    json: {\n      ...r,\n      purchase_roas: Math.round(r.purchase_roas * 1000) / 1000,\n      pct_roas: Math.round(parts.roas * 100),\n      pct_ctr: Math.round(parts.ctr * 100),\n      pct_hook: Math.round(parts.hook * 100),\n      pct_thumbstop: Math.round(parts.thumbstop * 100),\n      fatigue_penalty: Math.round(fatigue * 100),\n      confidence: Math.round(confidence * 100),\n      winner_score: Math.round(score * 10) / 10,\n      projected_spend_30d,\n      projected_revenue_30d,\n      projected_profit_30d: Math.round((projected_revenue_30d - projected_spend_30d) * 100) / 100,\n      cohort_size: rows.length,\n    },\n  };\n});\n\nscored.sort((a, b) => b.json.winner_score - a.json.winner_score);\nreturn scored;"}},{"id":"7f1a2b3c-1008-4a01-8b01-aa0000000008","name":"Shortlist Top Decile","type":"n8n-nodes-base.code","typeVersion":2,"position":[1440,300],"parameters":{"jsCode":"// Top-decile cut. Percentile scores are relative, so the cut is a rank cut, not a\n// score threshold - but we also insist on absolute sanity (profitable + real volume),\n// otherwise a bad week would still \"produce\" ten winners.\nconst MIN_ROAS = 1.6;\nconst MIN_PURCHASES = 5;\nconst DECILE = 0.10;\nconst MIN_WINNERS = 3;   // always look at a few, even in a thin account\nconst MAX_WINNERS = 15;  // a library entry nobody reads is not a library\n\n// An empty return would starve the IF node and the empty-scan alert would never fire,\n// so a zero-score sentinel is emitted instead and routed down the false branch.\nconst sentinel = [{ json: { winner_score: 0, rank: 0, count: 0, empty_scan: true, scanned_at: new Date().toISOString() } }];\n\nconst rows = $input.all().map((i) => i.json);\nif (!rows.length) return sentinel;\n\nconst cut = Math.min(MAX_WINNERS, Math.max(MIN_WINNERS, Math.ceil(rows.length * DECILE)));\nconst scoreFloor = rows[Math.min(cut, rows.length) - 1].winner_score;\n\nconst winners = rows\n  .slice(0, cut)\n  .filter((r) => r.purchase_roas >= MIN_ROAS && r.purchases >= MIN_PURCHASES)\n  .map((r, idx) => ({\n    json: {\n      ...r,\n      rank: idx + 1,\n      decile_cut_size: cut,\n      score_floor: Math.round(scoreFloor * 10) / 10,\n      // Tag the reason it won - this is what makes the library searchable later.\n      win_reason: r.pct_hook >= 80 && r.pct_roas >= 80 ? 'hook + efficiency'\n        : r.pct_roas >= 85 ? 'efficiency'\n        : r.pct_hook >= 85 ? 'scroll-stopping hook'\n        : r.pct_thumbstop >= 85 ? 'retention'\n        : 'balanced performer',\n      scale_verdict: r.frequency > 3.2 ? 'refresh before scaling'\n        : r.projected_profit_30d > 0 ? 'scale' : 'hold',\n      scanned_at: new Date().toISOString(),\n    },\n  }));\n\nreturn winners.length ? winners : sentinel;"}},{"id":"7f1a2b3c-1009-4a01-8b01-aa0000000009","name":"Any Winners This Scan?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1700,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.winner_score }}","rightValue":0,"operator":{"type":"number","operation":"gt"}}]},"options":{}}},{"id":"7f1a2b3c-1010-4a01-8b01-aa0000000010","name":"Alert Empty Winner Board","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1960,520],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-lab","mode":"name"},"text":"=⚠️ Winning creative scan finished with zero qualifying creatives. Nothing cleared ROAS 1.6 with 5+ purchases in the last 30d — the account is running on tired assets. Time to brief a new batch.","otherOptions":{}}},{"id":"7f1a2b3c-1011-4a01-8b01-aa0000000011","name":"Loop Winners","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[1960,120],"parameters":{"batchSize":1,"options":{}}},{"id":"7f1a2b3c-1012-4a01-8b01-aa0000000012","name":"Build Library Row","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2220,260],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"Ad name","value":"={{ $json.ad_name }}","type":"string"},{"id":"a2","name":"Campaign","value":"={{ $json.campaign_name }}","type":"string"},{"id":"a3","name":"Asset key","value":"={{ $json.asset_key }}","type":"string"},{"id":"a4","name":"Thumbnail","value":"={{ $json.thumbnail_url }}","type":"string"},{"id":"a5","name":"Hook copy","value":"={{ $json.body_copy }}","type":"string"},{"id":"a6","name":"Winner score","value":"={{ $json.winner_score }}","type":"number"},{"id":"a7","name":"ROAS","value":"={{ $json.purchase_roas }}","type":"number"},{"id":"a8","name":"CTR","value":"={{ $json.ctr }}","type":"number"},{"id":"a9","name":"Hook rate","value":"={{ $json.hook_rate }}","type":"number"},{"id":"a10","name":"Thumbstop rate","value":"={{ $json.thumbstop_rate }}","type":"number"},{"id":"a11","name":"Spend","value":"={{ $json.spend }}","type":"number"},{"id":"a12","name":"Projected 30d profit","value":"={{ $json.projected_profit_30d }}","type":"number"},{"id":"a13","name":"Why it won","value":"={{ $json.win_reason }}","type":"string"},{"id":"a14","name":"Verdict","value":"={{ $json.scale_verdict }}","type":"string"},{"id":"a15","name":"Scanned at","value":"={{ $json.scanned_at }}","type":"string"}]},"options":{}}},{"id":"7f1a2b3c-1013-4a01-8b01-aa0000000013","name":"File Into Creative Library","type":"n8n-nodes-base.airtable","typeVersion":2.1,"position":[2480,260],"parameters":{"operation":"create","base":{"__rl":true,"value":"appCREATIVELIB","mode":"id"},"table":{"__rl":true,"value":"tblWinningAds","mode":"id"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}}},{"id":"7f1a2b3c-1014-4a01-8b01-aa0000000014","name":"Summarize Winner Batch","type":"n8n-nodes-base.code","typeVersion":2,"position":[2220,-60],"parameters":{"jsCode":"// Batch summary for the Slack digest + the sheet log row.\nconst w = $input.all().map((i) => i.json);\nif (!w.length) return [{ json: { count: 0, digest: 'No creatives cleared the winner bar this scan.' } }];\n\nconst sum = (k) => w.reduce((a, r) => a + (Number(r[k]) || 0), 0);\nconst totalSpend = Math.round(sum('spend') * 100) / 100;\nconst totalRevenue = Math.round(sum('revenue') * 100) / 100;\n\nconst lines = w\n  .sort((a, b) => b.winner_score - a.winner_score)\n  .map((r) => `${r.rank}. *${r.ad_name}* — score ${r.winner_score} | ROAS ${r.purchase_roas} | CTR ${r.ctr}% | hook ${r.hook_rate}% | ${r.win_reason} → ${r.scale_verdict}`);\n\nreturn [{\n  json: {\n    count: w.length,\n    cohort_size: w[0].cohort_size,\n    decile_cut_size: w[0].decile_cut_size,\n    score_floor: w[0].score_floor,\n    total_spend: totalSpend,\n    total_revenue: totalRevenue,\n    blended_roas: totalSpend > 0 ? Math.round((totalRevenue / totalSpend) * 100) / 100 : 0,\n    projected_profit_30d: Math.round(sum('projected_profit_30d') * 100) / 100,\n    top_ad: w[0].ad_name,\n    scanned_at: new Date().toISOString(),\n    digest: lines.join('\\n'),\n  },\n}];"}},{"id":"7f1a2b3c-1015-4a01-8b01-aa0000000015","name":"Post Winner Digest To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2480,-60],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-lab","mode":"name"},"text":"=🏆 Top-decile creatives ({{ $json.count }} of {{ $json.cohort_size }} scanned, score floor {{ $json.score_floor }})\nBlended ROAS {{ $json.blended_roas }} on ${{ $json.total_spend }} spend · projected 30d profit ${{ $json.projected_profit_30d }}\n\n{{ $json.digest }}","otherOptions":{}}},{"id":"7f1a2b3c-1016-4a01-8b01-aa0000000016","name":"Append Scan History","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2740,-60],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Winner scans"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"7f1a2b3c-2001-4a01-8b01-bb0000000001","name":"Note Ingest","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-420,40],"parameters":{"content":"## 1 · INGEST\nPull every ACTIVE ad plus its 30-day insights, then join them so each row carries both the creative asset (image_hash / video_id / body copy) and its performance.\n\nThe insights call already filters spend > $150 so we do not score noise.","height":220,"width":460,"color":4}},{"id":"7f1a2b3c-2002-4a01-8b01-bb0000000002","name":"Note Scoring","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[900,20],"parameters":{"content":"## 2 · SCORE & CUT\nNormalize → drop thin ads (4k impressions, $150, 3 days) → dedupe the same asset across ad sets → percentile-rank ROAS (45%), CTR (20%), hook rate (20%), thumbstop (15%).\n\nFatigue haircut above frequency 3.2, confidence haircut under 10 purchases. Then take the top decile (min 3, max 15) that also clears ROAS 1.6 and 5 purchases.","height":280,"width":500,"color":5}},{"id":"7f1a2b3c-2003-4a01-8b01-bb0000000003","name":"Note Output","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1940,-320],"parameters":{"content":"## 3 · FILE & REPORT\nEach winner is written as a row in the Airtable creative library (thumbnail, hook copy, why it won, scale verdict). When the loop finishes, the batch is summarized into a Slack digest and appended to the scan-history sheet.\n\nEmpty scans are loud on purpose — zero winners means the account needs new briefs.","height":240,"width":500,"color":3}}],"connections":{"Every Morning 7am":{"main":[[{"node":"Fetch Active Ads","type":"main","index":0}]]},"Fetch Active Ads":{"main":[[{"node":"Fetch 30d Ad Insights","type":"main","index":0},{"node":"Join Creative Meta With Insights","type":"main","index":0}]]},"Fetch 30d Ad Insights":{"main":[[{"node":"Join Creative Meta With Insights","type":"main","index":1}]]},"Join Creative Meta With Insights":{"main":[[{"node":"Normalize Creative Metrics","type":"main","index":0}]]},"Normalize Creative Metrics":{"main":[[{"node":"Drop Statistically Thin Ads","type":"main","index":0}]]},"Drop Statistically Thin Ads":{"main":[[{"node":"Score Creatives (ROAS/CTR/Hook/Thumbstop)","type":"main","index":0}]]},"Score Creatives (ROAS/CTR/Hook/Thumbstop)":{"main":[[{"node":"Shortlist Top Decile","type":"main","index":0}]]},"Shortlist Top Decile":{"main":[[{"node":"Any Winners This Scan?","type":"main","index":0}]]},"Any Winners This Scan?":{"main":[[{"node":"Loop Winners","type":"main","index":0}],[{"node":"Alert Empty Winner Board","type":"main","index":0}]]},"Loop Winners":{"main":[[{"node":"Summarize Winner Batch","type":"main","index":0}],[{"node":"Build Library Row","type":"main","index":0}]]},"Build Library Row":{"main":[[{"node":"File Into Creative Library","type":"main","index":0}]]},"File Into Creative Library":{"main":[[{"node":"Loop Winners","type":"main","index":0}]]},"Summarize Winner Batch":{"main":[[{"node":"Post Winner Digest To Slack","type":"main","index":0}]]},"Post Winner Digest To Slack":{"main":[[{"node":"Append Scan History","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}