{
  "name": "Anchor Labs - Webhook Idempotency (Postgres)",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "stripe-idempotency",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "a1000000-0000-4000-8000-000000000001",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        0
      ],
      "webhookId": "5d0c7c1e-3f7a-4c55-9a51-idempotency1"
    },
    {
      "parameters": {
        "jsCode": "// Idempotency key: provider event id from the body (Stripe event.id).\n// Fallback when there is no id: SHA-256 of the raw request body.\n// Never use stripe-signature: it carries a timestamp and changes on every retry.\n// Self-hosted n8n needs NODE_FUNCTION_ALLOW_BUILTIN=crypto for require('crypto').\nconst crypto = require('crypto');\nconst items = $input.all();\nconst out = [];\nfor (let i = 0; i < items.length; i++) {\n  const item = items[i];\n  const body = item.json.body ?? {};\n  const eventId = body && typeof body.id === 'string' ? body.id.trim() : '';\n  let key;\n  let keySource;\n  if (eventId) {\n    key = 'evt:' + eventId;\n    keySource = 'body.id';\n  } else {\n    let raw;\n    if (item.binary && item.binary.data) {\n      raw = await this.helpers.getBinaryDataBuffer(i, 'data');\n      keySource = 'sha256(raw body)';\n    } else {\n      raw = Buffer.from(JSON.stringify(body));\n      keySource = 'sha256(JSON body)';\n    }\n    key = 'sha256:' + crypto.createHash('sha256').update(raw).digest('hex');\n  }\n  out.push({ json: { key, keySource, eventId, eventType: body.type ?? null } });\n}\nreturn out;"
      },
      "id": "a1000000-0000-4000-8000-000000000002",
      "name": "Compute Idempotency Key",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO processed_events (key, first_seen) VALUES ($1, now()) ON CONFLICT (key) DO NOTHING RETURNING key;",
        "options": {
          "queryReplacement": "={{ [$json.key] }}"
        }
      },
      "id": "a1000000-0000-4000-8000-000000000003",
      "name": "Claim Key (ON CONFLICT DO NOTHING)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        440,
        0
      ],
      "alwaysOutputData": true,
      "credentials": {
        "postgres": {
          "name": "Postgres"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1000000-0000-4000-8000-000000000001",
              "leftValue": "={{ $json.key }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "exists",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000004",
      "name": "Row Returned?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO business_log (event_key, event_type) VALUES ($1, $2) RETURNING id, event_key, created_at;",
        "options": {
          "queryReplacement": "={{ [$('Compute Idempotency Key').item.json.key, $('Compute Idempotency Key').item.json.eventType] }}"
        }
      },
      "id": "a1000000-0000-4000-8000-000000000005",
      "name": "Business Step (insert business_log)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        880,
        -100
      ],
      "credentials": {
        "postgres": {
          "name": "Postgres"
        }
      }
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { status: 'processed', key: $('Compute Idempotency Key').item.json.key } }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "a1000000-0000-4000-8000-000000000006",
      "name": "Respond 200 processed",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        1100,
        -100
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { status: 'duplicate', key: $('Compute Idempotency Key').item.json.key } }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "a1000000-0000-4000-8000-000000000007",
      "name": "Respond 200 duplicate",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        880,
        100
      ]
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Compute Idempotency Key",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute Idempotency Key": {
      "main": [
        [
          {
            "node": "Claim Key (ON CONFLICT DO NOTHING)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claim Key (ON CONFLICT DO NOTHING)": {
      "main": [
        [
          {
            "node": "Row Returned?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Row Returned?": {
      "main": [
        [
          {
            "node": "Business Step (insert business_log)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond 200 duplicate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Business Step (insert business_log)": {
      "main": [
        [
          {
            "node": "Respond 200 processed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
