{
  "id": "mvxVBIvdjGEVWgGX",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Build a self-hosted multi-touch attribution engine with Supabase (first, last, linear, time-decay)",
  "tags": [],
  "nodes": [
    {
      "id": "f98a9dfc-744f-4fd3-a5f6-ddfbbcdc8e4f",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7392,
        144
      ],
      "parameters": {
        "width": 908,
        "height": 892,
        "content": "\n## Multi-touch attribution engine - Overview\n\n\nA self-hosted version of the attribution tools you would pay a SaaS for. It saves every marketing touch in Supabase, merges sessions that share an email into one person, then splits revenue across channels four ways so you can compare them.\n\n### Why it matters\nLast-touch overcredits the closer. First-touch overcredits the discovery. Seeing all four models at once tells you what your budget is actually driven by, on data you own.\n\n### Setup\n1. Create the mta_touches table (SQL below).\n2. Add a Supabase credential and pick it on the three Supabase nodes.\n3. Hit Run for the demo. In production, swap \"Generate journeys\" for a Webhook.\n\n```sql\ncreate table mta_touches (\n  id bigint generated always as identity primary key,\n  visitor_id text, email text, channel text,\n  ts timestamptz, is_conversion boolean, revenue numeric\n);\n```\n\n### Customize\nChange HALF_LIFE_DAYS and CHANNELS in Config. Swap the Print node for a Sheet or Slack.\n\nBuilt by nocode.expert. https://nocode.expert\n\n---\n\n\n## 1. Configure and ingest\nSet your channels and settings, then bring in touches. The demo builds fake journeys, and some share an email on purpose so you can watch the stitching work. Swap in a Webhook for real data.\n\n---\n\n## 2. Store in Supabase\nClear old demo rows, break the batch into one item per touch, then insert each touch into the table as its own row.\n\n---\n\n## 3. Stitch and model\nLoad the touches back, merge visitors who share an email into one person, then work out first, last, linear and time-decay credit per channel.\n\n---\n\n## 4. Report\nShow the four models side by side so you can compare channel credit. Swap this for a Sheet, dashboard, or Slack message.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "ab665b4e-8d3c-45b4-9314-0cdbc3fe1648",
      "name": "Section: Ingest",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8528,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 504,
        "height": 320,
        "content": "## Configure and ingest\nLoad settings and receive touches (a Webhook in production; synthetic journeys in the demo)."
      },
      "typeVersion": 1
    },
    {
      "id": "9f4f46b2-4507-46d7-86fd-8eff8653267e",
      "name": "Section: Store",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9056,
        336
      ],
      "parameters": {
        "color": 7,
        "height": 320,
        "content": "## Store in Supabase\nWrite every touch to the Supabase touches table."
      },
      "typeVersion": 1
    },
    {
      "id": "938ffaf7-b91d-41cc-b275-59884d68cca6",
      "name": "Section: Stitch & model",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9312,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 660,
        "height": 320,
        "content": "## Stitch and model\nMerge identities by email, then compute the four attribution models."
      },
      "typeVersion": 1
    },
    {
      "id": "461302e1-bde0-44a5-97dc-c66327b22a29",
      "name": "Section: Report",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10000,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 816,
        "height": 320,
        "content": "## Report\nCompare channel credit across models (swap for a dashboard)."
      },
      "typeVersion": 1
    },
    {
      "id": "ab0c4e8a-b198-4eed-ae62-30cd5b8a9b0f",
      "name": "Run manually",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        8368,
        496
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "62d4a6ba-2419-45be-a911-e0671c17e886",
      "name": "Set config: channels, time-decay half-life",
      "type": "n8n-nodes-base.code",
      "position": [
        8592,
        496
      ],
      "parameters": {
        "jsCode": "// HALF_LIFE_DAYS controls time-decay weighting. In production, replace the\n// \"Generate journeys\" node with a Webhook that receives real page/form/click\n// touches; keep the rest of the pipeline as-is.\nreturn [{ json: {\n  HALF_LIFE_DAYS: 7,\n  CHANNELS: ['paid_search', 'paid_social', 'organic', 'email', 'referral', 'direct'],\n  N_VISITORS: 40,     // synthetic visitors for the demo run\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "0db083b0-f341-4d9e-bc8c-51262dac415b",
      "name": "Generate synthetic multi-touch journeys (replace with Webhook)",
      "type": "n8n-nodes-base.code",
      "position": [
        8848,
        496
      ],
      "parameters": {
        "jsCode": "// DEMO ONLY: generate synthetic multi-touch journeys. Each visitor has 2-4\n// touches across channels over ~14 days; some visitors share an email (to prove\n// identity stitching), and converting journeys carry revenue on the last touch.\n// In production, delete this node and feed real touches from a Webhook instead.\nconst cfg = $json;\nconst CH = cfg.CHANNELS;\nconst pick = (a) => a[Math.floor(Math.random() * a.length)];\nconst now = Date.now();\nconst touches = [];\nconst emails = ['user@example.com', 'user@example.com', 'user@example.com', 'user@example.com', 'user@example.com'];\n\nfor (let v = 0; v < cfg.N_VISITORS; v++) {\n  const vid = 'demo-' + v;\n  const n = 2 + Math.floor(Math.random() * 3);\n  const converts = Math.random() < 0.45;\n  // ~30% of journeys resolve to a shared known email (identity stitching)\n  const email = Math.random() < 0.3 ? pick(emails) : null;\n  for (let i = 0; i < n; i++) {\n    const daysAgo = (n - i) * (1 + Math.floor(Math.random() * 4));\n    const last = i === n - 1;\n    touches.push({\n      visitor_id: vid,\n      email: last ? email : (Math.random() < 0.4 ? email : null),\n      channel: pick(CH),\n      ts: new Date(now - daysAgo * 864e5).toISOString(),\n      is_conversion: last && converts,\n      revenue: last && converts ? 50 + Math.floor(Math.random() * 250) : 0,\n    });\n  }\n}\nreturn [{ json: { ...cfg, touches } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "0cdcb7af-2a52-427c-9c3e-b4012ab9bc77",
      "name": "Compute first / last / linear / time-decay attribution",
      "type": "n8n-nodes-base.code",
      "position": [
        10320,
        496
      ],
      "parameters": {
        "jsCode": "// Compute channel credit under four attribution models across converting\n// journeys, weighted by revenue.\nconst cfg = $json;\nconst HL = cfg.HALF_LIFE_DAYS;\nconst models = { first_touch: {}, last_touch: {}, linear: {}, time_decay: {} };\nconst add = (m, ch, v) => { m[ch] = (m[ch] || 0) + v; };\n\nlet convJourneys = 0, revenue = 0;\nfor (const j of cfg.journeys) {\n  if (!j.converted) continue;\n  convJourneys++; revenue += j.revenue;\n  const t = j.touches;\n  const rev = j.revenue;\n  add(models.first_touch, t[0].channel, rev);\n  add(models.last_touch, t[t.length - 1].channel, rev);\n  t.forEach((x) => add(models.linear, x.channel, rev / t.length));\n  // time decay: weight by 2^(-daysBeforeConversion / half_life), normalized\n  const w = t.map((x) => Math.pow(2, -((j.convTs - new Date(x.ts).getTime()) / 864e5) / HL));\n  const wSum = w.reduce((n, x) => n + x, 0) || 1;\n  t.forEach((x, i) => add(models.time_decay, x.channel, rev * (w[i] / wSum)));\n}\nconst round = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, +v.toFixed(2)]));\nreturn [{ json: { channels: cfg.CHANNELS, models: { first_touch: round(models.first_touch), last_touch: round(models.last_touch), linear: round(models.linear), time_decay: round(models.time_decay) }, convJourneys, revenue: +revenue.toFixed(2), visitorsMerged: cfg.visitorsMerged } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "93664882-e84b-4505-b249-612e7913ba7b",
      "name": "Print attribution model comparison",
      "type": "n8n-nodes-base.code",
      "position": [
        10576,
        496
      ],
      "parameters": {
        "jsCode": "// Print the attribution comparison across all four models.\nconst m = $json;\nconst chans = [...new Set([].concat(...Object.values(m.models).map((o) => Object.keys(o))))].sort();\nconst pad = (s, n) => String(s).padStart(n);\n\nconst out = [];\nout.push('');\nout.push('==========================================================');\nout.push('  MULTI-TOUCH ATTRIBUTION ENGINE  |  nocode.expert');\nout.push('==========================================================');\nout.push('Converting journeys: ' + m.convJourneys + '   Revenue: $' + m.revenue.toLocaleString() + '   Identities merged by email: ' + m.visitorsMerged);\nout.push('');\nout.push('Channel'.padEnd(14) + pad('First', 10) + pad('Last', 10) + pad('Linear', 10) + pad('TimeDecay', 12));\nout.push('-'.repeat(56));\nfor (const c of chans) {\n  out.push(c.padEnd(14) + pad('$' + (m.models.first_touch[c] || 0), 10) + pad('$' + (m.models.last_touch[c] || 0), 10) + pad('$' + (m.models.linear[c] || 0), 10) + pad('$' + (m.models.time_decay[c] || 0), 12));\n}\nout.push('==========================================================');\nconsole.log(out.join('\\n'));\nreturn [{ json: m }];"
      },
      "typeVersion": 2
    },
    {
      "id": "30832f02-1d10-43af-a769-50f4745d7e69",
      "name": "Clear demo rows (Supabase)",
      "type": "n8n-nodes-base.supabase",
      "position": [
        9136,
        496
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "visitor_id",
              "keyValue": "demo-%",
              "condition": "like"
            }
          ]
        },
        "tableId": "mta_touches",
        "operation": "delete"
      },
      "typeVersion": 1
    },
    {
      "id": "322f3688-b184-4aa6-96c6-cab8a53213f5",
      "name": "Split touches into rows",
      "type": "n8n-nodes-base.code",
      "position": [
        9424,
        496
      ],
      "parameters": {
        "jsCode": "// Split the generated touch batch into one item per row so the Supabase\n// 'Create a row' node inserts them one by one (native per-item execution).\nreturn $('Generate synthetic multi-touch journeys (replace with Webhook)').first().json.touches.map(t => ({ json: t }));"
      },
      "typeVersion": 2
    },
    {
      "id": "3b2857fe-a32b-47a5-a4ff-bfff675ac00c",
      "name": "Store touch (Supabase)",
      "type": "n8n-nodes-base.supabase",
      "position": [
        9616,
        496
      ],
      "parameters": {
        "tableId": "mta_touches",
        "dataToSend": "autoMapInputData"
      },
      "typeVersion": 1
    },
    {
      "id": "4be3c604-e323-4e4c-9ce4-3ab7904ab52f",
      "name": "Stitch identity by email",
      "type": "n8n-nodes-base.code",
      "position": [
        10048,
        496
      ],
      "parameters": {
        "jsCode": "// Stitch identity: visitor_ids that share an email are merged into one person,\n// their touches combined and time-ordered. Pure data transform over Supabase rows.\nconst cfg = $('Set config: channels, time-decay half-life').first().json;\nconst rows = $input.all().map(i => i.json);\nconst byVisitor = {};\nfor (const r of rows) (byVisitor[r.visitor_id] = byVisitor[r.visitor_id] || []).push(r);\nconst emailOf = {};\nfor (const r of rows) if (r.email) emailOf[r.visitor_id] = r.email;\nconst people = {};\nfor (const [vid, ts] of Object.entries(byVisitor)) { const identity = emailOf[vid] || vid; (people[identity] = people[identity] || []).push(...ts); }\nconst journeys = Object.entries(people).map(([identity, ts]) => {\n  ts.sort((a, b) => new Date(a.ts) - new Date(b.ts));\n  const conv = ts.find((t) => t.is_conversion);\n  return { identity, touches: ts, converted: !!conv, revenue: conv ? Number(conv.revenue) : 0, convTs: conv ? new Date(conv.ts).getTime() : null };\n});\nreturn [{ json: { CHANNELS: cfg.CHANNELS, HALF_LIFE_DAYS: cfg.HALF_LIFE_DAYS, journeys, visitorsMerged: Object.keys(byVisitor).length - Object.keys(people).length } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "d9268f44-5aa1-4796-90f9-df5c833d9519",
      "name": "Load touches (Supabase)",
      "type": "n8n-nodes-base.supabase",
      "position": [
        9824,
        496
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "visitor_id",
              "keyValue": "demo-%",
              "condition": "like"
            }
          ]
        },
        "tableId": "mta_touches",
        "operation": "getAll",
        "returnAll": true
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "bf1eb3d7-5a6e-4889-967e-0b5b64f1800f",
  "connections": {
    "Run manually": {
      "main": [
        [
          {
            "node": "Set config: channels, time-decay half-life",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Store touch (Supabase)": {
      "main": [
        [
          {
            "node": "Load touches (Supabase)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load touches (Supabase)": {
      "main": [
        [
          {
            "node": "Stitch identity by email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split touches into rows": {
      "main": [
        [
          {
            "node": "Store touch (Supabase)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stitch identity by email": {
      "main": [
        [
          {
            "node": "Compute first / last / linear / time-decay attribution",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clear demo rows (Supabase)": {
      "main": [
        [
          {
            "node": "Split touches into rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set config: channels, time-decay half-life": {
      "main": [
        [
          {
            "node": "Generate synthetic multi-touch journeys (replace with Webhook)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute first / last / linear / time-decay attribution": {
      "main": [
        [
          {
            "node": "Print attribution model comparison",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate synthetic multi-touch journeys (replace with Webhook)": {
      "main": [
        [
          {
            "node": "Clear demo rows (Supabase)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}