{
  "name": "Spun helpdesk bridge: Zendesk",
  "nodes": [
    {
      "parameters": {
        "content": "## Spun helpdesk bridge: Zendesk\nWhatsApp conversations in Spun become Zendesk tickets; public agent comments on the ticket go back to the customer on WhatsApp.\n\nImport, then follow SETUP.md in this folder. In short:\n1. Credentials: \"Spun API key\" (Header Auth: Authorization = Bearer wap_...) and \"Zendesk OAuth2\" (generic OAuth2 credential, grant type Client Credentials, token URL https://<subdomain>.zendesk.com/oauth/tokens, the scopes in SETUP.md). Select them on every SPUN: and ZENDESK: node.\n2. Settings node: replace the PASTE_... values (your Zendesk URL, the integration agent's user id, the webhook signing secret; group, brand and line field ids are optional).\n3. Replace PASTE-A-LONG-RANDOM-SUFFIX in the \"Helpdesk reply\" path with a long random string (keep the zendesk-reply- prefix).\n4. Activate, create the Zendesk webhook and the reply trigger (SETUP.md), then the Spun webhook subscription pointing at \"Spun events\".\n\nZendesk shapes come from ./API-NOTES.md (public docs, not yet live-tested). Every write to Zendesk goes through the API, so the triggers exclude it with \"Update via is not Web service\".",
        "height": 520,
        "width": 760,
        "color": 4
      },
      "id": "note-0115-0000-4000-8000-0000000000115",
      "name": "Note 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -660
      ]
    },
    {
      "parameters": {
        "content": "## 1. Receive and verify (Spun side)\nSpun POSTs the envelope {event_id, event, timestamp, org_id, data}. The Webhook node keeps the RAW body (Options > Raw Body) because the signature is an HMAC over the exact bytes.\nFlow: raw body to text > HMAC-SHA256 with the signing secret (Crypto node) > constant-time compare > answer 200 at once (or 401 on a bad signature). Everything after \"Ack 200\" runs after Spun already has its answer, so a slow desk API never causes a retry.",
        "height": 180,
        "width": 1040,
        "color": 5
      },
      "id": "note-0116-0000-4000-8000-0000000000116",
      "name": "Note 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -260
      ]
    },
    {
      "parameters": {
        "content": "## 2. Dedupe and route\nRetries reuse event_id; the same WhatsApp message can also arrive twice. Both are dropped here. Then one branch per event: message.inbound, message.outbound, conversation.closed, conversation.reopened. Any other event is acknowledged and ignored.",
        "height": 150,
        "width": 760,
        "color": 5
      },
      "id": "note-0117-0000-4000-8000-0000000000117",
      "name": "Note 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1040,
        120
      ]
    },
    {
      "parameters": {
        "content": "## 3. Contact (move 1)\nOwn map first (phone -> user id). Then the exact lookup on the workflow's own user external id (spun:+<e164>), then a phone search that adopts an existing end user only on an exact digit match, then create_or_update keyed on that external id. Zendesk cannot take a requester by phone, so every ticket needs this user id. An adopted user is never edited.",
        "height": 150,
        "width": 760,
        "color": 6
      },
      "id": "note-0118-0000-4000-8000-0000000000118",
      "name": "Note 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1560,
        560
      ]
    },
    {
      "parameters": {
        "content": "## 4. Ticket (moves 2 and 3)\nOwn map (chat_id -> ticket) first. A known ticket is re-read once so a ticket an agent closed in the desk gets a NEW ticket instead of a comment on a closed one. Fallback lookup by chat_id, then create. First message = ticket description; later messages = comments. Media arrives as a link (valid 24 h) or as \"still processing\" when media_pending is true.",
        "height": 170,
        "width": 900,
        "color": 6
      },
      "id": "note-0119-0000-4000-8000-0000000000119",
      "name": "Note 5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2600,
        560
      ]
    },
    {
      "parameters": {
        "content": "## 5. Echoes, closes, reopens (Spun side)\nmessage.outbound: a reply this workflow sent is skipped (loop guard 2); anything else sent from the Spun inbox or the phone is added as a PRIVATE note (loop guard 1).\nconversation.closed / conversation.reopened: close or reopen the mapped ticket, unless the reason is the one this workflow itself sends (loop guard on the close path).",
        "height": 170,
        "width": 900,
        "color": 6
      },
      "id": "note-0120-0000-4000-8000-0000000000120",
      "name": "Note 6",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1560,
        1440
      ]
    },
    {
      "parameters": {
        "content": "## 6. Agent replies (Zendesk side)\nA Zendesk trigger (public comment, tag spun, update not via the API) calls the webhook with ids only: {kind, ticket_id, external_id, updater_id}. The node answers 200 at once (Zendesk waits 12 s). Zendesk signs the body: base64 HMAC-SHA256 of timestamp + raw body. The Crypto node computes it, the adapter compares it in constant time and drops everything while the secret is still a placeholder.\nA comment event then reads the 5 newest comments of the ticket and keeps the public ones written by the updater within reply_window_minutes whose author is an agent or admin. Two optional triggers report solved and reopened tickets for the desk-side close. The generic filter drops the integration user's own comments and anything forwarded before, and POST /send-message uses the Zendesk comment id as idempotency_key.",
        "height": 220,
        "width": 1560,
        "color": 3
      },
      "id": "note-0121-0000-4000-8000-0000000000121",
      "name": "Note 7",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        2100
      ]
    },
    {
      "parameters": {
        "content": "## Nodes marked ZENDESK: are the only ones that talk to the helpdesk\nThey (and the small adapter Code node right after each) are what differs from the generic template. Nodes marked SPUN: call the Spun Actions API. Nodes marked State: read or write the workflow's own maps. Everything else is desk-agnostic.",
        "height": 130,
        "width": 760,
        "color": 7
      },
      "id": "note-0122-0000-4000-8000-0000000000122",
      "name": "Note 8",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -120
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "spun-events",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "node-0124-0000-4000-8000-0000000000124",
      "name": "Spun events",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "wh-0123-0000-4000-8000-0000000000123"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "zendesk-reply-PASTE-A-LONG-RANDOM-SUFFIX",
        "responseMode": "onReceived",
        "options": {
          "rawBody": true
        }
      },
      "id": "node-0126-0000-4000-8000-0000000000126",
      "name": "Helpdesk reply",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        2200
      ],
      "webhookId": "wh-0125-0000-4000-8000-0000000000125"
    },
    {
      "parameters": {
        "jsCode": "// SETTINGS - the only node you edit after import (besides credentials).\n// Every value below is a placeholder. Replace the PASTE_... values; keep the rest unless\n// SETUP.md says otherwise. Optional ids that still start with PASTE are left out of the\n// ticket. The settings object is copied onto every item so that later nodes read\n// $json.settings.<name> without depending on which trigger fired.\n// zendesk_tags must keep \"spun\": the triggers and the fallback ticket lookup rely on it.\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  \"zendesk_base\": \"https://PASTE_YOUR_SUBDOMAIN.zendesk.com\",\n  \"zendesk_webhook_secret\": \"PASTE_THE_SIGNING_SECRET_OF_THE_ZENDESK_WEBHOOK\",\n  \"zendesk_tags\": [\"spun\", \"whatsapp\"],\n  \"zendesk_group_id\": \"PASTE_OPTIONAL_GROUP_ID_FOR_NEW_TICKETS\",\n  \"zendesk_brand_id\": \"PASTE_OPTIONAL_BRAND_ID_FOR_NEW_TICKETS\",\n  \"zendesk_line_field_id\": \"PASTE_OPTIONAL_ID_OF_THE_SPUN_LINE_TICKET_FIELD\",\n  // Optional third ticket field \"Spun LID\": the privacy id of a chat whose phone WhatsApp hides\n  // (SETUP.md section 5).\n  \"zendesk_lid_field_id\": \"PASTE_OPTIONAL_ID_OF_THE_SPUN_LID_TICKET_FIELD\",\n  \"desk_integration_user_id\": \"PASTE_THE_ZENDESK_USER_ID_OF_THE_INTEGRATION_AGENT\",\n  \"reply_window_minutes\": 15,\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_zendesk\",\n  \"desk_reopen_reason\": \"reopened_in_zendesk\",\n  \"close_in_spun_when_desk_closes\": false,\n  \"dedupe_hours\": 24\n};\nreturn $input.all().map((item) => ({\n  json: { ...item.json, settings },\n  binary: item.binary,\n  pairedItem: item.pairedItem,\n}));"
      },
      "id": "node-0127-0000-4000-8000-0000000000127",
      "name": "Settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        1100
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0131-0000-4000-8000-0000000000131",
                    "leftValue": "={{ String($json.webhookUrl || '').split('/').pop() }}",
                    "rightValue": "spun-events",
                    "operator": {
                      "type": "string",
                      "operation": "startsWith"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "spun"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0132-0000-4000-8000-0000000000132",
                    "leftValue": "={{ String($json.webhookUrl || '').split('/').pop() }}",
                    "rightValue": "zendesk-reply-",
                    "operator": {
                      "type": "string",
                      "operation": "startsWith"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "desk"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0130-0000-4000-8000-0000000000130",
      "name": "Route by trigger",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        520,
        1100
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {}
      },
      "id": "node-0133-0000-4000-8000-0000000000133",
      "name": "Raw body to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        780,
        0
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.rawBody }}",
        "dataPropertyName": "computedHmac",
        "secret": "={{ $json.settings.spun_webhook_secret }}",
        "encoding": "hex"
      },
      "id": "node-0134-0000-4000-8000-0000000000134",
      "name": "HMAC of raw body",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        1040,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Compare the Spun signature with the HMAC computed by the Crypto node, then parse the body.\n// X-Webhook-Signature is \"sha256=<lowercase hex HMAC-SHA256 of the exact raw body>\".\nconst j = $input.item.json;\nconst headers = j.headers || {};\nconst header = String(headers['x-webhook-signature'] || '');\nconst expected = 'sha256=' + String(j.computedHmac || '');\nlet ok = header.length === expected.length && header.length > 7;\nif (ok) {\n  // constant-time compare without a crypto import (the Code node has no require by default)\n  let diff = 0;\n  for (let i = 0; i < expected.length; i++) diff |= header.charCodeAt(i) ^ expected.charCodeAt(i);\n  ok = diff === 0;\n}\nlet envelope = {};\ntry { envelope = JSON.parse(j.rawBody || '{}'); } catch (e) { ok = false; }\nreturn {\n  json: {\n    signature_ok: ok,\n    event_id: envelope.event_id || headers['x-webhook-delivery'] || null,\n    event: envelope.event || headers['x-webhook-event'] || null,\n    timestamp: envelope.timestamp || null,\n    org_id: envelope.org_id || null,\n    data: envelope.data || {},\n    settings: j.settings,\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0135-0000-4000-8000-0000000000135",
      "name": "Check signature + parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0136-0000-4000-8000-0000000000136",
              "leftValue": "={{ $json.signature_ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0137-0000-4000-8000-0000000000137",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, event_id: $json.event_id }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "node-0138-0000-4000-8000-0000000000138",
      "name": "Ack 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1820,
        -60
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: 'invalid_signature' }) }}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "node-0139-0000-4000-8000-0000000000139",
      "name": "Reject 401",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1820,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "// Dedupe on event_id (Spun retries reuse it) and, for message events, on data.message_id.\n// State lives in n8n workflow static data: it persists only for PRODUCTION executions of an\n// active workflow and is saved when the execution ends. Swap for a Data Table, Redis or a\n// database when you need durability or run several n8n workers (see README \"State\").\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.seenEvents ??= {};\nstate.seenMessages ??= {};\nconst now = Date.now();\nconst ttl = (Number(j.settings.dedupe_hours) || 24) * 3600 * 1000;\nfor (const key of Object.keys(state.seenEvents)) if (now - state.seenEvents[key] > ttl) delete state.seenEvents[key];\nfor (const key of Object.keys(state.seenMessages)) if (now - state.seenMessages[key] > ttl) delete state.seenMessages[key];\nlet duplicate = false;\nif (j.event_id) {\n  if (state.seenEvents[j.event_id]) duplicate = true;\n  state.seenEvents[j.event_id] = now;\n}\nconst messageId = j.data && j.data.message_id;\nif (!duplicate && messageId && String(j.event || '').startsWith('message.')) {\n  if (state.seenMessages[messageId]) duplicate = true;\n  state.seenMessages[messageId] = now;\n}\nreturn { json: { ...j, duplicate } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0140-0000-4000-8000-0000000000140",
      "name": "State: seen before?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        -60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0141-0000-4000-8000-0000000000141",
              "leftValue": "={{ !$json.duplicate }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0142-0000-4000-8000-0000000000142",
      "name": "New delivery?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2340,
        -60
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0143-0000-4000-8000-0000000000143",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "message.inbound",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "inbound"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0144-0000-4000-8000-0000000000144",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "message.outbound",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "outbound"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0145-0000-4000-8000-0000000000145",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "conversation.closed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "closed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0146-0000-4000-8000-0000000000146",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "conversation.reopened",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reopened"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0147-0000-4000-8000-0000000000147",
      "name": "Route by event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        2600,
        -60
      ]
    },
    {
      "parameters": {},
      "id": "node-0148-0000-4000-8000-0000000000148",
      "name": "Duplicate, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2600,
        220
      ]
    },
    {
      "parameters": {},
      "id": "node-0149-0000-4000-8000-0000000000149",
      "name": "Other event, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        280
      ]
    },
    {
      "parameters": {
        "jsCode": "// Shape the Spun message.inbound payload for the helpdesk.\n// Fields used (all on data, added by Spun on 10/2026): is_group, peer_phone_e164, peer_lid,\n// identity_state, contact_id, contact_name, contact_email, caption, media_url (signed link,\n// valid 24 h), media_mime, media_filename, media_size, media_pending.\n// Privacy-ID (LID) chats: WhatsApp can hide a customer's number behind a privacy id\n// (\"<digits>@lid\"). Spun then sends peer_lid (the digits) and fills peer_phone_e164 only once it\n// knows the real number (identity_state lid_linked). Until then (lid_unlinked) the chat still\n// becomes a ticket; the contact is keyed by the privacy id instead of a phone.\nconst j = $input.item.json;\nconst d = j.data || {};\nconst s = j.settings;\nconst chatId = String(d.chat_id || '');\nconst phone = d.peer_phone_e164 || null;\nconst lidMatch = /^(\\d+)@lid$/.exec(chatId);\nconst lid = d.peer_lid || (lidMatch ? lidMatch[1] : null);\nconst identity_state = d.identity_state || (lid ? (phone ? 'lid_linked' : 'lid_unlinked') : 'phone');\nlet handle = true;\nlet skip_reason = null;\n// \"Skipped\" is reserved for chats with no single person behind them: groups and newsletters.\nif (d.is_group || /@(g\\.us|newsletter|broadcast)$/.test(chatId) || (!phone && !lid)) {\n  handle = false; skip_reason = 'group or newsletter chat (no single person to make a contact for)';\n}\nconst who = phone || 'privacy ID ' + lid;\nconst name = d.contact_name || d.sender_name || d.chat_name || who;\nconst type = d.type || 'text';\nconst lines = [];\nif (d.text) lines.push(String(d.text));\nif (type !== 'text') {\n  if (d.media_pending) {\n    lines.push('[' + type + '] Media is still being processed in Spun. Open the conversation in Spun to view it.');\n  } else if (d.media_url) {\n    const meta = [d.media_filename, d.media_mime, d.media_size ? Math.round(Number(d.media_size) / 1024) + ' KB' : null].filter(Boolean).join(', ');\n    lines.push('[' + type + '] ' + d.media_url + (meta ? ' (' + meta + ')' : '') + ' - link valid for 24 hours');\n  } else {\n    lines.push('[' + type + '] Open the conversation in Spun to view it.');\n  }\n  if (d.caption) lines.push(String(d.caption));\n}\nconst text = lines.join('\\n') || '[' + type + ']';\nconst whenIso = d.timestamp ? new Date(Number(d.timestamp) * 1000).toISOString() : (j.timestamp || new Date().toISOString());\nconst firstLine = (d.text || d.caption || '[' + type + ']').split('\\n')[0];\nreturn {\n  json: {\n    settings: s,\n    event_id: j.event_id,\n    event: j.event,\n    org_id: j.org_id,\n    handle,\n    skip_reason,\n    chat_id: d.chat_id,\n    message_id: d.message_id || null,\n    phone,\n    lid,\n    identity_state,\n    name,\n    email: d.contact_email || null,\n    spun_contact_id: d.contact_id || null,\n    line_id: d.integration_id || null,\n    type,\n    text,\n    comment_text: s.inbound_prefix + ' ' + name + ' (' + who + '): ' + text,\n    subject: ('WhatsApp: ' + firstLine).slice(0, 80),\n    when: whenIso,\n    media_url: d.media_url || null,\n    media_pending: !!d.media_pending,\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0150-0000-4000-8000-0000000000150",
      "name": "Prepare message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0151-0000-4000-8000-0000000000151",
              "leftValue": "={{ $json.handle }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0152-0000-4000-8000-0000000000152",
      "name": "Handle this message?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        660
      ]
    },
    {
      "parameters": {},
      "id": "node-0153-0000-4000-8000-0000000000153",
      "name": "Skipped (group or newsletter)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Read the template's own maps: phone -> contact id, chat_id -> ticket. Desk search is only the\n// fallback (search indexes lag by minutes on most desks).\n// A privacy-ID (LID) chat with no known phone is keyed \"lid:<digits>\"; when Spun later reports the\n// same chat with a phone (identity_state lid_linked), the same contact keeps being used.\n// That contact gets the phone ONCE (add_phone; state.lidPhoneAdded remembers the attempt).\n// When the maps miss, the user is looked up by the template's own external id: spun:+<e164>,\n// or spun-lid:<digits> for a privacy-ID chat with no known phone (that lane skips the phone search).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.tickets ??= {};\nstate.lidPhoneAdded ??= {};\nconst lidKey = j.lid ? 'lid:' + j.lid : null;\nconst byPhone = j.phone ? state.contacts[j.phone] || null : null;\nconst byLid = lidKey ? state.contacts[lidKey] || null : null;\nconst contact_id = byPhone || byLid;\nconst add_phone = !!(j.phone && byLid && !byPhone && !state.lidPhoneAdded[j.lid]);\nconst ticket = state.tickets[j.chat_id] || null;\nconst ticket_known = !!(ticket && ticket.ticket_id && ticket.status !== 'closed');\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, add_phone, ticket_id: ticket_known ? ticket.ticket_id : null, ticket_known } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0154-0000-4000-8000-0000000000154",
      "name": "State: lookup contact + ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1820,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0155-0000-4000-8000-0000000000155",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0156-0000-4000-8000-0000000000156",
      "name": "Contact known?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2080,
        660
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/users",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "external_id",
              "value": "={{ $json.phone ? 'spun:' + $json.phone : 'spun-lid:' + $json.lid }}"
            }
          ]
        }
      },
      "id": "node-0157-0000-4000-8000-0000000000157",
      "name": "ZENDESK: find user by external id",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2340,
        880
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Exact lookup on the workflow's own user external id (spun:+<e164>, or spun-lid:<digits> for a\n// privacy-ID chat with no known phone); no search index involved.\nconst r = $input.item.json;\nconst j = $('Contact known?').item.json;\nconst want = (j.phone ? 'spun:' + j.phone : 'spun-lid:' + j.lid).toLowerCase();\nconst list = (r && Array.isArray(r.users)) ? r.users : [];\nconst u = list.find((x) => String(x.external_id || '').toLowerCase() === want);\nconst contact_id = u ? String(u.id) : null;\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'external_id' } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0158-0000-4000-8000-0000000000158",
      "name": "Pick matching user (external id)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0159-0000-4000-8000-0000000000159",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0160-0000-4000-8000-0000000000160",
      "name": "User found by external id?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        880
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/users/search",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "query",
              "value": "={{ 'phone:' + $json.phone }}"
            }
          ]
        }
      },
      "id": "node-0161-0000-4000-8000-0000000000161",
      "name": "ZENDESK: search user by phone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1100
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Adopt an end user the account already has. Zendesk search tokenises and its index lags, so\n// accept a hit only on an exact digit match, oldest user first. An adopted user is used as it\n// is and never edited (no external id stamped, no name or email change).\nconst r = $input.item.json;\nconst j = $('Contact known?').item.json;\nconst digits = (v) => String(v || '').replace(/\\D/g, '');\nconst want = digits(j.phone);\nconst list = (r && Array.isArray(r.users)) ? r.users : [];\nconst hit = list\n  .filter((u) => u.role === 'end-user' && u.phone && digits(u.phone) === want)\n  .sort((a, b) => Number(a.id) - Number(b.id))[0];\nconst contact_id = hit ? String(hit.id) : null;\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'phone' } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0162-0000-4000-8000-0000000000162",
      "name": "Pick matching user (phone)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        1100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0163-0000-4000-8000-0000000000163",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0164-0000-4000-8000-0000000000164",
      "name": "User found by phone?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3640,
        1100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/users/create_or_update",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ user: Object.assign({ external_id: $json.phone ? 'spun:' + $json.phone : 'spun-lid:' + $json.lid, name: String($json.name).slice(0, 255), role: 'end-user', skip_verify_email: true }, $json.phone ? { phone: $json.phone } : {}) }) }}"
      },
      "id": "node-0165-0000-4000-8000-0000000000165",
      "name": "ZENDESK: create or update user",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3900,
        1100
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// create_or_update answers 201 (created) or 200 (updated, same external id) with the user object.\n// No email is sent, so a user the account already has by email is never matched or changed.\nconst r = $input.item.json;\nconst j = $('User to create').item.json;\nconst contact_id = r && r.user && r.user.id ? String(r.user.id) : null;\nif (!contact_id) throw new Error('Zendesk user create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, contact_id, contact_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0166-0000-4000-8000-0000000000166",
      "name": "Read new user id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        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-0167-0000-4000-8000-0000000000167",
      "name": "State: remember contact",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0168-0000-4000-8000-0000000000168",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0169-0000-4000-8000-0000000000169",
      "name": "Open ticket known?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2340,
        440
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0170-0000-4000-8000-0000000000170",
      "name": "ZENDESK: read ticket status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        440
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zendesk: \"closed\" is final (a new message needs a follow-up ticket). \"solved\" still takes\n// comments; the append sets it back to open. A 404 (deleted ticket) counts as not open.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst t = r && r.ticket ? r.ticket : null;\nconst still_open = !!t && t.status !== 'closed' && t.status !== 'deleted';\nreturn { json: { ...j, still_open, ticket_status: t ? t.status : null, followup_source_id: t && t.status === 'closed' ? String(t.id) : null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0171-0000-4000-8000-0000000000171",
      "name": "Ticket still open?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        440
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0172-0000-4000-8000-0000000000172",
              "leftValue": "={{ $json.still_open }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0173-0000-4000-8000-0000000000173",
      "name": "Still open?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3120,
        440
      ]
    },
    {
      "parameters": {
        "jsCode": "// Mark the mapped ticket \"closed\" without touching the desk.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, ticket_known: false, ticket_id: null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0174-0000-4000-8000-0000000000174",
      "name": "State: mark ticket closed (desk closed it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        660
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $('Open ticket known?').item.json.settings.zendesk_base }}/api/v2/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "external_id",
              "value": "={{ $('Open ticket known?').item.json.chat_id }}"
            }
          ]
        }
      },
      "id": "node-0175-0000-4000-8000-0000000000175",
      "name": "ZENDESK: find tickets by external_id (chat_id)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        660
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zendesk fallback: GET /tickets?external_id=<chat_id> is exact (no search index), but not unique\n// and it leaves out archived tickets. Take the newest ticket tagged \"spun\" that is not closed; when\n// all are closed, the newest closed one becomes the follow-up source of the new ticket.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst list = (r && Array.isArray(r.tickets)) ? r.tickets : [];\nlet earlier = null;\ntry { earlier = $('Ticket still open?').item.json.followup_source_id || null; } catch (e) { earlier = null; }\nconst ours = list\n  .filter((t) => String(t.external_id || '') === String(j.chat_id) && Array.isArray(t.tags) && t.tags.includes('spun'))\n  .sort((a, b) => Number(b.id) - Number(a.id));\nconst open = ours.find((t) => t.status !== 'closed') || null;\nconst lastClosed = ours.find((t) => t.status === 'closed') || null;\nconst ticket_id = open ? String(open.id) : null;\nconst followup_source_id = open ? null : (earlier || (lastClosed ? String(lastClosed.id) : null));\nreturn { json: { ...j, ticket_id, ticket_known: !!ticket_id, ticket_status: open ? open.status : null, followup_source_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0176-0000-4000-8000-0000000000176",
      "name": "Pick open ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0177-0000-4000-8000-0000000000177",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0178-0000-4000-8000-0000000000178",
      "name": "Open ticket found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4160,
        660
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the chat_id -> ticket map entry (status \"open\").\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nif (j.chat_id && j.ticket_id) {\n  state.tickets[j.chat_id] = { ticket_id: String(j.ticket_id), status: 'open', updated_at: Date.now() };\n}\nreturn { json: { ...j, ticket_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0179-0000-4000-8000-0000000000179",
      "name": "State: remember found ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        440
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $('Open ticket known?').item.json.settings.zendesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $('Open ticket known?').item.json; const t = { comment: { html_body: String(j.comment_text).slice(0, 20000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>'), public: true, author_id: Number(j.contact_id) } }; if (['pending', 'hold', 'solved'].includes($json.ticket_status)) t.status = 'open'; return JSON.stringify({ ticket: t }); })() }}"
      },
      "id": "node-0180-0000-4000-8000-0000000000180",
      "name": "ZENDESK: append message as comment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4680,
        440
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Open ticket known?').item.json.settings.zendesk_base }}/api/v2/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $('Open ticket known?').item.json; const s = j.settings; const set = (v) => v !== undefined && v !== null && String(v) !== '' && !String(v).startsWith('PASTE'); const t = { subject: String(j.subject).slice(0, 150), comment: { html_body: String(j.comment_text).slice(0, 20000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>'), public: true, author_id: Number(j.contact_id) }, requester_id: Number(j.contact_id), submitter_id: Number(j.contact_id), external_id: j.chat_id, tags: Array.from(new Set(['spun'].concat(s.zendesk_tags || []))) }; if ($json.followup_source_id) t.via_followup_source_id = Number($json.followup_source_id); if (set(s.zendesk_group_id)) t.group_id = Number(s.zendesk_group_id); if (set(s.zendesk_brand_id)) t.brand_id = Number(s.zendesk_brand_id); const cf = []; if (set(s.zendesk_line_field_id) && j.line_id) cf.push({ id: Number(s.zendesk_line_field_id), value: String(j.line_id) }); if (set(s.zendesk_lid_field_id) && j.lid) cf.push({ id: Number(s.zendesk_lid_field_id), value: String(j.lid) }); if (cf.length) t.custom_fields = cf; return JSON.stringify({ ticket: t }); })() }}"
      },
      "id": "node-0181-0000-4000-8000-0000000000181",
      "name": "ZENDESK: create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4420,
        880
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zendesk answers 201 {ticket: {id, status: \"new\", ...}, audit}.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst t = r && r.ticket ? r.ticket : null;\nconst ticket_id = t && t.id ? String(t.id) : null;\nif (!ticket_id) throw new Error('Zendesk ticket create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, ticket_id, ticket_url: j.settings.zendesk_base + '/agent/tickets/' + ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0182-0000-4000-8000-0000000000182",
      "name": "Read new ticket id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4680,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the chat_id -> ticket map entry (status \"open\").\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nif (j.chat_id && j.ticket_id) {\n  state.tickets[j.chat_id] = { ticket_id: String(j.ticket_id), status: 'open', updated_at: Date.now() };\n}\nreturn { json: { ...j, ticket_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0183-0000-4000-8000-0000000000183",
      "name": "State: remember new ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4940,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Bookkeeping after an append: last activity on the ticket.\n// Zendesk answers {ticket, audit} on success. Anything else (429 from the 30 updates per ticket per\n// 10 minutes limit, 422 on a ticket with 5,000 comments) fails this execution so it shows in the\n// n8n execution list instead of passing silently; the message itself is still in Spun.\nconst j = $('Open ticket known?').item.json;\nconst r = $input.item.json;\nif (!r || !r.ticket) throw new Error('Zendesk did not accept the comment on ticket ' + j.ticket_id + ': ' + JSON.stringify(r).slice(0, 500));\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) t.updated_at = Date.now();\nreturn { json: { ...j, appended: true, desk_response: r } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0184-0000-4000-8000-0000000000184",
      "name": "State: remember message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4940,
        440
      ]
    },
    {
      "parameters": {
        "jsCode": "// message.outbound: is this the echo of a reply this workflow sent (loop guard 2)?\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.sentMessages ??= {};\nstate.tickets ??= {};\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-0185-0000-4000-8000-0000000000185",
      "name": "Own send or ticket?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0186-0000-4000-8000-0000000000186",
              "leftValue": "={{ $json.is_echo }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0187-0000-4000-8000-0000000000187",
      "name": "Echo of our own reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0188-0000-4000-8000-0000000000188",
      "name": "Echo, nothing to add",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        1240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0189-0000-4000-8000-0000000000189",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0190-0000-4000-8000-0000000000190",
      "name": "Ticket known for this chat?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1820,
        1400
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ ticket: { comment: { html_body: String($json.note_text).slice(0, 20000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>'), public: false } } }) }}"
      },
      "id": "node-0191-0000-4000-8000-0000000000191",
      "name": "ZENDESK: add private note (sent from Spun)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        1400
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {},
      "id": "node-0192-0000-4000-8000-0000000000192",
      "name": "No ticket, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2080,
        1600
      ]
    },
    {
      "parameters": {
        "jsCode": "// conversation.closed from Spun -> close the mapped ticket. Loop guard: a close this workflow\n// itself requested (reason = settings.desk_close_reason) comes back as conversation.closed too.\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst ticket = state.tickets[d.chat_id] || null;\nconst ours = d.reason === j.settings.desk_close_reason;\nconst close_in_desk = !!(ticket && ticket.ticket_id && ticket.status !== 'closed' && !ours);\nreturn { json: { settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, ticket_id: ticket ? ticket.ticket_id : null, close_in_desk, closed_by: d.closed_by || null, reason: d.reason || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0193-0000-4000-8000-0000000000193",
      "name": "Prepare close",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0194-0000-4000-8000-0000000000194",
              "leftValue": "={{ $json.close_in_desk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0195-0000-4000-8000-0000000000195",
      "name": "Close ticket in desk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        1320
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ ticket: { status: 'solved' } }) }}"
      },
      "id": "node-0196-0000-4000-8000-0000000000196",
      "name": "ZENDESK: solve ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1320
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Close ticket in desk?').item.json;\n// Mark the mapped ticket \"closed\" only when Zendesk accepted the solve. An account that requires\n// an assignee answers 422; the ticket then stays open in Zendesk and in the map.\nconst r = $input.item.json;\nconst solved = !!(r && r.ticket && ['solved', 'closed'].includes(r.ticket.status));\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t && solved) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, desk_solved: solved, desk_error: solved ? null : r, ticket_known: !solved, ticket_id: solved ? null : j.ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0197-0000-4000-8000-0000000000197",
      "name": "State: mark ticket closed (Spun closed it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0198-0000-4000-8000-0000000000198",
      "name": "Nothing to close",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3120,
        1540
      ]
    },
    {
      "parameters": {
        "jsCode": "// conversation.reopened from Spun -> reopen the mapped ticket (same loop guard as close).\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst ticket = state.tickets[d.chat_id] || null;\nconst ours = d.reason === j.settings.desk_reopen_reason;\nconst reopen_in_desk = !!(ticket && ticket.ticket_id && ticket.status === 'closed' && !ours);\nreturn { json: { settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, ticket_id: ticket ? ticket.ticket_id : null, reopen_in_desk, reason: d.reason || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0199-0000-4000-8000-0000000000199",
      "name": "Prepare reopen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0200-0000-4000-8000-0000000000200",
              "leftValue": "={{ $json.reopen_in_desk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0201-0000-4000-8000-0000000000201",
      "name": "Reopen ticket in desk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3900,
        1320
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ ticket: { status: 'open' } }) }}"
      },
      "id": "node-0202-0000-4000-8000-0000000000202",
      "name": "ZENDESK: reopen ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4160,
        1320
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Reopen ticket in desk?').item.json;\n// Mark the mapped ticket \"open\" without touching the desk.\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) { t.status = 'open'; t.updated_at = Date.now(); }\nreturn { json: { ...j, ticket_known: !!t, ticket_id: (t && t.ticket_id) || j.ticket_id || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0203-0000-4000-8000-0000000000203",
      "name": "State: mark ticket open (Spun reopened it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0204-0000-4000-8000-0000000000204",
      "name": "Nothing to reopen",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        4160,
        1540
      ]
    },
    {
      "parameters": {
        "jsCode": "// Zendesk trigger webhook (SETUP.md section 7). The body carries ids only:\n// {\"kind\": \"comment\" | \"solved\" | \"reopened\", \"ticket_id\", \"external_id\", \"updater_id\"}.\n// Signature: X-Zendesk-Webhook-Signature = base64(HMAC-SHA256(secret, timestamp + raw body)), with\n// the timestamp from X-Zendesk-Webhook-Signature-Timestamp. \"HMAC of reply body\" computed it.\n// Fail closed: nothing passes while zendesk_webhook_secret is still a placeholder.\nconst KINDS = { comment: 'agent_reply', solved: 'ticket_closed', reopened: 'ticket_reopened' };\nconst out = [];\n$input.all().forEach((item, itemIndex) => {\n  const j = item.json;\n  const settings = j.settings;\n  const headers = j.headers || {};\n  const secret = String(settings.zendesk_webhook_secret || '');\n  const secretSet = secret !== '' && !secret.startsWith('PASTE');\n  const header = String(headers['x-zendesk-webhook-signature'] || '');\n  const expected = String(j.computedHmac || '');\n  let ok = secretSet && expected.length > 0 && header.length === expected.length;\n  if (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  }\n  let body = null;\n  try { body = JSON.parse(j.rawBody || ''); } catch (e) { body = null; }\n  const kindIn = body && body.kind ? String(body.kind) : '';\n  const ticket_id = body && /^\\d+$/.test(String(body.ticket_id || '')) ? String(body.ticket_id) : '';\n  let kind = 'ignore';\n  let why = null;\n  if (!secretSet) why = 'zendesk_webhook_secret is not set in Settings';\n  else if (!ok) why = 'bad or missing X-Zendesk-Webhook-Signature';\n  else if (!body || !ticket_id) why = 'body is not the trigger JSON from SETUP.md';\n  else if (!KINDS[kindIn]) why = 'unknown kind: ' + kindIn;\n  else kind = KINDS[kindIn];\n  out.push({\n    json: {\n      settings,\n      kind,\n      why,\n      event_type: kindIn || null,\n      ticket_id,\n      external_id: body && body.external_id ? String(body.external_id) : null,\n      updater_id: body && /^\\d+$/.test(String(body.updater_id || '')) ? String(body.updater_id) : null,\n      desk_event_id: ticket_id + ':' + kindIn + ':' + String(headers['x-zendesk-webhook-signature-timestamp'] || ''),\n      comment_id: null,\n      text: '',\n      is_public: false,\n      author_type: 'system',\n      author_id: null,\n    },\n    pairedItem: { item: itemIndex },\n  });\n});\nreturn out;"
      },
      "id": "node-0205-0000-4000-8000-0000000000205",
      "name": "ZENDESK adapter: verify + read trigger body",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        2200
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0206-0000-4000-8000-0000000000206",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "agent_reply",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reply"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0207-0000-4000-8000-0000000000207",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "ticket_closed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "closed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0208-0000-4000-8000-0000000000208",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "ticket_reopened",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reopened"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0209-0000-4000-8000-0000000000209",
      "name": "Route by desk event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        1560,
        2200
      ]
    },
    {
      "parameters": {},
      "id": "node-0210-0000-4000-8000-0000000000210",
      "name": "Ignored desk event",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2340,
        2480
      ]
    },
    {
      "parameters": {
        "jsCode": "// Loop guards 1 and 3 on the desk side, then resolve the chat id for the ticket.\n// Passes a reply only when: public, written by an agent, not by the integration user,\n// not starting with the inbound prefix (our own echo), and not forwarded before.\nconst j = $input.item.json;\nconst s = j.settings;\nconst state = $getWorkflowStaticData('global');\nstate.sentComments ??= {};\nstate.tickets ??= {};\nlet forward = true;\nlet reason = null;\nif (!j.is_public) { forward = false; reason = 'private note'; }\nelse if (j.author_type !== 'agent') { forward = false; reason = 'not an agent'; }\nelse if (s.desk_integration_user_id && String(j.author_id) === String(s.desk_integration_user_id)) { forward = false; reason = 'written by the integration user'; }\nelse if (String(j.text || '').trim().startsWith(s.inbound_prefix)) { forward = false; reason = 'echo of an inbound message'; }\nelse if (!j.comment_id) { forward = false; reason = 'no comment id'; }\nelse if (state.sentComments[j.comment_id]) { forward = false; reason = 'already forwarded'; }\nelse if (!String(j.text || '').trim()) { forward = false; reason = 'empty text'; }\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nreturn { json: { ...j, forward, filter_reason: reason, chat_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0211-0000-4000-8000-0000000000211",
      "name": "Reply filter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        1980
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0212-0000-4000-8000-0000000000212",
              "leftValue": "={{ $json.forward }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0213-0000-4000-8000-0000000000213",
      "name": "Forward this reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2600,
        1980
      ]
    },
    {
      "parameters": {},
      "id": "node-0214-0000-4000-8000-0000000000214",
      "name": "Reply filtered out",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        2200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0215-0000-4000-8000-0000000000215",
              "leftValue": "={{ !!$json.chat_id }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0216-0000-4000-8000-0000000000216",
      "name": "Chat id resolved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        1900
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0217-0000-4000-8000-0000000000217",
      "name": "ZENDESK: read ticket (find chat id)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        2020
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Zendesk ticket object: the workflow stores the Spun chat id in the ticket's external_id and\n// tags the ticket \"spun\". A ticket without both was not created by this workflow.\nconst r = $input.item.json;\nconst j = $('Chat id resolved?').item.json;\nconst t = r && r.ticket ? r.ticket : null;\nconst cid = t && t.external_id && Array.isArray(t.tags) && t.tags.includes('spun') ? String(t.external_id) : null;\nif (!cid) throw new Error('Zendesk ticket ' + j.ticket_id + ' has no Spun chat id in external_id or no \"spun\" tag; was it created by this workflow?');\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nstate.tickets[cid] ??= { ticket_id: String(j.ticket_id), status: t.status === 'closed' ? 'closed' : 'open', updated_at: Date.now() };\nreturn { json: { ...j, chat_id: cid } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0218-0000-4000-8000-0000000000218",
      "name": "Read chat id from ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        2020
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Chat id resolved?').item.json.settings.spun_api_base }}/send-message",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ to: $('Chat id resolved?').item.json.chat_id, text: $('Chat id resolved?').item.json.text, idempotency_key: String($('Chat id resolved?').item.json.comment_id).slice(0, 200) }) }}"
      },
      "id": "node-0219-0000-4000-8000-0000000000219",
      "name": "SPUN: send-message",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        1900
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Remember our own send (loop guard 2) and the forwarded comment (loop guard 3).\nconst r = $input.item.json;\nconst j = $('Chat id resolved?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.sentMessages ??= {};\nstate.sentComments ??= {};\nconst now = Date.now();\nif (r.message_id) state.sentMessages[r.message_id] = now;\nif (j.comment_id) state.sentComments[j.comment_id] = now;\nconst ttl = (Number(j.settings.dedupe_hours) || 24) * 3600 * 1000;\nfor (const k of Object.keys(state.sentMessages)) if (now - state.sentMessages[k] > ttl) delete state.sentMessages[k];\nfor (const k of Object.keys(state.sentComments)) if (now - state.sentComments[k] > ttl) delete state.sentComments[k];\nreturn { json: { ...j, spun_response: r, sent: !!r.message_id, queued: !!r.queued, queue_id: r.queue_id || null, error: r.error || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0220-0000-4000-8000-0000000000220",
      "name": "State: remember sent message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        1900
      ]
    },
    {
      "parameters": {
        "jsCode": "// Ticket closed in the desk -> close the conversation in Spun (needs the manage_conversations\n// scope and a Spun server release that includes POST /conversations/close).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nconst t = chat_id ? state.tickets[chat_id] : null;\nconst act = !!(chat_id && t && t.status !== 'closed' && j.settings.close_in_spun_when_desk_closes);\nreturn { json: { ...j, chat_id, act, idempotency_key: ('desk-close-' + j.desk_event_id).slice(0, 200) } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0221-0000-4000-8000-0000000000221",
      "name": "Prepare Spun close",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        2640
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0222-0000-4000-8000-0000000000222",
              "leftValue": "={{ $json.act }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0223-0000-4000-8000-0000000000223",
      "name": "Close in Spun?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2600,
        2640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.spun_api_base }}/conversations/close",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, reason: $json.settings.desk_close_reason, idempotency_key: $json.idempotency_key }) }}"
      },
      "id": "node-0224-0000-4000-8000-0000000000224",
      "name": "SPUN: close conversation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2860,
        2640
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Mark the ticket \"closed\" after the Spun call (desk-initiated change).\nconst j = $('Close in Spun?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = j.chat_id ? state.tickets[j.chat_id] : null;\nif (t) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, spun_response: $input.item.json } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0225-0000-4000-8000-0000000000225",
      "name": "State: mark ticket closed (desk)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0226-0000-4000-8000-0000000000226",
      "name": "Not closing in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        2860
      ]
    },
    {
      "parameters": {
        "jsCode": "// Ticket reopened in the desk -> reopen the conversation in Spun (same requirements as close).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nconst t = chat_id ? state.tickets[chat_id] : null;\nconst act = !!(chat_id && t && t.status === 'closed' && j.settings.close_in_spun_when_desk_closes);\nreturn { json: { ...j, chat_id, act, idempotency_key: ('desk-reopen-' + j.desk_event_id).slice(0, 200) } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0227-0000-4000-8000-0000000000227",
      "name": "Prepare Spun reopen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        2640
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0228-0000-4000-8000-0000000000228",
              "leftValue": "={{ $json.act }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0229-0000-4000-8000-0000000000229",
      "name": "Reopen in Spun?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3640,
        2640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.spun_api_base }}/conversations/reopen",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, reason: $json.settings.desk_reopen_reason, idempotency_key: $json.idempotency_key }) }}"
      },
      "id": "node-0230-0000-4000-8000-0000000000230",
      "name": "SPUN: reopen conversation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3900,
        2640
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Mark the ticket \"open\" after the Spun call (desk-initiated change).\nconst j = $('Reopen in Spun?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = j.chat_id ? state.tickets[j.chat_id] : null;\nif (t) { t.status = 'open'; t.updated_at = Date.now(); }\nreturn { json: { ...j, spun_response: $input.item.json } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0231-0000-4000-8000-0000000000231",
      "name": "State: mark ticket open (desk)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0232-0000-4000-8000-0000000000232",
      "name": "Not reopening in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3900,
        2860
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {}
      },
      "id": "node-0233-0000-4000-8000-0000000000233",
      "name": "Reply raw body to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        780,
        2200
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ String(($json.headers || {})['x-zendesk-webhook-signature-timestamp'] || '') + $json.rawBody }}",
        "dataPropertyName": "computedHmac",
        "secret": "={{ $json.settings.zendesk_webhook_secret }}",
        "encoding": "base64"
      },
      "id": "node-0234-0000-4000-8000-0000000000234",
      "name": "HMAC of reply body",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        1040,
        2200
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/tickets/{{ $json.ticket_id }}/comments",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "sort",
              "value": "-created_at"
            },
            {
              "name": "page[size]",
              "value": "5"
            },
            {
              "name": "include",
              "value": "users"
            }
          ]
        }
      },
      "id": "node-0235-0000-4000-8000-0000000000235",
      "name": "ZENDESK: read latest comments",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1820,
        1980
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "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// Zendesk comments, newest first, with the authors sideloaded (include=users). Keep the public\n// comments written by the user who made the update (updater_id from the trigger), created within\n// reply_window_minutes, oldest first. author_type is \"agent\" only for role agent or admin, so the\n// requester's own comments (every inbound WhatsApp message) never pass the reply filter.\nconst out = [];\n$input.all().forEach((item, itemIndex) => {\n  const r = item.json;\n  const ev = $('Route by desk event').itemMatching(itemIndex).json;\n  const windowMs = (Number(ev.settings.reply_window_minutes) || 15) * 60 * 1000;\n  const users = {};\n  for (const u of (r && Array.isArray(r.users) ? r.users : [])) users[String(u.id)] = u;\n  const comments = (r && Array.isArray(r.comments) ? r.comments : [])\n    .filter((c) => c.public === true && String(c.author_id) === String(ev.updater_id) && Date.now() - Date.parse(c.created_at) <= windowMs)\n    .sort((a, b) => Number(a.id) - Number(b.id));\n  const mark = out.length;\n  for (const c of comments) {\n    const author = users[String(c.author_id)];\n    out.push({ json: {\n      ...ev,\n      kind: 'agent_reply',\n      comment_id: String(c.id),\n      text: c.html_body ? htmlToText(c.html_body) : String(c.plain_body || c.body || '').trim(),\n      is_public: true,\n      author_type: author && (author.role === 'agent' || author.role === 'admin') ? 'agent' : 'customer',\n      author_id: String(c.author_id),\n      desk_event_id: 'comment:' + String(c.id),\n    } });\n  }\n  if (out.length === mark) {\n    const why = r && Array.isArray(r.comments) ? 'no public comment by the updater in the reply window' : 'reading comments failed: ' + JSON.stringify(r).slice(0, 300);\n    out.push({ json: { ...ev, kind: 'agent_reply', comment_id: null, text: '', is_public: true, author_type: 'system', why } });\n  }\n  for (let k = mark; k < out.length; k++) out[k].pairedItem = { item: itemIndex };\n});\nreturn out;"
      },
      "id": "node-0236-0000-4000-8000-0000000000236",
      "name": "ZENDESK adapter: pick agent comments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        1980
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0901-0000-4000-8000-000000000901",
              "leftValue": "={{ !!$json.phone }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0901-0000-4000-8000-000000000901",
      "name": "Phone to search?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        1000
      ]
    },
    {
      "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-0902-0000-4000-8000-000000000902",
      "name": "User to create",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3770,
        1460
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0903-0000-4000-8000-000000000903",
              "leftValue": "={{ $json.add_phone }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0903-0000-4000-8000-000000000903",
      "name": "Add phone to privacy-ID user?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2080,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.zendesk_base }}/api/v2/users/create_or_update",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ user: { external_id: 'spun-lid:' + $json.lid, phone: $json.phone } }) }}"
      },
      "id": "node-0904-0000-4000-8000-000000000904",
      "name": "ZENDESK: add phone to privacy-ID user",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2210,
        120
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Zendesk OAuth2 (generic OAuth2 credential, client credentials)"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// One attempt per privacy ID, whatever Zendesk answered (a failed update stays visible in\n// phone_added_error). create_or_update answers 200 with the user it updated; on success the\n// phone maps to this user as well. The user keeps its spun-lid external id.\nconst r = $input.item.json;\nconst j = $('Add phone to privacy-ID user?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.lidPhoneAdded ??= {};\nstate.lidPhoneAdded[j.lid] = Date.now();\nconst ok = !!(r && r.user && String(r.user.id) === String(j.contact_id));\nif (ok) state.contacts[j.phone] = j.contact_id;\nreturn { json: { ...j, phone_added: ok, phone_added_error: ok ? null : JSON.stringify(r).slice(0, 500) } };"
      },
      "id": "node-0905-0000-4000-8000-000000000905",
      "name": "State: phone added once",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        240
      ]
    }
  ],
  "connections": {
    "Spun events": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Helpdesk reply": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Route by trigger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by trigger": {
      "main": [
        [
          {
            "node": "Raw body to text",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply raw body to text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Raw body to text": {
      "main": [
        [
          {
            "node": "HMAC of raw body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HMAC of raw body": {
      "main": [
        [
          {
            "node": "Check signature + parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check signature + parse": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Ack 200",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject 401",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ack 200": {
      "main": [
        [
          {
            "node": "State: seen before?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: seen before?": {
      "main": [
        [
          {
            "node": "New delivery?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New delivery?": {
      "main": [
        [
          {
            "node": "Route by event",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Duplicate, ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by event": {
      "main": [
        [
          {
            "node": "Prepare message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Own send or ticket?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare close",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare reopen",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Other event, ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare message": {
      "main": [
        [
          {
            "node": "Handle this message?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle this message?": {
      "main": [
        [
          {
            "node": "State: lookup contact + ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skipped (group or newsletter)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: lookup contact + ticket": {
      "main": [
        [
          {
            "node": "Contact known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact known?": {
      "main": [
        [
          {
            "node": "Add phone to privacy-ID user?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "ZENDESK: find user by external id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: find user by external id": {
      "main": [
        [
          {
            "node": "Pick matching user (external id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching user (external id)": {
      "main": [
        [
          {
            "node": "User found by external id?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "User found by external id?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Phone to search?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: search user by phone": {
      "main": [
        [
          {
            "node": "Pick matching user (phone)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching user (phone)": {
      "main": [
        [
          {
            "node": "User found by phone?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "User found by phone?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "User to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: create or update user": {
      "main": [
        [
          {
            "node": "Read new user id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read new user 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": "ZENDESK: read ticket status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "ZENDESK: find tickets by external_id (chat_id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: 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": "ZENDESK: 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": "ZENDESK: find tickets by external_id (chat_id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: find tickets by external_id (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": "ZENDESK: create ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember found ticket": {
      "main": [
        [
          {
            "node": "ZENDESK: append message as comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: 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
          }
        ]
      ]
    },
    "ZENDESK: 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": "ZENDESK: 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": "ZENDESK: solve ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to close",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: solve 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": "ZENDESK: reopen ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to reopen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: reopen ticket": {
      "main": [
        [
          {
            "node": "State: mark ticket open (Spun reopened it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK adapter: verify + read trigger body": {
      "main": [
        [
          {
            "node": "Route by desk event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by desk event": {
      "main": [
        [
          {
            "node": "ZENDESK: read latest comments",
            "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": "ZENDESK: read ticket (find chat id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: 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
          }
        ]
      ]
    },
    "Reply raw body to text": {
      "main": [
        [
          {
            "node": "HMAC of reply body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HMAC of reply body": {
      "main": [
        [
          {
            "node": "ZENDESK adapter: verify + read trigger body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: read latest comments": {
      "main": [
        [
          {
            "node": "ZENDESK adapter: pick agent comments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK adapter: pick agent comments": {
      "main": [
        [
          {
            "node": "Reply filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Phone to search?": {
      "main": [
        [
          {
            "node": "ZENDESK: search user by phone",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "User to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "User to create": {
      "main": [
        [
          {
            "node": "ZENDESK: create or update user",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add phone to privacy-ID user?": {
      "main": [
        [
          {
            "node": "ZENDESK: add phone to privacy-ID user",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ZENDESK: add phone to privacy-ID user": {
      "main": [
        [
          {
            "node": "State: phone added once",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: phone added once": {
      "main": [
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true
  },
  "staticData": null,
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false,
    "instanceId": "REPLACED_ON_IMPORT"
  }
}
