{
  "name": "Domain Health Hub alerts to Syncro",
  "nodes": [
    {
      "id": "670e98f2-15d7-41bb-abd7-f87f5cae18cc",
      "name": "DHH alert",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [0, 300],
      "webhookId": "80ec7bec-819b-469e-82cd-807f5a3ce210",
      "parameters": {
        "httpMethod": "POST",
        "path": "dhh-alerts-syncro",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      }
    },
    {
      "id": "4aac431a-e71b-43e8-9f63-5c78823c87b6",
      "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": "83189c53-9ae5-4aeb-9541-e79237052cde",
      "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": "4a5a9691-e728-45e0-92d5-f2fed2d099a4",
      "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": "c4a70505-aeb1-43a6-a964-a6c4072d4432",
              "leftValue": "={{ $json.fresh === true && (\"sha256=\" + $json.expected) === $json.signature }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "8a668df9-4219-4e43-b4eb-2ade77402368",
      "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": "ea978119-7215-4e88-aa13-e834b1beb46a",
      "name": "Ticket details",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [880, 200],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Your Syncro settings: fill these in once. Everything else is worked\n// out from the alert.\nconst SETTINGS = {\n  \"baseUrl\": \"https://YOURSUBDOMAIN.syncromsp.com/api/v1\",\n  \"defaultCustomerId\": 0,\n  \"customerIds\": {\n    \"Example Client Ltd\": 1234567\n  },\n  \"problemType\": \"Other\",\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 customerId if you've mapped its Domain\n// Health Hub client name above, otherwise the default.\nconst customerId = Object.prototype.hasOwnProperty.call(SETTINGS.customerIds, client)\n  ? SETTINGS.customerIds[client]\n  : SETTINGS.defaultCustomerId;\n\nreturn [\n  {\n    json: {\n      ...SETTINGS,\n      customerId,\n      title,\n      description: lines.join(\"\\n\"),\n      client,\n      domain,\n      event: alert.event,\n    },\n  },\n];\n"
      }
    },
    {
      "id": "b4c274b1-9fc7-44e4-8c87-77b47165ea8b",
      "name": "Find open ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1100, 200],
      "parameters": {
        "method": "GET",
        "url": "={{ $json.baseUrl }}/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "query",
              "value": "={{ $json.title }}"
            },
            {
              "name": "status",
              "value": "Not Closed"
            }
          ]
        },
        "options": {}
      },
      "alwaysOutputData": true
    },
    {
      "id": "f2506333-e666-4ed8-b3e3-9f2555966901",
      "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.subject;\nconst found = candidates.find((t) => t && titleOf(t) === ticket.title);\nreturn [{ json: { ...ticket, ticketId: found ? found.id : null } }];\n"
      }
    },
    {
      "id": "fc1e5029-8cc4-4492-918f-fb5118d63833",
      "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": "1b4e5890-205a-44a2-a6ad-0d0c1bd45af3",
              "leftValue": "={{ $json.ticketId !== null && $json.ticketId !== undefined }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "ae61f11d-4161-49fa-81c4-214c945bc89f",
      "name": "Add note",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 100],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/tickets/{{ $json.ticketId }}/comment",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ subject: \"Domain Health Hub\", body: $json.description, hidden: true, do_not_email: true }) }}",
        "options": {}
      }
    },
    {
      "id": "5d385b54-9194-40ee-949e-3e859173ed11",
      "name": "Create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 300],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.baseUrl }}/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ customer_id: $json.customerId, subject: $json.title, problem_type: $json.problemType, status: \"New\", comments_attributes: [{ subject: \"Domain Health Hub alert\", body: $json.description, hidden: true, do_not_email: true }] }) }}",
        "options": {}
      }
    },
    {
      "id": "ab1cfcc4-7d3b-4157-baeb-a062a39ac47d",
      "name": "Done",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [1980, 200],
      "parameters": {
        "respondWith": "json",
        "responseBody": "{ \"ok\": true }",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "df3e2fd0-2ee9-42b3-aaf9-4da2b73d0b3a",
      "name": "Read me",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-20, -80],
      "parameters": {
        "width": 660,
        "height": 320,
        "content": "## Domain Health Hub alerts to Syncro\nAn n8n recipe, not a native integration. Full guide: https://domainhealthhub.com/integrations/syncro\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 **Bearer Auth** credential with a Syncro API token allowed to list, create and edit tickets.\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": {}
}
