{
  "name": "Spun helpdesk bridge (generic template)",
  "nodes": [
    {
      "parameters": {
        "content": "## Spun helpdesk bridge: generic template\nWhatsApp conversations in Spun become tickets in your helpdesk, and public agent replies on the ticket go back to the customer on WhatsApp.\n\nImport, then:\n1. Credentials: create \"Spun API key\" (Header Auth, name Authorization, value \"Bearer wap_...\") and \"Helpdesk API token\" (Header Auth), and select them on every SPUN: and DESK: node.\n2. Settings node: replace every PASTE_... value.\n3. Replace the webhook path suffix of \"Helpdesk reply\" with a long random string (it is the secret for the desk side).\n4. Adapt the DESK: nodes (URLs, bodies) and the adapter Code node right after each to your desk's API. Zoho Desk is done for you in ../zoho-desk/.\n5. Activate the workflow, then create the Spun webhook subscription (events message.inbound, message.outbound, conversation.closed, conversation.reopened; template raw; with a signing secret) pointing at the production URL of \"Spun events\".\n\nReads: ../README.md (node list, state, live-test checklist) and https://docs.spun.com/guides/helpdesk-integrations.",
        "height": 520,
        "width": 760,
        "color": 4
      },
      "id": "note-0001-0000-4000-8000-000000000001",
      "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-0002-0000-4000-8000-000000000002",
      "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-0003-0000-4000-8000-000000000003",
      "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.",
        "height": 150,
        "width": 760,
        "color": 6
      },
      "id": "note-0004-0000-4000-8000-000000000004",
      "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-0005-0000-4000-8000-000000000005",
      "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-0006-0000-4000-8000-000000000006",
      "name": "Note 6",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1560,
        1440
      ]
    },
    {
      "parameters": {
        "content": "## 6. Agent replies (desk side)\nYour helpdesk calls \"Helpdesk reply\" when an agent writes on a ticket. The desk adapter turns the desk's payload into {kind, ticket_id, comment_id, text, is_public, author_type, author_id, desk_event_id}; the generic filter keeps only public agent comments not written by the integration user and not forwarded before, then POST /send-message with the comment id as idempotency_key. Desk-side close or reopen reaches Spun through POST /conversations/close and /reopen when close_in_spun_when_desk_closes is true (the Spun key then needs the manage_conversations scope and the Spun release that includes those endpoints).\nThis webhook answers 200 at once (\"Respond immediately\") because most desks require an answer within a few seconds and some unsubscribe on an error status. The node also accepts GET (answered 200 and dropped) for desks that validate the URL with a GET before saving the webhook.",
        "height": 220,
        "width": 1100,
        "color": 3
      },
      "id": "note-0007-0000-4000-8000-000000000007",
      "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-0008-0000-4000-8000-000000000008",
      "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-0010-0000-4000-8000-000000000010",
      "name": "Spun events",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "wh-0009-0000-4000-8000-000000000009"
    },
    {
      "parameters": {
        "multipleMethods": true,
        "httpMethod": [
          "GET",
          "POST"
        ],
        "path": "desk-reply-PASTE-A-LONG-RANDOM-SUFFIX",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "node-0012-0000-4000-8000-000000000012",
      "name": "Helpdesk reply",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        2200
      ],
      "webhookId": "wh-0011-0000-4000-8000-000000000011"
    },
    {
      "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  \"desk_api_base\": \"https://PASTE-YOUR-HELPDESK-API-BASE/api\",\n  \"desk_integration_user_id\": \"PASTE_THE_HELPDESK_USER_ID_THAT_OWNS_THE_API_TOKEN\",\n  \"desk_chat_id_field\": \"PASTE_THE_TICKET_CUSTOM_FIELD_NAME_FOR_THE_SPUN_CHAT_ID\",\n  // Optional contact custom field \"Spun LID\": the privacy id of a chat whose phone WhatsApp\n  // hides. Set it only when your desk can search contacts by that field (README, \"WhatsApp\n  // privacy IDs (LID)\"). Left out while it still starts with PASTE: such a chat is then\n  // create-only and the workflow map is its only dedupe.\n  \"desk_contact_lid_field\": \"PASTE_OPTIONAL_CONTACT_CUSTOM_FIELD_NAME_FOR_THE_SPUN_LID\",\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_helpdesk\",\n  \"desk_reopen_reason\": \"reopened_in_helpdesk\",\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-0013-0000-4000-8000-000000000013",
      "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-0017-0000-4000-8000-000000000017",
                    "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-0018-0000-4000-8000-000000000018",
                    "leftValue": "={{ String($json.webhookUrl || '').split('/').pop() }}",
                    "rightValue": "desk-reply-PASTE-A-LONG-RANDOM-SUFFIX",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "desk"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0016-0000-4000-8000-000000000016",
      "name": "Route by trigger",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        520,
        1100
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {}
      },
      "id": "node-0019-0000-4000-8000-000000000019",
      "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-0020-0000-4000-8000-000000000020",
      "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-0021-0000-4000-8000-000000000021",
      "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-0022-0000-4000-8000-000000000022",
              "leftValue": "={{ $json.signature_ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0023-0000-4000-8000-000000000023",
      "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-0024-0000-4000-8000-000000000024",
      "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-0025-0000-4000-8000-000000000025",
      "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-0026-0000-4000-8000-000000000026",
      "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-0027-0000-4000-8000-000000000027",
              "leftValue": "={{ !$json.duplicate }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0028-0000-4000-8000-000000000028",
      "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-0029-0000-4000-8000-000000000029",
                    "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-0030-0000-4000-8000-000000000030",
                    "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-0031-0000-4000-8000-000000000031",
                    "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-0032-0000-4000-8000-000000000032",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "conversation.reopened",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reopened"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0033-0000-4000-8000-000000000033",
      "name": "Route by event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        2600,
        -60
      ]
    },
    {
      "parameters": {},
      "id": "node-0034-0000-4000-8000-000000000034",
      "name": "Duplicate, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2600,
        220
      ]
    },
    {
      "parameters": {},
      "id": "node-0035-0000-4000-8000-000000000035",
      "name": "Other event, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        280
      ]
    },
    {
      "parameters": {
        "jsCode": "// Shape the Spun message.inbound payload for the helpdesk.\n// Fields used (all on data, added by Spun on 10/2026): is_group, peer_phone_e164, peer_lid,\n// identity_state, contact_id, contact_name, contact_email, caption, media_url (signed link,\n// valid 24 h), media_mime, media_filename, media_size, media_pending.\n// Privacy-ID (LID) chats: WhatsApp can hide a customer's number behind a privacy id\n// (\"<digits>@lid\"). Spun then sends peer_lid (the digits) and fills peer_phone_e164 only once it\n// knows the real number (identity_state lid_linked). Until then (lid_unlinked) the chat still\n// becomes a ticket; the contact is keyed by the privacy id instead of a phone.\nconst j = $input.item.json;\nconst d = j.data || {};\nconst s = j.settings;\nconst chatId = String(d.chat_id || '');\nconst phone = d.peer_phone_e164 || null;\nconst lidMatch = /^(\\d+)@lid$/.exec(chatId);\nconst lid = d.peer_lid || (lidMatch ? lidMatch[1] : null);\nconst identity_state = d.identity_state || (lid ? (phone ? 'lid_linked' : 'lid_unlinked') : 'phone');\nlet handle = true;\nlet skip_reason = null;\n// \"Skipped\" is reserved for chats with no single person behind them: groups and newsletters.\nif (d.is_group || /@(g\\.us|newsletter|broadcast)$/.test(chatId) || (!phone && !lid)) {\n  handle = false; skip_reason = 'group or newsletter chat (no single person to make a contact for)';\n}\nconst who = phone || 'privacy ID ' + lid;\nconst name = d.contact_name || d.sender_name || d.chat_name || who;\nconst type = d.type || 'text';\nconst lines = [];\nif (d.text) lines.push(String(d.text));\nif (type !== 'text') {\n  if (d.media_pending) {\n    lines.push('[' + type + '] Media is still being processed in Spun. Open the conversation in Spun to view it.');\n  } else if (d.media_url) {\n    const meta = [d.media_filename, d.media_mime, d.media_size ? Math.round(Number(d.media_size) / 1024) + ' KB' : null].filter(Boolean).join(', ');\n    lines.push('[' + type + '] ' + d.media_url + (meta ? ' (' + meta + ')' : '') + ' - link valid for 24 hours');\n  } else {\n    lines.push('[' + type + '] Open the conversation in Spun to view it.');\n  }\n  if (d.caption) lines.push(String(d.caption));\n}\nconst text = lines.join('\\n') || '[' + type + ']';\nconst whenIso = d.timestamp ? new Date(Number(d.timestamp) * 1000).toISOString() : (j.timestamp || new Date().toISOString());\nconst firstLine = (d.text || d.caption || '[' + type + ']').split('\\n')[0];\nreturn {\n  json: {\n    settings: s,\n    event_id: j.event_id,\n    event: j.event,\n    org_id: j.org_id,\n    handle,\n    skip_reason,\n    chat_id: d.chat_id,\n    message_id: d.message_id || null,\n    phone,\n    lid,\n    identity_state,\n    name,\n    email: d.contact_email || null,\n    spun_contact_id: d.contact_id || null,\n    line_id: d.integration_id || null,\n    type,\n    text,\n    comment_text: s.inbound_prefix + ' ' + name + ' (' + who + '): ' + text,\n    subject: ('WhatsApp: ' + firstLine).slice(0, 80),\n    when: whenIso,\n    media_url: d.media_url || null,\n    media_pending: !!d.media_pending,\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0036-0000-4000-8000-000000000036",
      "name": "Prepare message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0037-0000-4000-8000-000000000037",
              "leftValue": "={{ $json.handle }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0038-0000-4000-8000-000000000038",
      "name": "Handle this message?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        660
      ]
    },
    {
      "parameters": {},
      "id": "node-0039-0000-4000-8000-000000000039",
      "name": "Skipped (group or newsletter)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Read the template's own maps: phone -> contact id, chat_id -> ticket. Desk search is only the\n// fallback (search indexes lag by minutes on most desks).\n// A privacy-ID (LID) chat with no known phone is keyed \"lid:<digits>\"; when Spun later reports the\n// same chat with a phone (identity_state lid_linked), the same contact keeps being used.\n// contact_lane picks the search when the maps miss: phone, the Spun LID contact field, or\n// create (a privacy-ID chat while desk_contact_lid_field is still a placeholder).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.tickets ??= {};\nconst lidKey = j.lid ? 'lid:' + j.lid : null;\nconst byPhone = j.phone ? state.contacts[j.phone] || null : null;\nconst byLid = lidKey ? state.contacts[lidKey] || null : null;\nconst contact_id = byPhone || byLid;\nconst contact_lane = j.phone ? 'phone' : ((f) => f !== '' && !f.startsWith('PASTE'))(String(j.settings.desk_contact_lid_field || '')) ? '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, contact_lane, ticket_id: ticket_known ? ticket.ticket_id : null, ticket_known } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0040-0000-4000-8000-000000000040",
      "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-0041-0000-4000-8000-000000000041",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0042-0000-4000-8000-000000000042",
      "name": "Contact known?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2080,
        660
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.desk_api_base }}/contacts/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "phone",
              "value": "={{ $json.phone }}"
            }
          ]
        }
      },
      "id": "node-0043-0000-4000-8000-000000000043",
      "name": "DESK: search contact by phone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2340,
        880
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// ADAPTER: map your desk's search response to a contact id. Accept a hit only when the digits\n// of its phone equal the digits of the WhatsApp number. Never accept on search relevance alone.\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 = Array.isArray(r) ? r : (r.data || r.results || r.contacts || []);\nlet contact_id = null;\nfor (const c of list) {\n  if ([c.phone, c.mobile, c.phone_number].some((p) => p && digits(p) === want)) { contact_id = String(c.id); break; }\n}\nreturn { json: { ...j, contact_id, contact_known: !!contact_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0044-0000-4000-8000-000000000044",
      "name": "Pick matching contact",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0045-0000-4000-8000-000000000045",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0046-0000-4000-8000-000000000046",
      "name": "Contact found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        880
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.desk_api_base }}/contacts",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify(Object.assign({ name: $json.name, phone: $json.phone || undefined, email: $json.email || undefined }, $json.lid && ((f) => f !== '' && !f.startsWith('PASTE'))(String($json.settings.desk_contact_lid_field || '')) ? { custom_fields: { [$json.settings.desk_contact_lid_field]: String($json.lid) } } : {})) }}"
      },
      "id": "node-0047-0000-4000-8000-000000000047",
      "name": "DESK: create contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3380,
        1100
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// ADAPTER: read the new contact's id from your desk's create response. A privacy-ID (LID) chat\n// with no known phone is created with the name (and the Spun LID field when set) only; a desk\n// that requires a phone or email on every contact rejects it here.\nconst r = $input.item.json;\nconst j = $('Contact to create').item.json;\nconst contact_id = r.id ? String(r.id) : (r.data && r.data.id ? String(r.data.id) : null);\nif (!contact_id) throw new Error('Contact create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, contact_id, contact_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0048-0000-4000-8000-000000000048",
      "name": "Read new contact id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        1100
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the map entries: phone -> contact id, and \"lid:<digits>\" -> contact id for a privacy-ID\n// chat, so a later message (with or without a phone) finds the same contact.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nif (j.phone && j.contact_id) state.contacts[j.phone] = j.contact_id;\nif (j.lid && j.contact_id) state.contacts['lid:' + j.lid] = j.contact_id;\nreturn { json: { ...j, contact_known: !!j.contact_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0049-0000-4000-8000-000000000049",
      "name": "State: remember contact",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0050-0000-4000-8000-000000000050",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0051-0000-4000-8000-000000000051",
      "name": "Open ticket known?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2340,
        440
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.desk_api_base }}/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0052-0000-4000-8000-000000000052",
      "name": "DESK: read ticket status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        440
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// ADAPTER: decide from your desk's ticket object whether the ticket is still open.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst status = String(r.status || (r.data && r.data.status) || '').toLowerCase();\nconst still_open = !!status && !['closed', 'solved', 'resolved', 'done'].includes(status);\nreturn { json: { ...j, still_open } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0053-0000-4000-8000-000000000053",
      "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-0054-0000-4000-8000-000000000054",
              "leftValue": "={{ $json.still_open }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0055-0000-4000-8000-000000000055",
      "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-0056-0000-4000-8000-000000000056",
      "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.desk_api_base }}/tickets/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "query",
              "value": "={{ $('Open ticket known?').item.json.settings.desk_chat_id_field + ':' + $('Open ticket known?').item.json.chat_id }}"
            },
            {
              "name": "status",
              "value": "open"
            }
          ]
        }
      },
      "id": "node-0057-0000-4000-8000-000000000057",
      "name": "DESK: find open ticket by chat_id",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        660
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// ADAPTER: pick the open ticket whose custom field equals this chat_id exactly (fallback path).\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst list = Array.isArray(r) ? r : (r.data || r.results || r.tickets || []);\nlet ticket_id = null;\nfor (const t of list) {\n  const cf = (t.custom_fields && t.custom_fields[j.settings.desk_chat_id_field]) || (t.cf && t.cf[j.settings.desk_chat_id_field]);\n  const status = String(t.status || '').toLowerCase();\n  if (cf === j.chat_id && !['closed', 'solved', 'resolved', 'done'].includes(status)) { ticket_id = String(t.id); break; }\n}\nreturn { json: { ...j, ticket_id, ticket_known: !!ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0058-0000-4000-8000-000000000058",
      "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-0059-0000-4000-8000-000000000059",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0060-0000-4000-8000-000000000060",
      "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-0061-0000-4000-8000-000000000061",
      "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.desk_api_base }}/tickets/{{ $json.ticket_id }}/comments",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ body: $('Open ticket known?').item.json.comment_text, public: true }) }}"
      },
      "id": "node-0062-0000-4000-8000-000000000062",
      "name": "DESK: append message as comment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4680,
        440
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Open ticket known?').item.json.settings.desk_api_base }}/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ subject: $('Open ticket known?').item.json.subject, description: $('Open ticket known?').item.json.comment_text, requester_id: $('Open ticket known?').item.json.contact_id, custom_fields: { [$('Open ticket known?').item.json.settings.desk_chat_id_field]: $('Open ticket known?').item.json.chat_id } }) }}"
      },
      "id": "node-0063-0000-4000-8000-000000000063",
      "name": "DESK: create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4420,
        880
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// ADAPTER: read the new ticket's id from your desk's create response.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst ticket_id = r.id ? String(r.id) : (r.data && r.data.id ? String(r.data.id) : null);\nif (!ticket_id) throw new Error('Ticket create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0064-0000-4000-8000-000000000064",
      "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-0065-0000-4000-8000-000000000065",
      "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-0066-0000-4000-8000-000000000066",
      "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 ??= {};\nconst is_echo = !!(d.message_id && state.sentMessages[d.message_id]);\nconst ticket = state.tickets[d.chat_id] || null;\nconst ticket_known = !!(ticket && ticket.status !== 'closed');\nconst type = d.type || 'text';\nlet text = d.text ? String(d.text) : '';\nif (type !== 'text') {\n  text = (text ? text + '\\n' : '') + '[' + type + '] ' + (d.media_pending ? 'media still processing in Spun' : (d.media_url || 'see the conversation in Spun'));\n  if (d.caption) text += '\\n' + d.caption;\n}\nreturn {\n  json: {\n    settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, message_id: d.message_id || null,\n    is_echo, ticket_known, ticket_id: ticket_known ? ticket.ticket_id : null,\n    note_text: j.settings.outbound_prefix + ' ' + (d.sender_name || 'agent') + ': ' + (text || '[' + type + ']'),\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0067-0000-4000-8000-000000000067",
      "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-0068-0000-4000-8000-000000000068",
              "leftValue": "={{ $json.is_echo }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0069-0000-4000-8000-000000000069",
      "name": "Echo of our own reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0070-0000-4000-8000-000000000070",
      "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-0071-0000-4000-8000-000000000071",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0072-0000-4000-8000-000000000072",
      "name": "Ticket known for this chat?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1820,
        1400
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.desk_api_base }}/tickets/{{ $json.ticket_id }}/comments",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ body: $json.note_text, public: false }) }}"
      },
      "id": "node-0073-0000-4000-8000-000000000073",
      "name": "DESK: add private note (sent from Spun)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        1400
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {},
      "id": "node-0074-0000-4000-8000-000000000074",
      "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-0075-0000-4000-8000-000000000075",
      "name": "Prepare close",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0076-0000-4000-8000-000000000076",
              "leftValue": "={{ $json.close_in_desk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0077-0000-4000-8000-000000000077",
      "name": "Close ticket in desk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        1320
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.desk_api_base }}/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ status: 'closed' }) }}"
      },
      "id": "node-0078-0000-4000-8000-000000000078",
      "name": "DESK: close ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1320
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "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-0079-0000-4000-8000-000000000079",
      "name": "State: mark ticket closed (Spun closed it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0080-0000-4000-8000-000000000080",
      "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-0081-0000-4000-8000-000000000081",
      "name": "Prepare reopen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0082-0000-4000-8000-000000000082",
              "leftValue": "={{ $json.reopen_in_desk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0083-0000-4000-8000-000000000083",
      "name": "Reopen ticket in desk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3900,
        1320
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.desk_api_base }}/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ status: 'open' }) }}"
      },
      "id": "node-0084-0000-4000-8000-000000000084",
      "name": "DESK: reopen ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4160,
        1320
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "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-0085-0000-4000-8000-000000000085",
      "name": "State: mark ticket open (Spun reopened it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0086-0000-4000-8000-000000000086",
      "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// ADAPTER: turn your desk's webhook payload into one item per event with this shape:\n// { kind: 'agent_reply' | 'ticket_closed' | 'ticket_reopened' | 'ignore', ticket_id, comment_id,\n//   text (plain text), is_public, author_type ('agent' | 'customer' | 'system'), author_id, desk_event_id }\n// Some desks POST one event, others an array. Handle both.\nconst out = [];\n$input.all().forEach((item, itemIndex) => {\n  const settings = item.json.settings;\n  const body = item.json.body || {};\n  const events = Array.isArray(body) ? body : [body];\n  for (const ev of events) {\n    out.push({ json: {\n      settings,\n      kind: ev.comment ? 'agent_reply' : (ev.status === 'closed' ? 'ticket_closed' : (ev.status === 'open' && ev.previous_status === 'closed' ? 'ticket_reopened' : 'ignore')),\n      ticket_id: String(ev.ticket_id || (ev.ticket && ev.ticket.id) || ''),\n      comment_id: ev.comment ? String(ev.comment.id) : null,\n      text: ev.comment ? htmlToText(ev.comment.body_html || ev.comment.body || '') : '',\n      is_public: ev.comment ? !!ev.comment.public : false,\n      author_type: ev.comment && ev.comment.author && ev.comment.author.role === 'agent' ? 'agent' : 'customer',\n      author_id: ev.comment && ev.comment.author ? String(ev.comment.author.id) : null,\n      desk_event_id: String(ev.id || (ev.comment && ev.comment.id) || Date.now()),\n    }, pairedItem: { item: itemIndex } });\n  }\n});\nreturn out;"
      },
      "id": "node-0087-0000-4000-8000-000000000087",
      "name": "DESK 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-0088-0000-4000-8000-000000000088",
                    "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-0089-0000-4000-8000-000000000089",
                    "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-0090-0000-4000-8000-000000000090",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "ticket_reopened",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reopened"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0091-0000-4000-8000-000000000091",
      "name": "Route by desk event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        1040,
        2200
      ]
    },
    {
      "parameters": {},
      "id": "node-0092-0000-4000-8000-000000000092",
      "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-0093-0000-4000-8000-000000000093",
      "name": "Reply filter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        1980
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0094-0000-4000-8000-000000000094",
              "leftValue": "={{ $json.forward }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0095-0000-4000-8000-000000000095",
      "name": "Forward this reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        1980
      ]
    },
    {
      "parameters": {},
      "id": "node-0096-0000-4000-8000-000000000096",
      "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-0097-0000-4000-8000-000000000097",
              "leftValue": "={{ !!$json.chat_id }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0098-0000-4000-8000-000000000098",
      "name": "Chat id resolved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1820,
        1900
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.desk_api_base }}/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0099-0000-4000-8000-000000000099",
      "name": "DESK: read ticket (find chat id)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        2020
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// ADAPTER: read the Spun chat id from the ticket's custom field (fallback when the map has no entry).\nconst r = $input.item.json;\nconst j = $('Chat id resolved?').item.json;\nconst cf = (r.custom_fields && r.custom_fields[j.settings.desk_chat_id_field]) || (r.cf && r.cf[j.settings.desk_chat_id_field]) || null;\nif (!cf) throw new Error('Ticket ' + j.ticket_id + ' has no Spun chat id; was it created by this workflow?');\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nstate.tickets[cf] ??= { ticket_id: String(j.ticket_id), status: 'open', updated_at: Date.now() };\nreturn { json: { ...j, chat_id: cf } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0100-0000-4000-8000-0000000000100",
      "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-0101-0000-4000-8000-0000000000101",
      "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 ??= {};\nconst now = Date.now();\nif (r.message_id) state.sentMessages[r.message_id] = now;\nif (j.comment_id) state.sentComments[j.comment_id] = now;\nconst ttl = (Number(j.settings.dedupe_hours) || 24) * 3600 * 1000;\nfor (const k of Object.keys(state.sentMessages)) if (now - state.sentMessages[k] > ttl) delete state.sentMessages[k];\nfor (const k of Object.keys(state.sentComments)) if (now - state.sentComments[k] > ttl) delete state.sentComments[k];\nreturn { json: { ...j, spun_response: r, sent: !!r.message_id, queued: !!r.queued, queue_id: r.queue_id || null, error: r.error || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0102-0000-4000-8000-0000000000102",
      "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-0103-0000-4000-8000-0000000000103",
      "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-0104-0000-4000-8000-0000000000104",
              "leftValue": "={{ $json.act }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0105-0000-4000-8000-0000000000105",
      "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-0106-0000-4000-8000-0000000000106",
      "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-0107-0000-4000-8000-0000000000107",
      "name": "State: mark ticket closed (desk)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0108-0000-4000-8000-0000000000108",
      "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-0109-0000-4000-8000-0000000000109",
      "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-0110-0000-4000-8000-0000000000110",
              "leftValue": "={{ $json.act }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0111-0000-4000-8000-0000000000111",
      "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-0112-0000-4000-8000-0000000000112",
      "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-0113-0000-4000-8000-0000000000113",
      "name": "State: mark ticket open (desk)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0114-0000-4000-8000-0000000000114",
      "name": "Not reopening in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        2860
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0901-0000-4000-8000-000000000901",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "phone",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "phone"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0901-0001-4000-8000-000000000901",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "lid",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "lid"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0901-0002-4000-8000-000000000901",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "create",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "create"
            }
          ]
        },
        "options": {}
      },
      "id": "node-0901-0000-4000-8000-000000000901",
      "name": "Contact search lane",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        2210,
        1000
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.desk_api_base }}/contacts/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "={{ $json.settings.desk_contact_lid_field }}",
              "value": "={{ $json.lid }}"
            }
          ]
        }
      },
      "id": "node-0902-0000-4000-8000-000000000902",
      "name": "DESK: search contact by Spun LID",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        1210
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Helpdesk API token (Header Auth)"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ADAPTER: map your desk's search response to a contact id. Accept a hit only when its Spun LID\n// field holds exactly this privacy id. Never accept on search relevance alone.\nconst r = $input.item.json;\nconst j = $('Contact search lane').item.json;\nconst field = j.settings.desk_contact_lid_field;\nconst list = Array.isArray(r) ? r : (r.data || r.results || r.contacts || []);\nlet contact_id = null;\nfor (const c of list) {\n  const v = c && ((c.custom_fields && c.custom_fields[field]) ?? c[field]);\n  if (v != null && String(v).trim() === String(j.lid)) { contact_id = String(c.id); break; }\n}\nreturn { json: { ...j, contact_id, contact_known: !!contact_id } };"
      },
      "id": "node-0903-0000-4000-8000-000000000903",
      "name": "Pick matching contact (Spun LID)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        1210
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0904-0000-4000-8000-000000000904",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0904-0000-4000-8000-000000000904",
      "name": "Contact found by Spun LID?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3120,
        1210
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Every lane that found nobody ends here, so the create call reads one node.\nreturn { json: $input.item.json };"
      },
      "id": "node-0905-0000-4000-8000-000000000905",
      "name": "Contact to create",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        1100
      ]
    }
  ],
  "connections": {
    "Spun events": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Helpdesk reply": {
      "main": [
        [],
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Route by trigger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by trigger": {
      "main": [
        [
          {
            "node": "Raw body to text",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "DESK 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": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact search lane",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: search contact by phone": {
      "main": [
        [
          {
            "node": "Pick matching contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching contact": {
      "main": [
        [
          {
            "node": "Contact found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact found?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: 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": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open ticket known?": {
      "main": [
        [
          {
            "node": "DESK: read ticket status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "DESK: find open ticket by chat_id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: 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": "DESK: 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": "DESK: find open ticket by chat_id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: 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": "DESK: create ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember found ticket": {
      "main": [
        [
          {
            "node": "DESK: append message as comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: 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
          }
        ]
      ]
    },
    "DESK: 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": "DESK: 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": "DESK: close ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to close",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: 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": "DESK: reopen ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to reopen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: reopen ticket": {
      "main": [
        [
          {
            "node": "State: mark ticket open (Spun reopened it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK 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": "DESK: read ticket (find chat id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: 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
          }
        ]
      ]
    },
    "Contact search lane": {
      "main": [
        [
          {
            "node": "DESK: search contact by phone",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "DESK: search contact by Spun LID",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DESK: 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": "Contact to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact to create": {
      "main": [
        [
          {
            "node": "DESK: create contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true
  },
  "staticData": null,
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false,
    "instanceId": "REPLACED_ON_IMPORT"
  }
}
