{
  "name": "Domain Health Hub alerts to HaloPSA",
  "nodes": [
    {
      "id": "d5d6521e-cfd7-4f90-b5a2-69772cd578c6",
      "name": "DHH alert",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [0, 300],
      "webhookId": "e47af0e7-e9e5-4189-ae8c-ed61e32611dc",
      "parameters": {
        "httpMethod": "POST",
        "path": "dhh-alerts-halopsa",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      }
    },
    {
      "id": "5051f5c3-c158-49c9-883e-215c37ad263d",
      "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": "c3c729f4-d8a0-4ad8-a5e6-377d51bddaa0",
      "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": "5ba676ec-db78-4249-a932-1866e069b875",
      "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": "73d7dcb4-0220-4c46-a323-f9a07450592d",
              "leftValue": "={{ $json.fresh === true && (\"sha256=\" + $json.expected) === $json.signature }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "5b4d80dd-4544-4ff1-b152-a271e39d8b27",
      "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": "e2206f73-24e6-444f-8f8c-8bf66e5b5401",
      "name": "Ticket details",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [880, 200],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Your HaloPSA settings: fill these in once. Everything else is worked\n// out from the alert.\nconst SETTINGS = {\n  \"baseUrl\": \"https://YOURTENANT.halopsa.com\",\n  \"ticketTypeId\": 1,\n  \"defaultClientId\": 1,\n  \"clientIds\": {\n    \"Example Client Ltd\": 12\n  },\n  \"noteOutcome\": \"Private Note\",\n  \"titleMax\": 250\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 clientId if you've mapped its Domain\n// Health Hub client name above, otherwise the default.\nconst clientId = Object.prototype.hasOwnProperty.call(SETTINGS.clientIds, client)\n  ? SETTINGS.clientIds[client]\n  : SETTINGS.defaultClientId;\n\nreturn [\n  {\n    json: {\n      ...SETTINGS,\n      clientId,\n      title,\n      description: lines.join(\"\\n\"),\n      client,\n      domain,\n      event: alert.event,\n    },\n  },\n];\n"
      }
    },
    {
      "id": "7cdb198e-3af3-44d7-be8b-eb028f10b021",
      "name": "Find open ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1100, 200],
      "parameters": {
        "method": "GET",
        "url": "={{ $json.baseUrl }}/api/Tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "search",
              "value": "={{ $json.title }}"
            },
            {
              "name": "open_only",
              "value": "true"
            },
            {
              "name": "count",
              "value": "50"
            }
          ]
        },
        "options": {}
      },
      "alwaysOutputData": true
    },
    {
      "id": "d8e7f8c2-353e-4fc0-969a-b06d8a714d2c",
      "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 body = $input.first().json;\nconst candidates = Array.isArray(body.tickets) ? body.tickets : [];\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": "e7ca7cc0-cb8c-4163-91c5-a6dce8cc600f",
      "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": "b0b154ef-c34a-4542-ad7e-eec95ca7c73b",
              "leftValue": "={{ $json.ticketId !== null && $json.ticketId !== undefined }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "ad2514e8-0645-4bf9-89f7-bd0ece58ef7d",
      "name": "Add note",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 100],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/api/Actions",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify([{ ticket_id: $json.ticketId, outcome: $json.noteOutcome, note: $json.description, hiddenfromuser: true }]) }}",
        "options": {}
      }
    },
    {
      "id": "6acb47c9-2993-4f56-89b0-a7cb0a3f6fbf",
      "name": "Create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 300],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/api/Tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify([{ summary: $json.title, details: $json.description, tickettype_id: $json.ticketTypeId, client_id: $json.clientId }]) }}",
        "options": {}
      }
    },
    {
      "id": "56f76de7-db57-4b98-a2aa-d7f752300935",
      "name": "Done",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [1980, 200],
      "parameters": {
        "respondWith": "json",
        "responseBody": "{ \"ok\": true }",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "be64af63-4fd1-4fce-be24-8cc2f96eed5f",
      "name": "Read me",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-20, -80],
      "parameters": {
        "width": 660,
        "height": 320,
        "content": "## Domain Health Hub alerts to HaloPSA\nAn n8n recipe, not a native integration. Full guide: https://domainhealthhub.com/integrations/halopsa\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 an **OAuth2 API** credential: grant type Client Credentials, Access Token URL `https://YOURTENANT.halopsa.com/auth/token`, scope `all`, with the Client ID and Secret of a HaloPSA API application.\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": {}
}
