This workflow corresponds to n8n.io template #16699 — we link there as the canonical source.
This workflow follows the Datatable → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
slackApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs on a schedule, polls a JSON API endpoint, derives and stores a snapshot of the response schema in an n8n Data Table, and posts a Slack alert only when the response structure or field types change. Runs every 6 hours on a schedule. Loads the configured API…
Source: https://n8n.io/workflows/16699/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow is designed for engineering teams, project managers, and IT operations who need consistent visibility into team availability across multiple projects. It’s perfect for organizations that
This professional-grade n8n workflow automation is designed for crypto traders, investors, and market analysts who need real-time volume change alerts across different market cap segments. Whether you
This workflow is an automated system that tracks End-of-Life (EOL) dates for software and technologies used across your projects. It eliminates the need to manually monitor EOL dates in spreadsheets o
This workflow continuously monitors the Meta Ads Library for new creatives from a specific competitor pages, logs them into Google Sheets, and sends a concise Telegram notification with the number of
Enhance financial oversight with this automated n8n workflow. Triggered every 5 minutes, it fetches real-time bank transactions via an API, enriches and transforms the data, and applies smart logic to