AutomationFlowsEmail & Gmail › Turn Podium Conversations Into a Full CRM with Claude, Sheets and Gmail

Turn Podium Conversations Into a Full CRM with Claude, Sheets and Gmail

ByTurag Sarkar @turagsarkar on n8n.io

This template turns Podium's conversation inbox into a full sales CRM with a custom funnel, AI message classification, automated drip follow-ups, daily admin reports, and a live Kanban dashboard. Six sub-flows live on one canvas — each clearly separated by a coloured sticky-note…

Webhook trigger★★★★★ complexity44 nodesHTTP RequestGoogle SheetsGmail
Email & Gmail Trigger: Webhook Nodes: 44 Complexity: ★★★★★ Added:
Turn Podium Conversations Into a Full CRM with Claude, Sheets and Gmail — n8n workflow card showing HTTP Request, Google Sheets, Gmail integration

This workflow corresponds to n8n.io template #15784 — we link there as the canonical source.

This workflow follows the Gmail → Google Sheets recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "JJ83XNNFFdOtzPqF",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Podium CRM Pipeline",
  "tags": [],
  "nodes": [
    {
      "id": "0ecc1d6b-3496-45eb-9aea-ffe7c7a69650",
      "name": "\ud83d\udcd8 Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4608,
        4848
      ],
      "parameters": {
        "color": 3,
        "width": 760,
        "height": 520,
        "content": "## Podium CRM Pipeline\n### AI Message Router + Funnel Updater + Quote Drip + Daily Report + Live Dashboard\n\n**Author:** Turag Sarkar  \u00b7  \u2709\ufe0f turagsarkar@gmail.com\n\n**Who this is for**\nService businesses on Podium (workshops, salons, dental, trades) that want a custom sales funnel, automatic message classification, follow-up drips on stale quotes, and a live Kanban dashboard \u2014 all on top of Podium's existing conversation inbox.\n\n**What it does**\n1. Every inbound/outbound Podium message is classified by Claude AI into a funnel stage (New Enquiry \u2192 Contacted \u2192 Booked In \u2192 In Workshop \u2192 Quote Sent \u2192 Approved \u2192 Won/Lost/Rebooked).\n2. The contact's funnel stage and opportunity value are pushed back into Podium as custom attributes.\n3. Quotes sitting unanswered are dripped at 48h / 5d / 10d, then auto-moved to Lost.\n4. A daily email digest flags stuck leads, quotes awaiting reply, and cars ready for pickup.\n5. A web-based Kanban dashboard renders the full pipeline live from a Google Sheet event log.\n\n**Why a Google Sheet?**\nPodium's API has no pagination on contacts and no funnel/stage endpoints. The Sheet is used as an append-only event log \u2014 every stage change, value update, and drip is logged. The dashboard and daily report read from the Sheet, not Podium, which makes everything fast and gives you a full audit trail.\n\n**Replace before running** \u2014 see green sticky notes in each section."
      },
      "typeVersion": 1
    },
    {
      "id": "a15ab80d-3caa-4e6d-b75e-013ce4f51ad2",
      "name": "Section 1 background",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4608,
        5456
      ],
      "parameters": {
        "color": 4,
        "width": 1900,
        "height": 720,
        "content": "## SECTION 1 \u2014 AI Message Router\nPodium webhook \u2192 extract message \u2192 dedupe \u2192 Claude classifies \u2192 parse \u2192 branch to (A) stage update and (B) value update."
      },
      "typeVersion": 1
    },
    {
      "id": "568c305b-ffc8-44f7-b52b-090a3c705174",
      "name": "Setup note S1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4640,
        5520
      ],
      "parameters": {
        "color": 5,
        "width": 380,
        "height": 320,
        "content": "### \ud83d\udfe2 Setup (Section 1)\n1. **Podium webhook** \u2014 register a webhook in Podium's developer console pointing to this node's Production URL. Subscribe to `conversation.message.created`.\n2. **Anthropic credential** \u2014 create an Anthropic API credential in n8n for the Claude node.\n3. **Google Sheets credential** \u2014 create one Google Sheets OAuth2 credential and reuse across all Sheets nodes.\n4. Replace `YOUR_GOOGLE_SHEET_ID` in every Google Sheets node with your sheet's ID."
      },
      "typeVersion": 1
    },
    {
      "id": "2603cb25-c9c8-475c-9d90-4435831b6484",
      "name": "Podium Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        5056,
        5840
      ],
      "parameters": {
        "path": "podium-message",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2
    },
    {
      "id": "91039e80-9db9-4c82-bd36-ad4175be6522",
      "name": "Extract Message Context",
      "type": "n8n-nodes-base.code",
      "position": [
        5280,
        5840
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\nconst data = body.data || body;\nconst meta = data.metadata || {};\nconst msg = data.message || data;\n\nconst direction = (meta.eventType || msg.direction || '').toLowerCase().includes('inbound') ? 'inbound' : 'outbound';\nconst conversation_uid = data.conversationUid || msg.conversationUid || msg.contactUid || '';\nconst message_uid = msg.uid || msg.id || data.uid || '';\nconst message_body = msg.body || msg.text || '';\nconst contact_name = data.contact?.name || msg.contactName || '';\nconst channel_type = (data.channel?.type || msg.channelType || '').toLowerCase();\nconst channel_identifier = data.channel?.identifier || msg.channelIdentifier || '';\n\nreturn [{ json: { direction, conversation_uid, message_uid, message_body, contact_name, channel_type, channel_identifier } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "414d45f6-cf72-4e3d-b7eb-1fe86d313be9",
      "name": "Has Message Body?",
      "type": "n8n-nodes-base.if",
      "position": [
        5488,
        5840
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.message_body }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "0c1464c5-d6c3-4511-8f39-c9ca36f53990",
      "name": "Claude Classify",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        5712,
        5840
      ],
      "parameters": {
        "url": "https://api.anthropic.com/v1/messages",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"claude-haiku-4-5\",\n  \"max_tokens\": 300,\n  \"temperature\": 0,\n  \"messages\": [\n    {\n      \"role\": \"user\",\n      \"content\": \"You are a sales funnel classifier for a service business on Podium. Analyse this message and return JSON ONLY (no prose).\\n\\nMessage direction: {{ $json.direction }}\\nContact name: {{ $json.contact_name }}\\nMessage: \\\"\\\"\\\"{{ $json.message_body }}\\\"\\\"\\\"\\n\\nReturn:\\n{\\n  \\\"funnel_stage\\\": one of [\\\"New Enquiry\\\",\\\"Contacted\\\",\\\"Booked In\\\",\\\"In Workshop\\\",\\\"Quote Sent\\\",\\\"Approved\\\",\\\"Won\\\",\\\"Lost\\\",\\\"Rebooked\\\",\\\"Unqualified\\\", null],\\n  \\\"opp_value_cents\\\": integer or null,\\n  \\\"contact_name\\\": string or null\\n}\\n\\nRules:\\n- Inbound message with any content -> default to \\\"New Enquiry\\\" unless clearly later in funnel.\\n- \\\"Booked In\\\" requires explicit confirmed date/time.\\n- \\\"Approved\\\" requires explicit acceptance of a prior quote.\\n- \\\"Quote Sent\\\" only when an actual quote/price is sent outbound.\\n- \\\"Lost\\\" / \\\"Won\\\" only on explicit signal.\\n- If unsure -> null.\"\n    }\n  ]\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "54c22373-4887-4bf3-b75d-9ec3811e7f9e",
      "name": "Parse Claude Output",
      "type": "n8n-nodes-base.code",
      "position": [
        5936,
        5840
      ],
      "parameters": {
        "jsCode": "const STAGE_UID = {\n  'New Enquiry':  'YOUR_NEW_ENQUIRY_STAGE_UID',\n  'Contacted':    'YOUR_CONTACTED_STAGE_UID',\n  'Booked In':    'YOUR_BOOKED_IN_STAGE_UID',\n  'In Workshop':  'YOUR_IN_WORKSHOP_STAGE_UID',\n  'Quote Sent':   'YOUR_QUOTE_SENT_STAGE_UID',\n  'Approved':     'YOUR_APPROVED_STAGE_UID',\n  'Won':          'YOUR_WON_STAGE_UID',\n  'Lost':         'YOUR_LOST_STAGE_UID',\n  'Rebooked':     'YOUR_REBOOKED_STAGE_UID',\n  'Unqualified':  'YOUR_UNQUALIFIED_STAGE_UID'\n};\nconst r = $input.first().json;\nconst raw = r.content?.[0]?.text || r.message?.content?.[0]?.text || r.text || '';\nlet parsed = {};\ntry { parsed = JSON.parse(raw.match(/\\{[\\s\\S]*\\}/)?.[0] || '{}'); } catch (e) { parsed = {}; }\nconst src = $('Extract Message Context').first().json;\nconst stage_uid = parsed.funnel_stage ? STAGE_UID[parsed.funnel_stage] : null;\nreturn [{ json: {\n  conversation_uid: src.conversation_uid,\n  channel_identifier: src.channel_identifier,\n  channel_type: src.channel_type,\n  contact_name: parsed.contact_name || src.contact_name || '',\n  funnel_stage: parsed.funnel_stage,\n  funnel_stage_uid: stage_uid,\n  opp_value_cents: parsed.opp_value_cents || null\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "bb060159-4905-46f1-86e8-2c35b4b85743",
      "name": "Has Stage?",
      "type": "n8n-nodes-base.if",
      "position": [
        6160,
        5728
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.funnel_stage_uid }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "46f9e323-4189-4093-9afb-db6853e6a8b8",
      "name": "Has Value?",
      "type": "n8n-nodes-base.if",
      "position": [
        6160,
        5952
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "number",
                "operation": "gt",
                "singleValue": false
              },
              "leftValue": "={{ $json.opp_value_cents }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "b33d8557-096c-4ccf-8f7a-adeae31e57e5",
      "name": "Section 2 background",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4608,
        6208
      ],
      "parameters": {
        "color": 6,
        "width": 1900,
        "height": 520,
        "content": "## SECTION 2 \u2014 Funnel Stage Updater\nGets the contact from Podium, skips if same stage, upserts the contact with new stage attribute, logs the event."
      },
      "typeVersion": 1
    },
    {
      "id": "84d12992-dab3-4c7b-81cc-fbe78a8a36ce",
      "name": "Setup note S2-S3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4640,
        6272
      ],
      "parameters": {
        "color": 5,
        "width": 380,
        "height": 360,
        "content": "### \ud83d\udfe2 Setup (Section 2 & 3)\nReplace these in the Code nodes:\n- `YOUR_PODIUM_LOCATION_UID` \u2014 find it in Podium \u2192 Settings \u2192 Locations\n- `YOUR_FUNNEL_STAGE_ATTRIBUTE_UID` \u2014 your custom Podium contact attribute for funnel stage\n- `YOUR_OPP_VALUE_ATTRIBUTE_UID` \u2014 your custom Podium contact attribute for opportunity value (integer cents)\n- All `YOUR_*_STAGE_UID` \u2014 the option UIDs inside your funnel stage attribute\n\n**Podium credential**: HTTP nodes use OAuth2 generic credential. Create one Podium OAuth2 app and reuse it."
      },
      "typeVersion": 1
    },
    {
      "id": "64946ea5-3e06-4340-8493-02f11e91b393",
      "name": "Get Contact (Stage)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        5056,
        6432
      ],
      "parameters": {
        "url": "=https://api.podium.com/v4/contacts/{{ $json.conversation_uid }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "b5a03e3a-8fdf-4010-aaf6-071513ec9a7e",
      "name": "Compare Current Stage",
      "type": "n8n-nodes-base.code",
      "position": [
        5280,
        6432
      ],
      "parameters": {
        "jsCode": "const STAGE_ATTR = 'YOUR_FUNNEL_STAGE_ATTRIBUTE_UID';\nconst LOCATION_UID = 'YOUR_PODIUM_LOCATION_UID';\nconst contact = $input.first().json || {};\nconst trigger = $('Parse Claude Output').first().json;\nconst attr = (contact.attributes || []).find(a => a.uid === STAGE_ATTR);\nconst current = attr?.value || null;\nconst target = trigger.funnel_stage_uid;\n\nconst chType = (trigger.channel_type || '').toLowerCase();\nconst chId = trigger.channel_identifier || null;\nlet phone = chType === 'phone' ? chId : null;\nlet email = chType === 'email' ? chId : null;\nphone = phone || (contact.phoneNumbers && contact.phoneNumbers[0]) || null;\nemail = email || (contact.emails && contact.emails[0]) || null;\nconst name = contact.name || trigger.contact_name || 'Contact';\n\nconst body = { name, locations: [LOCATION_UID], attributes: [{ uid: STAGE_ATTR, value: target }] };\nif (phone) body.phoneNumber = phone;\nif (email) body.email = email;\n\nreturn [{ json: {\n  conversation_uid: trigger.conversation_uid,\n  funnel_stage_uid: target,\n  funnel_stage: trigger.funnel_stage,\n  current_stage_uid: current,\n  should_write: current !== target,\n  request_body: JSON.stringify(body),\n  name,\n  channel_identifier: chId || phone || email || '',\n  channel_type: chType || (phone ? 'phone' : email ? 'email' : '')\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "c2a3b56d-1e88-4ff3-8ecb-55141fc526b6",
      "name": "Stage Changed?",
      "type": "n8n-nodes-base.if",
      "position": [
        5488,
        6432
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.should_write }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "99499d7a-0814-404f-ab4e-db5f3e748133",
      "name": "Upsert Stage",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        5712,
        6432
      ],
      "parameters": {
        "url": "https://api.podium.com/v4/contacts",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={{ $json.request_body }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "360e9757-1601-4759-886c-5d8522d4fca8",
      "name": "Log Stage Change",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5936,
        6432
      ],
      "parameters": {
        "columns": {
          "value": {
            "name": "={{ $('Compare Current Stage').first().json.name }}",
            "action": "FUNNEL_STAGE_SET",
            "timestamp": "={{ $now }}",
            "channel_type": "={{ $('Compare Current Stage').first().json.channel_type }}",
            "opp_value_cents": "",
            "conversation_uid": "={{ $('Compare Current Stage').first().json.conversation_uid }}",
            "funnel_stage_uid": "={{ $('Compare Current Stage').first().json.funnel_stage_uid }}",
            "channel_identifier": "={{ $('Compare Current Stage').first().json.channel_identifier }}"
          },
          "schema": [
            {
              "id": "timestamp",
              "type": "string",
              "required": false,
              "displayName": "timestamp",
              "canBeUsedToMatch": true
            },
            {
              "id": "conversation_uid",
              "type": "string",
              "required": false,
              "displayName": "conversation_uid",
              "canBeUsedToMatch": true
            },
            {
              "id": "name",
              "type": "string",
              "required": false,
              "displayName": "name",
              "canBeUsedToMatch": true
            },
            {
              "id": "channel_identifier",
              "type": "string",
              "required": false,
              "displayName": "channel_identifier",
              "canBeUsedToMatch": true
            },
            {
              "id": "channel_type",
              "type": "string",
              "required": false,
              "displayName": "channel_type",
              "canBeUsedToMatch": true
            },
            {
              "id": "funnel_stage_uid",
              "type": "string",
              "required": false,
              "displayName": "funnel_stage_uid",
              "canBeUsedToMatch": true
            },
            {
              "id": "opp_value_cents",
              "type": "string",
              "required": false,
              "displayName": "opp_value_cents",
              "canBeUsedToMatch": true
            },
            {
              "id": "action",
              "type": "string",
              "required": false,
              "displayName": "action",
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "d6217d33-ed3c-416a-aa9d-f5896fb8d6b5",
      "name": "Section 3 background",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4608,
        6768
      ],
      "parameters": {
        "color": 6,
        "width": 1900,
        "height": 520,
        "content": "## SECTION 3 \u2014 Opportunity Value Updater\nFetches contact, only writes if new quote value is higher than current, upserts via /v4/contacts, logs the event."
      },
      "typeVersion": 1
    },
    {
      "id": "5164ec69-6e7b-4be0-97b4-8db4f3014ab7",
      "name": "Get Contact (Value)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        5056,
        6960
      ],
      "parameters": {
        "url": "=https://api.podium.com/v4/contacts/{{ $json.conversation_uid }}",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "1182f27d-3365-4376-b195-6c0e9cafc06c",
      "name": "Compare Current Value",
      "type": "n8n-nodes-base.code",
      "position": [
        5280,
        6960
      ],
      "parameters": {
        "jsCode": "const VALUE_ATTR = 'YOUR_OPP_VALUE_ATTRIBUTE_UID';\nconst LOCATION_UID = 'YOUR_PODIUM_LOCATION_UID';\nconst contact = $input.first().json || {};\nconst trigger = $('Parse Claude Output').first().json;\nconst attr = (contact.attributes || []).find(a => a.uid === VALUE_ATTR);\nconst current = Number(attr?.value ?? 0);\nconst target = Number(trigger.opp_value_cents);\n\nconst chType = (trigger.channel_type || '').toLowerCase();\nconst chId = trigger.channel_identifier || null;\nlet phone = chType === 'phone' ? chId : null;\nlet email = chType === 'email' ? chId : null;\nphone = phone || (contact.phoneNumbers && contact.phoneNumbers[0]) || null;\nemail = email || (contact.emails && contact.emails[0]) || null;\nconst name = contact.name || trigger.contact_name || 'Contact';\n\nconst body = { name, locations: [LOCATION_UID], attributes: [{ uid: VALUE_ATTR, value: String(target) }] };\nif (phone) body.phoneNumber = phone;\nif (email) body.email = email;\n\nreturn [{ json: {\n  conversation_uid: trigger.conversation_uid,\n  target_cents: target,\n  current_cents: current,\n  should_write: !current || target > current,\n  request_body: JSON.stringify(body),\n  name,\n  channel_identifier: chId || phone || email || '',\n  channel_type: chType || (phone ? 'phone' : email ? 'email' : '')\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "240908d9-794b-4fd5-9836-334b45146c78",
      "name": "Only If Higher",
      "type": "n8n-nodes-base.if",
      "position": [
        5488,
        6960
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.should_write }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "2db4719a-884f-45c1-88d6-5a4944a78fb0",
      "name": "Upsert Value",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        5712,
        6960
      ],
      "parameters": {
        "url": "https://api.podium.com/v4/contacts",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={{ $json.request_body }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "1570ae7e-0989-40ca-9739-2954134c41e0",
      "name": "Log Value Change",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5936,
        6960
      ],
      "parameters": {
        "columns": {
          "value": {
            "name": "={{ $('Compare Current Value').first().json.name }}",
            "action": "OPP_VALUE_SET",
            "timestamp": "={{ $now }}",
            "channel_type": "={{ $('Compare Current Value').first().json.channel_type }}",
            "opp_value_cents": "={{ $('Compare Current Value').first().json.target_cents }}",
            "conversation_uid": "={{ $('Compare Current Value').first().json.conversation_uid }}",
            "funnel_stage_uid": "",
            "channel_identifier": "={{ $('Compare Current Value').first().json.channel_identifier }}"
          },
          "schema": [
            {
              "id": "timestamp",
              "type": "string",
              "required": false,
              "displayName": "timestamp",
              "canBeUsedToMatch": true
            },
            {
              "id": "conversation_uid",
              "type": "string",
              "required": false,
              "displayName": "conversation_uid",
              "canBeUsedToMatch": true
            },
            {
              "id": "name",
              "type": "string",
              "required": false,
              "displayName": "name",
              "canBeUsedToMatch": true
            },
            {
              "id": "channel_identifier",
              "type": "string",
              "required": false,
              "displayName": "channel_identifier",
              "canBeUsedToMatch": true
            },
            {
              "id": "channel_type",
              "type": "string",
              "required": false,
              "displayName": "channel_type",
              "canBeUsedToMatch": true
            },
            {
              "id": "funnel_stage_uid",
              "type": "string",
              "required": false,
              "displayName": "funnel_stage_uid",
              "canBeUsedToMatch": true
            },
            {
              "id": "opp_value_cents",
              "type": "string",
              "required": false,
              "displayName": "opp_value_cents",
              "canBeUsedToMatch": true
            },
            {
              "id": "action",
              "type": "string",
              "required": false,
              "displayName": "action",
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "b25f7278-b14b-431f-86f8-23e2a6cb834b",
      "name": "Section 4 background",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4608,
        7328
      ],
      "parameters": {
        "color": 7,
        "width": 1900,
        "height": 520,
        "content": "## SECTION 4 \u2014 Quote Follow-up Drip\nEvery hour, reads the event-log Sheet, finds contacts stuck in Quote Sent, sends drip 1 at 48h, drip 2 at 5 days, drip 3 at 10 days, then auto-moves to Lost. Respects business hours (Mon\u2013Sat 08:00\u201318:00)."
      },
      "typeVersion": 1
    },
    {
      "id": "a94e8e71-cdc4-435c-9872-f36eea221887",
      "name": "Setup note S4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4640,
        7392
      ],
      "parameters": {
        "color": 5,
        "width": 380,
        "height": 220,
        "content": "### \ud83d\udfe2 Setup (Section 4)\n- Replace `YOUR_PODIUM_LOCATION_UID` and stage UIDs in the Code node.\n- Edit the 3 drip message bodies to match your brand voice.\n- Adjust business hours / timezone at the top of the Code node if not Mon\u2013Sat 08\u201318 AEST."
      },
      "typeVersion": 1
    },
    {
      "id": "d3c39d7f-ac78-4fc0-8ba9-1eb3fbeead79",
      "name": "Every Hour",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        5056,
        7552
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "b637af0c-48d2-4011-86c6-68929632fdef",
      "name": "Read Event Log",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5280,
        7552
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "3c156bd6-def1-4f3a-b592-d4458e3f151b",
      "name": "Filter Eligible",
      "type": "n8n-nodes-base.code",
      "position": [
        5488,
        7552
      ],
      "parameters": {
        "jsCode": "const QUOTE_SENT = 'YOUR_QUOTE_SENT_STAGE_UID';\nconst LOST       = 'YOUR_LOST_STAGE_UID';\nconst APPROVED   = 'YOUR_APPROVED_STAGE_UID';\nconst WON        = 'YOUR_WON_STAGE_UID';\nconst REBOOKED   = 'YOUR_REBOOKED_STAGE_UID';\nconst TERMINAL = new Set([APPROVED, LOST, WON, REBOOKED]);\nconst TIMEZONE = 'Australia/Sydney';\n\nconst now = new Date();\nconst rows = $input.all().map(r => r.json);\n\n// Business hours guard (Mon\u2013Sat 08:00\u201318:00 in TIMEZONE)\nconst local = new Date(now.toLocaleString('en-US', { timeZone: TIMEZONE }));\nconst hr = local.getHours(), dow = local.getDay();\nif (dow === 0 || hr < 8 || hr >= 18) return [];\n\nconst byUid = {};\nfor (const r of rows) {\n  if (!r.conversation_uid) continue;\n  const ts = r.timestamp ? new Date(r.timestamp).getTime() : 0;\n  const c = byUid[r.conversation_uid] = byUid[r.conversation_uid] || {\n    uid: r.conversation_uid, name: '', phone: '', email: '', channel_type: '',\n    quote_sent_at: 0, current_stage: null, last_stage_ts: 0, drip_count: 0, moved_off_quote: false\n  };\n  if (r.name) c.name = r.name;\n  if (r.channel_identifier) {\n    if ((r.channel_type || '').toLowerCase() === 'email') c.email = r.channel_identifier;\n    else c.phone = r.channel_identifier;\n    c.channel_type = r.channel_type || c.channel_type;\n  }\n  if (r.action === 'FUNNEL_STAGE_SET' && r.funnel_stage_uid && ts >= c.last_stage_ts) {\n    c.current_stage = r.funnel_stage_uid;\n    c.last_stage_ts = ts;\n    if (r.funnel_stage_uid === QUOTE_SENT) c.quote_sent_at = ts;\n    if (TERMINAL.has(r.funnel_stage_uid)) c.moved_off_quote = true;\n  }\n  if (r.action && r.action.startsWith('DRIP_SENT')) {\n    c.drip_count = Math.max(c.drip_count, Number(r.action.replace('DRIP_SENT_','')) || 1);\n  }\n}\n\nconst out = [];\nfor (const uid in byUid) {\n  const c = byUid[uid];\n  if (c.current_stage !== QUOTE_SENT) continue;\n  if (c.moved_off_quote) continue;\n  if (!c.phone && !c.email) continue;\n  if (!c.quote_sent_at) continue;\n  const hours = (now.getTime() - c.quote_sent_at) / 3600000;\n  let next = 0;\n  if (c.drip_count === 0 && hours >= 48)  next = 1;\n  if (c.drip_count === 1 && hours >= 120) next = 2;\n  if (c.drip_count === 2 && hours >= 240) next = 3;\n  if (!next) continue;\n  const firstName = (c.name || '').split(' ')[0] || 'there';\n  const bodies = {\n    1: `Hi ${firstName}, just checking in on the quote we sent through \u2014 happy to answer any questions.`,\n    2: `Hi ${firstName}, wanted to make sure the quote landed. Keen to help whenever suits.`,\n    3: `Hi ${firstName}, last check-in on the quote. If now's not the right time we'll close this out \u2014 just reply to keep it open.`\n  };\n  out.push({ json: {\n    conversation_uid: uid, name: c.name,\n    channel_identifier: c.phone || c.email,\n    channel_type: c.phone ? 'phone' : 'email',\n    drip_step: next, sms_body: bodies[next],\n    move_to_lost: next === 3\n  }});\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "657101a8-19c5-4b8d-8b17-7b081f3b23b4",
      "name": "Send Drip Message",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        5712,
        7552
      ],
      "parameters": {
        "url": "https://api.podium.com/v4/messages",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={\n  \"locationUid\": \"YOUR_PODIUM_LOCATION_UID\",\n  \"channel\": { \"type\": \"{{ $json.channel_type }}\", \"identifier\": \"{{ $json.channel_identifier }}\" },\n  \"body\": \"{{ $json.sms_body }}\"\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "dde70fe9-e4aa-4444-97b0-08d5a95ab01c",
      "name": "Log Drip",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5936,
        7552
      ],
      "parameters": {
        "columns": {
          "value": {
            "name": "={{ $('Filter Eligible').item.json.name }}",
            "action": "=DRIP_SENT_{{ $('Filter Eligible').item.json.drip_step }}",
            "timestamp": "={{ $now }}",
            "channel_type": "={{ $('Filter Eligible').item.json.channel_type }}",
            "opp_value_cents": "",
            "conversation_uid": "={{ $('Filter Eligible').item.json.conversation_uid }}",
            "funnel_stage_uid": "",
            "channel_identifier": "={{ $('Filter Eligible').item.json.channel_identifier }}"
          },
          "schema": [
            {
              "id": "timestamp",
              "type": "string",
              "required": false,
              "displayName": "timestamp",
              "canBeUsedToMatch": true
            },
            {
              "id": "conversation_uid",
              "type": "string",
              "required": false,
              "displayName": "conversation_uid",
              "canBeUsedToMatch": true
            },
            {
              "id": "name",
              "type": "string",
              "required": false,
              "displayName": "name",
              "canBeUsedToMatch": true
            },
            {
              "id": "channel_identifier",
              "type": "string",
              "required": false,
              "displayName": "channel_identifier",
              "canBeUsedToMatch": true
            },
            {
              "id": "channel_type",
              "type": "string",
              "required": false,
              "displayName": "channel_type",
              "canBeUsedToMatch": true
            },
            {
              "id": "funnel_stage_uid",
              "type": "string",
              "required": false,
              "displayName": "funnel_stage_uid",
              "canBeUsedToMatch": true
            },
            {
              "id": "opp_value_cents",
              "type": "string",
              "required": false,
              "displayName": "opp_value_cents",
              "canBeUsedToMatch": true
            },
            {
              "id": "action",
              "type": "string",
              "required": false,
              "displayName": "action",
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "5fb3cf3e-a75b-4297-9862-d45b1f2faef2",
      "name": "Final Drip?",
      "type": "n8n-nodes-base.if",
      "position": [
        6160,
        7552
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $('Filter Eligible').item.json.move_to_lost }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "815c5c7c-dd77-4789-89b2-0c84a0a22500",
      "name": "Move to Lost",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6368,
        7472
      ],
      "parameters": {
        "url": "https://api.podium.com/v4/contacts",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={\n  \"name\": \"{{ $('Filter Eligible').item.json.name || 'Contact' }}\",\n  \"locations\": [\"YOUR_PODIUM_LOCATION_UID\"],\n  \"{{ $('Filter Eligible').item.json.channel_type === 'phone' ? 'phoneNumber' : 'email' }}\": \"{{ $('Filter Eligible').item.json.channel_identifier }}\",\n  \"attributes\": [{ \"uid\": \"YOUR_FUNNEL_STAGE_ATTRIBUTE_UID\", \"value\": \"YOUR_LOST_STAGE_UID\" }]\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "31184c40-5712-4e92-8b59-e402657aa229",
      "name": "Section 5 background",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4608,
        7888
      ],
      "parameters": {
        "color": 2,
        "width": 1900,
        "height": 520,
        "content": "## SECTION 5 \u2014 Daily Admin Report\nEvery morning at 08:00, reads the event log and emails a digest: stuck leads >48h, quotes awaiting reply >24h, cars in workshop >24h, new today."
      },
      "typeVersion": 1
    },
    {
      "id": "f66a13b1-f85c-4db4-bf53-2dd3c2f42b88",
      "name": "Setup note S5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4640,
        7952
      ],
      "parameters": {
        "color": 5,
        "width": 380,
        "height": 220,
        "content": "### \ud83d\udfe2 Setup (Section 5)\n- Set `YOUR_RECIPIENT_EMAIL` in the Gmail node's Send To field.\n- Replace all stage UIDs and the `STAGE_LABEL` map in the Code node.\n- Connect a Gmail OAuth2 credential."
      },
      "typeVersion": 1
    },
    {
      "id": "f8cd67bf-7da8-44bd-8f16-58a286ffb467",
      "name": "Daily 08:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        5056,
        8112
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "c9a8bbcb-94d5-4460-9129-c85f064e824b",
      "name": "Read Event Log (Daily)",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5280,
        8112
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "581ed0a8-f4b1-4b5f-848f-7cf3a5c7ba21",
      "name": "Compute Daily Report",
      "type": "n8n-nodes-base.code",
      "position": [
        5488,
        8112
      ],
      "parameters": {
        "jsCode": "const QUOTE_SENT  = 'YOUR_QUOTE_SENT_STAGE_UID';\nconst IN_WORKSHOP = 'YOUR_IN_WORKSHOP_STAGE_UID';\nconst NEW_ENQ     = 'YOUR_NEW_ENQUIRY_STAGE_UID';\nconst CONTACTED   = 'YOUR_CONTACTED_STAGE_UID';\nconst BOOKED_IN   = 'YOUR_BOOKED_IN_STAGE_UID';\nconst APPROVED    = 'YOUR_APPROVED_STAGE_UID';\nconst STAGE_LABEL = { [NEW_ENQ]:'New Enquiry', [CONTACTED]:'Contacted', [BOOKED_IN]:'Booked In', [IN_WORKSHOP]:'In Workshop', [QUOTE_SENT]:'Quote Sent', [APPROVED]:'Approved' };\nconst ACTIVE = new Set([NEW_ENQ, CONTACTED, BOOKED_IN, IN_WORKSHOP, QUOTE_SENT, APPROVED]);\n\nconst now = new Date(), nowMs = now.getTime();\nconst rows = $input.all().map(r => r.json);\nconst byUid = {};\nfor (const r of rows) {\n  if (!r.conversation_uid) continue;\n  const ts = r.timestamp ? new Date(r.timestamp).getTime() : 0;\n  const c = byUid[r.conversation_uid] = byUid[r.conversation_uid] || { uid:r.conversation_uid, name:'', channel_identifier:'', current_stage:null, last_stage_ts:0, last_value_cents:0 };\n  if (r.name) c.name = r.name;\n  if (r.channel_identifier) c.channel_identifier = r.channel_identifier;\n  if (r.action === 'FUNNEL_STAGE_SET' && r.funnel_stage_uid && ts >= c.last_stage_ts) { c.current_stage = r.funnel_stage_uid; c.last_stage_ts = ts; }\n  if (r.action === 'OPP_VALUE_SET' && r.opp_value_cents) c.last_value_cents = Number(r.opp_value_cents) || 0;\n}\nconst contacts = Object.values(byUid);\nconst H48 = 48*3600*1000, H24 = 24*3600*1000;\nconst stuck = contacts.filter(c => ACTIVE.has(c.current_stage) && (nowMs - c.last_stage_ts) >= H48);\nconst quotesWaiting = contacts.filter(c => c.current_stage === QUOTE_SENT && (nowMs - c.last_stage_ts) >= H24);\nconst workshopLong = contacts.filter(c => c.current_stage === IN_WORKSHOP && (nowMs - c.last_stage_ts) >= H24);\nconst start = new Date(now); start.setHours(0,0,0,0);\nconst newToday = contacts.filter(c => c.last_stage_ts >= start.getTime());\nconst fmtAUD = n => '$' + Math.round((Number(n)||0)/100).toLocaleString('en-US');\nconst fmtHrs = ms => { const h = Math.floor(ms/3600000); return h<24?h+'h':Math.floor(h/24)+'d '+(h%24)+'h'; };\nconst row = c => `<tr><td>${c.name||c.uid}</td><td>${STAGE_LABEL[c.current_stage]||'-'}</td><td>${c.channel_identifier}</td><td>${fmtHrs(nowMs-c.last_stage_ts)}</td><td style=\\\"text-align:right\\\">${c.last_value_cents?fmtAUD(c.last_value_cents):'-'}</td></tr>`;\nconst tbl = list => list.length ? list.map(row).join('') : '<tr><td colspan=\\\"5\\\" style=\\\"text-align:center;color:#888;padding:14px\\\">Nothing to flag</td></tr>';\nconst pipeline = contacts.filter(c => ACTIVE.has(c.current_stage)).reduce((s,c)=>s+c.last_value_cents,0);\nconst date = now.toLocaleDateString('en-US', { weekday:'long', day:'numeric', month:'short', year:'numeric' });\nconst html = `<div style=\\\"font:14px -apple-system,Segoe UI,sans-serif;max-width:780px;margin:0 auto;color:#1f2328\\\"><h1 style=\\\"font-size:22px\\\">Daily Pipeline Report</h1><p style=\\\"color:#656d76\\\">${date}</p><p>Active pipeline value: <strong>${fmtAUD(pipeline)}</strong></p><h2 style=\\\"font-size:16px;border-bottom:2px solid #ffa657;padding-bottom:6px\\\">\ud83d\udfe0 Quotes awaiting reply &gt; 24h (${quotesWaiting.length})</h2><table style=\\\"width:100%;border-collapse:collapse;font-size:13px\\\"><thead><tr style=\\\"background:#f6f8fa\\\"><th style=\\\"text-align:left;padding:8px;border:1px solid #d0d7de\\\">Name</th><th style=\\\"text-align:left;padding:8px;border:1px solid #d0d7de\\\">Stage</th><th style=\\\"text-align:left;padding:8px;border:1px solid #d0d7de\\\">Channel</th><th style=\\\"text-align:left;padding:8px;border:1px solid #d0d7de\\\">In stage</th><th style=\\\"text-align:right;padding:8px;border:1px solid #d0d7de\\\">Value</th></tr></thead><tbody>${tbl(quotesWaiting)}</tbody></table><h2 style=\\\"font-size:16px;border-bottom:2px solid #d29922;padding-bottom:6px\\\">\ud83d\udd27 In Workshop &gt; 24h (${workshopLong.length})</h2><table style=\\\"width:100%;border-collapse:collapse;font-size:13px\\\"><tbody>${tbl(workshopLong)}</tbody></table><h2 style=\\\"font-size:16px;border-bottom:2px solid #cf222e;padding-bottom:6px\\\">\u26a0\ufe0f Stuck in stage &gt; 48h (${stuck.length})</h2><table style=\\\"width:100%;border-collapse:collapse;font-size:13px\\\"><tbody>${tbl(stuck)}</tbody></table><h2 style=\\\"font-size:16px;border-bottom:2px solid #3fb950;padding-bottom:6px\\\">\ud83c\udd95 Moved today (${newToday.length})</h2><table style=\\\"width:100%;border-collapse:collapse;font-size:13px\\\"><tbody>${tbl(newToday)}</tbody></table></div>`;\nreturn [{ json: { html, subject: `Daily Pipeline Report - ${date}` } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "a89e644b-fe44-49b2-88ef-d22ff6e8871e",
      "name": "Email Daily Report",
      "type": "n8n-nodes-base.gmail",
      "position": [
        5712,
        8112
      ],
      "parameters": {
        "sendTo": "YOUR_RECIPIENT_EMAIL",
        "message": "={{ $json.html }}",
        "options": {},
        "subject": "={{ $json.subject }}"
      },
      "typeVersion": 2.1
    },
    {
      "id": "725c81a1-f6e2-4e87-ae4e-599c7b4e4d10",
      "name": "Section 6 background",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4608,
        8448
      ],
      "parameters": {
        "color": 5,
        "width": 1900,
        "height": 520,
        "content": "## SECTION 6 \u2014 Live Kanban Dashboard\nBrowser-accessible URL that renders a live Podium-style Kanban from the Sheet event log. 10 funnel columns + metric tiles + recent activity feed. No live Podium calls \u2014 reads only from Sheet, so it's instant."
      },
      "typeVersion": 1
    },
    {
      "id": "c58e8ed3-9d38-49b9-83e0-4ab7fc79cc79",
      "name": "Setup note S6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4640,
        8512
      ],
      "parameters": {
        "color": 5,
        "width": 380,
        "height": 240,
        "content": "### \ud83d\udfe2 Setup (Section 6)\nAfter activating, copy this node's **Production URL** and open it in a browser to view the dashboard. Bookmark it. The dashboard refreshes on each page load.\n\nReplace stage UIDs in the Code node's `STAGE_LABEL` map."
      },
      "typeVersion": 1
    },
    {
      "id": "119f3c8a-fa39-4a53-8eaa-ffda52893ebd",
      "name": "Dashboard Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        5056,
        8672
      ],
      "parameters": {
        "path": "dashboard",
        "options": {},
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "fa40ce09-8b76-4f6f-b696-47714f4e6f32",
      "name": "Read Event Log (Dash)",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5280,
        8672
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "3c27d0ff-2f46-4de6-9d39-89088863655a",
      "name": "Build Dashboard HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        5488,
        8672
      ],
      "parameters": {
        "jsCode": "const STAGE_LABEL = {\n  'YOUR_NEW_ENQUIRY_STAGE_UID':'New Enquiry',\n  'YOUR_CONTACTED_STAGE_UID':'Contacted',\n  'YOUR_BOOKED_IN_STAGE_UID':'Booked In',\n  'YOUR_IN_WORKSHOP_STAGE_UID':'In Workshop',\n  'YOUR_QUOTE_SENT_STAGE_UID':'Quote Sent',\n  'YOUR_APPROVED_STAGE_UID':'Approved',\n  'YOUR_WON_STAGE_UID':'Won',\n  'YOUR_LOST_STAGE_UID':'Lost',\n  'YOUR_REBOOKED_STAGE_UID':'Rebooked',\n  'YOUR_UNQUALIFIED_STAGE_UID':'Unqualified'\n};\nconst STAGE_ORDER = Object.keys(STAGE_LABEL);\nconst rows = $input.all().map(r => r.json).filter(r => r.conversation_uid);\nconst now = Date.now();\nconst fmtAUD = n => '$' + Math.round((Number(n)||0)/100).toLocaleString('en-US');\nconst fmtDate = ts => { const d = (now - ts) / 60000; if (d < 60) return Math.floor(d)+'m'; if (d < 1440) return Math.floor(d/60)+'h'; return Math.floor(d/1440)+'d'; };\nconst hash = s => { let h = 0; for (let i=0;i<s.length;i++) h = (h*31+s.charCodeAt(i))|0; return Math.abs(h); };\nconst colors = ['#7c3aed','#0ea5e9','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4','#84cc16'];\n\n// Build cards per stage from latest stage-set events\nconst byUid = {};\nfor (const r of rows) {\n  const ts = r.timestamp ? new Date(r.timestamp).getTime() : 0;\n  const c = byUid[r.conversation_uid] = byUid[r.conversation_uid] || { uid:r.conversation_uid, name:r.name||'', channel:r.channel_identifier||'', value:0, stage:null, ts:0 };\n  if (r.name) c.name = r.name;\n  if (r.channel_identifier) c.channel = r.channel_identifier;\n  if (r.action === 'FUNNEL_STAGE_SET' && r.funnel_stage_uid && ts >= c.ts) { c.stage = r.funnel_stage_uid; c.ts = ts; }\n  if (r.action === 'OPP_VALUE_SET' && r.opp_value_cents) c.value = Number(r.opp_value_cents) || c.value;\n}\nconst contacts = Object.values(byUid).filter(c => c.stage);\nconst byStage = {};\nfor (const s of STAGE_ORDER) byStage[s] = [];\nfor (const c of contacts) if (byStage[c.stage]) byStage[c.stage].push(c);\nfor (const s in byStage) byStage[s].sort((a,b)=>b.ts-a.ts);\n\nconst card = c => {\n  const initials = (c.name||'?').split(' ').map(w=>w[0]).slice(0,2).join('').toUpperCase();\n  const color = colors[hash(c.name||c.uid) % colors.length];\n  return `<div style=\\\"background:white;border:1px solid #e5e7eb;border-radius:8px;padding:10px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.04)\\\"><div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:6px\\\"><div style=\\\"width:28px;height:28px;border-radius:50%;background:${color};color:white;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600\\\">${initials}</div><div style=\\\"flex:1;font-size:13px;font-weight:600;color:#111827\\\">${c.name||'Contact'}</div></div><div style=\\\"font-size:11px;color:#6b7280\\\">${c.channel||''}</div><div style=\\\"display:flex;justify-content:space-between;margin-top:6px;font-size:11px\\\"><span style=\\\"color:#059669;font-weight:600\\\">${c.value?fmtAUD(c.value):''}</span><span style=\\\"color:#9ca3af\\\">${fmtDate(c.ts)}</span></div></div>`;\n};\nconst col = s => `<div style=\\\"flex:0 0 240px;background:#f3f4f6;border-radius:10px;padding:10px;margin-right:10px\\\"><div style=\\\"font-size:12px;font-weight:700;text-transform:uppercase;color:#374151;margin-bottom:10px;display:flex;justify-content:space-between\\\"><span>${STAGE_LABEL[s]}</span><span style=\\\"background:white;border-radius:10px;padding:1px 8px;font-size:11px\\\">${byStage[s].length}</span></div>${byStage[s].map(card).join('')||'<div style=\\\"color:#9ca3af;font-size:12px;text-align:center;padding:20px 0\\\">Empty</div>'}</div>`;\n\nconst ACTIVE_UIDS = STAGE_ORDER.slice(0, 6);\nconst pipeline = contacts.filter(c => ACTIVE_UIDS.includes(c.stage)).reduce((s,c)=>s+c.value,0);\nconst won = contacts.filter(c => c.stage === 'YOUR_WON_STAGE_UID').reduce((s,c)=>s+c.value,0);\nconst rebooked = contacts.filter(c => c.stage === 'YOUR_REBOOKED_STAGE_UID').length;\nconst valued = contacts.filter(c => c.value > 0);\nconst avg = valued.length ? Math.round(valued.reduce((s,c)=>s+c.value,0)/valued.length) : 0;\n\nconst html = `<!DOCTYPE html><html><head><meta charset=\\\"utf-8\\\"><title>Pipeline Dashboard</title><style>body{margin:0;font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;color:#111827}</style></head><body><div style=\\\"max-width:1600px;margin:0 auto;padding:20px\\\"><h1 style=\\\"font-size:24px;margin:0 0 16px\\\">Pipeline Dashboard</h1><div style=\\\"display:flex;gap:12px;margin-bottom:24px;flex-wrap:wrap\\\"><div style=\\\"flex:1;min-width:200px;background:white;border-radius:10px;padding:16px;border:1px solid #e5e7eb\\\"><div style=\\\"font-size:11px;color:#6b7280;text-transform:uppercase;font-weight:600\\\">Active pipeline</div><div style=\\\"font-size:24px;font-weight:700;margin-top:4px\\\">${fmtAUD(pipeline)}</div></div><div style=\\\"flex:1;min-width:200px;background:white;border-radius:10px;padding:16px;border:1px solid #e5e7eb\\\"><div style=\\\"font-size:11px;color:#6b7280;text-transform:uppercase;font-weight:600\\\">Avg deal size</div><div style=\\\"font-size:24px;font-weight:700;margin-top:4px\\\">${fmtAUD(avg)}</div></div><div style=\\\"flex:1;min-width:200px;background:white;border-radius:10px;padding:16px;border:1px solid #e5e7eb\\\"><div style=\\\"font-size:11px;color:#6b7280;text-transform:uppercase;font-weight:600\\\">Won revenue</div><div style=\\\"font-size:24px;font-weight:700;margin-top:4px\\\">${fmtAUD(won)}</div></div><div style=\\\"flex:1;min-width:200px;background:white;border-radius:10px;padding:16px;border:1px solid #e5e7eb\\\"><div style=\\\"font-size:11px;color:#6b7280;text-transform:uppercase;font-weight:600\\\">Rebooked</div><div style=\\\"font-size:24px;font-weight:700;margin-top:4px\\\">${rebooked}</div></div></div><div style=\\\"display:flex;overflow-x:auto;padding-bottom:20px\\\">${STAGE_ORDER.map(col).join('')}</div></div></body></html>`;\nreturn [{ json: { html } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "321e30ff-0fd0-4c07-b0fd-fc4c0483836e",
      "name": "Respond with HTML",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        5712,
        8672
      ],
      "parameters": {
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        },
        "respondWith": "text",
        "responseBody": "={{ $json.html }}"
      },
      "typeVersion": 1.1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "7e1b8a42-7b15-4db3-b10b-1e0baa532dfb",
  "connections": {
    "Log Drip": {
      "main": [
        [
          {
            "node": "Final Drip?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every Hour": {
      "main": [
        [
          {
            "node": "Read Event Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Stage?": {
      "main": [
        [
          {
            "node": "Get Contact (Stage)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Value?": {
      "main": [
        [
          {
            "node": "Get Contact (Value)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily 08:00": {
      "main": [
        [
          {
            "node": "Read Event Log (Daily)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Final Drip?": {
      "main": [
        [
          {
            "node": "Move to Lost",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Stage": {
      "main": [
        [
          {
            "node": "Log Stage Change",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Value": {
      "main": [
        [
          {
            "node": "Log Value Change",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only If Higher": {
      "main": [
        [
          {
            "node": "Upsert Value",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Podium Webhook": {
      "main": [
        [
          {
            "node": "Extract Message Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Event Log": {
      "main": [
        [
          {
            "node": "Filter Eligible",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stage Changed?": {
      "main": [
        [
          {
            "node": "Upsert Stage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude Classify": {
      "main": [
        [
          {
            "node": "Parse Claude Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Eligible": {
      "main": [
        [
          {
            "node": "Send Drip Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dashboard Webhook": {
      "main": [
        [
          {
            "node": "Read Event Log (Dash)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Message Body?": {
      "main": [
        [
          {
            "node": "Claude Classify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Drip Message": {
      "main": [
        [
          {
            "node": "Log Drip",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Contact (Stage)": {
      "main": [
        [
          {
            "node": "Compare Current Stage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Contact (Value)": {
      "main": [
        [
          {
            "node": "Compare Current Value",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Claude Output": {
      "main": [
        [
          {
            "node": "Has Stage?",
            "type": "main",
            "index": 0
          },
          {
            "node": "Has Value?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Dashboard HTML": {
      "main": [
        [
          {
            "node": "Respond with HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute Daily Report": {
      "main": [
        [
          {
            "node": "Email Daily Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare Current Stage": {
      "main": [
        [
          {
            "node": "Stage Changed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare Current Value": {
      "main": [
        [
          {
            "node": "Only If Higher",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Event Log (Dash)": {
      "main": [
        [
          {
            "node": "Build Dashboard HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Event Log (Daily)": {
      "main": [
        [
          {
            "node": "Compute Daily Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Message Context": {
      "main": [
        [
          {
            "node": "Has Message Body?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This template turns Podium's conversation inbox into a full sales CRM with a custom funnel, AI message classification, automated drip follow-ups, daily admin reports, and a live Kanban dashboard. Six sub-flows live on one canvas — each clearly separated by a coloured sticky-note…

Source: https://n8n.io/workflows/15784/ — original creator credit. Request a take-down →

More Email & Gmail workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Email & Gmail

Automate WhatsApp communication for recruitment agencies with an interactive, structured customer experience. This workflow handles pricing inquiries, request submissions, tracking, complaints, and hu

HTTP Request, Google Sheets, Gmail +1
Email & Gmail

Ticketing Backend automates registration, QR-ticket generation, email delivery, and check-in validation using Google Sheets, Gmail, and a webhook scanner — reducing manual ticket prep from ~3 hours to

Google Sheets, HTTP Request, Gmail
Email & Gmail

Who is this for? This template is ideal for event organizers, conference managers, and community teams who need an automated participant management system. Perfect for workshops, conferences, meetups,

Google Sheets, HTTP Request, Gmail +1
Email & Gmail

Streamline and standardize your entire client onboarding process with a single end-to-end automation. 🚀📋 This workflow captures detailed client intake data via webhook, automatically creates a fully s

Slack, Asana, HTTP Request +4
Email & Gmail

Human Approval AI Response. Uses httpRequest, slack, gmail, googleSheets. Webhook trigger; 20 nodes.

HTTP Request, Slack, Gmail +2