{
  "id": "HkG9aYueINUTHZ05",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "HubSpot \u2192 Linear Customers Sync (via Snowflake)",
  "tags": [
    {
      "id": "17",
      "name": "snowflake",
      "createdAt": "2023-09-18T17:05:02.756Z",
      "updatedAt": "2023-09-18T17:05:02.756Z"
    },
    {
      "id": "6Ek7V8f4xbM9vWLj",
      "name": "linear",
      "createdAt": "2024-11-08T12:12:15.330Z",
      "updatedAt": "2024-11-08T12:12:15.330Z"
    }
  ],
  "nodes": [
    {
      "id": "522c2916-0b5c-4445-992a-aa50d34365c4",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2096,
        1184
      ],
      "parameters": {
        "width": 480,
        "height": 848,
        "content": "## HubSpot \u2192 Linear Customers Sync (via Snowflake)\n\n### How it works\n\n1. Scheduled trigger initiates data retrieval from HubSpot via Snowflake.\n2. Data is processed and paginated results are fetched.\n3. Final output is formatted and customers are matched.\n4. Based on the match, the workflow routes to either create or update customers in Linear.\n5. Slack update is sent with the results.\n\n### Setup steps\n\n- [ ] Configure Snowflake credentials for data retrieval.\n- [ ] Set up Linear API credentials for customer management.\n- [ ] Connect Slack account for updates.\n- [ ] Schedule the trigger according to desired frequency.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "6c668fcf-392b-44e5-82c1-c8c99eb931c4",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1536,
        1280
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 272,
        "content": "## Trigger and initial data fetch\n\nInitial trigger and data retrieval from HubSpot via Snowflake."
      },
      "typeVersion": 1
    },
    {
      "id": "f4f8a7ac-10ea-48fc-a447-9f49623a68a9",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -864,
        1200
      ],
      "parameters": {
        "color": 7,
        "width": 1104,
        "height": 352,
        "content": "## Pagination setup and iteration\n\nSet up pagination and iterate through pages of customer data."
      },
      "typeVersion": 1
    },
    {
      "id": "7b07d5a6-584e-4f32-a51d-579c4338aaf1",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        272,
        1328
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 336,
        "content": "## Data processing and formatting\n\nProcess fetched data and format the final output."
      },
      "typeVersion": 1
    },
    {
      "id": "f6c6d0e6-6345-4392-8676-5ab0f314de0b",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        544,
        1376
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Customer matching and routing\n\nMatch customers for actions and route to create or update functions."
      },
      "typeVersion": 1
    },
    {
      "id": "769cecd7-923f-4091-8c9d-de3faa8b6f71",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        992,
        1184
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 592,
        "content": "## Customer management in Linear\n\nCreate or update customers in Linear based on the routing decision."
      },
      "typeVersion": 1
    },
    {
      "id": "d99ddc31-d233-4bc8-9527-107ce1e11a1d",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        704,
        1712
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 320,
        "content": "## Notify via Slack\n\nSend a Slack update with the result of the customer management process."
      },
      "typeVersion": 1
    },
    {
      "id": "a53566ce-2e8f-4041-9f48-f1ca1ef6c843",
      "name": "Post Update to Linear",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1040,
        1616
      ],
      "parameters": {
        "url": "https://api.linear.app/graphql",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"query\": \"mutation CustomerUpdate($id: String!, $input: CustomerUpdateInput!) { customerUpdate(id: $id, input: $input) { success customer { id name revenue size status { id name } } } }\",\n  \"variables\": {\n    \"id\": \"{{ $json.linearId }}\",\n    \"input\": {\n      \"revenue\": {{ $json.snowflakeData.arr }},\n      \"size\": {{ $json.snowflakeData.seats }}\n    }\n  }\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "linearApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "99aed38a-fd51-4909-8c35-55e8bf13e9c0",
      "name": "Post Create to Linear",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1040,
        1376
      ],
      "parameters": {
        "url": "https://api.linear.app/graphql",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"query\": \"mutation CustomerCreate($input: CustomerCreateInput!) { customerCreate(input: $input) { success customer { id name domains externalIds revenue size status { id name } } } }\",\n  \"variables\": {\n    \"input\": {\n      \"name\": \"{{ $json.snowflakeData.name }}\",\n      \"domains\": [\"{{ $json.snowflakeData.domain }}\"],\n      \"externalIds\": [\"{{ $json.snowflakeData.companyId }}\"],\n      \"revenue\": {{ $json.snowflakeData.arr }},\n      \"size\": {{ $json.snowflakeData.seats }}\n    }\n  }\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "linearApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "2ba32a25-8b4d-4e17-929d-a06a9a9456ef",
      "name": "Route by Customer Action",
      "type": "n8n-nodes-base.switch",
      "position": [
        816,
        1504
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "create",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "is-create",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "create"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "update",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "is-update",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "update"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "a5dad935-c564-41c6-8e24-42ecaafdaf6b",
      "name": "Format Output Data",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        1504
      ],
      "parameters": {
        "jsCode": "// Format final output to match expected structure\nconst data = $input.all()[0].json;\nconst allCustomers = data.allCustomers;\n\nconsole.log(`\u2705 Pagination complete: ${allCustomers.length} total customers`);\n\nreturn [{\n  json: {\n    data: {\n      customers: {\n        nodes: allCustomers\n      }\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "c3b9602a-dc66-4c39-a30a-6242e76308fb",
      "name": "Set Next Page Parameters",
      "type": "n8n-nodes-base.set",
      "position": [
        96,
        1312
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "cursor-next",
              "name": "cursor",
              "type": "string",
              "value": "={{ $json.cursor }}"
            },
            {
              "id": "page-next",
              "name": "pageNumber",
              "type": "number",
              "value": "={{ $json.pageNumber }}"
            },
            {
              "id": "all-customers-next",
              "name": "allCustomers",
              "type": "array",
              "value": "={{ $json.allCustomers }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "91fe3a62-f8cb-4307-9070-84f5da8a8bf9",
      "name": "If More Pages Exist",
      "type": "n8n-nodes-base.if",
      "position": [
        -144,
        1312
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-next",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.hasNextPage }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "e67f6802-7ac9-41ac-bf83-ee37c80a338c",
      "name": "Process Customer Page",
      "type": "n8n-nodes-base.code",
      "position": [
        -368,
        1312
      ],
      "parameters": {
        "jsCode": "// Process page results and prepare for next iteration\nconst response = $input.all()[0].json;\nconst customers = response.data.customers.nodes;\nconst pageInfo = response.data.customers.pageInfo;\n\n// Get accumulated data \u2014 use Prepare Next Iteration if available (pages 2+),\n// otherwise fall back to Initialize Pagination (first page)\nlet previousData;\ntry {\n  previousData = $('Set Next Page Parameters').all()[0].json;\n} catch (e) {\n  previousData = $('Set Initial Pagination').all()[0].json;\n}\n\nconst allCustomers = [...(previousData.allCustomers || [])];\nconst pageNumber = (previousData.pageNumber || 0) + 1;\n\n// Add new customers to collection\nallCustomers.push(...customers);\n\nconsole.log(`\ud83d\udcc4 Page ${pageNumber}: Fetched ${customers.length} customers (Total: ${allCustomers.length})`);\n\nreturn [{\n  json: {\n    cursor: pageInfo.endCursor,\n    hasNextPage: pageInfo.hasNextPage,\n    pageNumber: pageNumber,\n    allCustomers: allCustomers,\n    currentPageCustomers: customers\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "f4c8955b-57fe-4473-b2e2-a225a0e1a453",
      "name": "Fetch Customer Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -592,
        1392
      ],
      "parameters": {
        "url": "https://api.linear.app/graphql",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"query\": \"query GetCustomers($after: String) { customers(first: 250, after: $after) { nodes { id name domains externalIds revenue size tier { id name } status { id name } owner { id name email } logoUrl createdAt updatedAt } pageInfo { hasNextPage endCursor } } }\",\n  \"variables\": {{ $json.cursor ? '{\"after\": \"' + $json.cursor + '\"}' : '{}' }}\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "linearApi"
      },
      "credentials": {
        "linearApi": {
          "name": "<your credential>"
        }
      },
      "executeOnce": false,
      "typeVersion": 4.2
    },
    {
      "id": "de50b0ee-62be-41bd-bda5-356b3741a408",
      "name": "Set Initial Pagination",
      "type": "n8n-nodes-base.set",
      "position": [
        -816,
        1392
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "cursor-init",
              "name": "cursor",
              "type": "string",
              "value": ""
            },
            {
              "id": "page-init",
              "name": "pageNumber",
              "type": "number",
              "value": "=0"
            },
            {
              "id": "all-customers-init",
              "name": "allCustomers",
              "type": "array",
              "value": "=[]"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "da68e303-94ba-407d-9d8d-0bf5f06b04cc",
      "name": "Limit HubSpot Records",
      "type": "n8n-nodes-base.limit",
      "disabled": true,
      "position": [
        -1040,
        1392
      ],
      "parameters": {
        "maxItems": 10
      },
      "typeVersion": 1
    },
    {
      "id": "e903a180-efdf-440b-b37c-9b63e94efe90",
      "name": "When Scheduled at 2 PM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1488,
        1392
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 14
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "8e56c48c-f9b0-4009-a96d-952a469162ff",
      "name": "Match and Update Customers",
      "type": "n8n-nodes-base.code",
      "position": [
        592,
        1504
      ],
      "parameters": {
        "jsCode": "// Customer matching: create and update only\n\n// Get Snowflake customers\nconst snowflakeCustomers = $('Limit HubSpot Records').all().map(item => ({\n  companyId: item.json.COMPANY_ID.toString(),\n  name: item.json.COMPANY_NAME,\n  domain: item.json.DOMAIN,\n  arr: Math.round(item.json.ARR || 0),\n  seats: item.json.SEATS || 0\n}));\n\n// Get Linear customers\nconst linearData = $('Format Output Data').all()[0].json.data.customers.nodes;\n\n// Build lookup maps\n// 1. Index ALL externalIds (not just [0]) \u2014 handles multi-ID customers\n//    and cases where Snowflake ID matches a non-primary externalId\nconst linearByExternalId = new Map();\nlinearData.forEach(c => {\n  (c.externalIds || []).forEach(id => {\n    if (!linearByExternalId.has(id)) linearByExternalId.set(id, c);\n  });\n});\n\n// 2. Domain-based fallback \u2014 handles cases where HubSpot re-keyed\n//    the company (new COMPANY_ID) but the domain is unchanged\nconst linearByDomain = new Map();\nlinearData.forEach(c => {\n  (c.domains || []).forEach(d => {\n    if (d && !linearByDomain.has(d)) linearByDomain.set(d, c);\n  });\n});\n\n// Resolve a Snowflake customer to its Linear counterpart\nfunction findLinearCustomer(sfCustomer) {\n  return linearByExternalId.get(sfCustomer.companyId)\n    || linearByDomain.get(sfCustomer.domain)\n    || null;\n}\n\nconst toCreate = [];\nconst toUpdate = [];\nconst matchLog = [];\n\nsnowflakeCustomers.forEach(sfCustomer => {\n  const linearCustomer = findLinearCustomer(sfCustomer);\n\n  if (!linearCustomer) {\n    toCreate.push({ action: 'create', snowflakeData: sfCustomer });\n    return;\n  }\n\n  const matchedVia = linearByExternalId.has(sfCustomer.companyId) ? 'externalId' : 'domain';\n  matchLog.push(`  \u2713 [${matchedVia}] ${sfCustomer.name}`);\n\n  const revenueChanged = linearCustomer.revenue !== sfCustomer.arr;\n  const sizeChanged = linearCustomer.size !== sfCustomer.seats;\n\n  if (revenueChanged || sizeChanged) {\n    toUpdate.push({\n      action: 'update',\n      linearId: linearCustomer.id,\n      linearData: linearCustomer,\n      snowflakeData: sfCustomer,\n      changes: { revenue: revenueChanged, size: sizeChanged }\n    });\n  }\n});\n\nconsole.log('\\n\ud83d\udcc8 Action Summary:');\nconsole.log('  \ud83c\udd95 Create: ' + toCreate.length);\nconsole.log('  \u270f\ufe0f Update: ' + toUpdate.length);\nconsole.log('  \ud83d\udcca Total: ' + (toCreate.length + toUpdate.length));\nif (matchLog.length) {\n  console.log('\\n\ud83d\udd17 Domain fallback matches:');\n  matchLog.filter(l => l.includes('domain')).forEach(l => console.log(l));\n}\n\nreturn [\n  ...toCreate.map(item => ({ json: item })),\n  ...toUpdate.map(item => ({ json: item }))\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "98ecdb91-fdbe-46b0-a6d9-57e9afe57dff",
      "name": "Post Slack Notification",
      "type": "n8n-nodes-base.slack",
      "onError": "continueRegularOutput",
      "position": [
        752,
        1872
      ],
      "parameters": {
        "text": "=Linear Customer Sync \u2014 Run Summary",
        "select": "channel",
        "blocksUi": "={\n  \"blocks\": [\n    {\n      \"type\": \"header\",\n      \"text\": { \"type\": \"plain_text\", \"text\": \"\ud83d\udd04 Linear Customer Sync\", \"emoji\": true }\n    },\n    {\n      \"type\": \"section\",\n      \"fields\": [\n        { \"type\": \"mrkdwn\", \"text\": \"*{{ $json.action.toTitleCase() }}*: `{{ $json.snowflakeData.name }}` (`${{ $json.snowflakeData.arr }}`, `{{ $json.snowflakeData.seats }} seats`)\" }\n      ]\n    }\n  ]\n}",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#your-slack-channel"
        },
        "messageType": "block",
        "otherOptions": {}
      },
      "executeOnce": false,
      "retryOnFail": false,
      "typeVersion": 2.1
    },
    {
      "id": "cb703602-d451-4ffd-8168-e968fb2f0a79",
      "name": "Retrieve HubSpot Data from Snowflake",
      "type": "n8n-nodes-base.snowflake",
      "position": [
        -1264,
        1392
      ],
      "parameters": {
        "query": "SELECT\n  comp.COMPANY_ID,\n  comp.NAME AS COMPANY_NAME,\n  comp.DOMAIN,\n  SUM(d.AMOUNT) AS ARR,\n  MAX(d.NUMBER_OF_SEATS) AS SEATS\nFROM\n  \"HUBSPOT\".\"COMPANIES\" comp\nJOIN\n  \"HUBSPOT\".\"ASSOCIATIONS_DEALS_TO_COMPANIES\" assoc\n  ON comp.COMPANY_ID = assoc.COMPANY_ID\nJOIN\n  \"HUBSPOT\".\"DEALS\" d\n  ON assoc.DEAL_ID = d.DEAL_ID\nWHERE\n  d.DEALSTAGE = 'closedwon'\nGROUP BY\n  comp.COMPANY_ID,\n  comp.NAME,\n  comp.DOMAIN\nORDER BY\n  ARR DESC",
        "operation": "executeQuery"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "callerPolicy": "workflowsFromSameOwner",
    "errorWorkflow": "WRas2GJ7w02TmUYO",
    "timeSavedMode": "fixed",
    "availableInMCP": false,
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true,
    "timeSavedPerExecution": 60,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "versionId": "371569d3-b69a-4bef-a213-e5d2fb9d110d",
  "connections": {
    "Format Output Data": {
      "main": [
        [
          {
            "node": "Match and Update Customers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Customer Data": {
      "main": [
        [
          {
            "node": "Process Customer Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If More Pages Exist": {
      "main": [
        [
          {
            "node": "Set Next Page Parameters",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format Output Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Limit HubSpot Records": {
      "main": [
        [
          {
            "node": "Set Initial Pagination",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Customer Page": {
      "main": [
        [
          {
            "node": "If More Pages Exist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Initial Pagination": {
      "main": [
        [
          {
            "node": "Fetch Customer Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Scheduled at 2 PM": {
      "main": [
        [
          {
            "node": "Retrieve HubSpot Data from Snowflake",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Customer Action": {
      "main": [
        [
          {
            "node": "Post Create to Linear",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post Update to Linear",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Next Page Parameters": {
      "main": [
        [
          {
            "node": "Fetch Customer Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match and Update Customers": {
      "main": [
        [
          {
            "node": "Route by Customer Action",
            "type": "main",
            "index": 0
          },
          {
            "node": "Post Slack Notification",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Retrieve HubSpot Data from Snowflake": {
      "main": [
        [
          {
            "node": "Limit HubSpot Records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}