{
  "name": "Domain Health Hub alerts to Autotask",
  "nodes": [
    {
      "id": "89a92e55-b34f-4f5f-90f7-6b9056c7cc4d",
      "name": "DHH alert",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [0, 300],
      "webhookId": "0a851553-83bd-498e-9329-e0775c6d52e4",
      "parameters": {
        "httpMethod": "POST",
        "path": "dhh-alerts-autotask",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      }
    },
    {
      "id": "6b148d52-5d51-4e32-b894-ec288c53c93e",
      "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": "9ddc6dcf-3f4e-4d43-a487-4f92f27314b3",
      "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": "ff81de1a-5cc6-4a05-9b87-0e3d8548af52",
      "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": "a70522a4-480c-40ca-a208-3c50a44339ae",
              "leftValue": "={{ $json.fresh === true && (\"sha256=\" + $json.expected) === $json.signature }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "ad0a3fda-3fee-4fcf-a535-ff4ce3ad41ed",
      "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": "509dfe26-7cb7-4590-956b-fd1878eee500",
      "name": "Ticket details",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [880, 200],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Your Autotask settings: fill these in once. Everything else is worked\n// out from the alert.\nconst SETTINGS = {\n  \"baseUrl\": \"https://webservicesN.autotask.net/ATServicesRest/V1.0\",\n  \"defaultCompanyId\": 0,\n  \"companyIds\": {\n    \"Example Client Ltd\": 29683500\n  },\n  \"queueId\": 0,\n  \"priority\": 2,\n  \"status\": 1,\n  \"completeStatus\": 5,\n  \"dueInHours\": 24,\n  \"noteType\": 1,\n  \"notePublish\": 1,\n  \"titleMax\": 255\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      dueDateTime: new Date(Date.now() + SETTINGS.dueInHours * 3600 * 1000).toISOString(),\n      title,\n      description: lines.join(\"\\n\"),\n      client,\n      domain,\n      event: alert.event,\n    },\n  },\n];\n"
      }
    },
    {
      "id": "45b2a0a6-588f-4525-b365-c72ba7b56c12",
      "name": "Find open ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1100, 200],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/Tickets/query",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpCustomAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filter: [{ op: \"and\", items: [{ op: \"eq\", field: \"title\", value: $json.title }, { op: \"noteq\", field: \"status\", value: $json.completeStatus }] }] }) }}",
        "options": {}
      },
      "alwaysOutputData": true
    },
    {
      "id": "00fa6870-c70a-4352-b80f-49858f551a5d",
      "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.items) ? body.items : [];\nconst titleOf = (t) => t.title;\nconst found = candidates.find((t) => t && titleOf(t) === ticket.title);\nreturn [{ json: { ...ticket, ticketId: found ? found.id : null } }];\n"
      }
    },
    {
      "id": "1a88df68-6c45-48d3-b249-6439286bce04",
      "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": "68ba1903-4b51-47e5-bbef-3d315c3d75de",
              "leftValue": "={{ $json.ticketId !== null && $json.ticketId !== undefined }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "2ce5fb9f-3d95-45b7-a62f-c9bb8dd3e0bf",
      "name": "Add note",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 100],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/Tickets/{{ $json.ticketId }}/Notes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpCustomAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ title: \"Domain Health Hub\", description: $json.description, noteType: $json.noteType, publish: $json.notePublish }) }}",
        "options": {}
      }
    },
    {
      "id": "62b24af8-c6f7-47bc-9daf-4dc2ef1b60d3",
      "name": "Create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 300],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/Tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpCustomAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ companyID: $json.companyId, title: $json.title, description: $json.description, status: $json.status, priority: $json.priority, queueID: $json.queueId, dueDateTime: $json.dueDateTime }) }}",
        "options": {}
      }
    },
    {
      "id": "c10065d8-99fa-43c1-8c33-da727f6b0db8",
      "name": "Done",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [1980, 200],
      "parameters": {
        "respondWith": "json",
        "responseBody": "{ \"ok\": true }",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "b61590bd-b1a1-4d97-b3b4-372f6be331dd",
      "name": "Read me",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-20, -80],
      "parameters": {
        "width": 660,
        "height": 320,
        "content": "## Domain Health Hub alerts to Autotask\nAn n8n recipe, not a native integration. Full guide: https://domainhealthhub.com/integrations/autotask\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 **Custom Auth** credential holding `{\"headers\": {\"ApiIntegrationCode\": \"...\", \"UserName\": \"...\", \"Secret\": \"...\"}}` for an Autotask API user.\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": {}
}
