{"name":"Meta pixel power workflow","nodes":[{"id":"ee000001-1111-4222-8333-444455556601","name":"Receive Server Conversion Event","type":"n8n-nodes-base.webhook","typeVersion":2,"position":[-760,300],"parameters":{"path":"meta-capi-event","options":{}}},{"id":"ee000002-1111-4222-8333-444455556602","name":"Validate And Normalize Event","type":"n8n-nodes-base.code","typeVersion":2,"position":[-520,300],"parameters":{"jsCode":"// ---- Server-side conversion event intake ---------------------------------\n// The browser pixel is lossy (ad blockers, ITP, iOS). This webhook takes the\n// SAME event from the server (checkout webhook, CRM, backend) and prepares it\n// for the Conversions API. Anything malformed is rejected here rather than\n// silently degrading match quality downstream.\n\nconst CFG = {\n  allowedEvents: ['Purchase', 'Lead', 'CompleteRegistration', 'AddToCart', 'InitiateCheckout', 'Subscribe'],\n  maxEventAgeHours: 168,   // Meta rejects events older than 7 days\n  requiredPurchaseFields: ['value', 'currency']\n};\n\nconst body = $input.first().json.body || $input.first().json;\nconst events = Array.isArray(body) ? body : (Array.isArray(body.events) ? body.events : [body]);\n\nconst clean = (v) => (v === null || v === undefined ? '' : String(v).trim().toLowerCase());\nconst digits = (v) => String(v || '').replace(/[^0-9]/g, '');\n\n// Meta's normalization rules, applied BEFORE hashing. Getting these wrong is the\n// #1 cause of a low match rate: gmail dots, +tags, phone country codes, whitespace.\nfunction normEmail(raw) {\n  const e = clean(raw);\n  if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(e)) return '';\n  const [user, domain] = e.split('@');\n  const localOnly = user.split('+')[0];\n  const local = (domain === 'gmail.com' || domain === 'googlemail.com')\n    ? localOnly.replace(/\\./g, '')\n    : localOnly;\n  return local + '@' + (domain === 'googlemail.com' ? 'gmail.com' : domain);\n}\n\nfunction normPhone(raw, country) {\n  let d = digits(raw);\n  if (!d) return '';\n  // strip leading zeros / 00 international prefix, then prepend a country code\n  d = d.replace(/^00/, '').replace(/^0+/, '');\n  const cc = { us: '1', ca: '1', gb: '44', de: '49', fr: '33', au: '61', nl: '31' }[clean(country)] || '';\n  if (cc && !d.startsWith(cc)) d = cc + d;\n  return d.length >= 8 && d.length <= 15 ? d : '';\n}\n\nconst out = [];\n\nfor (const raw of events) {\n  const errors = [];\n  const eventName = String(raw.event_name || '').trim();\n  if (!CFG.allowedEvents.includes(eventName)) errors.push('unsupported event_name: ' + (eventName || '(empty)'));\n\n  // event_time must be unix SECONDS; a JS timestamp in ms is a classic bug\n  let eventTime = Number(raw.event_time || 0);\n  if (eventTime > 1e12) eventTime = Math.round(eventTime / 1000);\n  if (!eventTime) eventTime = Math.floor(Date.now() / 1000);\n  const ageHours = (Math.floor(Date.now() / 1000) - eventTime) / 3600;\n  if (ageHours > CFG.maxEventAgeHours) errors.push('event is ' + ageHours.toFixed(0) + 'h old, past the 7d window');\n  if (ageHours < -1) errors.push('event_time is in the future');\n\n  // event_id is the dedupe key shared with the browser pixel. Without it Meta\n  // counts the same purchase twice and every ROAS number in the account lies.\n  const eventId = String(raw.event_id || raw.order_id || '').trim();\n  if (!eventId) errors.push('missing event_id (cannot dedupe against the pixel)');\n\n  const value = Number(raw.value ?? raw.order_total ?? 0);\n  const currency = String(raw.currency || 'USD').toUpperCase();\n  if (eventName === 'Purchase') {\n    if (!(value > 0)) errors.push('Purchase with value <= 0');\n    if (!/^[A-Z]{3}$/.test(currency)) errors.push('invalid currency: ' + currency);\n  }\n\n  const user = raw.user_data || raw.customer || {};\n  const identifiers = {\n    em: normEmail(user.email ?? raw.email),\n    ph: normPhone(user.phone ?? raw.phone, user.country ?? raw.country),\n    fn: clean(user.first_name).replace(/[^a-z\\u00c0-\\u024f]/g, ''),\n    ln: clean(user.last_name).replace(/[^a-z\\u00c0-\\u024f]/g, ''),\n    ct: clean(user.city).replace(/[^a-z]/g, ''),\n    st: clean(user.state).slice(0, 2),\n    zp: clean(user.zip).split('-')[0].replace(/\\s/g, ''),\n    country: clean(user.country).slice(0, 2),\n    db: digits(user.date_of_birth).slice(0, 8),\n    ge: ['m', 'f'].includes(clean(user.gender).charAt(0)) ? clean(user.gender).charAt(0) : '',\n    external_id: clean(user.customer_id ?? raw.customer_id)\n  };\n\n  // Un-hashed pass-through signals - these are the strongest match keys and must\n  // NOT be hashed: click id, browser id, ip, user agent.\n  const passthrough = {\n    fbc: String(user.fbc || raw.fbc || '').trim(),\n    fbp: String(user.fbp || raw.fbp || '').trim(),\n    client_ip_address: String(user.ip || raw.client_ip_address || '').trim(),\n    client_user_agent: String(user.user_agent || raw.client_user_agent || '').trim()\n  };\n\n  // Rebuild fbc from a raw fbclid if the site only forwarded the query param.\n  const fbclid = String(raw.fbclid || '').trim();\n  if (!passthrough.fbc && fbclid) {\n    passthrough.fbc = 'fb.1.' + (eventTime * 1000) + '.' + fbclid;\n  }\n\n  const hasAnyIdentifier = Object.values(identifiers).some(Boolean) ||\n    passthrough.fbc || passthrough.fbp || passthrough.client_ip_address;\n  if (!hasAnyIdentifier) errors.push('no usable identifier on the event');\n\n  out.push({\n    json: {\n      valid: errors.length === 0,\n      errors,\n      error_summary: errors.join(' | ') || 'ok',\n      event_name: eventName,\n      event_id: eventId,\n      event_time: eventTime,\n      event_source_url: raw.event_source_url || raw.page_url || '',\n      action_source: raw.action_source || 'website',\n      order_id: String(raw.order_id || eventId),\n      value: Math.round(value * 100) / 100,\n      currency,\n      contents: Array.isArray(raw.contents) ? raw.contents : [],\n      identifiers,\n      passthrough,\n      received_at: new Date().toISOString()\n    }\n  });\n}\n\nreturn out;"}},{"id":"ee000003-1111-4222-8333-444455556603","name":"Payload Usable?","type":"n8n-nodes-base.if","typeVersion":2,"position":[-280,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"v1","leftValue":"={{ $json.valid }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"ee000004-1111-4222-8333-444455556604","name":"Log Rejected Payload","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[-40,540],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2","mode":"list","cachedResultName":"Rejected"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"ee000005-1111-4222-8333-444455556605","name":"Alert On Malformed Events","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[200,540],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#tracking-alerts","mode":"name"},"text":"=⚠️ CAPI payload rejected — {{ $json.event_name || \"(no event_name)\" }} / id {{ $json.event_id || \"(none)\" }}\n{{ $json.error_summary }}\nFix the sender before this eats your attribution.","otherOptions":{}}},{"id":"ee000006-1111-4222-8333-444455556606","name":"Hash PII To SHA256","type":"n8n-nodes-base.code","typeVersion":2,"position":[-40,300],"parameters":{"jsCode":"// ---- SHA-256 hashing of PII ----------------------------------------------\n// Meta requires every identifier to arrive as a lowercase hex SHA-256 digest.\n// The n8n sandbox has no require(), so this is a self-contained implementation.\n// Raw PII never leaves this node: only digests are written to the output.\n\nconst K = [\n  0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,\n  0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,\n  0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,\n  0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,\n  0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,\n  0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,\n  0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,\n  0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2\n];\n\nfunction sha256(msg) {\n  // UTF-8 encode\n  const bytes = [];\n  for (const ch of unescape(encodeURIComponent(msg))) bytes.push(ch.charCodeAt(0));\n\n  const bitLen = bytes.length * 8;\n  bytes.push(0x80);\n  while (bytes.length % 64 !== 56) bytes.push(0);\n  for (let i = 7; i >= 0; i--) bytes.push((bitLen / Math.pow(2, i * 8)) & 0xff);\n\n  let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];\n  const rotr = (x, n) => (x >>> n) | (x << (32 - n));\n\n  for (let i = 0; i < bytes.length; i += 64) {\n    const w = new Array(64);\n    for (let j = 0; j < 16; j++) {\n      w[j] = (bytes[i + j * 4] << 24) | (bytes[i + j * 4 + 1] << 16) |\n             (bytes[i + j * 4 + 2] << 8) | bytes[i + j * 4 + 3];\n    }\n    for (let j = 16; j < 64; j++) {\n      const s0 = rotr(w[j - 15], 7) ^ rotr(w[j - 15], 18) ^ (w[j - 15] >>> 3);\n      const s1 = rotr(w[j - 2], 17) ^ rotr(w[j - 2], 19) ^ (w[j - 2] >>> 10);\n      w[j] = (w[j - 16] + s0 + w[j - 7] + s1) | 0;\n    }\n    let [a, b, c, d, e, f, g, hh] = h;\n    for (let j = 0; j < 64; j++) {\n      const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);\n      const ch = (e & f) ^ (~e & g);\n      const t1 = (hh + S1 + ch + K[j] + w[j]) | 0;\n      const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);\n      const maj = (a & b) ^ (a & c) ^ (b & c);\n      const t2 = (S0 + maj) | 0;\n      hh = g; g = f; f = e; e = (d + t1) | 0;\n      d = c; c = b; b = a; a = (t1 + t2) | 0;\n    }\n    h = h.map((x, idx) => (x + [a, b, c, d, e, f, g, hh][idx]) | 0);\n  }\n  return h.map((x) => (x >>> 0).toString(16).padStart(8, '0')).join('');\n}\n\n// Weights reflect how much each key actually lifts Meta's match rate.\nconst WEIGHT = { em: 30, ph: 20, external_id: 12, fn: 6, ln: 6, ct: 4, st: 3, zp: 5, country: 2, db: 4, ge: 2 };\nconst PASSTHROUGH_WEIGHT = { fbc: 25, fbp: 12, client_ip_address: 6, client_user_agent: 4 };\n\nreturn $input.all().map((item) => {\n  const ev = item.json;\n  const hashed = {};\n  const provided = [];\n\n  for (const [key, raw] of Object.entries(ev.identifiers || {})) {\n    if (!raw) continue;\n    hashed[key] = [sha256(raw)];\n    provided.push(key);\n  }\n\n  const pass = {};\n  for (const [key, raw] of Object.entries(ev.passthrough || {})) {\n    if (raw) { pass[key] = raw; provided.push(key); }\n  }\n\n  // Predicted match quality (0-10, same scale Events Manager shows) so we can\n  // alert on degradation BEFORE Meta's dashboard catches up 48h later.\n  const earned = provided.reduce((s, k) => s + (WEIGHT[k] || PASSTHROUGH_WEIGHT[k] || 0), 0);\n  const maxScore = Object.values(WEIGHT).reduce((a, b) => a + b, 0) +\n                   Object.values(PASSTHROUGH_WEIGHT).reduce((a, b) => a + b, 0);\n  const emq = Math.round((Math.min(1, earned / (maxScore * 0.62)) * 10) * 10) / 10;\n\n  return {\n    json: {\n      ...ev,\n      identifiers: undefined,          // drop raw PII from the item\n      passthrough: undefined,\n      user_data_hashed: hashed,\n      user_data_passthrough: pass,\n      identifiers_provided: provided,\n      identifier_count: provided.length,\n      has_email: Boolean(hashed.em),\n      has_click_id: Boolean(pass.fbc),\n      predicted_emq: emq\n    }\n  };\n});"}},{"id":"ee000007-1111-4222-8333-444455556607","name":"Read Browser Event Ledger","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[200,300],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Event Ledger"},"options":{}}},{"id":"ee000008-1111-4222-8333-444455556608","name":"Dedupe Against Browser Events","type":"n8n-nodes-base.code","typeVersion":2,"position":[440,300],"parameters":{"jsCode":"// ---- Dedupe against the browser pixel -------------------------------------\n// Meta dedupes on (event_name + event_id) but only inside a ~48h window and only\n// if BOTH sides send the same id. We keep our own ledger so we can (a) suppress\n// a server event the pixel already delivered within the safe window, and\n// (b) prove the dedupe rate to whoever asks why conversions \"dropped\".\n\nconst DEDUPE_WINDOW_HOURS = 48;\n\n// The ledger sheet is this node's input; the event comes from the hash step.\nconst ledger = $input.all().map((i) => i.json);\nconst ev = $('Hash PII To SHA256').first().json;\n\nconst nowSec = Math.floor(Date.now() / 1000);\nconst key = (r) => String(r.event_name || '') + '::' + String(r.event_id || '');\nconst evKey = ev.event_name + '::' + ev.event_id;\n\nlet priorBrowser = null;\nlet priorServer = null;\nlet windowRows = 0;\n\nfor (const row of ledger) {\n  const rowTime = Number(row.event_time || 0);\n  if (!rowTime || (nowSec - rowTime) / 3600 > DEDUPE_WINDOW_HOURS) continue;\n  windowRows++;\n  if (key(row) !== evKey) continue;\n  const src = String(row.source || '').toLowerCase();\n  if (src === 'browser' || src === 'pixel') priorBrowser = row;\n  else priorServer = row;\n}\n\n// Suppress only on an exact server-side replay (a retried webhook). A matching\n// BROWSER event is fine to send: that is what the dedupe key is for, and sending\n// it is how we recover the events the pixel lost on the way to Meta.\nconst isServerReplay = Boolean(priorServer);\nconst browserAlreadySaw = Boolean(priorBrowser);\n\nconst rows = ledger.length;\nconst browserRows = ledger.filter((r) => String(r.source || '').toLowerCase() === 'browser').length;\nconst serverRows = rows - browserRows;\nconst overlap = new Set(ledger.filter((r) => String(r.source || '').toLowerCase() === 'browser').map(key));\nconst matchedPairs = ledger.filter((r) => String(r.source || '').toLowerCase() !== 'browser' && overlap.has(key(r))).length;\n\nreturn [{\n  json: {\n    ...ev,\n    is_duplicate: isServerReplay,\n    dedupe_reason: isServerReplay\n      ? 'server event_id ' + ev.event_id + ' already forwarded at ' + (priorServer.sent_at || 'unknown time')\n      : (browserAlreadySaw\n          ? 'pixel already fired this event_id - sending anyway, Meta will dedupe on event_id'\n          : 'first sighting of this event_id'),\n    matched_browser_event: browserAlreadySaw,\n    ledger_rows_in_window: windowRows,\n    ledger_browser_rows: browserRows,\n    ledger_server_rows: serverRows,\n    // How much of our server traffic the pixel also managed to deliver. A low\n    // number means the pixel is being blocked and CAPI is carrying the account.\n    pixel_coverage_pct: serverRows ? Math.round((matchedPairs / serverRows) * 1000) / 10 : 0\n  }\n}];"}},{"id":"ee000009-1111-4222-8333-444455556609","name":"Already Forwarded?","type":"n8n-nodes-base.if","typeVersion":2,"position":[680,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"d1","leftValue":"={{ $json.is_duplicate }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"ee000010-1111-4222-8333-444455556610","name":"Mark Event As Suppressed","type":"n8n-nodes-base.code","typeVersion":2,"position":[920,60],"parameters":{"jsCode":"// A suppressed event is not a failure - it is a retried webhook we correctly\n// refused to forward twice. Log it so the count is auditable.\nreturn $input.all().map((i) => i.json).map((ev) => ({ json: {\n  event_id: ev.event_id,\n  event_name: ev.event_name,\n  event_time: ev.event_time,\n  source: 'server-suppressed',\n  order_id: ev.order_id,\n  value: ev.value,\n  currency: ev.currency,\n  sent_at: new Date().toISOString(),\n  delivered: false,\n  api_error: null,\n  emq_score: ev.predicted_emq,\n  emq_grade: 'n/a',\n  dedupe_reason: ev.dedupe_reason,\n  pixel_coverage_pct: ev.pixel_coverage_pct,\n  needs_attention: false\n} }));"}},{"id":"ee000011-1111-4222-8333-444455556611","name":"Log Suppressed Duplicate","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1160,60],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Event Ledger"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"ee000012-1111-4222-8333-444455556612","name":"Build Conversions API Payload","type":"n8n-nodes-base.code","typeVersion":2,"position":[920,300],"parameters":{"jsCode":"// ---- Build the Conversions API payload -------------------------------------\n// One /events call, exactly the shape Meta expects. Empty keys are stripped:\n// sending an empty array for em/ph counts as a \"provided but unmatched\" key and\n// drags the Event Match Quality score down.\n\nconst ev = $input.first().json;\n\nconst userData = {};\nfor (const [k, v] of Object.entries(ev.user_data_hashed || {})) {\n  if (Array.isArray(v) && v.length && v[0]) userData[k] = v;\n}\nfor (const [k, v] of Object.entries(ev.user_data_passthrough || {})) {\n  if (v) userData[k] = v;\n}\n\nconst customData = {\n  currency: ev.currency,\n  value: ev.value,\n  order_id: ev.order_id,\n  content_type: 'product'\n};\nif (Array.isArray(ev.contents) && ev.contents.length) {\n  customData.contents = ev.contents.map((c) => ({\n    id: String(c.id ?? c.sku ?? c.product_id ?? ''),\n    quantity: Number(c.quantity || 1),\n    item_price: Number(c.item_price ?? c.price ?? 0)\n  })).filter((c) => c.id);\n  customData.content_ids = customData.contents.map((c) => c.id);\n  customData.num_items = customData.contents.reduce((s, c) => s + c.quantity, 0);\n}\n\nconst payload = {\n  data: [{\n    event_name: ev.event_name,\n    event_time: ev.event_time,\n    event_id: ev.event_id,            // <- the dedupe key shared with the pixel\n    event_source_url: ev.event_source_url || undefined,\n    action_source: ev.action_source,\n    user_data: userData,\n    custom_data: customData,\n    opt_out: false\n  }],\n  // Drop this once the numbers line up in Events Manager; while test_event_code\n  // is set the events do NOT count toward optimisation.\n  test_event_code: undefined\n};\n\nreturn [{\n  json: {\n    ...ev,\n    capi_payload: payload,\n    payload_json: JSON.stringify(payload),\n    keys_sent: Object.keys(userData)\n  }\n}];"}},{"id":"ee000013-1111-4222-8333-444455556613","name":"Send To Conversions API","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1160,300],"parameters":{"method":"POST","url":"https://graph.facebook.com/v21.0/PIXEL_ID/events","sendQuery":true,"queryParameters":{"parameters":[{"name":"access_token","value":"CAPI_ACCESS_TOKEN"}]},"sendBody":true,"specifyBody":"json","jsonBody":"={{ $json.payload_json }}","options":{}}},{"id":"ee000014-1111-4222-8333-444455556614","name":"Score Match Quality","type":"n8n-nodes-base.code","typeVersion":2,"position":[1400,300],"parameters":{"jsCode":"// ---- Read Meta's response and track match quality --------------------------\n// The API returns events_received / fbtrace_id, and (for a bad payload) a nested\n// error object. We turn every send into one ledger row plus a rolling EMQ read\n// so a broken checkout field shows up here, not in next month's ROAS.\n\nconst res = $input.first().json;\nconst ev = $('Build Conversions API Payload').first().json;\n\nconst err = res.error || (res.body && res.body.error) || null;\nconst received = Number(res.events_received ?? (res.body && res.body.events_received) ?? 0);\nconst success = !err && received > 0;\n\n// Weighted coverage of the keys Meta actually cares about. predicted_emq was\n// scored off the keys that survived hashing, which is exactly the set we sent;\n// CRITICAL tracks the handful that carry most of the match rate.\nconst CRITICAL = ['em', 'ph', 'fbc', 'fbp', 'external_id'];\nconst sent = ev.keys_sent || [];\nconst criticalHit = CRITICAL.filter((k) => sent.includes(k));\nconst emq = Number(ev.predicted_emq || 0);\n\nlet grade = 'poor';\nif (emq >= 8) grade = 'great';\nelse if (emq >= 6.5) grade = 'good';\nelse if (emq >= 5) grade = 'ok';\n\nconst gaps = [];\nif (!sent.includes('em')) gaps.push('no email - biggest single match key');\nif (!sent.includes('ph')) gaps.push('no phone');\nif (!sent.includes('fbc')) gaps.push('no fbc (fbclid never captured or cookie expired)');\nif (!sent.includes('fbp')) gaps.push('no fbp (_fbp cookie not forwarded to the server)');\nif (!sent.includes('external_id')) gaps.push('no external_id (stable customer id would lift match rate)');\nif (!sent.includes('client_user_agent')) gaps.push('no user agent');\n\nreturn [{\n  json: {\n    event_id: ev.event_id,\n    event_name: ev.event_name,\n    event_time: ev.event_time,\n    source: 'server',\n    order_id: ev.order_id,\n    value: ev.value,\n    currency: ev.currency,\n    sent_at: new Date().toISOString(),\n    delivered: success,\n    events_received: received,\n    fbtrace_id: res.fbtrace_id || (res.body && res.body.fbtrace_id) || null,\n    api_error: err ? (err.message || String(err)) + (err.error_user_title ? ' / ' + err.error_user_title : '') : null,\n    keys_sent: sent.join(','),\n    key_count: sent.length,\n    critical_keys_hit: criticalHit.length + '/' + CRITICAL.length,\n    emq_score: emq,\n    emq_grade: grade,\n    matched_browser_event: ev.matched_browser_event,\n    pixel_coverage_pct: ev.pixel_coverage_pct,\n    match_gaps: gaps.join(' | ') || 'full identifier set',\n    needs_attention: !success || emq < 6.5\n  }\n}];"}},{"id":"ee000015-1111-4222-8333-444455556615","name":"Append To Event Ledger","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1640,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Event Ledger"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"ee000016-1111-4222-8333-444455556616","name":"Match Quality Slipping?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1880,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"q1","leftValue":"={{ $json.emq_score }}","rightValue":6.5,"operator":{"type":"number","operation":"lt"}}]},"options":{}}},{"id":"ee000017-1111-4222-8333-444455556617","name":"Alert On Weak Match Quality","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2120,120],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#tracking-alerts","mode":"name"},"text":"=📉 Weak match quality — {{ $json.event_name }} ({{ $json.emq_grade }} {{ $json.emq_score }}/10)\nKeys sent: {{ $json.keys_sent }} · critical {{ $json.critical_keys_hit }}\nGaps: {{ $json.match_gaps }}\nPixel coverage on server events: {{ $json.pixel_coverage_pct }}%","otherOptions":{}}},{"id":"ee000018-1111-4222-8333-444455556618","name":"Confirm Healthy Match","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2120,480],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"event_id","value":"={{ $json.event_id }}","type":"string"},{"id":"h2","name":"event_name","value":"={{ $json.event_name }}","type":"string"},{"id":"h3","name":"emq_score","value":"={{ $json.emq_score }}","type":"number"},{"id":"h4","name":"emq_grade","value":"={{ $json.emq_grade }}","type":"string"},{"id":"h5","name":"keys_sent","value":"={{ $json.keys_sent }}","type":"string"},{"id":"h6","name":"pixel_coverage_pct","value":"={{ $json.pixel_coverage_pct }}","type":"number"},{"id":"h7","name":"status","value":"healthy","type":"string"}]},"options":{}}},{"id":"ee000019-1111-4222-8333-444455556619","name":"Log Match Quality Trend","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2360,480],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Match Quality"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"ee000020-1111-4222-8333-444455556620","name":"Note Intake","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-800,-60],"parameters":{"content":"## 1. INTAKE + HASHING\nWebhook takes the same conversion your pixel fires (order webhook / CRM). Events are validated (event_name, unix-second event_time, 7d window, event_id present), normalized Meta-style (gmail dots, +tags, phone country codes) and hashed with a self-contained SHA-256 — raw PII never leaves the hash node. fbc/fbp/ip/user-agent stay un-hashed.","height":320,"width":460,"color":4}},{"id":"ee000021-1111-4222-8333-444455556621","name":"Note Dedupe","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[400,-60],"parameters":{"content":"## 2. DEDUPE ON EVENT_ID\nOwn 48h ledger of browser + server events. A repeated SERVER event_id is a retried webhook → suppressed. A matching BROWSER event_id is sent anyway — that is exactly what the shared event_id is for, and it recovers what the blocked pixel lost. pixel_coverage_pct tells you how much the pixel is still delivering.","height":320,"width":460,"color":3}},{"id":"ee000022-1111-4222-8333-444455556622","name":"Note Quality","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1840,-140],"parameters":{"content":"## 3. SEND + TRACK MATCH QUALITY\nPOST /PIXEL_ID/events, then score every send: which identifier keys actually went out, weighted into an Events-Manager-style EMQ (0-10). Below 6.5 pings #tracking-alerts with the exact missing keys, so a broken checkout field surfaces today instead of in next month's ROAS.","height":300,"width":460,"color":5}}],"connections":{"Receive Server Conversion Event":{"main":[[{"node":"Validate And Normalize Event","type":"main","index":0}]]},"Validate And Normalize Event":{"main":[[{"node":"Payload Usable?","type":"main","index":0}]]},"Payload Usable?":{"main":[[{"node":"Hash PII To SHA256","type":"main","index":0}],[{"node":"Log Rejected Payload","type":"main","index":0}]]},"Log Rejected Payload":{"main":[[{"node":"Alert On Malformed Events","type":"main","index":0}]]},"Hash PII To SHA256":{"main":[[{"node":"Read Browser Event Ledger","type":"main","index":0}]]},"Read Browser Event Ledger":{"main":[[{"node":"Dedupe Against Browser Events","type":"main","index":0}]]},"Dedupe Against Browser Events":{"main":[[{"node":"Already Forwarded?","type":"main","index":0}]]},"Already Forwarded?":{"main":[[{"node":"Mark Event As Suppressed","type":"main","index":0}],[{"node":"Build Conversions API Payload","type":"main","index":0}]]},"Mark Event As Suppressed":{"main":[[{"node":"Log Suppressed Duplicate","type":"main","index":0}]]},"Build Conversions API Payload":{"main":[[{"node":"Send To Conversions API","type":"main","index":0}]]},"Send To Conversions API":{"main":[[{"node":"Score Match Quality","type":"main","index":0}]]},"Score Match Quality":{"main":[[{"node":"Append To Event Ledger","type":"main","index":0}]]},"Append To Event Ledger":{"main":[[{"node":"Match Quality Slipping?","type":"main","index":0}]]},"Match Quality Slipping?":{"main":[[{"node":"Alert On Weak Match Quality","type":"main","index":0}],[{"node":"Confirm Healthy Match","type":"main","index":0}]]},"Confirm Healthy Match":{"main":[[{"node":"Log Match Quality Trend","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}