{
  "name": "Alert Slack when an API response changes structure",
  "tags": [],
  "nodes": [
    {
      "id": "note-overview",
      "name": "Overview and setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -512,
        -640
      ],
      "parameters": {
        "color": 1,
        "width": 492,
        "height": 840,
        "content": "## Alert Slack when an API response changes structure\n\nPolls an API endpoint on a schedule, derives the shape of its response (every field path and its type), stores that shape in a Data Table, and posts a Slack alert only when the contract breaks. Ordinary value churn, a number that changed or an extra array element, is ignored. Only structural and type changes fire an alert.\n\n### Who is it for\n\nBackend and platform engineers who depend on a third party or internal API and want to know the moment its response shape changes, before it breaks something downstream.\n\n### How it works\n\n1. The schedule runs on the interval you set.\n2. Settings holds the endpoint URL, a label for it, and the Slack channel.\n3. The endpoint is fetched. A transient failure retries, then skips the run so the saved shape is never overwritten by a bad fetch.\n4. A Code node derives the new schema, loads the prior snapshot, and diffs them: removed field, type change, new required field, nullability flip, new optional field, each tagged by severity.\n5. The snapshot is refreshed with the new shape.\n6. Slack is posted only when a high or medium change is found.\n\n### Setup\n\n1. Create a Data Table named API Contract Snapshots with two text columns: endpointKey and schema_object.\n2. Open Settings and set endpointUrl, endpointKey, and slackChannel.\n3. Select that Data Table in the Load prior snapshot and Update snapshot nodes.\n4. Assign a Slack credential to the alert node.\n5. Run once to seed the snapshot, then activate."
      },
      "typeVersion": 1
    },
    {
      "id": "note-fetch",
      "name": "Note: fetch safely",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        368,
        48
      ],
      "parameters": {
        "color": 7,
        "width": 796,
        "height": 344,
        "content": "## Fetch safely\nPulls the endpoint with retries. A failed fetch or a non JSON body routes to Skip run, so a bad response never overwrites the saved shape and never fires a false alarm."
      },
      "typeVersion": 1
    },
    {
      "id": "note-snapshot",
      "name": "Note: snapshot",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        464,
        -304
      ],
      "parameters": {
        "color": 7,
        "width": 700,
        "height": 328,
        "content": "## Snapshot in a Data Table\nThe prior shape is read here before the diff, and the new shape is written back by Update snapshot after it. Keyed by endpointKey. The first run seeds the snapshot and stays silent."
      },
      "typeVersion": 1
    },
    {
      "id": "note-derive",
      "name": "Note: derive schema",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        464,
        -624
      ],
      "parameters": {
        "color": 7,
        "width": 700,
        "height": 296,
        "content": "## Derive the schema, ignore value churn\nBuilds a field path and type map. Array indices collapse to [] so changed values and list length never register. Only paths, types, and nullability are compared."
      },
      "typeVersion": 1
    },
    {
      "id": "note-severity",
      "name": "Note: severity and alerting",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1200,
        -304
      ],
      "parameters": {
        "color": 7,
        "width": 492,
        "height": 360,
        "content": "## Breaking changes and severity\nRemoved field and type change are high. New required field and nullability flip are medium. A brand new optional field is low. Slack fires only on high or medium."
      },
      "typeVersion": 1
    },
    {
      "id": "schedule",
      "name": "Check on a schedule",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        48,
        208
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 6
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "settings",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "position": [
        224,
        208
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "set-url",
              "name": "endpointUrl",
              "type": "string",
              "value": "https://jsonplaceholder.typicode.com/users"
            },
            {
              "id": "set-key",
              "name": "endpointKey",
              "type": "string",
              "value": "jsonplaceholder-users"
            },
            {
              "id": "set-chan",
              "name": "slackChannel",
              "type": "string",
              "value": "REPLACE_WITH_SLACK_CHANNEL_ID"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "http",
      "name": "Fetch the endpoint",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueErrorOutput",
      "maxTries": 3,
      "position": [
        416,
        208
      ],
      "parameters": {
        "url": "={{ $('Settings').item.json.endpointUrl }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "n8n-api-contract-drift-watcher"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 5000
    },
    {
      "id": "load",
      "name": "Load prior snapshot",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        592,
        -112
      ],
      "parameters": {
        "limit": 1,
        "filters": {
          "conditions": [
            {
              "keyName": "endpointKey",
              "keyValue": "={{ $('Settings').item.json.endpointKey }}",
              "condition": "eq"
            }
          ]
        },
        "resource": "row",
        "matchType": "allConditions",
        "operation": "get",
        "returnAll": false,
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "REPLACE_WITH_DATA_TABLE_ID",
          "cachedResultName": "API Contract Snapshots"
        }
      },
      "typeVersion": 1.1,
      "alwaysOutputData": true
    },
    {
      "id": "code",
      "name": "Derive schema and diff contract",
      "type": "n8n-nodes-base.code",
      "onError": "continueErrorOutput",
      "position": [
        800,
        -480
      ],
      "parameters": {
        "jsCode": "// API Contract Drift Watcher: derive schema, load prior snapshot, run a\n// contract-aware diff, classify each breaking change by severity, build the alert.\n// Runs once for all items. Value churn (changed values, extra array elements) is\n// suppressed by design: only field paths, types, nullability and presence compare.\n\nconst MAX_DEPTH = 15;        // guard against pathological nesting\nconst MAX_PATHS = 4000;      // guard the Data Table cell size\n\nconst settings = $('Settings').first().json;\nconst endpointKey = (settings.endpointKey || '').toString().trim() || 'default-endpoint';\n\n// 1) Pull the fetched body. The HTTP node uses Full Response, so the parsed body\n//    sits under .body intact (a top-level array is not split into items).\nconst resp = $('Fetch the endpoint').first().json;\nconst body = (resp && Object.prototype.hasOwnProperty.call(resp, 'body')) ? resp.body : resp;\n\nconst rootType = jsonType(body);\nif (rootType !== 'object' && rootType !== 'array') {\n  // A 200 that is not JSON (an HTML login page, plain text) is not a schema we can\n  // diff. Throw so the node error output routes to Skip run and the snapshot is\n  // left untouched, rather than overwriting a good shape with garbage.\n  throw new Error('Response body was not a JSON object or array (got ' + rootType + '). Run skipped, snapshot left unchanged.');\n}\n\n// 2) Derive the new schema: a flat map of normalized field path -> { type, nullable, required }.\nconst newSchema = deriveSchema(body);\nconst pathCount = Object.keys(newSchema).length;\nif (pathCount > MAX_PATHS) {\n  throw new Error('Derived schema has ' + pathCount + ' paths, over the ' + MAX_PATHS + ' guard. Point the watcher at a representative endpoint. Run skipped.');\n}\n\n// 3) Load the prior snapshot (Data Table get; zero rows on the first run).\nconst priorRows = $('Load prior snapshot').all()\n  .map(function (r) { return r.json; })\n  .filter(function (r) { return r && r.schema_object; });\nconst priorRow = priorRows.find(function (r) { return r.endpointKey === endpointKey; }) || null;\nconst isFirstRun = !priorRow;\n\nlet changes = [];\nif (!isFirstRun) {\n  let priorSchema = {};\n  try { priorSchema = JSON.parse(priorRow.schema_object) || {}; } catch (e) { priorSchema = {}; }\n  changes = diffSchemas(priorSchema, newSchema);\n}\n\n// 4) Severity rollup. A breaking change is high or medium. Low (a brand-new\n//    optional field) is recorded for context but does not raise an alert on its own.\nconst counts = { high: 0, medium: 0, low: 0 };\nfor (const c of changes) { counts[c.severity] = (counts[c.severity] || 0) + 1; }\nconst breaking = changes.filter(function (c) { return c.severity === 'high' || c.severity === 'medium'; });\nconst hasBreakingChange = breaking.length > 0;\nconst rank = { high: 3, medium: 2, low: 1 };\nlet topSeverity = 'low';\nfor (const c of changes) { if (rank[c.severity] > rank[topSeverity]) topSeverity = c.severity; }\n\n// 5) Build the Slack message (only used when hasBreakingChange is true).\nconst header = topSeverity === 'high'\n  ? ':red_circle: HIGH severity API contract drift'\n  : ':large_orange_circle: MEDIUM severity API contract drift';\nconst lines = changes.map(function (c) {\n  const dot = c.severity === 'high' ? ':red_circle:' : (c.severity === 'medium' ? ':large_orange_circle:' : ':large_yellow_circle:');\n  return dot + ' *' + c.kind + '* at `' + c.path + '`' + (c.detail ? ' (' + c.detail + ')' : '');\n});\nconst alertText = header + ' on *' + endpointKey + '*\\n'\n  + counts.high + ' high, ' + counts.medium + ' medium, ' + counts.low + ' low\\n\\n'\n  + lines.join('\\n');\n\nreturn [{\n  json: {\n    endpointKey: endpointKey,\n    isFirstRun: isFirstRun,\n    hasBreakingChange: hasBreakingChange,\n    topSeverity: topSeverity,\n    counts: counts,\n    changes: changes,\n    alertText: alertText,\n    pathCount: pathCount,\n    schema_object: JSON.stringify(newSchema),\n    checkedAt: new Date().toISOString(),\n  },\n}];\n\n// ----------------------------- helpers -----------------------------\nfunction jsonType(v) {\n  if (v === null) return 'null';\n  if (v === undefined) return 'undefined';\n  if (Array.isArray(v)) return 'array';\n  return typeof v; // 'string' | 'number' | 'boolean' | 'object'\n}\n\n// Build path -> { type, nullable, required }. Array indices collapse to [] so\n// adding/removing elements and changing values never registers as a change.\n// required = the field is present in every instance of its container\n// (present in all elements of an array of objects, or present on a singleton object).\nfunction deriveSchema(root) {\n  const counts = {};                 // path -> times observed (after index collapse)\n  const typeSets = {};               // path -> Set of JSON types\n  const nullCounts = {};             // path -> times observed as null\n  const containerInstances = { '': 1 }; // container path -> number of object instances\n\n  function record(p, value) {\n    counts[p] = (counts[p] || 0) + 1;\n    const t = jsonType(value);\n    (typeSets[p] = typeSets[p] || new Set()).add(t);\n    if (t === 'null') nullCounts[p] = (nullCounts[p] || 0) + 1;\n  }\n\n  function walk(value, p, depth) {\n    if (p !== '') record(p, value);\n    const t = jsonType(value);\n    if (depth >= MAX_DEPTH) return;\n    if (t === 'object') {\n      containerInstances[p] = (containerInstances[p] || 0) + 1;\n      for (const k of Object.keys(value)) {\n        const childPath = p === '' ? k : p + '.' + k;\n        walk(value[k], childPath, depth + 1);\n      }\n    } else if (t === 'array') {\n      const elemPath = p + '[]';\n      for (const el of value) walk(el, elemPath, depth + 1);\n    }\n  }\n\n  walk(root, '', 0);\n\n  const schema = {};\n  for (const p of Object.keys(counts)) {\n    const nonNull = Array.from(typeSets[p]).filter(function (x) { return x !== 'null'; });\n    let type;\n    if (nonNull.length === 0) type = 'null';\n    else if (nonNull.length === 1) type = nonNull[0];\n    else type = 'mixed(' + nonNull.sort().join('|') + ')';\n    const parent = parentOf(p);\n    const parentInstances = containerInstances[parent] || 1;\n    schema[p] = {\n      type: type,\n      nullable: (nullCounts[p] || 0) > 0,\n      required: counts[p] === parentInstances && parentInstances > 0,\n    };\n  }\n  return schema;\n}\n\nfunction parentOf(p) {\n  if (p.endsWith('[]')) return p.slice(0, -2);\n  const dot = p.lastIndexOf('.');\n  return dot >= 0 ? p.slice(0, dot) : '';\n}\n\nfunction isAncestor(anc, p) {\n  if (p === anc) return false;\n  return p.indexOf(anc + '.') === 0 || p.indexOf(anc + '[]') === 0;\n}\n\nfunction ancestorInSet(p, set) {\n  for (const anc of set) { if (isAncestor(anc, p)) return true; }\n  return false;\n}\n\n// Contract-aware diff. Only structural and type changes are reported; value churn\n// is invisible because deriveSchema never records values or array length.\nfunction diffSchemas(prior, next) {\n  const priorPaths = Object.keys(prior);\n  const nextPaths = Object.keys(next);\n  const nextSet = new Set(nextPaths);\n  const priorSet = new Set(priorPaths);\n\n  const removed = priorPaths.filter(function (p) { return !nextSet.has(p); });\n  const added = nextPaths.filter(function (p) { return !priorSet.has(p); });\n  const common = priorPaths.filter(function (p) { return nextSet.has(p); });\n\n  // Type change ignores null-typed sides (null is a value state, handled by nullability).\n  const typeChanged = common.filter(function (p) {\n    const a = prior[p].type, b = next[p].type;\n    return a !== b && a !== 'null' && b !== 'null';\n  });\n  const removedSet = new Set(removed);\n  const addedSet = new Set(added);\n  const typeChangedSet = new Set(typeChanged);\n\n  const changes = [];\n\n  // Removed field (high). Report the top-most removed path only, and not a path\n  // whose ancestor changed type (that is a consequence, not a separate break).\n  for (const p of removed) {\n    if (ancestorInSet(p, removedSet)) continue;\n    if (ancestorInSet(p, typeChangedSet)) continue;\n    changes.push({ kind: 'removed field', path: p, severity: 'high', detail: 'was ' + prior[p].type });\n  }\n\n  // Type change (high), e.g. string to number.\n  for (const p of typeChanged) {\n    if (ancestorInSet(p, typeChangedSet)) continue;\n    changes.push({ kind: 'type change', path: p, severity: 'high', detail: prior[p].type + ' to ' + next[p].type });\n  }\n\n  // New field. Required (present in every instance of its container) is medium;\n  // otherwise it is a brand-new optional field, low. Report the top-most new path only.\n  for (const p of added) {\n    if (ancestorInSet(p, addedSet)) continue;\n    if (ancestorInSet(p, typeChangedSet)) continue;\n    if (next[p].required) {\n      changes.push({ kind: 'new required field', path: p, severity: 'medium', detail: 'type ' + next[p].type });\n    } else {\n      changes.push({ kind: 'new optional field', path: p, severity: 'low', detail: 'type ' + next[p].type });\n    }\n  }\n\n  // Nullability flip (medium): present in both, never null before, null now.\n  for (const p of common) {\n    if (typeChangedSet.has(p) || ancestorInSet(p, typeChangedSet)) continue;\n    if (!prior[p].nullable && next[p].nullable) {\n      changes.push({ kind: 'nullability flip', path: p, severity: 'medium', detail: 'now returns null' });\n    }\n  }\n\n  const order = { high: 0, medium: 1, low: 2 };\n  changes.sort(function (x, y) { return (order[x.severity] - order[y.severity]) || x.path.localeCompare(y.path); });\n  return changes;\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "save",
      "name": "Update snapshot",
      "type": "n8n-nodes-base.dataTable",
      "maxTries": 3,
      "position": [
        992,
        -112
      ],
      "parameters": {
        "columns": {
          "value": {
            "endpointKey": "={{ $('Derive schema and diff contract').item.json.endpointKey }}",
            "schema_object": "={{ $('Derive schema and diff contract').item.json.schema_object }}"
          },
          "mappingMode": "defineBelow"
        },
        "filters": {
          "conditions": [
            {
              "keyName": "endpointKey",
              "keyValue": "={{ $('Derive schema and diff contract').item.json.endpointKey }}",
              "condition": "eq"
            }
          ]
        },
        "resource": "row",
        "matchType": "allConditions",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "REPLACE_WITH_DATA_TABLE_ID",
          "cachedResultName": "API Contract Snapshots"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1.1,
      "waitBetweenTries": 5000
    },
    {
      "id": "gate",
      "name": "Breaking change found?",
      "type": "n8n-nodes-base.if",
      "position": [
        1280,
        -112
      ],
      "parameters": {
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $('Derive schema and diff contract').item.json.hasBreakingChange }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "slack",
      "name": "Post drift alert to Slack",
      "type": "n8n-nodes-base.slack",
      "maxTries": 3,
      "position": [
        1504,
        -112
      ],
      "parameters": {
        "text": "={{ $('Derive schema and diff contract').item.json.alertText }}",
        "select": "channel",
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Settings').item.json.slackChannel }}"
        },
        "operation": "post",
        "messageType": "text",
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 2.5,
      "waitBetweenTries": 5000
    },
    {
      "id": "skip",
      "name": "Skip run (no valid response)",
      "type": "n8n-nodes-base.noOp",
      "position": [
        976,
        224
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "connections": {
    "Settings": {
      "main": [
        [
          {
            "node": "Fetch the endpoint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update snapshot": {
      "main": [
        [
          {
            "node": "Breaking change found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch the endpoint": {
      "main": [
        [
          {
            "node": "Load prior snapshot",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip run (no valid response)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check on a schedule": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load prior snapshot": {
      "main": [
        [
          {
            "node": "Derive schema and diff contract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Breaking change found?": {
      "main": [
        [
          {
            "node": "Post drift alert to Slack",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Derive schema and diff contract": {
      "main": [
        [
          {
            "node": "Update snapshot",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip run (no valid response)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}