{
  "name": "Spun helpdesk bridge: Zoho Desk",
  "nodes": [
    {
      "parameters": {
        "content": "## Spun helpdesk bridge: Zoho Desk\nWhatsApp conversations in Spun become Zoho Desk tickets; public agent comments on the ticket go back 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 \"Zoho Desk OAuth2\" (generic OAuth2 credential, authorization code, Zoho accounts host of your data centre, the scopes listed in SETUP.md). Select them on every SPUN: and ZOHO: node.\n2. Settings node: replace every PASTE_... value (orgId, departmentId, the custom field API names read from GET /api/v1/fields?module=tickets and ?module=contacts, a UUID for sourceId, the integration agent id, the webhook id).\n3. Replace the webhook path suffix of \"Helpdesk reply\" with a long random string.\n4. Activate, create the Zoho webhook (ignoreSourceId = the same UUID, Ticket_Comment_Add + Ticket_Thread_Add, optionally Ticket_Update), then create the Spun webhook subscription pointing at \"Spun events\".\n\nZoho shapes come from ./API-NOTES.md (public docs, not yet live-tested). Every write to Zoho carries orgId and sourceId headers.",
        "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), desk search second, create third. The search exists only as a fallback: desk search indexes lag by minutes, which is how duplicate contacts happen. The \"Pick matching contact\" adapter compares digits, never trusts search relevance.\nPrivacy-ID (LID) chats with no known phone are keyed by the privacy id: searched by the Spun LID contact field, created with that field and no phone, and given the phone once when Spun later reports it.",
        "height": 150,
        "width": 760,
        "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 (moves 2 and 3)\nOwn map (chat_id -> ticket) first. A known ticket is re-read once so a ticket an agent closed in the desk gets a NEW ticket instead of a comment on a closed one. Fallback lookup by chat_id, then create. First message = ticket description; later messages = comments. Media arrives as a link (valid 24 h) or as \"still processing\" when media_pending is true.",
        "height": 170,
        "width": 900,
        "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 added as a PRIVATE note (loop guard 1).\nconversation.closed / conversation.reopened: close or reopen the mapped ticket, 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 (Zoho side)\nZoho POSTs a JSON ARRAY of events to \"Helpdesk reply\" and expects 200 within 5 seconds (a 410 would unsubscribe), so this node responds immediately. The adapter iterates the array: Ticket_Comment_Add with isPublic and commenter.type AGENT = agent reply; Ticket_Thread_Add with direction out and channel not EMAIL = agent reply; Ticket_Update whose statusType became Closed / Open = ticket_closed / ticket_reopened (payload shape assumed, see SETUP.md). The generic filter then drops private notes, the integration user's own comments and anything forwarded before, and POST /send-message uses the Zoho comment id as idempotency_key.\nAuthenticity: Zoho signs with X-ZDesk-JWT (RS256, JWKS), which the Crypto node cannot verify; the long random webhook path is the secret here, and the adapter checks the JWT's iss (orgId) and aud (webhookId) claims without verifying the signature. See SETUP.md for the optional JWT node verification.\n\nThe trigger runs in \"Respond to Webhook\" mode: \"Desk URL check 200\" answers Zoho's GET validation, \"Desk ack 200\" answers every POST at once (Zoho allows 5 s) and then the lane continues. A webhook trigger in \"immediately\" mode fails with \"Unused Respond to Webhook node found in the workflow\" because this lane shares Settings with the Spun lane (n8n 1.x, live-verified 10/2026).",
        "height": 220,
        "width": 1100,
        "color": 3
      },
      "id": "note-0121-0000-4000-8000-0000000000121",
      "name": "Note 7",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        2100
      ]
    },
    {
      "parameters": {
        "content": "## Nodes marked DESK: / ZOHO: are the only ones that talk to the helpdesk\nSwap them (and the small adapter Code node right after each) to target another helpdesk. Nodes marked SPUN: call the Spun Actions API. Nodes marked State: read or write the workflow's own maps. Everything else is desk-agnostic.",
        "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": {
        "multipleMethods": true,
        "httpMethod": [
          "GET",
          "POST"
        ],
        "path": "zoho-desk-reply-PASTE-A-LONG-RANDOM-SUFFIX",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "node-0126-0000-4000-8000-0000000000126",
      "name": "Helpdesk reply",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        2200
      ],
      "webhookId": "wh-0125-0000-4000-8000-0000000000125"
    },
    {
      "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 the\n// README says otherwise. The settings object is copied onto every item so that later nodes\n// read $json.settings.<name> without depending on which trigger fired.\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  \"zoho_desk_base\": \"https://desk.zoho.eu\",\n  \"zoho_org_id\": \"PASTE_ORG_ID_FROM_GET_/api/v1/organizations\",\n  \"zoho_department_id\": \"PASTE_DEPARTMENT_ID_FROM_GET_/api/v1/departments\",\n  \"zoho_cf_chat_id\": \"PASTE_API_NAME_OF_THE_SPUN_CHAT_ID_CUSTOM_FIELD_e.g._cf_spun_chat_id\",\n  \"zoho_cf_line\": \"PASTE_API_NAME_OF_THE_SPUN_LINE_CUSTOM_FIELD_e.g._cf_spun_line\",\n  // Privacy-ID (LID) chats (SETUP.md step 5): API names of the \"Spun LID\" field on the ticket\n  // layout and on the contact layout. Without the contact field a privacy-ID contact is created\n  // without a search first (the workflow's own map is then the only dedupe).\n  \"zoho_cf_lid\": \"PASTE_API_NAME_OF_THE_SPUN_LID_TICKET_FIELD_e.g._cf_spun_lid\",\n  \"zoho_contact_cf_lid\": \"PASTE_API_NAME_OF_THE_SPUN_LID_CONTACT_FIELD_e.g._cf_spun_lid\",\n  \"zoho_channel_value\": \"Chat\",\n  \"zoho_open_status_label\": \"Open\",\n  \"zoho_source_id\": \"PASTE_A_UUID_YOU_GENERATED_(same value as the webhook ignoreSourceId)\",\n  \"zoho_webhook_id\": \"PASTE_THE_ID_ZOHO_RETURNED_WHEN_YOU_CREATED_THE_WEBHOOK\",\n  \"desk_integration_user_id\": \"PASTE_THE_ZOHO_AGENT_ID_THAT_OWNS_THE_OAUTH_TOKEN\",\n  // Pilot mode: while non-empty, only chats with these E.164 numbers (with +) become tickets;\n  // every other inbound message ends at \"Skipped\" in n8n. Empty = every chat. The list is\n  // phone-only: a privacy-ID (LID) chat whose phone Spun does not know yet is skipped too.\n  \"only_from_phones\": [],\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  \"desk_close_reason\": \"closed_in_zoho_desk\",\n  \"desk_reopen_reason\": \"reopened_in_zoho_desk\",\n  \"close_in_spun_when_desk_closes\": false,\n  \"dedupe_hours\": 24\n};\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": "equals"
                    }
                  }
                ],
                "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": "zoho-desk-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": {
          "keepSource": "json"
        }
      },
      "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}\n// Pilot mode is phone-only: a non-empty list also skips privacy-ID chats with no known phone.\nif (handle && Array.isArray(s.only_from_phones) && s.only_from_phones.length && !s.only_from_phones.includes(phone)) {\n  handle = false;\n  skip_reason = phone ? 'not in only_from_phones (pilot mode)' : 'not in only_from_phones (pilot mode); this privacy-ID (LID) chat has no known phone';\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\n// the same chat with a phone (identity_state lid_linked), that contact gets the phone ONCE\n// (add_phone; state.lidPhoneAdded remembers the attempt).\n// contact_lane picks the search when the maps miss: phone, the Spun LID contact field, or\n// create (a privacy-ID chat while zoho_contact_cf_lid is still a placeholder).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.tickets ??= {};\nstate.lidPhoneAdded ??= {};\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 add_phone = !!(j.phone && byLid && !byPhone && !state.lidPhoneAdded[j.lid]);\nconst lidField = String(j.settings.zoho_contact_cf_lid || '');\nconst contact_lane = j.phone ? 'phone' : (lidField && !lidField.startsWith('PASTE') ? 'lid' : 'create');\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, add_phone, 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": {
        "method": "GET",
        "url": "={{ $('Contact known?').item.json.settings.zoho_desk_base }}/api/v1/contacts/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "autodetect"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $('Contact known?').item.json.settings.zoho_org_id }}"
            }
          ]
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "phone",
              "value": "={{ $('Contact known?').item.json.phone }}"
            },
            {
              "name": "limit",
              "value": "10"
            }
          ]
        }
      },
      "id": "node-0157-0000-4000-8000-0000000000157",
      "name": "ZOHO: search contact by phone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2340,
        880
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zoho search matches word tokens and its index lags; accept a hit only on an exact digit match.\n// Zoho answers 204 / empty / {data: []} when nothing matches: all three mean \"not found\".\nconst r = $input.item.json;\nconst j = $('Contact known?').item.json;\nconst digits = (v) => String(v || '').replace(/\\D/g, '');\nconst want = digits(j.phone);\nconst list = (r && Array.isArray(r.data)) ? r.data : [];\nlet contact_id = null;\nfor (const c of list) {\n  if ([c.phone, c.mobile].some((p) => p && digits(p) === want)) { contact_id = String(c.id); break; }\n}\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'phone' } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0158-0000-4000-8000-0000000000158",
      "name": "Pick matching contact (phone)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0159-0000-4000-8000-0000000000159",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0160-0000-4000-8000-0000000000160",
      "name": "Contact found by phone?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        880
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $('Contact known?').item.json.settings.zoho_desk_base }}/api/v1/contacts/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "autodetect"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $('Contact known?').item.json.settings.zoho_org_id }}"
            }
          ]
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "mobile",
              "value": "={{ $('Contact known?').item.json.phone }}"
            },
            {
              "name": "limit",
              "value": "10"
            }
          ]
        }
      },
      "id": "node-0161-0000-4000-8000-0000000000161",
      "name": "ZOHO: search contact by mobile",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1100
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zoho search matches word tokens and its index lags; accept a hit only on an exact digit match.\n// Zoho answers 204 / empty / {data: []} when nothing matches: all three mean \"not found\".\nconst r = $input.item.json;\nconst j = $('Contact known?').item.json;\nconst digits = (v) => String(v || '').replace(/\\D/g, '');\nconst want = digits(j.phone);\nconst list = (r && Array.isArray(r.data)) ? r.data : [];\nlet contact_id = null;\nfor (const c of list) {\n  if ([c.phone, c.mobile].some((p) => p && digits(p) === want)) { contact_id = String(c.id); break; }\n}\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'mobile' } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0162-0000-4000-8000-0000000000162",
      "name": "Pick matching contact (mobile)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        1100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0163-0000-4000-8000-0000000000163",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0164-0000-4000-8000-0000000000164",
      "name": "Contact found by mobile?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3640,
        1100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.zoho_desk_base }}/api/v1/contacts",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $json.settings.zoho_org_id }}"
            },
            {
              "name": "sourceId",
              "value": "={{ $json.settings.zoho_source_id }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $json; const body = { lastName: String(j.name).slice(0, 200), email: j.email || undefined }; if (j.phone) { body.phone = j.phone; body.mobile = j.phone; } const f = String(j.settings.zoho_contact_cf_lid || ''); if (j.lid && f && !f.startsWith('PASTE')) body.cf = { [f]: String(j.lid) }; return JSON.stringify(body); })() }}"
      },
      "id": "node-0165-0000-4000-8000-0000000000165",
      "name": "ZOHO: create contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3900,
        1320
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zoho returns the contact object; keep its id in the map (search would not find it for minutes).\nconst r = $input.item.json;\nconst j = $('Contact to create').item.json;\nconst contact_id = r && r.id ? String(r.id) : null;\nif (!contact_id) throw new Error('Zoho contact create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, searched: 'created', contact_id, contact_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0166-0000-4000-8000-0000000000166",
      "name": "Read new contact id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        1320
      ]
    },
    {
      "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.\n// A contact found through a LID lane (searched = lid / lid_ticket) while this event carries a\n// phone gets that phone once (add_phone; \"State: phone added once\" records the attempt).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.lidPhoneAdded ??= {};\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;\nconst add_phone = !!(j.phone && j.lid && j.contact_id && typeof j.searched === 'string' && j.searched.startsWith('lid') && !state.lidPhoneAdded[j.lid]);\nreturn { json: { ...j, contact_known: !!j.contact_id, add_phone } };",
        "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.zoho_desk_base }}/api/v1/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $json.settings.zoho_org_id }}"
            }
          ]
        }
      },
      "id": "node-0170-0000-4000-8000-0000000000170",
      "name": "ZOHO: read ticket status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        440
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zoho: trust statusType (Open | On Hold | Closed), never the status label (a custom label can read\n// \"Closed\" while its type is Open).\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst still_open = !!r && !!r.id && r.statusType !== 'Closed';\nreturn { json: { ...j, still_open } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0171-0000-4000-8000-0000000000171",
      "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": "GET",
        "url": "={{ $('Open ticket known?').item.json.settings.zoho_desk_base }}/api/v1/tickets/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "autodetect"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $('Open ticket known?').item.json.settings.zoho_org_id }}"
            }
          ]
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "customField1",
              "value": "={{ $('Open ticket known?').item.json.settings.zoho_cf_chat_id + ':' + $('Open ticket known?').item.json.chat_id }}"
            },
            {
              "name": "sortBy",
              "value": "-createdTime"
            },
            {
              "name": "limit",
              "value": "50"
            }
          ]
        }
      },
      "id": "node-0175-0000-4000-8000-0000000000175",
      "name": "ZOHO: find open ticket by chat_id",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        660
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zoho fallback: GET /tickets/search?customField1=<cf api name>:<chat_id>, filtered on statusType.\n// (The contact's ticket list omits `cf`, so it cannot be filtered on the chat id; live-verified 10/2026.\n// The search index can lag a few seconds behind a create; the state map above is the primary path.)\n// 204 (no match) arrives without a body: `data` is then absent and the loop is skipped.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst list = (r && Array.isArray(r.data)) ? r.data : [];\nlet ticket_id = null;\nfor (const t of list) {\n  const cf = t.cf ? t.cf[j.settings.zoho_cf_chat_id] : null;\n  if (cf === j.chat_id && t.statusType !== 'Closed') { ticket_id = String(t.id); break; }\n}\nreturn { json: { ...j, ticket_id, ticket_known: !!ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0176-0000-4000-8000-0000000000176",
      "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-0177-0000-4000-8000-0000000000177",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0178-0000-4000-8000-0000000000178",
      "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\").\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() };\n}\nreturn { json: { ...j, ticket_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0179-0000-4000-8000-0000000000179",
      "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.zoho_desk_base }}/api/v1/tickets/{{ $json.ticket_id }}/comments",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $('Open ticket known?').item.json.settings.zoho_org_id }}"
            },
            {
              "name": "sourceId",
              "value": "={{ $('Open ticket known?').item.json.settings.zoho_source_id }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ content: String($('Open ticket known?').item.json.comment_text).slice(0, 32000), isPublic: true, contentType: 'plainText' }) }}"
      },
      "id": "node-0180-0000-4000-8000-0000000000180",
      "name": "ZOHO: append message as comment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4680,
        440
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Open ticket known?').item.json.settings.zoho_desk_base }}/api/v1/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $('Open ticket known?').item.json.settings.zoho_org_id }}"
            },
            {
              "name": "sourceId",
              "value": "={{ $('Open ticket known?').item.json.settings.zoho_source_id }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $('Open ticket known?').item.json; const cf = {}; cf[j.settings.zoho_cf_chat_id] = j.chat_id; if (j.settings.zoho_cf_line && !String(j.settings.zoho_cf_line).startsWith('PASTE')) cf[j.settings.zoho_cf_line] = j.line_id; if (j.lid && j.settings.zoho_cf_lid && !String(j.settings.zoho_cf_lid).startsWith('PASTE')) cf[j.settings.zoho_cf_lid] = j.lid; return JSON.stringify({ subject: j.subject.slice(0, 255), description: j.comment_text.slice(0, 65535), departmentId: j.settings.zoho_department_id, contactId: j.contact_id, channel: j.settings.zoho_channel_value, phone: j.phone || undefined, cf }); })() }}"
      },
      "id": "node-0181-0000-4000-8000-0000000000181",
      "name": "ZOHO: create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4420,
        880
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zoho returns the ticket object (id, ticketNumber, statusType, webUrl, cf).\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('Zoho ticket create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, ticket_id, ticket_number: r.ticketNumber || null, ticket_url: r.webUrl || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0182-0000-4000-8000-0000000000182",
      "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\").\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() };\n}\nreturn { json: { ...j, ticket_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0183-0000-4000-8000-0000000000183",
      "name": "State: remember new ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4940,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Bookkeeping after an append: last activity on the ticket.\nconst j = $('Open ticket known?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) t.updated_at = Date.now();\nreturn { json: { ...j, appended: true, desk_response: $input.item.json } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0184-0000-4000-8000-0000000000184",
      "name": "State: remember message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4940,
        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 ??= {};\nstate.sentTexts ??= {};\nconst ECHO_WINDOW_MS = 15 * 60 * 1000;\nlet is_echo = !!(d.message_id && state.sentMessages[d.message_id]);\nif (!is_echo && d.chat_id && d.text) {\n  // Fallback for sends whose message_id Spun reported as \"unknown\" (see \"State: remember sent message\").\n  const key = d.chat_id + '\\n' + String(d.text).trim();\n  const at = state.sentTexts[key];\n  if (at && Date.now() - at <= ECHO_WINDOW_MS) { is_echo = true; delete state.sentTexts[key]; }\n}\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    note_text: j.settings.outbound_prefix + ' ' + (d.sender_name || 'agent') + ': ' + (text || '[' + type + ']'),\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0185-0000-4000-8000-0000000000185",
      "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": {
        "method": "POST",
        "url": "={{ $json.settings.zoho_desk_base }}/api/v1/tickets/{{ $json.ticket_id }}/comments",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $json.settings.zoho_org_id }}"
            },
            {
              "name": "sourceId",
              "value": "={{ $json.settings.zoho_source_id }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ content: String($json.note_text).slice(0, 32000), isPublic: false, contentType: 'plainText' }) }}"
      },
      "id": "node-0191-0000-4000-8000-0000000000191",
      "name": "ZOHO: add private note (sent from Spun)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        1400
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {},
      "id": "node-0192-0000-4000-8000-0000000000192",
      "name": "No ticket, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2080,
        1600
      ]
    },
    {
      "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": {
        "method": "POST",
        "url": "={{ $json.settings.zoho_desk_base }}/api/v1/closeTickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "autodetect"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $json.settings.zoho_org_id }}"
            },
            {
              "name": "sourceId",
              "value": "={{ $json.settings.zoho_source_id }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ ids: [String($json.ticket_id)] }) }}"
      },
      "id": "node-0196-0000-4000-8000-0000000000196",
      "name": "ZOHO: close ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1320
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Close ticket in desk?').item.json;\n// Mark the mapped ticket \"closed\" without touching the desk.\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-0197-0000-4000-8000-0000000000197",
      "name": "State: mark ticket closed (Spun closed it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        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": {
        "method": "PATCH",
        "url": "={{ $json.settings.zoho_desk_base }}/api/v1/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $json.settings.zoho_org_id }}"
            },
            {
              "name": "sourceId",
              "value": "={{ $json.settings.zoho_source_id }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ status: $json.settings.zoho_open_status_label }) }}"
      },
      "id": "node-0202-0000-4000-8000-0000000000202",
      "name": "ZOHO: reopen ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4160,
        1320
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Reopen ticket in desk?').item.json;\n// Mark the mapped ticket \"open\" without touching the desk.\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) { t.status = 'open'; t.updated_at = Date.now(); }\nreturn { json: { ...j, ticket_known: !!t, ticket_id: (t && t.ticket_id) || j.ticket_id || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0203-0000-4000-8000-0000000000203",
      "name": "State: mark ticket open (Spun reopened it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0204-0000-4000-8000-0000000000204",
      "name": "Nothing to reopen",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        4160,
        1540
      ]
    },
    {
      "parameters": {
        "jsCode": "// Shared helper used by the desk adapters: HTML comment body -> WhatsApp text.\nfunction htmlToText(html) {\n  return String(html || '')\n    .replace(/<br\\s*\\/?>/gi, '\\n')\n    .replace(/<\\/(p|div|li|h[1-6]|tr)>/gi, '\\n')\n    .replace(/<a [^>]*href=\"([^\"]+)\"[^>]*>(.*?)<\\/a>/gi, (m, href, label) => (label && label !== href ? label + ' (' + href + ')' : href))\n    .replace(/<[^>]+>/g, '')\n    .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '\"').replace(/&#39;/g, \"'\")\n    .replace(/\\n{3,}/g, '\\n\\n')\n    .trim();\n}\n// Zoho webhook payload: a JSON array of {eventType, orgId, eventTime, payload, prevState?}.\n// Ticket_Comment_Add / Ticket_Thread_Add payload shapes: API-NOTES.md section 8.1 (doc-verified).\n// Ticket_Update: the event exists and update events carry prevState when enabled on the\n// subscription (doc-verified); the payload being the ticket object with statusType is ASSUMED.\nfunction jwtClaims(token) {\n  try { const p = String(token).split('.')[1]; return JSON.parse(Buffer.from(p.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { return null; }\n}\nconst out = [];\n$input.all().forEach((item, itemIndex) => {\n  const settings = item.json.settings;\n  const headers = item.json.headers || {};\n  const claims = jwtClaims(headers['x-zdesk-jwt']);\n  // Claims are read, not signature-verified (see SETUP.md). A mismatch drops the delivery.\n  const expectIss = 'orgId:' + settings.zoho_org_id;\n  const expectAud = 'webhookId:' + settings.zoho_webhook_id;\n  const claimsOk = !claims || ((claims.iss === expectIss || String(settings.zoho_org_id).startsWith('PASTE')) && (claims.aud === expectAud || String(settings.zoho_webhook_id).startsWith('PASTE')));\n  const body = item.json.body;\n  const events = Array.isArray(body) ? body : (body ? [body] : []);\n  for (const ev of events) {\n    const p = ev.payload || {};\n    const base = { settings, ticket_id: String(p.ticketId || p.id || ''), desk_event_id: String(p.id || '') + ':' + String(ev.eventTime || ''), event_type: ev.eventType };\n    const mark = out.length;\n    if (!claimsOk) { out.push({ json: { ...base, kind: 'ignore', why: 'jwt claims mismatch' } }); continue; }\n    if (ev.eventType === 'Ticket_Comment_Add') {\n      out.push({ json: { ...base, kind: 'agent_reply', comment_id: String(p.id), text: p.contentType === 'plainText' ? String(p.content || '') : htmlToText(p.content), is_public: p.isPublic === true || p.isPublic === 'true', author_type: p.commenter && p.commenter.type === 'AGENT' ? 'agent' : 'customer', author_id: p.commenterId ? String(p.commenterId) : (p.commenter ? String(p.commenter.id) : null) } });\n    } else if (ev.eventType === 'Ticket_Thread_Add') {\n      const isReply = p.direction === 'out' && String(p.channel || '').toUpperCase() !== 'EMAIL' && !p.isDescriptionThread;\n      out.push({ json: { ...base, kind: isReply ? 'agent_reply' : 'ignore', comment_id: 'thread:' + String(p.id), text: htmlToText(p.content), is_public: p.visibility ? String(p.visibility).toLowerCase() === 'public' : true, author_type: p.author && p.author.type === 'AGENT' ? 'agent' : 'customer', author_id: p.author ? String(p.author.id) : null } });\n    } else if (ev.eventType === 'Ticket_Update') {\n      const prev = ev.prevState || {};\n      const nowClosed = p.statusType === 'Closed';\n      const wasClosed = prev.statusType === 'Closed';\n      let kind = 'ignore';\n      if (nowClosed && !wasClosed) kind = 'ticket_closed';\n      else if (!nowClosed && wasClosed && p.statusType) kind = 'ticket_reopened';\n      out.push({ json: { ...base, ticket_id: String(p.id || p.ticketId || ''), kind, comment_id: null, text: '', is_public: false, author_type: 'system', author_id: null } });\n    } else {\n      out.push({ json: { ...base, kind: 'ignore' } });\n    }\n    for (let k = mark; k < out.length; k++) out[k].pairedItem = { item: itemIndex };\n  }\n});\nreturn out;"
      },
      "id": "node-0205-0000-4000-8000-0000000000205",
      "name": "ZOHO adapter: normalize reply event",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        780,
        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": [
        1040,
        2200
      ]
    },
    {
      "parameters": {},
      "id": "node-0210-0000-4000-8000-0000000000210",
      "name": "Ignored desk event",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1300,
        2480
      ]
    },
    {
      "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": [
        1300,
        1980
      ]
    },
    {
      "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": [
        1560,
        1980
      ]
    },
    {
      "parameters": {},
      "id": "node-0214-0000-4000-8000-0000000000214",
      "name": "Reply filtered out",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        2200
      ]
    },
    {
      "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": [
        1820,
        1900
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.zoho_desk_base }}/api/v1/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $json.settings.zoho_org_id }}"
            }
          ]
        }
      },
      "id": "node-0217-0000-4000-8000-0000000000217",
      "name": "ZOHO: read ticket (find chat id)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        2020
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zoho ticket object: cf holds the custom fields by API name.\nconst r = $input.item.json;\nconst j = $('Chat id resolved?').item.json;\nconst cf = r && r.cf ? r.cf[j.settings.zoho_cf_chat_id] : null;\nif (!cf) throw new Error('Zoho ticket ' + j.ticket_id + ' has no Spun chat id custom field value; was it created by this workflow?');\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nstate.tickets[cf] ??= { ticket_id: String(j.ticket_id), status: r.statusType === 'Closed' ? 'closed' : 'open', updated_at: Date.now() };\nreturn { json: { ...j, chat_id: cf } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0218-0000-4000-8000-0000000000218",
      "name": "Read chat id from ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        2020
      ]
    },
    {
      "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": [
        2600,
        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 ??= {};\nstate.sentTexts ??= {};\nconst now = Date.now();\n// Spun's send-message answers message_id \"unknown\" for text sends (live 10/2026), so the id\n// alone cannot recognise the echo. Remember chat id + text as well; \"Own send or ticket?\"\n// consumes the entry on the first matching message.outbound within ECHO_WINDOW_MS.\nconst ECHO_WINDOW_MS = 15 * 60 * 1000;\nif (r.message_id && r.message_id !== 'unknown') state.sentMessages[r.message_id] = now;\nif (j.chat_id && j.text) state.sentTexts[j.chat_id + '\\n' + String(j.text).trim()] = now;\nfor (const k of Object.keys(state.sentTexts)) if (now - state.sentTexts[k] > ECHO_WINDOW_MS) delete state.sentTexts[k];\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": [
        2860,
        1900
      ]
    },
    {
      "parameters": {
        "jsCode": "// Ticket closed in the desk -> close the conversation in Spun (needs the manage_conversations\n// scope and a Spun server release that includes POST /conversations/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-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": [
        1300,
        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": [
        1560,
        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": [
        1820,
        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": [
        2080,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0226-0000-4000-8000-0000000000226",
      "name": "Not closing in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        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": [
        2340,
        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": [
        2600,
        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": [
        2860,
        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": [
        3120,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0232-0000-4000-8000-0000000000232",
      "name": "Not reopening in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        2860
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "ok",
        "options": {
          "responseCode": 200
        }
      },
      "id": "node-0100-0000-4000-8000-0000000000100",
      "name": "Desk URL check 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        260,
        2060
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "ok",
        "options": {
          "responseCode": 200
        }
      },
      "id": "node-0128-0000-4000-8000-0000000000128",
      "name": "Desk ack 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        260,
        2300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $('Contact known?').item.json.settings.zoho_desk_base }}/api/v1/contacts/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "autodetect"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $('Contact known?').item.json.settings.zoho_org_id }}"
            }
          ]
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "customField1",
              "value": "={{ $('Contact known?').item.json.settings.zoho_contact_cf_lid + ':' + $('Contact known?').item.json.lid }}"
            },
            {
              "name": "limit",
              "value": "10"
            }
          ]
        }
      },
      "id": "node-0901-0000-4000-8000-000000000901",
      "name": "ZOHO: search contact by Spun LID",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        1210
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Privacy-ID lane: accept a hit only when its Spun LID contact field holds exactly this privacy\n// id (search matches word tokens and its index lags). 204 / empty / {data: []} = not found.\nconst r = $input.item.json;\nconst j = $('Contact known?').item.json;\nconst field = j.settings.zoho_contact_cf_lid;\nconst list = (r && Array.isArray(r.data)) ? r.data : [];\nlet contact_id = null;\nfor (const c of list) {\n  const v = c && c.cf ? c.cf[field] : undefined;\n  if (v !== undefined && v !== null && String(v).trim() === String(j.lid)) { contact_id = String(c.id); break; }\n}\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'lid' } };"
      },
      "id": "node-0902-0000-4000-8000-000000000902",
      "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-0903-0000-4000-8000-000000000903",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0903-0000-4000-8000-000000000903",
      "name": "Contact found by Spun LID?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3120,
        1210
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0904-0000-4000-8000-000000000904",
                    "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-0904-0001-4000-8000-000000000904",
                    "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-0904-0002-4000-8000-000000000904",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "create",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "create"
            }
          ]
        },
        "options": {}
      },
      "id": "node-0904-0000-4000-8000-000000000904",
      "name": "Contact search lane",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        2210,
        1100
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Every contact lane that found nothing ends here: phone (searched by phone and mobile), privacy\n// ID (searched by the Spun LID contact field, then by the newest ticket carrying the privacy id in the\n// Spun LID ticket field) or privacy ID with neither field set up (create only).\nreturn { json: $input.item.json };"
      },
      "id": "node-0905-0000-4000-8000-000000000905",
      "name": "Contact to create",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        1210
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0906-0000-4000-8000-000000000906",
              "leftValue": "={{ $json.add_phone }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0906-0000-4000-8000-000000000906",
      "name": "Add phone to privacy-ID contact?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2080,
        300
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "={{ $json.settings.zoho_desk_base }}/api/v1/contacts/{{ $json.contact_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $json.settings.zoho_org_id }}"
            },
            {
              "name": "sourceId",
              "value": "={{ $json.settings.zoho_source_id }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ phone: $json.phone, mobile: $json.phone }) }}"
      },
      "id": "node-0907-0000-4000-8000-000000000907",
      "name": "ZOHO: add phone to privacy-ID contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2210,
        120
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// One attempt per privacy ID, whatever Zoho answered (a failed PATCH stays visible in\n// phone_added_error). On success the phone maps to this contact as well.\nconst r = $input.item.json;\nconst j = $('Add phone to privacy-ID contact?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.lidPhoneAdded ??= {};\nstate.lidPhoneAdded[j.lid] = Date.now();\nconst ok = !!(r && r.id && String(r.id) === String(j.contact_id));\nif (ok) state.contacts[j.phone] = j.contact_id;\nreturn { json: { ...j, phone_added: ok, phone_added_error: ok ? null : JSON.stringify(r).slice(0, 500) } };"
      },
      "id": "node-0908-0000-4000-8000-000000000908",
      "name": "State: phone added once",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        240
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $('Contact known?').item.json.settings.zoho_desk_base }}/api/v1/tickets/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "autodetect"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "orgId",
              "value": "={{ $('Contact known?').item.json.settings.zoho_org_id }}"
            }
          ]
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "customField1",
              "value": "={{ $('Contact known?').item.json.settings.zoho_cf_lid + ':' + $('Contact known?').item.json.lid }}"
            },
            {
              "name": "sortBy",
              "value": "-createdTime"
            },
            {
              "name": "limit",
              "value": "10"
            }
          ]
        }
      },
      "id": "node-0909-0000-4000-8000-000000000909",
      "name": "ZOHO: find contact via LID ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3380,
        1330
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zoho Desk OAuth2 (generic OAuth2 credential)"
        }
      },
      "notes": "Fallback for privacy-ID chats: Zoho may refuse contacts/search on a custom field (403 \"not authorized to access <field>\", seen live 10/2026 on an API-created field), so the newest ticket carrying this privacy id in the Spun LID ticket field names the contact. Requires zoho_cf_lid."
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Privacy-ID fallback: the newest ticket whose Spun LID ticket field holds exactly this privacy id\n// names the contact (tickets/search by a custom field is allowed where contacts/search may answer 403).\n// 204 / empty / {data: []} / an error body = not found.\nconst r = $input.item.json;\nconst j = $('Contact known?').item.json;\nconst field = j.settings.zoho_cf_lid;\nconst list = (r && Array.isArray(r.data)) ? r.data : [];\nlet contact_id = null;\nfor (const t of list) {\n  const v = t && t.cf ? t.cf[field] : undefined;\n  if (v !== undefined && v !== null && String(v).trim() === String(j.lid) && t.contactId) { contact_id = String(t.contactId); break; }\n}\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: contact_id ? 'lid_ticket' : (j.searched || 'lid') } };"
      },
      "id": "node-0910-0000-4000-8000-000000000910",
      "name": "Pick contact from LID ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        1330
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0911-0000-4000-8000-000000000911",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0911-0000-4000-8000-000000000911",
      "name": "Contact found via LID ticket?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3900,
        1330
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0912-0000-4000-8000-000000000912",
              "leftValue": "={{ $json.lid }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0912-0000-4000-8000-000000000912",
      "name": "Phone chat has privacy ID?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3380,
        1000
      ],
      "notes": "A chat that reached Spun as a privacy ID and later carries a phone (identity_state lid_linked) may already exist in Zoho under the privacy id: try the LID lanes before creating a second contact."
    }
  ],
  "connections": {
    "Spun events": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Helpdesk reply": {
      "main": [
        [
          {
            "node": "Desk URL check 200",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Desk ack 200",
            "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": "ZOHO adapter: normalize reply event",
            "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": "Add phone to privacy-ID contact?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact search lane",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: search contact by phone": {
      "main": [
        [
          {
            "node": "Pick matching contact (phone)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching contact (phone)": {
      "main": [
        [
          {
            "node": "Contact found by phone?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact found by phone?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "ZOHO: search contact by mobile",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: search contact by mobile": {
      "main": [
        [
          {
            "node": "Pick matching contact (mobile)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching contact (mobile)": {
      "main": [
        [
          {
            "node": "Contact found by mobile?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact found by mobile?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Phone chat has privacy ID?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: 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
          }
        ]
      ]
    },
    "State: remember contact": {
      "main": [
        [
          {
            "node": "Add phone to privacy-ID contact?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open ticket known?": {
      "main": [
        [
          {
            "node": "ZOHO: read ticket status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "ZOHO: find open ticket by chat_id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: 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": "ZOHO: append message as comment",
            "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": "ZOHO: find open ticket by chat_id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: 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": "ZOHO: create ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember found ticket": {
      "main": [
        [
          {
            "node": "ZOHO: append message as comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: 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
          }
        ]
      ]
    },
    "ZOHO: append message as comment": {
      "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": "ZOHO: add private note (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": "ZOHO: close ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to close",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: 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": "ZOHO: reopen ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to reopen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: reopen ticket": {
      "main": [
        [
          {
            "node": "State: mark ticket open (Spun reopened it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO adapter: normalize reply event": {
      "main": [
        [
          {
            "node": "Route by desk event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by desk event": {
      "main": [
        [
          {
            "node": "Reply filter",
            "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
          }
        ]
      ]
    },
    "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": "ZOHO: read ticket (find chat id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: read ticket (find chat id)": {
      "main": [
        [
          {
            "node": "Read chat id from ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read chat id from ticket": {
      "main": [
        [
          {
            "node": "SPUN: send-message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPUN: send-message": {
      "main": [
        [
          {
            "node": "State: remember sent message",
            "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
          }
        ]
      ]
    },
    "Desk ack 200": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add phone to privacy-ID contact?": {
      "main": [
        [
          {
            "node": "ZOHO: add phone to privacy-ID contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: add phone to privacy-ID contact": {
      "main": [
        [
          {
            "node": "State: phone added once",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: phone added once": {
      "main": [
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact search lane": {
      "main": [
        [
          {
            "node": "ZOHO: search contact by phone",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "ZOHO: search contact by Spun LID",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "ZOHO: find contact via LID ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: 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": "ZOHO: find contact via LID ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact to create": {
      "main": [
        [
          {
            "node": "ZOHO: create contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZOHO: find contact via LID ticket": {
      "main": [
        [
          {
            "node": "Pick contact from LID ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick contact from LID ticket": {
      "main": [
        [
          {
            "node": "Contact found via LID ticket?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact found via LID ticket?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Phone chat has privacy ID?": {
      "main": [
        [
          {
            "node": "ZOHO: search contact by Spun LID",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true
  },
  "staticData": null,
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false,
    "instanceId": "REPLACED_ON_IMPORT"
  }
}
