{
  "name": "Domain Health Hub alerts to ConnectWise PSA",
  "nodes": [
    {
      "id": "6f659c29-4d23-4152-9d86-6b35715f68d0",
      "name": "DHH alert",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [0, 300],
      "webhookId": "b12f1ac8-0b26-4d7d-9beb-6bc113cd6903",
      "parameters": {
        "httpMethod": "POST",
        "path": "dhh-alerts-connectwise",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      }
    },
    {
      "id": "9dd35921-642e-4f3b-971b-503644765b56",
      "name": "Prepare signature check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [220, 300],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Domain Health Hub signs every webhook. X-DHH-Signature is \"sha256=\"\n// followed by the hex HMAC-SHA256 of \"<X-DHH-Timestamp>.<raw body>\", made\n// with your webhook's signing secret. This node gets the exact bytes we sent\n// (the Webhook node's Raw Body option) and the two headers; the Crypto node\n// after it works out the HMAC with the secret held in an n8n credential.\nconst MAX_AGE_SECONDS = 300; // refuse anything older than five minutes (replays)\n\nconst item = $input.first();\nconst headers = item.json.headers || {};\n\nlet raw;\ntry {\n  raw = (await this.helpers.getBinaryDataBuffer(0, \"data\")).toString(\"utf8\");\n} catch (error) {\n  const inline = item.binary && item.binary.data && item.binary.data.data;\n  if (!inline) {\n    throw new Error('No raw body. In the Webhook node, add the option \"Raw Body\" and switch it on.');\n  }\n  raw = Buffer.from(inline, \"base64\").toString(\"utf8\");\n}\n\nconst timestamp = String(headers[\"x-dhh-timestamp\"] || \"\");\nconst signature = String(headers[\"x-dhh-signature\"] || \"\");\nconst age = Math.abs(Date.now() / 1000 - Number(timestamp));\nconst fresh = /^[0-9]+$/.test(timestamp) && age <= MAX_AGE_SECONDS;\n\nreturn [\n  {\n    json: {\n      signedPayload: timestamp + \".\" + raw,\n      signature,\n      fresh,\n      delivery: String(headers[\"x-dhh-delivery\"] || \"\"),\n      alert: item.json.body || {},\n    },\n  },\n];\n"
      }
    },
    {
      "id": "bac5fd3a-21c5-4ba1-b057-fc86ce072b2b",
      "name": "HMAC-SHA256",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 2,
      "position": [440, 300],
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.signedPayload }}",
        "dataPropertyName": "expected",
        "encoding": "hex"
      }
    },
    {
      "id": "a7958687-34ea-48e1-8fcb-86a9454f260a",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [660, 300],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "9c82ea8a-5df6-4968-a669-59ff1a3bb079",
              "leftValue": "={{ $json.fresh === true && (\"sha256=\" + $json.expected) === $json.signature }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "c8c9d934-ef74-4abd-9003-d860697418ab",
      "name": "Refuse",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [880, 520],
      "parameters": {
        "respondWith": "json",
        "responseBody": "{ \"error\": \"bad signature or stale timestamp\" }",
        "options": {
          "responseCode": 401
        }
      }
    },
    {
      "id": "23185ee0-db23-49a0-bdc1-b74132f5f38f",
      "name": "Ticket details",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [880, 200],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Your ConnectWise PSA settings: fill these in once. Everything else is worked\n// out from the alert.\nconst SETTINGS = {\n  \"baseUrl\": \"https://api-eu.myconnectwise.net/v4_6_release/apis/3.0\",\n  \"clientId\": \"YOUR-CLIENT-ID\",\n  \"board\": \"Service Desk\",\n  \"defaultCompanyId\": 0,\n  \"companyIds\": {\n    \"Example Client Ltd\": 250\n  },\n  \"titleMax\": 100\n};\n\nconst EVENT_NAMES = {\n  grade_dropped: \"Grade dropped\",\n  dns_changed: \"DNS changed\",\n  expiry_warning: \"Domain expiring\",\n  ssl_warning: \"Certificate problem\",\n  blacklisted: \"Blacklisted\",\n  dmarc_failure_spike: \"DMARC failures jumped\",\n  dkim_selector_unserved: \"DKIM selector not served\",\n  dkim_proof_missing: \"Hosted DKIM proof missing\",\n  dkim_zone_unverified: \"Hosted DKIM stopped\",\n  dkim_zone_displaced: \"Hosted DKIM taken over\",\n  test: \"Test alert\",\n};\n\nconst alert = $input.first().json.alert;\nconst domain = (alert.domain && alert.domain.name) || \"\";\nconst client = (alert.client && alert.client.name) || \"\";\nconst what = EVENT_NAMES[alert.event] || alert.event || \"Alert\";\n\n// One open ticket per alert type and domain: the title is the key, so a\n// repeat of the same problem adds a note instead of a new ticket.\nconst title = (domain ? \"DHH: \" + what + \" on \" + domain : \"DHH: \" + what).slice(0, SETTINGS.titleMax);\n\nconst lines = [\n  alert.title || what,\n  \"\",\n  alert.summary || \"\",\n  \"\",\n  \"Client: \" + (client || \"none\"),\n  \"Domain: \" + (domain || \"none\"),\n  \"Alert: \" + (alert.event || \"\"),\n  \"Raised: \" + (alert.created_at || \"\"),\n  \"Open in Domain Health Hub: \" + (alert.url || \"\"),\n  \"Alert ID: \" + (alert.id || \"\"),\n];\nif (alert.details && Object.keys(alert.details).length > 0) {\n  lines.push(\"\", \"Details:\", JSON.stringify(alert.details, null, 2));\n}\n\n// Where the ticket goes: the client's companyId if you've mapped its Domain\n// Health Hub client name above, otherwise the default.\nconst companyId = Object.prototype.hasOwnProperty.call(SETTINGS.companyIds, client)\n  ? SETTINGS.companyIds[client]\n  : SETTINGS.defaultCompanyId;\n\nreturn [\n  {\n    json: {\n      ...SETTINGS,\n      companyId,\n      // Titles never hold a double quote (it would end the string), but make sure.\n      conditions: 'summary=\"' + title.replace(/\"/g, \"\") + '\" and closedFlag=false',\n      title,\n      description: lines.join(\"\\n\"),\n      client,\n      domain,\n      event: alert.event,\n    },\n  },\n];\n"
      }
    },
    {
      "id": "ef226ef1-8809-4c92-bb4e-97861cf86ade",
      "name": "Find open ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1100, 200],
      "parameters": {
        "method": "GET",
        "url": "={{ $json.baseUrl }}/service/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "conditions",
              "value": "={{ $json.conditions }}"
            },
            {
              "name": "pageSize",
              "value": "25"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "clientId",
              "value": "={{ $json.clientId }}"
            }
          ]
        },
        "options": {}
      },
      "alwaysOutputData": true
    },
    {
      "id": "eed77ddd-6c88-486f-a01d-717126442bb1",
      "name": "Match open ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1320, 200],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// The search can match loosely, so keep only an open ticket whose title\n// is exactly ours.\nconst ticket = $(\"Ticket details\").first().json;\nconst candidates = $input.all().map((i) => i.json);\nconst titleOf = (t) => t.summary;\nconst found = candidates.find((t) => t && titleOf(t) === ticket.title);\nreturn [{ json: { ...ticket, ticketId: found ? found.id : null } }];\n"
      }
    },
    {
      "id": "f8499d6b-7eaf-4a30-a42c-b4f5f4bfb84d",
      "name": "Already open?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1540, 200],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c2cde7f8-1cc1-4484-be68-8a872ec6f32e",
              "leftValue": "={{ $json.ticketId !== null && $json.ticketId !== undefined }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "293bb91a-cb8e-4e1e-9426-d20511539790",
      "name": "Add note",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 100],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/service/tickets/{{ $json.ticketId }}/notes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "clientId",
              "value": "={{ $json.clientId }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.description, internalAnalysisFlag: true }) }}",
        "options": {}
      }
    },
    {
      "id": "6dc9b3a7-a6ad-4ccf-9f05-45ec5903a0a6",
      "name": "Create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 300],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/service/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "clientId",
              "value": "={{ $json.clientId }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ summary: $json.title, initialDescription: $json.description, board: { name: $json.board }, company: { id: $json.companyId } }) }}",
        "options": {}
      }
    },
    {
      "id": "58056c07-dce4-4357-b3bb-2becf0772257",
      "name": "Done",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [1980, 200],
      "parameters": {
        "respondWith": "json",
        "responseBody": "{ \"ok\": true }",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "bd25bcef-b248-4da9-9111-f4d021860815",
      "name": "Read me",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-20, -80],
      "parameters": {
        "width": 660,
        "height": 320,
        "content": "## Domain Health Hub alerts to ConnectWise PSA\nAn n8n recipe, not a native integration. Full guide: https://domainhealthhub.com/integrations/connectwise\n\n1. **HMAC-SHA256** node: create a **Crypto** credential and paste your Domain Health Hub webhook signing secret into **Hmac Secret**.\n2. **Find open ticket**, **Add note** and **Create ticket**: create a **Basic Auth** credential for an API member: user `yourcompanyid+PUBLICKEY`, password the private key.\n3. **Ticket details** node: fill in SETTINGS.\n4. Activate the workflow, add its production URL in Domain Health Hub (Alerts, Webhooks) and send a test alert."
      }
    }
  ],
  "connections": {
    "DHH alert": {
      "main": [
        [
          {
            "node": "Prepare signature check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare signature check": {
      "main": [
        [
          {
            "node": "HMAC-SHA256",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HMAC-SHA256": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Ticket details",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Refuse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ticket details": {
      "main": [
        [
          {
            "node": "Find open ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find open ticket": {
      "main": [
        [
          {
            "node": "Match open ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match open ticket": {
      "main": [
        [
          {
            "node": "Already open?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Already open?": {
      "main": [
        [
          {
            "node": "Add note",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add note": {
      "main": [
        [
          {
            "node": "Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create ticket": {
      "main": [
        [
          {
            "node": "Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
