{
  "name": "SeoPrimo - score.changed to Telegram or Slack",
  "nodes": [
    {
      "parameters": {
        "content": "## SeoPrimo: score.changed to Telegram\n1. Activate the workflow, copy the **Production URL** of the webhook and add it as a webhook endpoint in SeoPrimo (Integrations, Webhooks) for the event `score.changed`.\n2. Paste the endpoint secret (`whsec_...`) in **Verify signature**.\n3. Set the n8n env var `NODE_FUNCTION_ALLOW_BUILTIN=crypto`.\n4. Pick your Telegram credential and chat id (or enable the Slack node instead).",
        "height": 250,
        "width": 460
      },
      "id": "65ee00ad-bb2e-5e97-9c83-a2dcdf9e9162",
      "name": "Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -140
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "seoprimo-score-changed",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "059233c5-32bd-532d-9905-5fa24fbf691f",
      "name": "SeoPrimo webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "1905726b-9ce2-5622-b1a6-52709f718366"
    },
    {
      "parameters": {
        "jsCode": "// Verify X-SeoPrimo-Signature on the RAW body (Webhook node: option \"Raw Body\" ON).\n// Needs the env var NODE_FUNCTION_ALLOW_BUILTIN=crypto on the n8n server (or '*').\nconst crypto = require('crypto');\n\n// Paste the endpoint secret (whsec_...) here, or expose it as the env var SEOPRIMO_WEBHOOK_SECRET.\nlet secret = 'whsec_REPLACE_WITH_YOUR_SECRET';\ntry { if ($env.SEOPRIMO_WEBHOOK_SECRET) secret = $env.SEOPRIMO_WEBHOOK_SECRET; } catch (e) { /* env access blocked */ }\nconst TOLERANCE_SECONDS = 300;\n\nconst item = $input.first();\nconst header = String(item.json.headers['x-seoprimo-signature'] || '');\nconst raw = await this.helpers.getBinaryDataBuffer(0, 'data'); // the bytes exactly as received\n\nlet valid = false;\ntry {\n  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));\n  const t = parseInt(parts.t, 10);\n  const received = String(parts.v1 || '');\n  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(raw).digest('hex');\n  const fresh = Math.abs(Date.now() / 1000 - t) <= TOLERANCE_SECONDS;\n  valid = fresh\n    && received.length === expected.length\n    && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));\n} catch (e) {\n  valid = false;\n}\n\nconst event = valid ? JSON.parse(raw.toString('utf8')) : {};\nreturn [{ json: { valid, id: event.id, type: event.type, created: event.created, data: event.data || {} } }];\n"
      },
      "id": "623469a2-0ac7-5b24-8aa8-c8eec9465302",
      "name": "Verify signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "17bd8b20-dbf3-5e28-adcc-18f1ad037438",
              "leftValue": "={{ $json.valid }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "b098d75d-8cf3-5210-b4d7-f356a15fe951",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        480,
        300
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { \"received\": true } }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "00b7471d-0c60-508a-a82a-107aa53915df",
      "name": "Respond 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        720,
        200
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { \"error\": \"invalid_signature\" } }}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "17a16b93-a636-5123-9cad-1eeaa4bd9259",
      "name": "Respond 401",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        720,
        440
      ]
    },
    {
      "parameters": {
        "chatId": "REPLACE_WITH_CHAT_ID",
        "text": "=SeoPrimo Score: {{ $('Verify signature').item.json.data.previous_score }} -> {{ $('Verify signature').item.json.data.score }} ({{ $('Verify signature').item.json.data.delta > 0 ? '+' : '' }}{{ $('Verify signature').item.json.data.delta }}){{ $('Verify signature').item.json.data.preliminary ? ' preliminary' : '' }}\nSite: {{ $('Verify signature').item.json.data.site_id }}",
        "additionalFields": {}
      },
      "id": "90b081f8-c5e3-51e6-b54a-cff9cf7972f1",
      "name": "Telegram message",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        960,
        200
      ],
      "credentials": {
        "telegramApi": {
          "id": "REPLACE_WITH_CREDENTIAL_ID",
          "name": "Telegram account"
        }
      }
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_CHANNEL_ID",
          "mode": "id"
        },
        "text": "=SeoPrimo Score: {{ $('Verify signature').item.json.data.previous_score }} -> {{ $('Verify signature').item.json.data.score }} ({{ $('Verify signature').item.json.data.delta > 0 ? '+' : '' }}{{ $('Verify signature').item.json.data.delta }}) - site {{ $('Verify signature').item.json.data.site_id }}",
        "otherOptions": {}
      },
      "id": "3d0699b9-b021-5b95-978b-c9cd1b435fff",
      "name": "Slack message (disabled alternative)",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        960,
        400
      ],
      "credentials": {
        "slackApi": {
          "id": "REPLACE_WITH_CREDENTIAL_ID",
          "name": "Slack account"
        }
      },
      "disabled": true
    }
  ],
  "connections": {
    "SeoPrimo webhook": {
      "main": [
        [
          {
            "node": "Verify signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify signature": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Respond 200",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond 401",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond 200": {
      "main": [
        [
          {
            "node": "Telegram message",
            "type": "main",
            "index": 0
          },
          {
            "node": "Slack message (disabled alternative)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "8651d725-4ad9-5526-a5bb-679cb68622c5",
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
