{
  "name": "Stripe L2: Verified webhook",
  "nodes": [
    {
      "name": "Webhook",
      "webhookId": "b2c4e6a8-stripe-events",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [-400, 0],
      "parameters": {
        "httpMethod": "POST",
        "path": "stripe",
        "options": {
          "rawBody": true
        }
      }
    },
    {
      "name": "Verify Stripe signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-160, 0],
      "parameters": {
        "jsCode": "const crypto = require('crypto');\n\nconst headers = $input.first().json.headers ?? {};\nconst sigHeader = headers['stripe-signature'];\nif (!sigHeader) {\n  throw new Error('No Stripe-Signature header on this request');\n}\n\n// Raw Body is on, so the exact bytes Stripe signed are the binary property \"data\".\nconst rawBody = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');\n\n// Header shape: t=1757073600,v1=abc...,v1=def... — more than one v1 during a secret roll.\nlet timestamp = null;\nconst signatures = [];\nfor (const part of sigHeader.split(',')) {\n  const [key, value] = part.trim().split('=');\n  if (key === 't') timestamp = value;\n  if (key === 'v1') signatures.push(value);\n}\nif (!timestamp || signatures.length === 0) {\n  throw new Error('Stripe-Signature header is not in the expected format');\n}\n\nconst secret = $env.STRIPE_WEBHOOK_SECRET;\nif (!secret) {\n  throw new Error('STRIPE_WEBHOOK_SECRET is not readable from this Code node');\n}\n\nconst expected = crypto\n  .createHmac('sha256', secret)\n  .update(`${timestamp}.${rawBody}`)\n  .digest('hex');\nconst expectedBuf = Buffer.from(expected, 'hex');\n\nconst matched = signatures.some((sig) => {\n  const given = Buffer.from(sig, 'hex');\n  return given.length === expectedBuf.length && crypto.timingSafeEqual(given, expectedBuf);\n});\nif (!matched) {\n  throw new Error('Stripe signature verification failed');\n}\n\nconst ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));\nif (!Number.isFinite(ageSeconds) || ageSeconds > 300) {\n  throw new Error('Stripe event timestamp outside the five-minute tolerance');\n}\n\nreturn [{ json: JSON.parse(rawBody) }];"
      }
    },
    {
      "name": "Log event",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.7,
      "position": [80, 0],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO stripe_events (\n    id, type, api_version, livemode, request_id, created, data,\n    currency, amount_cents, balance_transaction\n)\nVALUES ($1, $2, $3, $4, $5, to_timestamp($6::bigint), $7::jsonb, $8, $9::bigint, $10)\nON CONFLICT (id) DO NOTHING\nRETURNING id;",
        "options": {
          "queryReplacement": "={{ [$json.id, $json.type, $json.api_version ?? null, $json.livemode ?? false, $json.request?.id ?? null, $json.created, JSON.stringify($json), $json.data.object.currency ?? null, $json.data.object.amount ?? $json.data.object.amount_total ?? $json.data.object.amount_paid ?? null, $json.data.object.balance_transaction ?? null] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Postgres"
        }
      },
      "disabled": true
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Verify Stripe signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Stripe signature": {
      "main": [
        [
          {
            "node": "Log event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {}
}
