{
  "name": "Enroutia · Idle workflow, spend anomaly and client budget alerts",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "enroutia-ops-alerts",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "name": "Enroutia events",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        0
      ],
      "webhookId": "enroutia-ops-alerts"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "enroutia_webhook_secret",
              "name": "ENROUTIA_WEBHOOK_SECRET",
              "value": "REPLACE_WITH_YOUR_WEBHOOK_SECRET",
              "type": "string"
            },
            {
              "id": "enroutia_api",
              "name": "ENROUTIA_API",
              "value": "https://platform.enroutia.com",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {
          "stripBinary": false
        }
      },
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Verifies X-Webhook-Signature (t=<unix>,v1=<hex>): HMAC-SHA256 of the secret over\n// \"{t}.{raw body}\", five minutes of tolerance, constant-time comparison.\n// Needs NODE_FUNCTION_ALLOW_BUILTIN=crypto on the n8n instance (see the README).\nconst crypto = require('crypto');\nconst TOLERANCE_S = 300;\n\n// The delivery as the Webhook node received it, and the secret from the Settings node\n// (n8n 2.x blocks environment access in nodes by default, so nothing here reads the environment).\nconst item = $input.first();\nconst received = $('Enroutia events').first();\nconst headers = received.json.headers || item.json.headers || {};\nconst secret = String($('Settings').first().json.ENROUTIA_WEBHOOK_SECRET || '');\n\n// The signed body is the one that arrived, byte for byte: re-serialising the parsed JSON\n// changes spacing and key order and the signature stops matching. That is why the\n// Webhook node has \"Raw Body\" on and the body is read from the binary.\nlet raw = null;\ntry {\n  raw = await this.helpers.getBinaryDataBuffer(0, 'data');\n} catch (error) {\n  const binary = item.binary || received.binary || {};\n  const inline = binary.data && binary.data.data;\n  if (inline) raw = Buffer.from(inline, 'base64');\n}\n\nconst reject = (reason) => [{ json: { valid: false, reason } }];\nif (!secret || secret === 'REPLACE_WITH_YOUR_WEBHOOK_SECRET') return reject('secret_missing');\nif (!raw) return reject('raw_body_unavailable');\n\nconst header = String(headers['x-webhook-signature'] || '');\nconst parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));\nconst t = Number(parts.t);\nif (!Number.isFinite(t)) return reject('signature_malformed');\nif (Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return reject('signature_expired');\n\nconst expected = crypto.createHmac('sha256', secret).update(t + '.').update(raw).digest();\nconst given = Buffer.from(String(parts.v1 || ''), 'hex');\nif (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {\n  return reject('signature_mismatch');\n}\n\nlet payload;\ntry {\n  payload = JSON.parse(raw.toString('utf8'));\n} catch (error) {\n  return reject('body_not_json');\n}\n\nreturn [{\n  json: {\n    valid: true,\n    event: String(headers['x-webhook-event'] || payload.event || ''),\n    data: payload.data || {},\n  },\n}];\n"
      },
      "name": "Verify signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "signature-valid",
              "leftValue": "={{ $json.valid }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={\"ok\":true}",
        "options": {
          "responseCode": 200
        }
      },
      "name": "Accept (200)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        880,
        -120
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={\"ok\":false}",
        "options": {
          "responseCode": 401
        }
      },
      "name": "Reject (401)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        880,
        160
      ]
    },
    {
      "parameters": {
        "jsCode": "// One message per operational event; every other event is dropped here.\nconst { event, data } = $('Verify signature').item.json;\nconst eur = (cents) => (Number(cents || 0) / 100).toFixed(2) + ' €';\nconst usage = $('Settings').first().json.ENROUTIA_API + '/panel/usage';\nconst clients = $('Settings').first().json.ENROUTIA_API + '/panel/clients';\nconst wf = data.workflow_name ? `${data.workflow_name} (${data.workflow})` : data.workflow;\n\nconst messages = {\n  workflow_idle: () => ({\n    subject: `Workflow stopped calling: ${wf}`,\n    text: `${wf} made no call for ${data.idle_days} days (last one ${data.last_seen_at}). ` +\n      `In the 30 days before it made ${data.calls_30d} calls for ${eur(data.cents_30d)}.\\n` +\n      `Check it is still active in n8n and that its trigger still fires.\\n${usage}`,\n  }),\n  spend_anomaly: () => ({\n    subject: `Spend anomaly: ${wf} at ${data.factor}× its usual rate`,\n    text: `${wf} made ${data.calls} calls for ${eur(data.cents)} in the last ${data.window_minutes} minutes, ` +\n      `against a usual ${data.baseline_calls_hour} calls and ${eur(data.baseline_cents_hour)} per hour.` +\n      (data.top_key ? `\\nMost of it came through the key \"${data.top_key.name}\".` : '') +\n      `\\nA loop, a retry storm or an uncapped batch looks like this.\\n${usage}`,\n  }),\n  client_budget_warning: () => ({\n    subject: `${data.client}: ${eur(data.spent_cents)} of ${eur(data.budget_cents)} this month`,\n    text: `The end client ${data.client}${data.tag ? ` (tag \"${data.tag}\")` : ''} has spent ${eur(data.spent_cents)} of its ${eur(data.budget_cents)} monthly budget.\\n${clients}`,\n  }),\n  client_budget_exceeded: () => ({\n    subject: `${data.client}: monthly budget spent, calls are refused`,\n    text: `The end client ${data.client}${data.tag ? ` (tag \"${data.tag}\")` : ''} has spent its ${eur(data.budget_cents)} monthly budget. ` +\n      `Its calls now get 402 until the month turns or the budget is raised.\\n${clients}`,\n  }),\n};\n\nconst compose = messages[event];\nif (!compose) return [];\nreturn [{ json: { event, ...compose() } }];\n"
      },
      "name": "Compose message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        -120
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "#enroutia-alerts",
          "mode": "name"
        },
        "text": "=*{{ $json.subject }}*\n{{ $json.text }}",
        "otherOptions": {}
      },
      "name": "Post to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        1320,
        -120
      ],
      "credentials": {
        "slackApi": {
          "name": "Slack account"
        }
      }
    }
  ],
  "connections": {
    "Enroutia events": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify signature": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Accept (200)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject (401)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Accept (200)": {
      "main": [
        [
          {
            "node": "Compose message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compose message": {
      "main": [
        [
          {
            "node": "Post to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Verify signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
