{
  "name": "Vouli IQ — verified webhook router",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "vouli-iq",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000001",
      "name": "Vouli IQ webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        300
      ],
      "webhookId": "a1b2c3d4-0001-4000-8000-0000000000aa"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Vouli IQ — verify the webhook signature BEFORE trusting anything in the body.\n//\n// Vouli IQ signs every delivery at the moment it sends it and adds:\n//   X-Vouli-Timestamp:    <unix seconds>\n//   X-Vouli-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of \"<t>.<raw body>\">\n// keyed with your subscription's signing secret. This node requires v2 and\n// refuses a delivery whose t is more than TOLERANCE_SECONDS from now, so a\n// captured request cannot be replayed later. During a secret rotation the\n// header carries one v2= per valid secret; a match on ANY of them is valid.\n// (The older X-Vouli-Signature header, with no timestamp, is still sent for\n// receivers that have not moved; this node does not accept it alone, because\n// stripping v2 would otherwise re-open replay.)\n//\n// Needs, on the n8n instance:\n//   NODE_FUNCTION_ALLOW_BUILTIN=crypto        (lets this node use crypto)\n//   VOULI_WEBHOOK_SECRET=whsec_...            (the secret shown once at subscription)\n//   N8N_BLOCK_ENV_ACCESS_IN_NODE=false        (lets this node read it)\nconst crypto = require('crypto');\nconst SECRET = $env.VOULI_WEBHOOK_SECRET || '';\nconst TOLERANCE_SECONDS = 300;\nconst item = $input.first();\nconst headers = item.json.headers || {};\nconst header = String(headers['x-vouli-signature-v2'] || '');\n\n// The Webhook node keeps the raw body (option \"Raw Body\") as binary \"data\":\n// the signature is over those exact bytes, never over re-serialised JSON.\nlet raw = '';\ntry {\n  raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');\n} catch (e) {\n  raw = item.binary && item.binary.data ? Buffer.from(item.binary.data.data, 'base64').toString('utf8') : '';\n}\n\n// Parse \"t=<digits>,v2=<64 hex>[,v2=...]\"; anything malformed is invalid.\nlet t = null;\nconst sigs = [];\nlet wellFormed = header.length > 0;\nfor (const part of header.split(',')) {\n  const p = part.trim();\n  const eq = p.indexOf('=');\n  if (eq <= 0) { wellFormed = false; break; }\n  const k = p.slice(0, eq);\n  const v = p.slice(eq + 1);\n  if (k === 't') {\n    if (t !== null || !/^[0-9]{1,12}$/.test(v)) { wellFormed = false; break; }\n    t = Number(v);\n  } else if (k === 'v2') {\n    if (!/^[0-9a-f]{64}$/.test(v)) { wellFormed = false; break; }\n    sigs.push(v);\n  }\n}\n\nconst now = Math.floor(Date.now() / 1000);\nconst fresh = wellFormed && t !== null && Math.abs(now - t) <= TOLERANCE_SECONDS;\nlet valid = false;\nif (SECRET.length > 0 && fresh && sigs.length > 0) {\n  const expected = crypto.createHmac('sha256', SECRET).update(t + '.' + raw, 'utf8').digest();\n  for (const s of sigs) {\n    if (crypto.timingSafeEqual(Buffer.from(s, 'hex'), expected)) valid = true;\n  }\n}\n\nlet event = null;\nif (valid) {\n  try { event = JSON.parse(raw); } catch (e) { event = null; }\n}\nreturn [{ json: { valid: valid && event !== null, type: event ? event.type : null, event } }];"
      },
      "id": "a1b2c3d4-0002-4000-8000-000000000002",
      "name": "Verify X-Vouli-Signature-V2",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "c0000000-0000-4000-8000-000000000001",
              "leftValue": "={{ $json.valid }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a1b2c3d4-0003-4000-8000-000000000003",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        480,
        300
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { \"received\": true } }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "a1b2c3d4-0004-4000-8000-000000000004",
      "name": "Respond 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        720,
        200
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "invalid signature",
        "options": {
          "responseCode": 401
        }
      },
      "id": "a1b2c3d4-0005-4000-8000-000000000005",
      "name": "Reject 401",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        720,
        420
      ]
    },
    {
      "parameters": {
        "mode": "rules",
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "e0000000-0000-4000-8000-000000000001",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "assessment.completed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "assessment.completed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "e0000000-0000-4000-8000-000000000002",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "verdict.changed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "verdict.changed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "e0000000-0000-4000-8000-000000000003",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "roadmap.updated",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "roadmap.updated"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "e0000000-0000-4000-8000-000000000004",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "risk.created",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "risk.created"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "e0000000-0000-4000-8000-000000000005",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "risk.severity_changed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "risk.severity_changed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "e0000000-0000-4000-8000-000000000006",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "compliance.posture_changed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "compliance.posture_changed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "e0000000-0000-4000-8000-000000000007",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "evidence.pack_filed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "evidence.pack_filed"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "Other event"
        }
      },
      "id": "a1b2c3d4-0006-4000-8000-000000000006",
      "name": "Route by event type",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        960,
        200
      ]
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0100-4000-8000-000000000100",
      "name": "On assessment.completed",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1240,
        -220
      ]
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0101-4000-8000-000000000101",
      "name": "On verdict.changed",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1240,
        -100
      ]
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0102-4000-8000-000000000102",
      "name": "On roadmap.updated",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1240,
        20
      ]
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0103-4000-8000-000000000103",
      "name": "On risk.created",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1240,
        140
      ]
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0104-4000-8000-000000000104",
      "name": "On risk.severity_changed",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1240,
        260
      ]
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0105-4000-8000-000000000105",
      "name": "On compliance.posture_changed",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1240,
        380
      ]
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0106-4000-8000-000000000106",
      "name": "On evidence.pack_filed",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1240,
        500
      ]
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0107-4000-8000-000000000107",
      "name": "Other event (ignore)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1240,
        620
      ]
    }
  ],
  "connections": {
    "Vouli IQ webhook": {
      "main": [
        [
          {
            "node": "Verify X-Vouli-Signature-V2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify X-Vouli-Signature-V2": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Respond 200",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject 401",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond 200": {
      "main": [
        [
          {
            "node": "Route by event type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by event type": {
      "main": [
        [
          {
            "node": "On assessment.completed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On verdict.changed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On roadmap.updated",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On risk.created",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On risk.severity_changed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On compliance.posture_changed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On evidence.pack_filed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Other event (ignore)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "tags": []
}
