{
  "name": "Idempotency guard: side-effect ledger",
  "nodes": [
    {
      "id": "n01-manual-trigger",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        300
      ],
      "parameters": {}
    },
    {
      "id": "n02-sample-batch",
      "name": "Sample batch",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        300
      ],
      "parameters": {
        "jsCode": "// Replace this node with your real source (Webhook, HTTP Request, Split Out ...).\n// It only needs to produce ONE item per business record, each with a stable id.\nreturn [1001,1002,1003,1004,1005].map(n => ({ json: { id: 'ORD-' + n, email: 'sample' + n + '@example.com', company: 'Sample Co' } }));"
      }
    },
    {
      "id": "n03-build-idem_key",
      "name": "Build idem_key",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// idem_key = <action>:<business id>. Never use $execution.id: a retry gets a new one.\nconst action = 'create_crm_contact';   // CHANGE per external write\nif (!$json.id) throw new Error('Record has no stable id, refusing to build a key');\nreturn { json: { idem_key: action + ':' + $json.id, action, record: $json } };"
      }
    },
    {
      "id": "n04-claim",
      "name": "Claim",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        660,
        300
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH c AS (\n  INSERT INTO side_effect_ledger (idem_key, action, status, execution_id)\n  VALUES ($1, $2, 'started', $3)\n  ON CONFLICT (idem_key) DO NOTHING\n  RETURNING idem_key\n)\nSELECT $1::text AS idem_key, EXISTS (SELECT 1 FROM c) AS claimed;",
        "options": {
          "queryReplacement": "={{ [$json.idem_key, $json.action, String($execution.id)] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Ledger Postgres"
        }
      }
    },
    {
      "id": "n05-claimed",
      "name": "Claimed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        880,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.claimed }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "n06-write-to-external-sy",
      "name": "Write to external system",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        100
      ],
      "parameters": {
        "method": "POST",
        "url": "https://example.com/api/placeholder-write",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Idempotency-Key",
              "value": "={{ $('Build idem_key').item.json.idem_key }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($('Build idem_key').item.json.record) }}",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "id": "n07-classify-response",
      "name": "Classify response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        60
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// 2xx = done. 4xx (not 408/409) = remote definitely rejected, safe to resend later.\n// Anything else (5xx, 408, 409) = the remote may have acted: outcome unknown.\nconst s = $json.statusCode;\nlet outcome = 'unknown';\nif (s >= 200 && s < 300) outcome = 'done';\nelse if (s >= 400 && s < 500 && s !== 408 && s !== 409) outcome = 'failed';\nreturn { json: {\n  idem_key: $('Build idem_key').item.json.idem_key,\n  outcome, status_code: s,\n  response_json: JSON.stringify($json.body ?? {}),\n  reason: 'HTTP ' + s,\n} };"
      }
    },
    {
      "id": "n08-outcome-done",
      "name": "Outcome done?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1540,
        60
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.outcome }}",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "rightValue": "done"
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "n09-outcome-failed",
      "name": "Outcome failed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1540,
        260
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.outcome }}",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "rightValue": "failed"
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "n10-mark-done",
      "name": "Mark done",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1760,
        -40
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE side_effect_ledger\nSET status = 'done', finished_at = now(), response = $2::jsonb\nWHERE idem_key = $1 AND status = 'started'\nRETURNING idem_key;",
        "options": {
          "queryReplacement": "={{ [$json.idem_key, $json.response_json] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Ledger Postgres"
        }
      }
    },
    {
      "id": "n11-mark-failed",
      "name": "Mark failed",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1760,
        160
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE side_effect_ledger\nSET status = 'failed', finished_at = now(), response = $2::jsonb\nWHERE idem_key = $1 AND status = 'started'\nRETURNING idem_key;",
        "options": {
          "queryReplacement": "={{ [$json.idem_key, $json.response_json] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Ledger Postgres"
        }
      }
    },
    {
      "id": "n12-lookup-status",
      "name": "Lookup status",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1100,
        560
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT idem_key, status, EXTRACT(EPOCH FROM (now() - started_at))::int AS age_seconds\nFROM side_effect_ledger\nWHERE idem_key = $1;",
        "options": {
          "queryReplacement": "={{ [$json.idem_key] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Ledger Postgres"
        }
      },
      "alwaysOutputData": true
    },
    {
      "id": "n13-status-done",
      "name": "Status done?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1320,
        560
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.status }}",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "rightValue": "done"
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "n14-skip-(already-done)",
      "name": "Skip (already done)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1540,
        500
      ],
      "parameters": {}
    },
    {
      "id": "n15-status-failed",
      "name": "Status failed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1540,
        680
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.status }}",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "rightValue": "failed"
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "n16-reclaim-failed-row",
      "name": "Reclaim failed row",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1760,
        620
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH r AS (\n  UPDATE side_effect_ledger\n  SET status = 'started', started_at = now(), finished_at = NULL, execution_id = $2\n  WHERE idem_key = $1 AND status = 'failed'\n  RETURNING idem_key\n)\nSELECT $1::text AS idem_key, EXISTS (SELECT 1 FROM r) AS claimed;",
        "options": {
          "queryReplacement": "={{ [$json.idem_key, String($execution.id)] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Ledger Postgres"
        }
      }
    },
    {
      "id": "n17-reclaimed",
      "name": "Reclaimed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1980,
        620
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.claimed }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "n18-review-payload",
      "name": "Review payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2200,
        820
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Builds the alert text for every way an item can end up needing a human.\nconst key = $json.idem_key ?? $('Build idem_key').item.json.idem_key;\nlet reason;\nif ($json.status === 'started') reason = 'Ledger row is still started (' + $json.age_seconds + 's old). Another run may be working on it; if not, the outcome is unknown.';\nelse if ($json.error) reason = 'No usable response from the remote (timeout or network error): ' + ($json.error.message ?? 'unknown');\nelse if ($json.outcome === 'unknown') reason = 'Remote answered ' + $json.status_code + ', it may have acted.';\nelse reason = 'Could not claim or reclaim the ledger row.';\nreturn { json: { idem_key: key, reason, hint: 'Check the remote system, then resolve the row (see schema.sql section 6). Do not resend blindly.' } };"
      }
    },
    {
      "id": "n19-alert:-needs-review",
      "name": "Alert: needs review",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2420,
        820
      ],
      "parameters": {
        "method": "POST",
        "url": "https://example.com/alert-placeholder",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 15000
        }
      }
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Sample batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sample batch": {
      "main": [
        [
          {
            "node": "Build idem_key",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build idem_key": {
      "main": [
        [
          {
            "node": "Claim",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claim": {
      "main": [
        [
          {
            "node": "Claimed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claimed?": {
      "main": [
        [
          {
            "node": "Write to external system",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Lookup status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write to external system": {
      "main": [
        [
          {
            "node": "Classify response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Review payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify response": {
      "main": [
        [
          {
            "node": "Outcome done?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Outcome done?": {
      "main": [
        [
          {
            "node": "Mark done",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Outcome failed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Outcome failed?": {
      "main": [
        [
          {
            "node": "Mark failed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Review payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lookup status": {
      "main": [
        [
          {
            "node": "Status done?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Status done?": {
      "main": [
        [
          {
            "node": "Skip (already done)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Status failed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Status failed?": {
      "main": [
        [
          {
            "node": "Reclaim failed row",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Review payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reclaim failed row": {
      "main": [
        [
          {
            "node": "Reclaimed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reclaimed?": {
      "main": [
        [
          {
            "node": "Write to external system",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Review payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Review payload": {
      "main": [
        [
          {
            "node": "Alert: needs review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}