{
  "name": "Spun helpdesk bridge: HubSpot Service Hub",
  "nodes": [
    {
      "parameters": {
        "content": "## Spun helpdesk bridge: HubSpot Service Hub\nWhatsApp conversations in Spun become HubSpot tickets, and every message is logged on the ticket as a WhatsApp communication. An agent answers by typing into the ticket property \"Spun reply\"; the text goes to the customer on WhatsApp.\n\nImport, then follow SETUP.md in this folder. In short:\n1. Credentials: \"Spun API key\" (Header Auth: Authorization = Bearer wap_...) and \"HubSpot key\" (Header Auth: Authorization = Bearer <service key or private app token>, scopes in SETUP.md). Select them on every SPUN: and HUBSPOT: node.\n2. Create the ticket properties spun_chat_id and spun_reply in HubSpot. Run the setup helper (bottom left) once to list your pipeline and stage ids.\n3. Settings node: replace the PASTE_... values (pipeline and stage ids, the app client secret, this workflow's reply URL) and set crm_sync_connected.\n4. Replace PASTE-A-LONG-RANDOM-SUFFIX in the \"Helpdesk reply\" path (letters, digits and dashes only; keep the hubspot-reply- prefix).\n5. Activate, add the HubSpot webhook subscriptions (SETUP.md), then the Spun webhook subscription pointing at \"Spun events\".\n\nHubSpot shapes come from ./API-NOTES.md (public docs, not yet live-tested). The Spun CRM contact sync owns contacts; this workflow owns tickets.",
        "height": 520,
        "width": 760,
        "color": 4
      },
      "id": "note-0115-0000-4000-8000-0000000000115",
      "name": "Note 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -660
      ]
    },
    {
      "parameters": {
        "content": "## 1. Receive and verify (Spun side)\nSpun POSTs the envelope {event_id, event, timestamp, org_id, data}. The Webhook node keeps the RAW body (Options > Raw Body) because the signature is an HMAC over the exact bytes.\nFlow: raw body to text > HMAC-SHA256 with the signing secret (Crypto node) > constant-time compare > answer 200 at once (or 401 on a bad signature). Everything after \"Ack 200\" runs after Spun already has its answer, so a slow desk API never causes a retry.",
        "height": 180,
        "width": 1040,
        "color": 5
      },
      "id": "note-0116-0000-4000-8000-0000000000116",
      "name": "Note 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -260
      ]
    },
    {
      "parameters": {
        "content": "## 2. Dedupe and route\nRetries reuse event_id; the same WhatsApp message can also arrive twice. Both are dropped here. Then one branch per event: message.inbound, message.outbound, conversation.closed, conversation.reopened. Any other event is acknowledged and ignored.",
        "height": 150,
        "width": 760,
        "color": 5
      },
      "id": "note-0117-0000-4000-8000-0000000000117",
      "name": "Note 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1040,
        120
      ]
    },
    {
      "parameters": {
        "content": "## 3. Contact (move 1)\nOwn map first (phone -> contact id). Then a phone search on the national number (HubSpot drops the country code when it indexes phones), which adopts a contact only on an exact digit match, oldest first. If none is found: with crm_sync_connected (the default) the ticket is made without a contact and linked on a later message, because the Spun CRM sync creates contacts; without the sync, the contact is created. A contact is never edited.",
        "height": 170,
        "width": 1040,
        "color": 6
      },
      "id": "note-0118-0000-4000-8000-0000000000118",
      "name": "Note 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1560,
        560
      ]
    },
    {
      "parameters": {
        "content": "## 4. Ticket and timeline (moves 2 and 3)\nOwn map (chat_id -> ticket) first. A known ticket is re-read once, so a ticket moved to a closed stage gets a NEW ticket. Fallback search by spun_chat_id in your pipeline, then create (first message = description, spun_chat_id set, linked to the contact). Every message, the first one too, is logged on the ticket as a WhatsApp communication, linked to the contact as well. Media arrives as a link (valid 24 h) or as \"still processing\".",
        "height": 170,
        "width": 1300,
        "color": 6
      },
      "id": "note-0119-0000-4000-8000-0000000000119",
      "name": "Note 5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2600,
        560
      ]
    },
    {
      "parameters": {
        "content": "## 5. Echoes, closes, reopens (Spun side)\nmessage.outbound: a reply this workflow sent is skipped (loop guard 2); anything else sent from the Spun inbox or the phone is logged on the ticket as a communication with the [Sent from Spun] prefix.\nconversation.closed / conversation.reopened: move the mapped ticket to the closed or open stage, unless the reason is the one this workflow itself sends (loop guard on the close path).",
        "height": 170,
        "width": 900,
        "color": 6
      },
      "id": "note-0120-0000-4000-8000-0000000000120",
      "name": "Note 6",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1560,
        1440
      ]
    },
    {
      "parameters": {
        "content": "## 6. Agent replies (HubSpot side)\nA HubSpot app webhook subscription (ticket property change on spun_reply, and optionally on the pipeline stage) calls this webhook with a batch of events. The node answers 200 at once (HubSpot waits 5 s and retries for up to 24 h). The adapter checks the v3 signature (app client secret, method + URL + body + timestamp, at most 5 minutes old) or the v1 signature (SHA-256 of client secret + body), drops everything while the secret is still a placeholder, and splits the batch into one item per event.\nA non-empty spun_reply is the reply text. The generic filter drops anything forwarded before, POST /send-message uses hs:<portal>:<ticket>:<time> as idempotency_key, then the property is cleared (that change arrives empty and is ignored) and the sent text is logged on the ticket. A failed send leaves a note on the ticket instead.",
        "height": 240,
        "width": 1560,
        "color": 3
      },
      "id": "note-0121-0000-4000-8000-0000000000121",
      "name": "Note 7",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        1760
      ]
    },
    {
      "parameters": {
        "content": "## Nodes marked HUBSPOT: are the only ones that talk to the helpdesk\nThey (and the small adapter Code node right after each) are what differs from the generic template. Nodes marked SPUN: call the Spun Actions API. Nodes marked State: read or write the workflow's own maps. Everything else is desk-agnostic. The setup helper at the bottom left only runs when you click it.",
        "height": 130,
        "width": 760,
        "color": 7
      },
      "id": "note-0122-0000-4000-8000-0000000000122",
      "name": "Note 8",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -120
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "spun-events",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "node-0124-0000-4000-8000-0000000000124",
      "name": "Spun events",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "wh-0123-0000-4000-8000-0000000000123"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "hubspot-reply-PASTE-A-LONG-RANDOM-SUFFIX",
        "responseMode": "onReceived",
        "options": {
          "rawBody": true
        }
      },
      "id": "node-0126-0000-4000-8000-0000000000126",
      "name": "Helpdesk reply",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        2200
      ],
      "webhookId": "wh-0300-0000-4000-8000-0000000000300"
    },
    {
      "parameters": {
        "jsCode": "// SETTINGS - the only node you edit after import (besides credentials).\n// Every value below is a placeholder. Replace the PASTE_... values; keep the rest unless\n// SETUP.md says otherwise. The setup helper (bottom left) lists the pipeline and stage ids.\n// The optional priority is left out of the ticket while it still starts with PASTE.\n// crm_sync_connected: true when the Spun CRM contact sync is connected to this HubSpot account\n// (keep true if unsure). Then this workflow never creates contacts; the sync does.\nconst settings = {\n  \"spun_api_base\": \"https://api.spun.com/api/integrations\",\n  \"spun_webhook_secret\": \"PASTE_THE_SIGNING_SECRET_OF_YOUR_SPUN_WEBHOOK_SUBSCRIPTION\",\n  \"hubspot_base\": \"https://api.hubapi.com\",\n  \"hubspot_api_version\": \"2026-09\",\n  \"hubspot_app_client_secret\": \"PASTE_THE_CLIENT_SECRET_OF_THE_HUBSPOT_APP_THAT_SENDS_THE_WEBHOOKS\",\n  \"hubspot_webhook_url\": \"PASTE_THE_EXACT_PRODUCTION_URL_OF_THE_HELPDESK_REPLY_NODE\",\n  \"hubspot_pipeline_id\": \"PASTE_THE_TICKET_PIPELINE_ID\",\n  \"hubspot_open_stage_id\": \"PASTE_THE_STAGE_ID_FOR_NEW_AND_REOPENED_TICKETS\",\n  \"hubspot_closed_stage_id\": \"PASTE_THE_STAGE_ID_TO_USE_WHEN_SPUN_CLOSES_A_CHAT\",\n  \"hubspot_closed_stage_ids\": [\"PASTE_EVERY_CLOSED_STAGE_ID_OF_THE_PIPELINE\"],\n  \"hubspot_ticket_priority\": \"PASTE_OPTIONAL_PRIORITY_LOW_MEDIUM_OR_HIGH\",\n  \"crm_sync_connected\": true,\n  // Optional \"Spun LID\" properties (single-line text, internal name spun_lid) on contacts and on\n  // tickets: the privacy id of a chat whose phone WhatsApp hides (SETUP.md sections 3 and 5).\n  // Left out while they still start with PASTE.\n  \"hubspot_contact_lid_property\": \"PASTE_OPTIONAL_spun_lid_ONCE_THE_CONTACT_PROPERTY_EXISTS\",\n  \"hubspot_ticket_lid_property\": \"PASTE_OPTIONAL_spun_lid_ONCE_THE_TICKET_PROPERTY_EXISTS\",\n  \"desk_integration_user_id\": \"\",\n  // Groups and newsletters are always skipped (no single person to make a contact for).\n  \"skip_groups\": true,\n  \"inbound_prefix\": \"[WhatsApp]\",\n  \"outbound_prefix\": \"[Sent from Spun]\",\n  \"reply_log_prefix\": \"[Sent from HubSpot]\",\n  \"desk_close_reason\": \"closed_in_hubspot\",\n  \"desk_reopen_reason\": \"reopened_in_hubspot\",\n  \"close_in_spun_when_desk_closes\": false,\n  \"dedupe_hours\": 24\n};\n// Nothing to edit below this line.\n// Every stage whose ticketState is CLOSED counts as closed; the Spun close stage always does.\nsettings.hubspot_closed_stage_ids = Array.from(new Set((Array.isArray(settings.hubspot_closed_stage_ids) ? settings.hubspot_closed_stage_ids : [])\n  .concat([settings.hubspot_closed_stage_id]).map(String).filter((v) => v !== '' && !v.startsWith('PASTE'))));\nreturn $input.all().map((item) => ({\n  json: { ...item.json, settings },\n  binary: item.binary,\n  pairedItem: item.pairedItem,\n}));"
      },
      "id": "node-0127-0000-4000-8000-0000000000127",
      "name": "Settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        1100
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0131-0000-4000-8000-0000000000131",
                    "leftValue": "={{ String($json.webhookUrl || '').split('/').pop() }}",
                    "rightValue": "spun-events",
                    "operator": {
                      "type": "string",
                      "operation": "startsWith"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "spun"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0132-0000-4000-8000-0000000000132",
                    "leftValue": "={{ String($json.webhookUrl || '').split('/').pop() }}",
                    "rightValue": "hubspot-reply-",
                    "operator": {
                      "type": "string",
                      "operation": "startsWith"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "desk"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0130-0000-4000-8000-0000000000130",
      "name": "Route by trigger",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        520,
        1100
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {}
      },
      "id": "node-0133-0000-4000-8000-0000000000133",
      "name": "Raw body to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        780,
        0
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.rawBody }}",
        "dataPropertyName": "computedHmac",
        "secret": "={{ $json.settings.spun_webhook_secret }}",
        "encoding": "hex"
      },
      "id": "node-0134-0000-4000-8000-0000000000134",
      "name": "HMAC of raw body",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        1040,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Compare the Spun signature with the HMAC computed by the Crypto node, then parse the body.\n// X-Webhook-Signature is \"sha256=<lowercase hex HMAC-SHA256 of the exact raw body>\".\nconst j = $input.item.json;\nconst headers = j.headers || {};\nconst header = String(headers['x-webhook-signature'] || '');\nconst expected = 'sha256=' + String(j.computedHmac || '');\nlet ok = header.length === expected.length && header.length > 7;\nif (ok) {\n  // constant-time compare without a crypto import (the Code node has no require by default)\n  let diff = 0;\n  for (let i = 0; i < expected.length; i++) diff |= header.charCodeAt(i) ^ expected.charCodeAt(i);\n  ok = diff === 0;\n}\nlet envelope = {};\ntry { envelope = JSON.parse(j.rawBody || '{}'); } catch (e) { ok = false; }\nreturn {\n  json: {\n    signature_ok: ok,\n    event_id: envelope.event_id || headers['x-webhook-delivery'] || null,\n    event: envelope.event || headers['x-webhook-event'] || null,\n    timestamp: envelope.timestamp || null,\n    org_id: envelope.org_id || null,\n    data: envelope.data || {},\n    settings: j.settings,\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0135-0000-4000-8000-0000000000135",
      "name": "Check signature + parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0136-0000-4000-8000-0000000000136",
              "leftValue": "={{ $json.signature_ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0137-0000-4000-8000-0000000000137",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, event_id: $json.event_id }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "node-0138-0000-4000-8000-0000000000138",
      "name": "Ack 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1820,
        -60
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: 'invalid_signature' }) }}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "node-0139-0000-4000-8000-0000000000139",
      "name": "Reject 401",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1820,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "// Dedupe on event_id (Spun retries reuse it) and, for message events, on data.message_id.\n// State lives in n8n workflow static data: it persists only for PRODUCTION executions of an\n// active workflow and is saved when the execution ends. Swap for a Data Table, Redis or a\n// database when you need durability or run several n8n workers (see README \"State\").\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.seenEvents ??= {};\nstate.seenMessages ??= {};\nconst now = Date.now();\nconst ttl = (Number(j.settings.dedupe_hours) || 24) * 3600 * 1000;\nfor (const key of Object.keys(state.seenEvents)) if (now - state.seenEvents[key] > ttl) delete state.seenEvents[key];\nfor (const key of Object.keys(state.seenMessages)) if (now - state.seenMessages[key] > ttl) delete state.seenMessages[key];\nlet duplicate = false;\nif (j.event_id) {\n  if (state.seenEvents[j.event_id]) duplicate = true;\n  state.seenEvents[j.event_id] = now;\n}\nconst messageId = j.data && j.data.message_id;\nif (!duplicate && messageId && String(j.event || '').startsWith('message.')) {\n  if (state.seenMessages[messageId]) duplicate = true;\n  state.seenMessages[messageId] = now;\n}\nreturn { json: { ...j, duplicate } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0140-0000-4000-8000-0000000000140",
      "name": "State: seen before?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        -60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0141-0000-4000-8000-0000000000141",
              "leftValue": "={{ !$json.duplicate }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0142-0000-4000-8000-0000000000142",
      "name": "New delivery?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2340,
        -60
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0143-0000-4000-8000-0000000000143",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "message.inbound",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "inbound"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0144-0000-4000-8000-0000000000144",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "message.outbound",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "outbound"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0145-0000-4000-8000-0000000000145",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "conversation.closed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "closed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0146-0000-4000-8000-0000000000146",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "conversation.reopened",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reopened"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0147-0000-4000-8000-0000000000147",
      "name": "Route by event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        2600,
        -60
      ]
    },
    {
      "parameters": {},
      "id": "node-0148-0000-4000-8000-0000000000148",
      "name": "Duplicate, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2600,
        220
      ]
    },
    {
      "parameters": {},
      "id": "node-0149-0000-4000-8000-0000000000149",
      "name": "Other event, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        280
      ]
    },
    {
      "parameters": {
        "jsCode": "// Shape the Spun message.inbound payload for the helpdesk.\n// Fields used (all on data, added by Spun on 10/2026): is_group, peer_phone_e164, peer_lid,\n// identity_state, contact_id, contact_name, contact_email, caption, media_url (signed link,\n// valid 24 h), media_mime, media_filename, media_size, media_pending.\n// Privacy-ID (LID) chats: WhatsApp can hide a customer's number behind a privacy id\n// (\"<digits>@lid\"). Spun then sends peer_lid (the digits) and fills peer_phone_e164 only once it\n// knows the real number (identity_state lid_linked). Until then (lid_unlinked) the chat still\n// becomes a ticket; the contact is keyed by the privacy id instead of a phone.\nconst j = $input.item.json;\nconst d = j.data || {};\nconst s = j.settings;\nconst chatId = String(d.chat_id || '');\nconst phone = d.peer_phone_e164 || null;\nconst lidMatch = /^(\\d+)@lid$/.exec(chatId);\nconst lid = d.peer_lid || (lidMatch ? lidMatch[1] : null);\nconst identity_state = d.identity_state || (lid ? (phone ? 'lid_linked' : 'lid_unlinked') : 'phone');\nlet handle = true;\nlet skip_reason = null;\n// \"Skipped\" is reserved for chats with no single person behind them: groups and newsletters.\nif (d.is_group || /@(g\\.us|newsletter|broadcast)$/.test(chatId) || (!phone && !lid)) {\n  handle = false; skip_reason = 'group or newsletter chat (no single person to make a contact for)';\n}\nconst who = phone || 'privacy ID ' + lid;\nconst name = d.contact_name || d.sender_name || d.chat_name || who;\nconst type = d.type || 'text';\nconst lines = [];\nif (d.text) lines.push(String(d.text));\nif (type !== 'text') {\n  if (d.media_pending) {\n    lines.push('[' + type + '] Media is still being processed in Spun. Open the conversation in Spun to view it.');\n  } else if (d.media_url) {\n    const meta = [d.media_filename, d.media_mime, d.media_size ? Math.round(Number(d.media_size) / 1024) + ' KB' : null].filter(Boolean).join(', ');\n    lines.push('[' + type + '] ' + d.media_url + (meta ? ' (' + meta + ')' : '') + ' - link valid for 24 hours');\n  } else {\n    lines.push('[' + type + '] Open the conversation in Spun to view it.');\n  }\n  if (d.caption) lines.push(String(d.caption));\n}\nconst text = lines.join('\\n') || '[' + type + ']';\nconst whenIso = d.timestamp ? new Date(Number(d.timestamp) * 1000).toISOString() : (j.timestamp || new Date().toISOString());\nconst firstLine = (d.text || d.caption || '[' + type + ']').split('\\n')[0];\nreturn {\n  json: {\n    settings: s,\n    event_id: j.event_id,\n    event: j.event,\n    org_id: j.org_id,\n    handle,\n    skip_reason,\n    chat_id: d.chat_id,\n    message_id: d.message_id || null,\n    phone,\n    lid,\n    identity_state,\n    name,\n    email: d.contact_email || null,\n    spun_contact_id: d.contact_id || null,\n    line_id: d.integration_id || null,\n    type,\n    text,\n    comment_text: s.inbound_prefix + ' ' + name + ' (' + who + '): ' + text,\n    subject: ('WhatsApp: ' + firstLine).slice(0, 80),\n    when: whenIso,\n    media_url: d.media_url || null,\n    media_pending: !!d.media_pending,\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0150-0000-4000-8000-0000000000150",
      "name": "Prepare message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0151-0000-4000-8000-0000000000151",
              "leftValue": "={{ $json.handle }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0152-0000-4000-8000-0000000000152",
      "name": "Handle this message?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        660
      ]
    },
    {
      "parameters": {},
      "id": "node-0153-0000-4000-8000-0000000000153",
      "name": "Skipped (group or newsletter)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Read the template's own maps: phone -> contact id, chat_id -> ticket. Desk search is only the\n// fallback (search indexes lag by minutes on most desks).\n// A privacy-ID (LID) chat with no known phone is keyed \"lid:<digits>\"; when Spun later reports the\n// same chat with a phone (identity_state lid_linked), the same contact keeps being used.\n// contact_lane picks the search when the maps miss: phone, the Spun LID contact property, or\n// none (a privacy-ID chat while hubspot_contact_lid_property is still a placeholder: straight\n// to the create decision).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.tickets ??= {};\nconst lidKey = j.lid ? 'lid:' + j.lid : null;\nconst byPhone = j.phone ? state.contacts[j.phone] || null : null;\nconst byLid = lidKey ? state.contacts[lidKey] || null : null;\nconst contact_id = byPhone || byLid;\nconst contact_lane = j.phone ? 'phone' : ((p) => p !== '' && !p.startsWith('PASTE'))(String(j.settings.hubspot_contact_lid_property || '')) ? 'lid' : 'none';\nconst ticket = state.tickets[j.chat_id] || null;\nconst ticket_known = !!(ticket && ticket.ticket_id && ticket.status !== 'closed');\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, contact_lane, ticket_id: ticket_known ? ticket.ticket_id : null, ticket_known } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0154-0000-4000-8000-0000000000154",
      "name": "State: lookup contact + ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1820,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0155-0000-4000-8000-0000000000155",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0156-0000-4000-8000-0000000000156",
      "name": "Contact known?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2080,
        660
      ]
    },
    {
      "parameters": {
        "jsCode": "// HubSpot indexes phone numbers without the country code, so the search uses the national\n// number (API-NOTES 2.1). E.164 calling codes are prefix-free: 1 and 7 are one digit, the list\n// below is every two-digit code, everything else is three digits.\nconst j = $input.item.json;\nconst TWO = ['20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '41', '43', '44', '45', '46', '47', '48', '49',\n  '51', '52', '53', '54', '55', '56', '57', '58', '60', '61', '62', '63', '64', '65', '66', '81', '82', '84', '86',\n  '90', '91', '92', '93', '94', '95', '98'];\nconst digits = String(j.phone || '').replace(/\\D/g, '');\nconst ccLen = digits[0] === '1' || digits[0] === '7' ? 1 : (TWO.includes(digits.slice(0, 2)) ? 2 : 3);\nconst national = digits.slice(ccLen).replace(/^0+/, '');\nreturn { json: { ...j, phone_digits: digits, phone_national: national, phone_tail: national.slice(-10) } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0301-0000-4000-8000-0000000000301",
      "name": "Build phone search",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        880
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/contacts/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filterGroups: [ { filters: [ { propertyName: 'phone', operator: 'EQ', value: $json.phone_national } ] }, { filters: [ { propertyName: 'mobilephone', operator: 'EQ', value: $json.phone_national } ] }, { filters: [ { propertyName: 'phone', operator: 'CONTAINS_TOKEN', value: $json.phone_tail } ] } ], properties: ['phone', 'mobilephone', 'firstname', 'lastname', 'createdate'], sorts: [ { propertyName: 'createdate', direction: 'ASCENDING' } ], limit: 10 }) }}"
      },
      "id": "node-0302-0000-4000-8000-0000000000302",
      "name": "HUBSPOT: search contact by phone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        880
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Adopt a contact the account already has. The search is broad (token match), so accept a hit\n// only when its phone or mobile phone is exactly this number, with or without the country code\n// and leading zeros; oldest contact first. An adopted contact is never edited.\nconst r = $input.item.json;\nconst j = $('Build phone search').item.json;\nconst norm = (v) => String(v || '').replace(/\\D/g, '').replace(/^0+/, '');\nconst same = (v) => { const d = norm(v); return d !== '' && (d === j.phone_national || d === j.phone_digits); };\nconst list = (r && Array.isArray(r.results)) ? r.results : [];\nconst hit = j.phone_national.length < 6 ? null : list\n  .filter((c) => c.properties && (same(c.properties.phone) || same(c.properties.mobilephone)))\n  .sort((a, b) => (Date.parse(a.properties.createdate || '') || 0) - (Date.parse(b.properties.createdate || '') || 0) || Number(a.id) - Number(b.id))[0];\nconst contact_id = hit ? String(hit.id) : null;\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'phone', search_error: r && r.status === 'error' ? r.message || 'search failed' : null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0303-0000-4000-8000-0000000000303",
      "name": "Pick matching contact (phone)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0304-0000-4000-8000-0000000000304",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0305-0000-4000-8000-0000000000305",
      "name": "Contact found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3120,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0306-0000-4000-8000-0000000000306",
              "leftValue": "={{ !$json.settings.crm_sync_connected }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0307-0000-4000-8000-0000000000307",
      "name": "Create contact?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3380,
        1100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/contacts",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $json; const p = {}; if (j.phone) p.phone = j.phone; const lp = String(j.settings.hubspot_contact_lid_property || ''); if (j.lid && lp && !lp.startsWith('PASTE')) p[lp] = String(j.lid); if (j.name && j.name !== j.phone && j.name !== 'WhatsApp customer') p.firstname = String(j.name).slice(0, 100); if (j.email) p.email = j.email; return JSON.stringify({ properties: p }); })() }}"
      },
      "id": "node-0308-0000-4000-8000-0000000000308",
      "name": "HUBSPOT: create contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        1100
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// HubSpot answers 201 with the contact. When Spun sent an email that a contact already has,\n// HubSpot answers 409 \"Contact already exists. Existing ID: <id>\"; that contact is adopted as is.\nconst r = $input.item.json;\nconst j = $('Create contact?').item.json;\nlet contact_id = r && r.id ? String(r.id) : null;\nif (!contact_id && r && r.message) {\n  const m = String(r.message).match(/Existing ID:\\s*(\\d+)/i);\n  if (m) contact_id = m[1];\n}\nif (!contact_id) throw new Error('HubSpot contact create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, contact_id, contact_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0309-0000-4000-8000-0000000000309",
      "name": "Read new contact id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        1100
      ]
    },
    {
      "parameters": {
        "jsCode": "// crm_sync_connected: the Spun CRM sync creates the contact (it polls every 30 s). The ticket is\n// made without a contact now and linked on a later message, so the two never create the same\n// person twice.\nreturn { json: { ...$input.item.json, contact_id: null, contact_known: false } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0310-0000-4000-8000-0000000000310",
      "name": "No contact yet (CRM sync owns contacts)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        980
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the map entries: phone -> contact id, and \"lid:<digits>\" -> contact id for a privacy-ID\n// chat, so a later message (with or without a phone) finds the same contact.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nif (j.phone && j.contact_id) state.contacts[j.phone] = j.contact_id;\nif (j.lid && j.contact_id) state.contacts['lid:' + j.lid] = j.contact_id;\nreturn { json: { ...j, contact_known: !!j.contact_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0167-0000-4000-8000-0000000000167",
      "name": "State: remember contact",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0168-0000-4000-8000-0000000000168",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0169-0000-4000-8000-0000000000169",
      "name": "Open ticket known?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2340,
        440
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/tickets/{{ $json.ticket_id }}?properties=hs_pipeline,hs_pipeline_stage,spun_chat_id",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0311-0000-4000-8000-0000000000311",
      "name": "HUBSPOT: read ticket status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        440
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// HubSpot has no closed flag: a ticket is closed when its stage is one of the CLOSED stages in\n// Settings. A message after that starts a NEW ticket. A 404 (deleted ticket) counts as not open.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst p = r && r.id && r.properties ? r.properties : null;\nconst stage = p ? String(p.hs_pipeline_stage || '') : '';\nconst still_open = !!p && !j.settings.hubspot_closed_stage_ids.includes(stage);\nreturn { json: { ...j, still_open, ticket_stage: p ? stage : null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0312-0000-4000-8000-0000000000312",
      "name": "Ticket still open?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        440
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0172-0000-4000-8000-0000000000172",
              "leftValue": "={{ $json.still_open }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0173-0000-4000-8000-0000000000173",
      "name": "Still open?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3120,
        440
      ]
    },
    {
      "parameters": {
        "jsCode": "// Mark the mapped ticket \"closed\" without touching the desk.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, ticket_known: false, ticket_id: null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0174-0000-4000-8000-0000000000174",
      "name": "State: mark ticket closed (desk closed it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        660
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/tickets/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filterGroups: [ { filters: [ { propertyName: 'spun_chat_id', operator: 'EQ', value: String($json.chat_id) }, { propertyName: 'hs_pipeline', operator: 'EQ', value: String($json.settings.hubspot_pipeline_id) }, { propertyName: 'hs_pipeline_stage', operator: 'NOT_IN', values: $json.settings.hubspot_closed_stage_ids } ] } ], properties: ['subject', 'hs_pipeline_stage', 'spun_chat_id'], sorts: [ { propertyName: 'createdate', direction: 'DESCENDING' } ], limit: 5 }) }}"
      },
      "id": "node-0313-0000-4000-8000-0000000000313",
      "name": "HUBSPOT: find open ticket by chat id",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        660
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// HubSpot fallback: search on spun_chat_id in the pipeline, open stages only, newest first. The\n// search index lags by a few moments, which is why the workflow keeps its own map.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst closed = j.settings.hubspot_closed_stage_ids;\nconst list = (r && Array.isArray(r.results)) ? r.results : [];\nconst open = list.find((t) => t.properties && String(t.properties.spun_chat_id || '') === String(j.chat_id) && !closed.includes(String(t.properties.hs_pipeline_stage || ''))) || null;\nconst ticket_id = open ? String(open.id) : null;\nreturn { json: { ...j, ticket_id, ticket_known: !!ticket_id, ticket_stage: open ? String(open.properties.hs_pipeline_stage) : null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0314-0000-4000-8000-0000000000314",
      "name": "Pick open ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0315-0000-4000-8000-0000000000315",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0316-0000-4000-8000-0000000000316",
      "name": "Open ticket found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4160,
        660
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the chat_id -> ticket map entry (status \"open\"). A found ticket may not be linked to the\n// contact yet, so contact_id stays empty and \"Prepare log\" links it.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nif (j.chat_id && j.ticket_id) {\n  state.tickets[j.chat_id] = { ticket_id: String(j.ticket_id), status: 'open', updated_at: Date.now(), contact_id: null };\n}\nreturn { json: { ...j, ticket_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0317-0000-4000-8000-0000000000317",
      "name": "State: remember found ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        440
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Open ticket known?').item.json.settings.hubspot_base }}/crm/objects/{{ $('Open ticket known?').item.json.settings.hubspot_api_version }}/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $('Open ticket known?').item.json; const s = j.settings; const set = (v) => v !== undefined && v !== null && String(v) !== '' && !String(v).startsWith('PASTE'); const p = { subject: String(j.subject).slice(0, 80), content: String(j.text).slice(0, 65000), hs_pipeline: String(s.hubspot_pipeline_id), hs_pipeline_stage: String(s.hubspot_open_stage_id), spun_chat_id: String(j.chat_id) }; if (set(s.hubspot_ticket_priority)) p.hs_ticket_priority = String(s.hubspot_ticket_priority); if (set(s.hubspot_ticket_lid_property) && j.lid) p[s.hubspot_ticket_lid_property] = String(j.lid); const a = j.contact_id ? [ { to: { id: String(j.contact_id) }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 16 }] } ] : []; return JSON.stringify({ properties: p, associations: a }); })() }}"
      },
      "id": "node-0318-0000-4000-8000-0000000000318",
      "name": "HUBSPOT: create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4420,
        880
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// HubSpot answers 201 with the ticket ({id, properties, ...}).\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst ticket_id = r && r.id ? String(r.id) : null;\nif (!ticket_id) throw new Error('HubSpot ticket create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0319-0000-4000-8000-0000000000319",
      "name": "Read new ticket id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4680,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the chat_id -> ticket map entry (status \"open\"). The ticket was created already linked\n// to the contact when the contact was known.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nif (j.chat_id && j.ticket_id) {\n  state.tickets[j.chat_id] = { ticket_id: String(j.ticket_id), status: 'open', updated_at: Date.now(), contact_id: j.contact_id ? String(j.contact_id) : null };\n}\nreturn { json: { ...j, ticket_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0320-0000-4000-8000-0000000000320",
      "name": "State: remember new ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4940,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Every inbound message, the first one too, is logged on the ticket as a WhatsApp communication.\n// A ticket not linked to the contact yet (made before the CRM sync pushed the contact, or found\n// by search) is linked first.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id] || null;\nconst need_link = !!(j.contact_id && t && String(t.contact_id || '') !== String(j.contact_id));\nreturn { json: { ...j, need_link } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0321-0000-4000-8000-0000000000321",
      "name": "Prepare log",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5200,
        440
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0322-0000-4000-8000-0000000000322",
              "leftValue": "={{ $json.need_link }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0323-0000-4000-8000-0000000000323",
      "name": "Link ticket to contact?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        5460,
        440
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/tickets/{{ $json.ticket_id }}/associations/default/contacts/{{ $json.contact_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0324-0000-4000-8000-0000000000324",
      "name": "HUBSPOT: link ticket to contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5720,
        300
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Record the link only when HubSpot accepted it; otherwise the next message tries again.\nconst r = $input.item.json;\nconst j = $('Prepare log').item.json;\nconst failed = !r || r.status === 'error' || !!r.category;\nif (!failed) {\n  const state = $getWorkflowStaticData('global');\n  state.tickets ??= {};\n  const t = state.tickets[j.chat_id];\n  if (t) t.contact_id = String(j.contact_id);\n}\nreturn { json: { ...j, linked: !failed, link_error: failed ? r : null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0325-0000-4000-8000-0000000000325",
      "name": "State: remember contact link",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5980,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Prepare log').item.json.settings.hubspot_base }}/crm/objects/{{ $('Prepare log').item.json.settings.hubspot_api_version }}/communications",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $('Prepare log').item.json; const a = [ { to: { id: String(j.ticket_id) }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 83 }] } ]; if (j.contact_id) a.push({ to: { id: String(j.contact_id) }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 81 }] }); return JSON.stringify({ properties: { hs_communication_channel_type: 'WHATS_APP', hs_communication_logged_from: 'CRM', hs_communication_body: String(j.comment_text).slice(0, 65000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>'), hs_timestamp: j.when }, associations: a }); })() }}"
      },
      "id": "node-0326-0000-4000-8000-0000000000326",
      "name": "HUBSPOT: log message as communication",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        6240,
        440
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Bookkeeping after the log: last activity on the ticket.\n// HubSpot answers 201 with the communication. Anything else (429 from the per-10-second limit,\n// a missing scope) fails this execution so it shows in the n8n execution list instead of\n// passing silently; the message itself is still in Spun.\nconst j = $('Prepare log').item.json;\nconst r = $input.item.json;\nif (!r || !r.id) throw new Error('HubSpot did not accept the communication on ticket ' + j.ticket_id + ': ' + JSON.stringify(r).slice(0, 500));\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) t.updated_at = Date.now();\nreturn { json: { ...j, logged: true, desk_response: r } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0327-0000-4000-8000-0000000000327",
      "name": "State: remember message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6500,
        440
      ]
    },
    {
      "parameters": {
        "jsCode": "// message.outbound: is this the echo of a reply this workflow sent (loop guard 2)?\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.sentMessages ??= {};\nstate.tickets ??= {};\nconst is_echo = !!(d.message_id && state.sentMessages[d.message_id]);\nconst ticket = state.tickets[d.chat_id] || null;\nconst ticket_known = !!(ticket && ticket.status !== 'closed');\nconst type = d.type || 'text';\nlet text = d.text ? String(d.text) : '';\nif (type !== 'text') {\n  text = (text ? text + '\\n' : '') + '[' + type + '] ' + (d.media_pending ? 'media still processing in Spun' : (d.media_url || 'see the conversation in Spun'));\n  if (d.caption) text += '\\n' + d.caption;\n}\nreturn {\n  json: {\n    settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, message_id: d.message_id || null,\n    is_echo, ticket_known, ticket_id: ticket_known ? ticket.ticket_id : null,\n    contact_id: ticket_known && ticket.contact_id ? ticket.contact_id : null,\n    when: d.timestamp ? new Date(Number(d.timestamp) * 1000).toISOString() : (j.timestamp || new Date().toISOString()),\n    note_text: j.settings.outbound_prefix + ' ' + (d.sender_name || 'agent') + ': ' + (text || '[' + type + ']'),\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0328-0000-4000-8000-0000000000328",
      "name": "Own send or ticket?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0186-0000-4000-8000-0000000000186",
              "leftValue": "={{ $json.is_echo }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0187-0000-4000-8000-0000000000187",
      "name": "Echo of our own reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0188-0000-4000-8000-0000000000188",
      "name": "Echo, nothing to add",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        1240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0189-0000-4000-8000-0000000000189",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0190-0000-4000-8000-0000000000190",
      "name": "Ticket known for this chat?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1820,
        1400
      ]
    },
    {
      "parameters": {},
      "id": "node-0192-0000-4000-8000-0000000000192",
      "name": "No ticket, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2080,
        1600
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/communications",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $json; const a = [ { to: { id: String(j.ticket_id) }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 83 }] } ]; if (j.contact_id) a.push({ to: { id: String(j.contact_id) }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 81 }] }); return JSON.stringify({ properties: { hs_communication_channel_type: 'WHATS_APP', hs_communication_logged_from: 'CRM', hs_communication_body: String(j.note_text).slice(0, 65000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>'), hs_timestamp: j.when }, associations: a }); })() }}"
      },
      "id": "node-0329-0000-4000-8000-0000000000329",
      "name": "HUBSPOT: log message sent from Spun",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        1400
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// conversation.closed from Spun -> close the mapped ticket. Loop guard: a close this workflow\n// itself requested (reason = settings.desk_close_reason) comes back as conversation.closed too.\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst ticket = state.tickets[d.chat_id] || null;\nconst ours = d.reason === j.settings.desk_close_reason;\nconst close_in_desk = !!(ticket && ticket.ticket_id && ticket.status !== 'closed' && !ours);\nreturn { json: { settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, ticket_id: ticket ? ticket.ticket_id : null, close_in_desk, closed_by: d.closed_by || null, reason: d.reason || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0193-0000-4000-8000-0000000000193",
      "name": "Prepare close",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0194-0000-4000-8000-0000000000194",
              "leftValue": "={{ $json.close_in_desk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0195-0000-4000-8000-0000000000195",
      "name": "Close ticket in desk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0198-0000-4000-8000-0000000000198",
      "name": "Nothing to close",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3120,
        1540
      ]
    },
    {
      "parameters": {
        "jsCode": "// conversation.reopened from Spun -> reopen the mapped ticket (same loop guard as close).\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst ticket = state.tickets[d.chat_id] || null;\nconst ours = d.reason === j.settings.desk_reopen_reason;\nconst reopen_in_desk = !!(ticket && ticket.ticket_id && ticket.status === 'closed' && !ours);\nreturn { json: { settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, ticket_id: ticket ? ticket.ticket_id : null, reopen_in_desk, reason: d.reason || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0199-0000-4000-8000-0000000000199",
      "name": "Prepare reopen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0200-0000-4000-8000-0000000000200",
              "leftValue": "={{ $json.reopen_in_desk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0201-0000-4000-8000-0000000000201",
      "name": "Reopen ticket in desk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3900,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0204-0000-4000-8000-0000000000204",
      "name": "Nothing to reopen",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        4160,
        1540
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ properties: { hs_pipeline_stage: String($json.settings.hubspot_closed_stage_id) } }) }}"
      },
      "id": "node-0330-0000-4000-8000-0000000000330",
      "name": "HUBSPOT: close ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1320
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Close ticket in desk?').item.json;\n// Mark the mapped ticket \"closed\" only when HubSpot accepted the stage change (an unknown stage\n// id or a missing scope answers 400 or 403; the ticket then stays open in HubSpot and in the map).\n// The stage change comes back as a webhook; \"Prepare Spun close\" ignores it because the map\n// already says closed.\nconst r = $input.item.json;\nconst closed = !!(r && r.id);\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t && closed) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, desk_closed: closed, desk_error: closed ? null : r, ticket_known: !closed, ticket_id: closed ? null : j.ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0331-0000-4000-8000-0000000000331",
      "name": "State: mark ticket closed (Spun closed it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        1320
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ properties: { hs_pipeline_stage: String($json.settings.hubspot_open_stage_id) } }) }}"
      },
      "id": "node-0332-0000-4000-8000-0000000000332",
      "name": "HUBSPOT: reopen ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4160,
        1320
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Reopen ticket in desk?').item.json;\n// Mark the mapped ticket \"open\" only when HubSpot accepted the stage change.\nconst r = $input.item.json;\nconst opened = !!(r && r.id);\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t && opened) { t.status = 'open'; t.updated_at = Date.now(); }\nreturn { json: { ...j, desk_reopened: opened, desk_error: opened ? null : r, ticket_known: !!(t && opened), ticket_id: (t && t.ticket_id) || j.ticket_id || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0333-0000-4000-8000-0000000000333",
      "name": "State: mark ticket open (Spun reopened it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        1320
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {}
      },
      "id": "node-0233-0000-4000-8000-0000000000233",
      "name": "Reply raw body to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        780,
        2200
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ 'POST' + String($json.settings.hubspot_webhook_url) + $json.rawBody + String(($json.headers || {})['x-hubspot-request-timestamp'] || '') }}",
        "dataPropertyName": "computedHmac",
        "secret": "={{ $json.settings.hubspot_app_client_secret }}",
        "encoding": "base64"
      },
      "id": "node-0334-0000-4000-8000-0000000000334",
      "name": "HMAC of reply body (v3)",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        1040,
        2200
      ]
    },
    {
      "parameters": {
        "action": "hash",
        "type": "SHA256",
        "value": "={{ String($json.settings.hubspot_app_client_secret) + $json.rawBody }}",
        "dataPropertyName": "computedV1",
        "encoding": "hex"
      },
      "id": "node-0335-0000-4000-8000-0000000000335",
      "name": "Hash of reply body (v1)",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        1300,
        2200
      ]
    },
    {
      "parameters": {
        "jsCode": "// HubSpot app webhook (SETUP.md section 6). The body is a JSON array of up to 100 events:\n// {objectId, propertyName, propertyValue, changeSource, sourceId, eventId, subscriptionId,\n//  portalId, appId, occurredAt, subscriptionType, attemptNumber}.\n// Signature v3: X-HubSpot-Signature-v3 = base64(HMAC-SHA256(client secret,\n//   'POST' + exact URL + raw body + X-HubSpot-Request-Timestamp)); reject when older than 5 minutes.\n// Signature v1: X-HubSpot-Signature = hex(SHA-256(client secret + raw body)).\n// The Crypto nodes computed both; whichever header HubSpot sent must match.\n// Fail closed: nothing passes while hubspot_app_client_secret is still a placeholder.\nfunction same(a, b) {\n  // constant-time compare without a crypto import (the Code node has no require by default)\n  if (!a || !b || a.length !== b.length) return false;\n  let diff = 0;\n  for (let i = 0; i < b.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n  return diff === 0;\n}\nconst out = [];\n$input.all().forEach((item, itemIndex) => {\n  const j = item.json;\n  const settings = j.settings;\n  const headers = j.headers || {};\n  const secret = String(settings.hubspot_app_client_secret || '');\n  const secretSet = secret !== '' && !secret.startsWith('PASTE');\n  const urlSet = !String(settings.hubspot_webhook_url || 'PASTE').startsWith('PASTE');\n  const v3 = String(headers['x-hubspot-signature-v3'] || '');\n  const v1 = String(headers['x-hubspot-signature'] || '').toLowerCase();\n  const ts = Number(headers['x-hubspot-request-timestamp'] || 0);\n  const v3ok = urlSet && v3 !== '' && same(v3, String(j.computedHmac || '')) && Math.abs(Date.now() - ts) <= 5 * 60 * 1000;\n  const v1ok = v1 !== '' && same(v1, String(j.computedV1 || '').toLowerCase());\n  let why = null;\n  if (!secretSet) why = 'hubspot_app_client_secret is not set in Settings';\n  else if (!v3ok && !v1ok) why = v3 && !urlSet ? 'hubspot_webhook_url is not set in Settings (needed for the v3 signature)' : 'bad, missing or expired HubSpot signature';\n  let events = null;\n  if (!why) {\n    try { events = JSON.parse(j.rawBody || ''); } catch (e) { events = null; }\n    if (!Array.isArray(events)) why = 'body is not a HubSpot webhook batch';\n  }\n  const base = { settings, comment_id: null, text: '', is_public: false, author_type: 'system', author_id: null };\n  if (why) {\n    out.push({ json: { ...base, kind: 'ignore', why, event_type: null, ticket_id: '', desk_event_id: null }, pairedItem: { item: itemIndex } });\n    return;\n  }\n  const maxAge = (Number(settings.dedupe_hours) || 24) * 3600 * 1000;\n  for (const e of events) {\n    const ticket_id = /^\\d+$/.test(String(e.objectId || '')) ? String(e.objectId) : '';\n    const isTicket = e.subscriptionType === 'ticket.propertyChange' || (e.subscriptionType === 'object.propertyChange' && String(e.objectTypeId) === '0-5');\n    const key = 'hs:' + e.portalId + ':' + ticket_id + ':' + e.occurredAt;\n    const value = e.propertyValue === undefined || e.propertyValue === null ? '' : String(e.propertyValue);\n    let kind = 'ignore';\n    let ev = null;\n    if (!isTicket || !ticket_id) ev = 'not a ticket property change: ' + String(e.subscriptionType || '');\n    else if (Date.now() - Number(e.occurredAt || 0) > maxAge) ev = 'older than dedupe_hours';\n    else if (e.propertyName === 'spun_reply') {\n      if (value.trim() === '') ev = 'spun_reply was cleared';\n      else kind = 'agent_reply';\n    } else if (e.propertyName === 'hs_pipeline_stage') {\n      kind = settings.hubspot_closed_stage_ids.includes(value) ? 'ticket_closed' : 'ticket_reopened';\n    } else ev = 'property not used by this workflow: ' + String(e.propertyName || '');\n    out.push({\n      json: {\n        ...base,\n        kind,\n        why: ev,\n        event_type: e.subscriptionType + ':' + String(e.propertyName || ''),\n        ticket_id,\n        change_source: e.changeSource || null,\n        desk_event_id: key,\n        comment_id: kind === 'agent_reply' ? key : null,\n        text: kind === 'agent_reply' ? value.trim() : '',\n        is_public: kind === 'agent_reply',\n        author_type: kind === 'agent_reply' ? 'agent' : 'system',\n        author_id: e.sourceId ? String(e.sourceId) : null,\n      },\n      pairedItem: { item: itemIndex },\n    });\n  }\n});\nreturn out;"
      },
      "id": "node-0336-0000-4000-8000-0000000000336",
      "name": "HUBSPOT adapter: verify + split events",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1560,
        2200
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0206-0000-4000-8000-0000000000206",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "agent_reply",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reply"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0207-0000-4000-8000-0000000000207",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "ticket_closed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "closed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0208-0000-4000-8000-0000000000208",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "ticket_reopened",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reopened"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0209-0000-4000-8000-0000000000209",
      "name": "Route by desk event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        1820,
        2200
      ]
    },
    {
      "parameters": {},
      "id": "node-0210-0000-4000-8000-0000000000210",
      "name": "Ignored desk event",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2080,
        2480
      ]
    },
    {
      "parameters": {
        "jsCode": "// Replies can only go back to WhatsApp for a ticket whose chat is known. The map normally has\n// it; after a lost map the chat id is read from the ticket's spun_chat_id property.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst in_map = Object.values(state.tickets).some((t) => String(t.ticket_id) === String(j.ticket_id));\nreturn { json: { ...j, in_map } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0337-0000-4000-8000-0000000000337",
      "name": "Look up ticket in map",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        1900
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0338-0000-4000-8000-0000000000338",
              "leftValue": "={{ $json.in_map }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0339-0000-4000-8000-0000000000339",
      "name": "Ticket in map?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2340,
        1900
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/tickets/{{ $json.ticket_id }}?properties=spun_chat_id,hs_pipeline_stage",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0340-0000-4000-8000-0000000000340",
      "name": "HUBSPOT: read ticket (find chat id)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        2060
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// The workflow stores the Spun chat id in the ticket property spun_chat_id. A ticket without it\n// was not created by this workflow. The map gets the entry unless the chat already has another\n// open ticket; a reply typed on an older ticket of that chat then ends at \"No Spun chat for this\n// ticket\" with the reason. Nothing throws here: one bad event must not drop the rest of the batch.\nconst r = $input.item.json;\nconst j = $('Ticket in map?').item.json;\nconst p = r && r.id && r.properties ? r.properties : null;\nconst cid = p && p.spun_chat_id ? String(p.spun_chat_id) : null;\nif (!cid) return { json: { ...j, chat_id: null, no_chat_reason: p ? 'ticket has no spun_chat_id (not created by this workflow)' : 'ticket could not be read: ' + JSON.stringify(r).slice(0, 300) } };\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst cur = state.tickets[cid];\nif (cur && String(cur.ticket_id) !== String(j.ticket_id) && cur.status !== 'closed') {\n  return { json: { ...j, chat_id: null, no_chat_reason: 'the chat now has ticket ' + cur.ticket_id + '; type the reply there' } };\n}\nconst closed = j.settings.hubspot_closed_stage_ids.includes(String(p.hs_pipeline_stage || ''));\nstate.tickets[cid] = { ticket_id: String(j.ticket_id), status: closed ? 'closed' : 'open', updated_at: Date.now(), contact_id: null };\nreturn { json: { ...j, chat_id: cid, no_chat_reason: null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0341-0000-4000-8000-0000000000341",
      "name": "Read chat id from ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        2060
      ]
    },
    {
      "parameters": {
        "jsCode": "// Loop guards 1 and 3 on the desk side, then resolve the chat id for the ticket.\n// Passes a reply only when: public, written by an agent, not by the integration user,\n// not starting with the inbound prefix (our own echo), and not forwarded before.\nconst j = $input.item.json;\nconst s = j.settings;\nconst state = $getWorkflowStaticData('global');\nstate.sentComments ??= {};\nstate.tickets ??= {};\nlet forward = true;\nlet reason = null;\nif (!j.is_public) { forward = false; reason = 'private note'; }\nelse if (j.author_type !== 'agent') { forward = false; reason = 'not an agent'; }\nelse if (s.desk_integration_user_id && String(j.author_id) === String(s.desk_integration_user_id)) { forward = false; reason = 'written by the integration user'; }\nelse if (String(j.text || '').trim().startsWith(s.inbound_prefix)) { forward = false; reason = 'echo of an inbound message'; }\nelse if (!j.comment_id) { forward = false; reason = 'no comment id'; }\nelse if (state.sentComments[j.comment_id]) { forward = false; reason = 'already forwarded'; }\nelse if (!String(j.text || '').trim()) { forward = false; reason = 'empty text'; }\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nreturn { json: { ...j, forward, filter_reason: reason, chat_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0211-0000-4000-8000-0000000000211",
      "name": "Reply filter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        1900
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0212-0000-4000-8000-0000000000212",
              "leftValue": "={{ $json.forward }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0213-0000-4000-8000-0000000000213",
      "name": "Forward this reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3380,
        1900
      ]
    },
    {
      "parameters": {},
      "id": "node-0214-0000-4000-8000-0000000000214",
      "name": "Reply filtered out",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3640,
        2100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0215-0000-4000-8000-0000000000215",
              "leftValue": "={{ !!$json.chat_id }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0216-0000-4000-8000-0000000000216",
      "name": "Chat id resolved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3640,
        1900
      ]
    },
    {
      "parameters": {},
      "id": "node-0342-0000-4000-8000-0000000000342",
      "name": "No Spun chat for this ticket",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3900,
        2100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Chat id resolved?').item.json.settings.spun_api_base }}/send-message",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ to: $('Chat id resolved?').item.json.chat_id, text: $('Chat id resolved?').item.json.text, idempotency_key: String($('Chat id resolved?').item.json.comment_id).slice(0, 200) }) }}"
      },
      "id": "node-0219-0000-4000-8000-0000000000219",
      "name": "SPUN: send-message",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3900,
        1900
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Remember our own send (loop guard 2) and the forwarded comment (loop guard 3).\nconst r = $input.item.json;\nconst j = $('Chat id resolved?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.sentMessages ??= {};\nstate.sentComments ??= {};\nconst now = Date.now();\nif (r.message_id) state.sentMessages[r.message_id] = now;\nif (j.comment_id) state.sentComments[j.comment_id] = now;\nconst ttl = (Number(j.settings.dedupe_hours) || 24) * 3600 * 1000;\nfor (const k of Object.keys(state.sentMessages)) if (now - state.sentMessages[k] > ttl) delete state.sentMessages[k];\nfor (const k of Object.keys(state.sentComments)) if (now - state.sentComments[k] > ttl) delete state.sentComments[k];\nreturn { json: { ...j, spun_response: r, sent: !!r.message_id, queued: !!r.queued, queue_id: r.queue_id || null, error: r.error || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0220-0000-4000-8000-0000000000220",
      "name": "State: remember sent message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        1900
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "={{ $('Chat id resolved?').item.json.settings.hubspot_base }}/crm/objects/{{ $('Chat id resolved?').item.json.settings.hubspot_api_version }}/tickets/{{ $('Chat id resolved?').item.json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ properties: { spun_reply: '' } }) }}"
      },
      "id": "node-0343-0000-4000-8000-0000000000343",
      "name": "HUBSPOT: clear spun_reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4420,
        1900
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Clearing spun_reply lets the agent type the next reply (HubSpot only notifies on a change).\n// The clear comes back as a webhook with an empty value and is ignored by the adapter.\n// Sent or queued: log the text on the ticket. Not sent: leave a note with the reason and the text.\nconst j = $('State: remember sent message').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id] || null;\nconst ok = !!(j.sent || j.queued);\nconst err = j.error ? (typeof j.error === 'string' ? j.error : JSON.stringify(j.error)) : 'no message id returned';\nreturn { json: {\n  settings: j.settings, ticket_id: j.ticket_id, chat_id: j.chat_id, contact_id: t && t.contact_id ? t.contact_id : null,\n  ok, cleared: !!($input.item.json && $input.item.json.id), when: new Date().toISOString(),\n  log_text: j.settings.reply_log_prefix + (j.queued && !j.sent ? ' (queued)' : '') + ': ' + j.text,\n  failure_text: 'Not sent to WhatsApp (' + String(err).slice(0, 300) + '): ' + j.text,\n} };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0344-0000-4000-8000-0000000000344",
      "name": "Prepare reply log",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4680,
        1900
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0345-0000-4000-8000-0000000000345",
              "leftValue": "={{ $json.ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0346-0000-4000-8000-0000000000346",
      "name": "Sent to WhatsApp?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4940,
        1900
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/communications",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $json; const a = [ { to: { id: String(j.ticket_id) }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 83 }] } ]; if (j.contact_id) a.push({ to: { id: String(j.contact_id) }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 81 }] }); return JSON.stringify({ properties: { hs_communication_channel_type: 'WHATS_APP', hs_communication_logged_from: 'CRM', hs_communication_body: String(j.log_text).slice(0, 65000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>'), hs_timestamp: j.when }, associations: a }); })() }}"
      },
      "id": "node-0347-0000-4000-8000-0000000000347",
      "name": "HUBSPOT: log agent reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5200,
        1820
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/notes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ properties: { hs_timestamp: $json.when, hs_note_body: String($json.failure_text).slice(0, 65000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>') }, associations: [ { to: { id: String($json.ticket_id) }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 228 }] } ] }) }}"
      },
      "id": "node-0348-0000-4000-8000-0000000000348",
      "name": "HUBSPOT: note send failure",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5200,
        2000
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Ticket closed in the desk -> close the conversation in Spun (needs the manage_conversations\n// scope, \"Close & Reopen Conversations\" on the API key).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nconst t = chat_id ? state.tickets[chat_id] : null;\nconst act = !!(chat_id && t && t.status !== 'closed' && j.settings.close_in_spun_when_desk_closes);\nreturn { json: { ...j, chat_id, act, idempotency_key: ('desk-close-' + j.desk_event_id).slice(0, 200) } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0221-0000-4000-8000-0000000000221",
      "name": "Prepare Spun close",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        2640
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0222-0000-4000-8000-0000000000222",
              "leftValue": "={{ $json.act }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0223-0000-4000-8000-0000000000223",
      "name": "Close in Spun?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2600,
        2640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.spun_api_base }}/conversations/close",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, reason: $json.settings.desk_close_reason, idempotency_key: $json.idempotency_key }) }}"
      },
      "id": "node-0224-0000-4000-8000-0000000000224",
      "name": "SPUN: close conversation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2860,
        2640
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Mark the ticket \"closed\" after the Spun call (desk-initiated change).\nconst j = $('Close in Spun?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = j.chat_id ? state.tickets[j.chat_id] : null;\nif (t) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, spun_response: $input.item.json } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0225-0000-4000-8000-0000000000225",
      "name": "State: mark ticket closed (desk)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0226-0000-4000-8000-0000000000226",
      "name": "Not closing in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        2860
      ]
    },
    {
      "parameters": {
        "jsCode": "// Ticket reopened in the desk -> reopen the conversation in Spun (same requirements as close).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nconst t = chat_id ? state.tickets[chat_id] : null;\nconst act = !!(chat_id && t && t.status === 'closed' && j.settings.close_in_spun_when_desk_closes);\nreturn { json: { ...j, chat_id, act, idempotency_key: ('desk-reopen-' + j.desk_event_id).slice(0, 200) } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0227-0000-4000-8000-0000000000227",
      "name": "Prepare Spun reopen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        2640
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0228-0000-4000-8000-0000000000228",
              "leftValue": "={{ $json.act }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0229-0000-4000-8000-0000000000229",
      "name": "Reopen in Spun?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3640,
        2640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.spun_api_base }}/conversations/reopen",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, reason: $json.settings.desk_reopen_reason, idempotency_key: $json.idempotency_key }) }}"
      },
      "id": "node-0230-0000-4000-8000-0000000000230",
      "name": "SPUN: reopen conversation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3900,
        2640
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Mark the ticket \"open\" after the Spun call (desk-initiated change).\nconst j = $('Reopen in Spun?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = j.chat_id ? state.tickets[j.chat_id] : null;\nif (t) { t.status = 'open'; t.updated_at = Date.now(); }\nreturn { json: { ...j, spun_response: $input.item.json } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0231-0000-4000-8000-0000000000231",
      "name": "State: mark ticket open (desk)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0232-0000-4000-8000-0000000000232",
      "name": "Not reopening in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3900,
        2860
      ]
    },
    {
      "parameters": {},
      "id": "node-0349-0000-4000-8000-0000000000349",
      "name": "Setup helper: run by hand",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        3100
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://api.hubapi.com/crm/pipelines/2026-09/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0350-0000-4000-8000-0000000000350",
      "name": "HUBSPOT setup: list ticket pipelines",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        3100
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// One row per stage. Copy into Settings: the pipeline id, one OPEN stage for new tickets, the\n// CLOSED stage Spun should use, and every CLOSED stage id of that pipeline.\nconst r = $input.first().json;\nconst rows = [];\nfor (const p of (r && Array.isArray(r.results) ? r.results : [])) {\n  for (const st of (Array.isArray(p.stages) ? p.stages : [])) {\n    rows.push({ json: { pipeline_id: String(p.id), pipeline: p.label, stage_id: String(st.id), stage: st.label, ticket_state: st.metadata ? st.metadata.ticketState : null, display_order: st.displayOrder } });\n  }\n}\nif (!rows.length) rows.push({ json: { error: 'No ticket pipelines returned. Check the HubSpot credential and its crm.schemas.tickets.read scope.', response: r } });\nreturn rows;"
      },
      "id": "node-0351-0000-4000-8000-0000000000351",
      "name": "Setup: show stage ids",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        3100
      ]
    },
    {
      "parameters": {
        "content": "## Setup helper\nClick \"Test workflow\" on this trigger once after the HubSpot credential is set. The table lists every ticket pipeline and stage with its id and ticketState (OPEN or CLOSED). It is never run by the webhooks.",
        "height": 200,
        "width": 760,
        "color": 4
      },
      "id": "note-0352-0000-4000-8000-0000000000352",
      "name": "Note 9",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        2900
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0901-0000-4000-8000-000000000901",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "phone",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "phone"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0901-0001-4000-8000-000000000901",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "lid",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "lid"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0901-0002-4000-8000-000000000901",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "none",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "none"
            }
          ]
        },
        "options": {}
      },
      "id": "node-0901-0000-4000-8000-000000000901",
      "name": "Contact search lane",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        2210,
        1100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.hubspot_base }}/crm/objects/{{ $json.settings.hubspot_api_version }}/contacts/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filterGroups: [ { filters: [ { propertyName: $json.settings.hubspot_contact_lid_property, operator: 'EQ', value: String($json.lid) } ] } ], properties: [$json.settings.hubspot_contact_lid_property, 'createdate'], sorts: [ { propertyName: 'createdate', direction: 'ASCENDING' } ], limit: 10 }) }}"
      },
      "id": "node-0902-0000-4000-8000-000000000902",
      "name": "HUBSPOT: search contact by Spun LID",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        1210
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "HubSpot key (Header Auth: Authorization = Bearer <service key or private app token>)"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Privacy-ID lane: accept a hit only when its Spun LID property holds exactly this privacy id,\n// oldest contact first. An adopted contact is never edited.\nconst r = $input.item.json;\nconst j = $('Contact search lane').item.json;\nconst prop = j.settings.hubspot_contact_lid_property;\nconst list = (r && Array.isArray(r.results)) ? r.results : [];\nconst hit = list.find((c) => c.properties && String(c.properties[prop] || '').trim() === String(j.lid));\nconst contact_id = hit ? String(hit.id) : null;\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'lid', search_error: r && r.status === 'error' ? r.message || 'search failed' : null } };"
      },
      "id": "node-0903-0000-4000-8000-000000000903",
      "name": "Pick matching contact (Spun LID)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        1210
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0904-0000-4000-8000-000000000904",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0904-0000-4000-8000-000000000904",
      "name": "Contact found by Spun LID?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3120,
        1210
      ]
    }
  ],
  "connections": {
    "Spun events": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Helpdesk reply": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Route by trigger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by trigger": {
      "main": [
        [
          {
            "node": "Raw body to text",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply raw body to text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Raw body to text": {
      "main": [
        [
          {
            "node": "HMAC of raw body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HMAC of raw body": {
      "main": [
        [
          {
            "node": "Check signature + parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check signature + parse": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Ack 200",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject 401",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ack 200": {
      "main": [
        [
          {
            "node": "State: seen before?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: seen before?": {
      "main": [
        [
          {
            "node": "New delivery?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New delivery?": {
      "main": [
        [
          {
            "node": "Route by event",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Duplicate, ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by event": {
      "main": [
        [
          {
            "node": "Prepare message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Own send or ticket?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare close",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare reopen",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Other event, ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare message": {
      "main": [
        [
          {
            "node": "Handle this message?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle this message?": {
      "main": [
        [
          {
            "node": "State: lookup contact + ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skipped (group or newsletter)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: lookup contact + ticket": {
      "main": [
        [
          {
            "node": "Contact known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact known?": {
      "main": [
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact search lane",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build phone search": {
      "main": [
        [
          {
            "node": "HUBSPOT: search contact by phone",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: search contact by phone": {
      "main": [
        [
          {
            "node": "Pick matching contact (phone)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching contact (phone)": {
      "main": [
        [
          {
            "node": "Contact found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact found?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create contact?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create contact?": {
      "main": [
        [
          {
            "node": "HUBSPOT: create contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No contact yet (CRM sync owns contacts)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: create contact": {
      "main": [
        [
          {
            "node": "Read new contact id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read new contact id": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "No contact yet (CRM sync owns contacts)": {
      "main": [
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember contact": {
      "main": [
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open ticket known?": {
      "main": [
        [
          {
            "node": "HUBSPOT: read ticket status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HUBSPOT: find open ticket by chat id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: read ticket status": {
      "main": [
        [
          {
            "node": "Ticket still open?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ticket still open?": {
      "main": [
        [
          {
            "node": "Still open?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still open?": {
      "main": [
        [
          {
            "node": "Prepare log",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "State: mark ticket closed (desk closed it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: mark ticket closed (desk closed it)": {
      "main": [
        [
          {
            "node": "HUBSPOT: find open ticket by chat id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: find open ticket by chat id": {
      "main": [
        [
          {
            "node": "Pick open ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick open ticket": {
      "main": [
        [
          {
            "node": "Open ticket found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open ticket found?": {
      "main": [
        [
          {
            "node": "State: remember found ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HUBSPOT: create ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember found ticket": {
      "main": [
        [
          {
            "node": "Prepare log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: create ticket": {
      "main": [
        [
          {
            "node": "Read new ticket id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read new ticket id": {
      "main": [
        [
          {
            "node": "State: remember new ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember new ticket": {
      "main": [
        [
          {
            "node": "Prepare log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare log": {
      "main": [
        [
          {
            "node": "Link ticket to contact?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Link ticket to contact?": {
      "main": [
        [
          {
            "node": "HUBSPOT: link ticket to contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HUBSPOT: log message as communication",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: link ticket to contact": {
      "main": [
        [
          {
            "node": "State: remember contact link",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember contact link": {
      "main": [
        [
          {
            "node": "HUBSPOT: log message as communication",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: log message as communication": {
      "main": [
        [
          {
            "node": "State: remember message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Own send or ticket?": {
      "main": [
        [
          {
            "node": "Echo of our own reply?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Echo of our own reply?": {
      "main": [
        [
          {
            "node": "Echo, nothing to add",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ticket known for this chat?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ticket known for this chat?": {
      "main": [
        [
          {
            "node": "HUBSPOT: log message sent from Spun",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No ticket, ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare close": {
      "main": [
        [
          {
            "node": "Close ticket in desk?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Close ticket in desk?": {
      "main": [
        [
          {
            "node": "HUBSPOT: close ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to close",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: close ticket": {
      "main": [
        [
          {
            "node": "State: mark ticket closed (Spun closed it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare reopen": {
      "main": [
        [
          {
            "node": "Reopen ticket in desk?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reopen ticket in desk?": {
      "main": [
        [
          {
            "node": "HUBSPOT: reopen ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to reopen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: reopen ticket": {
      "main": [
        [
          {
            "node": "State: mark ticket open (Spun reopened it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply raw body to text": {
      "main": [
        [
          {
            "node": "HMAC of reply body (v3)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HMAC of reply body (v3)": {
      "main": [
        [
          {
            "node": "Hash of reply body (v1)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Hash of reply body (v1)": {
      "main": [
        [
          {
            "node": "HUBSPOT adapter: verify + split events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT adapter: verify + split events": {
      "main": [
        [
          {
            "node": "Route by desk event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by desk event": {
      "main": [
        [
          {
            "node": "Look up ticket in map",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Spun close",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Spun reopen",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ignored desk event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Look up ticket in map": {
      "main": [
        [
          {
            "node": "Ticket in map?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ticket in map?": {
      "main": [
        [
          {
            "node": "Reply filter",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HUBSPOT: read ticket (find chat id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: read ticket (find chat id)": {
      "main": [
        [
          {
            "node": "Read chat id from ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read chat id from ticket": {
      "main": [
        [
          {
            "node": "Reply filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply filter": {
      "main": [
        [
          {
            "node": "Forward this reply?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Forward this reply?": {
      "main": [
        [
          {
            "node": "Chat id resolved?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply filtered out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chat id resolved?": {
      "main": [
        [
          {
            "node": "SPUN: send-message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Spun chat for this ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPUN: send-message": {
      "main": [
        [
          {
            "node": "State: remember sent message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember sent message": {
      "main": [
        [
          {
            "node": "HUBSPOT: clear spun_reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: clear spun_reply": {
      "main": [
        [
          {
            "node": "Prepare reply log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare reply log": {
      "main": [
        [
          {
            "node": "Sent to WhatsApp?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sent to WhatsApp?": {
      "main": [
        [
          {
            "node": "HUBSPOT: log agent reply",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HUBSPOT: note send failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Spun close": {
      "main": [
        [
          {
            "node": "Close in Spun?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Close in Spun?": {
      "main": [
        [
          {
            "node": "SPUN: close conversation",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Not closing in Spun",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPUN: close conversation": {
      "main": [
        [
          {
            "node": "State: mark ticket closed (desk)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Spun reopen": {
      "main": [
        [
          {
            "node": "Reopen in Spun?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reopen in Spun?": {
      "main": [
        [
          {
            "node": "SPUN: reopen conversation",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Not reopening in Spun",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPUN: reopen conversation": {
      "main": [
        [
          {
            "node": "State: mark ticket open (desk)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Setup helper: run by hand": {
      "main": [
        [
          {
            "node": "HUBSPOT setup: list ticket pipelines",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT setup: list ticket pipelines": {
      "main": [
        [
          {
            "node": "Setup: show stage ids",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact search lane": {
      "main": [
        [
          {
            "node": "Build phone search",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HUBSPOT: search contact by Spun LID",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create contact?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HUBSPOT: search contact by Spun LID": {
      "main": [
        [
          {
            "node": "Pick matching contact (Spun LID)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching contact (Spun LID)": {
      "main": [
        [
          {
            "node": "Contact found by Spun LID?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact found by Spun LID?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create contact?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true
  },
  "staticData": null,
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false,
    "instanceId": "REPLACED_ON_IMPORT"
  }
}
