This workflow corresponds to n8n.io template #16106 — we link there as the canonical source.
This workflow follows the HTTP Request → Postgres 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 →
{
"id": "8EVyWEwyCSlPNvhT",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Sync PostgreSQL changes to SharePoint lists with Microsoft Graph and Teams",
"tags": [],
"nodes": [
{
"id": "fd322a50-e8ed-4386-9789-5f7a9beb607b",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-3664,
448
],
"parameters": {
"width": 480,
"height": 736,
"content": "## PostgreSQL to SharePoint Sync: Incremental CDC with Graph Batch API and Teams Alerts\n\n### How it works\n\nThis workflow runs every 15 minutes to incrementally sync modified rows from PostgreSQL into a SharePoint list using Microsoft Graph batch requests. It reads the last successful run timestamp, queries only changed rows, batches the updates, verifies Graph responses, and posts Teams notifications for errors and run summaries. It also handles the no-change path by updating run state and sending a summary without calling Graph.\n\n### Setup steps\n\n- Configure PostgreSQL credentials and update the SQL query so it selects the source table rows modified since the stored last-run timestamp.\n- Configure Microsoft Graph authentication with permissions to update the target SharePoint site and list, then set the SharePoint site ID and list ID in the Config node.\n- Configure Microsoft Teams credentials and set the target team/channel details used by the error alert and sync summary nodes.\n- Set the desired batchSize in the Config node and verify the Graph batch payload builder maps PostgreSQL fields to the correct SharePoint list fields.\n- Initialize or clear the workflow static data timestamp as needed before the first production run.\n\n### Customization\n\nAdjust the schedule interval, SQL change-detection logic, field mappings, Graph batch size, alert deduplication rules, and Teams message content to match the source schema and operational requirements."
},
"typeVersion": 1
},
{
"id": "d92be2b8-f044-4809-947b-75f730a850ab",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-3072,
688
],
"parameters": {
"color": 7,
"width": 640,
"height": 352,
"content": "## Schedule and configuration\n\nStarts the workflow on a 15-minute schedule, loads sync configuration values, and retrieves the last successful run timestamp from workflow static data."
},
"typeVersion": 1
},
{
"id": "a3de35e8-a404-44ff-95fa-2a8911802acf",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2352,
688
],
"parameters": {
"color": 7,
"width": 608,
"height": 336,
"content": "### Query changed rows\n\nQueries PostgreSQL for rows modified since the last run, aggregates the returned rows into one item, and decides whether there is anything to sync."
},
"typeVersion": 1
},
{
"id": "e6847c2b-94f6-4f6b-b462-fa267eab2e13",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1680,
896
],
"parameters": {
"color": 7,
"width": 368,
"height": 368,
"content": "## Handle empty result\n\nHandles the branch where no PostgreSQL rows need syncing by updating timestamp-related state before the summary notification."
},
"typeVersion": 1
},
{
"id": "2dc6b602-453e-4f54-96ad-807cfe33a0df",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1248,
640
],
"parameters": {
"color": 7,
"width": 464,
"height": 368,
"content": "## Prepare Graph batches\n\nBuilds Microsoft Graph batch request payloads for the changed rows and feeds them into a batch loop for controlled processing."
},
"typeVersion": 1
},
{
"id": "ca09c3e8-6b84-44ee-bcfa-8c7bdd271576",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
-720,
272
],
"parameters": {
"color": 7,
"width": 816,
"height": 320,
"content": "## Execute batch requests\n\nPosts each prepared payload to the Microsoft Graph $batch endpoint, verifies the batch response, and loops back to request the next batch."
},
"typeVersion": 1
},
{
"id": "9095b111-4a65-4a9e-81eb-7c3175e1ba19",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
-688,
640
],
"parameters": {
"color": 7,
"width": 400,
"height": 368,
"content": "## Evaluate run results\n\nCollects the completed loop results and checks whether any batch operation produced errors during the sync run."
},
"typeVersion": 1
},
{
"id": "93102439-abeb-403a-b3cf-479a402ef652",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"position": [
-208,
640
],
"parameters": {
"color": 7,
"width": 608,
"height": 368,
"content": "## Send error alerts\n\nDeduplicates detected sync errors, determines whether a new alert should be sent, and posts an error notification to Microsoft Teams when needed."
},
"typeVersion": 1
},
{
"id": "c16152a7-b2a3-4a22-979b-782376cee502",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"position": [
496,
784
],
"parameters": {
"color": 7,
"width": 496,
"height": 448,
"content": "## Finalize and summarize\n\nUpdates the last-run timestamp after processing and sends the final Microsoft Teams sync summary for both changed-row and no-row executions."
},
"typeVersion": 1
},
{
"id": "f508f884-ea57-4223-9d8c-708b605d4218",
"name": "Every 15 Minutes Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-3024,
848
],
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 15
}
]
}
},
"typeVersion": 1.2
},
{
"id": "d3da0733-d1d3-42b0-83e2-93af3e80560e",
"name": "Set Configuration Parameters",
"type": "n8n-nodes-base.set",
"position": [
-2800,
848
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "c09-01",
"name": "sharepointSiteId",
"type": "string",
"value": "YOUR_SHAREPOINT_SITE_ID"
},
{
"id": "c09-02",
"name": "sharepointListId",
"type": "string",
"value": "YOUR_SHAREPOINT_LIST_ID"
},
{
"id": "c09-03",
"name": "batchSize",
"type": "number",
"value": 20
},
{
"id": "c09-04",
"name": "teamsTeamId",
"type": "string",
"value": "YOUR_TEAMS_TEAM_ID"
},
{
"id": "c09-05",
"name": "teamsChannelId",
"type": "string",
"value": "YOUR_TEAMS_CHANNEL_ID"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "9c221c97-d4bb-40c9-bafc-12987d0ae614",
"name": "Fetch Last Run Timestamp",
"type": "n8n-nodes-base.code",
"position": [
-2576,
848
],
"parameters": {
"jsCode": "// Retrieve last successful run timestamp from workflow static data\nconst workflowStaticData = $getWorkflowStaticData('global');\nconst lastRun = workflowStaticData.lastSuccessfulRun || '1970-01-01T00:00:00.000Z';\n\n// Capture execution start time to prevent timestamp drift / lost rows\nconst currentRunStart = new Date().toISOString();\n\nreturn [{ json: { lastRun, currentRunStart, isFirstRun: lastRun === '1970-01-01T00:00:00.000Z' } }];"
},
"typeVersion": 2
},
{
"id": "f926be55-bee2-47d5-b172-c7e65f579ca0",
"name": "Query Updated Rows from Postgres",
"type": "n8n-nodes-base.postgres",
"position": [
-2304,
848
],
"parameters": {
"query": "SELECT * FROM your_table WHERE updated_at >= $1::timestamptz ORDER BY updated_at ASC",
"options": {},
"operation": "executeQuery"
},
"typeVersion": 2.5
},
{
"id": "133aa998-a22a-4568-b0fd-fbdc91aa42b3",
"name": "Aggregate Postgres Results",
"type": "n8n-nodes-base.code",
"position": [
-2080,
848
],
"parameters": {
"jsCode": "// Collects all rows from the Postgres node into a single item.\n// Timestamps are pulled from Get Last Run Timestamp via a guarded cross-node reference.\n// IMPORTANT: continueOnFail must remain OFF on the Postgres node above.\n// If Postgres errors, execution stops here and the watermark is never touched \u2014 correct behavior.\n// If continueOnFail were ever enabled on Postgres, a failed query would pass an empty\n// array through and could incorrectly advance the watermark. Do not enable it.\n\nlet lastRunData;\ntry {\n lastRunData = $('Fetch Last Run Timestamp').first().json;\n} catch(e) {\n throw new Error('Get Last Run Timestamp output is unavailable. Do not rename or disable that node. Original error: ' + e.message);\n}\n\nconst rows = $input.all().map(item => item.json);\n\nreturn [{ json: {\n rows,\n rowCount: rows.length,\n lastRun: lastRunData.lastRun,\n currentRunStart: lastRunData.currentRunStart,\n isFirstRun: lastRunData.isFirstRun\n} }];"
},
"typeVersion": 2
},
{
"id": "3ba113aa-4174-43cb-9a57-40555a1582b2",
"name": "Check Rows to Sync",
"type": "n8n-nodes-base.if",
"position": [
-1888,
848
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "cond-rows",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ $json.rowCount }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2.2
},
{
"id": "aef0710b-b12d-4ae8-8265-f6d9ff1419d4",
"name": "Update Timestamp No Rows",
"type": "n8n-nodes-base.code",
"position": [
-1552,
1072
],
"parameters": {
"jsCode": "const workflowStaticData = $getWorkflowStaticData('global');\nconst newTimestamp = $('Fetch Last Run Timestamp').first().json.currentRunStart;\nworkflowStaticData.lastSuccessfulRun = newTimestamp;\n\n// Output matches the shape expected by Send Sync Summary\nreturn [{ json: {\n lastRunUpdated: newTimestamp,\n watermarkAdvanced: true,\n syncComplete: true,\n totalSuccess: 0,\n totalFailures: 0\n} }];"
},
"typeVersion": 2
},
{
"id": "10db101c-8012-457e-a73d-1e4c90114774",
"name": "Construct Graph Batch Payloads",
"type": "n8n-nodes-base.code",
"position": [
-1200,
832
],
"parameters": {
"jsCode": "const rows = $('Aggregate Postgres Results').first().json.rows || [];\nconst sharepointSiteId = $('Set Configuration Parameters').first().json.sharepointSiteId;\nconst sharepointListId = $('Set Configuration Parameters').first().json.sharepointListId;\n\n// FIX #11: Enforce the hard Graph API limit of 20 requests per $batch call\nconst batchSize = Math.min($('Set Configuration Parameters').first().json.batchSize || 20, 20);\n\nconst batches = [];\nfor (let i = 0; i < rows.length; i += batchSize) {\n const chunk = rows.slice(i, i + batchSize);\n const requests = chunk.map((row, j) => ({\n id: String(row.id || i + j + 1),\n method: 'POST',\n url: `/sites/${sharepointSiteId}/lists/${sharepointListId}/items`,\n headers: { 'Content-Type': 'application/json' },\n // FIX #4: TODO \u2014 map your actual Postgres column names to SharePoint field internal names.\n // Example: if your table has columns (id, customer_name, amount), update fields like:\n // Title: String(row.customer_name || ''),\n // Amount: row.amount,\n body: {\n fields: {\n Title: String(row.id || row.name || ''),\n ExternalId: String(row.id || ''),\n SyncedAt: new Date().toISOString()\n }\n }\n // FIX #6: dependsOn is intentionally omitted for pure POST creates.\n // If you extend this to upsert (check-then-write), add:\n // dependsOn: [String(checkRequestId)]\n // to chain the existence-check request before the write.\n }));\n batches.push({ requests, batchIndex: batches.length, totalBatches: Math.ceil(rows.length / batchSize) });\n}\n\nreturn batches.map(b => ({ json: b }));"
},
"typeVersion": 2
},
{
"id": "96e6f940-f1fa-4ab4-ad3c-5e3df4cc8e41",
"name": "Loop Over Batch Payloads",
"type": "n8n-nodes-base.splitInBatches",
"position": [
-976,
832
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "9686c086-68af-4b35-b29e-4f38b101162a",
"name": "Post Batch to Graph API",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 5,
"position": [
-640,
416
],
"parameters": {
"url": "https://graph.microsoft.com/v1.0/$batch",
"method": "POST",
"options": {},
"jsonBody": "={{ { \"requests\": $json.requests } }}",
"sendBody": true,
"specifyBody": "json",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "microsoftGraphOAuth2Api"
},
"retryOnFail": true,
"typeVersion": 4.2,
"continueOnFail": true,
"waitBetweenTries": 3000
},
{
"id": "fb282479-0804-4b53-839b-63a1501cb6d8",
"name": "Verify Batch API Response",
"type": "n8n-nodes-base.code",
"position": [
-336,
416
],
"parameters": {
"jsCode": "const batchResponse = $input.item.json || {};\n\nif (!batchResponse.responses) {\n return [{\n json: {\n successCount: 0,\n failCount: 1,\n throttledCount: 0,\n failedIds: ['ENTIRE_BATCH_FAILED'],\n throttledIds: [],\n errorDetails: batchResponse.error ? batchResponse.error.message : 'Unknown Network or API Outage',\n batchProcessed: false\n }\n }];\n}\n\nconst responses = batchResponse.responses;\nconst succeeded = responses.filter(r => r.status >= 200 && r.status < 300);\nconst throttled = responses.filter(r => r.status === 429);\nconst failed = responses.filter(r => r.status < 200 || (r.status >= 300 && r.status !== 429));\n\n// FIX #7: Extract Retry-After header from throttled responses for visibility.\n// Note: n8n's httpRequest does not expose per-sub-request headers from $batch responses.\n// Throttled IDs are surfaced in the alert so operators know which rows to manually retry.\n// For automated backoff, consider routing throttledIds to a separate queue or\n// re-triggering with a Wait node using the max Retry-After value.\nconst retryAfterValues = throttled\n .map(r => r.headers && r.headers['retry-after'] ? parseInt(r.headers['retry-after'], 10) : null)\n .filter(v => v !== null);\nconst maxRetryAfter = retryAfterValues.length > 0 ? Math.max(...retryAfterValues) : null;\n\nreturn [{\n json: {\n successCount: succeeded.length,\n failCount: failed.length,\n throttledCount: throttled.length,\n failedIds: failed.map(r => r.id),\n throttledIds: throttled.map(r => r.id),\n maxRetryAfterSeconds: maxRetryAfter,\n batchProcessed: true\n }\n}];"
},
"typeVersion": 2
},
{
"id": "ea9f1ca7-ef3e-4748-bd39-fb901ecdf166",
"name": "Prepare Next Batch",
"type": "n8n-nodes-base.noOp",
"position": [
-48,
416
],
"parameters": {},
"typeVersion": 1
},
{
"id": "9b572cb2-c5a0-43f5-9f21-fb96b62dc58d",
"name": "Process Looped Results",
"type": "n8n-nodes-base.code",
"position": [
-640,
848
],
"parameters": {
"jsCode": "// FIX #5: Use the documented .all() accessor instead of the fragile runIndex/$items() loop.\n// $('Verify Batch API Response').all() correctly returns outputs across all SplitInBatches iterations.\nconst allItems = $('Verify Batch API Response').all();\n\nlet totalSuccess = 0;\nlet allFailures = [];\n\nfor (const item of allItems) {\n const res = item.json;\n if (res.successCount) totalSuccess += res.successCount;\n if (res.failedIds && res.failedIds.length > 0) allFailures.push(...res.failedIds);\n if (res.throttledIds && res.throttledIds.length > 0) allFailures.push(...res.throttledIds);\n}\n\nconst actualFailures = allFailures.filter(id => id !== 'ENTIRE_BATCH_FAILED');\nconst finalFailures = actualFailures.length > 0 ? actualFailures : allFailures;\n\nreturn [{\n json: {\n totalSuccess,\n totalFailures: finalFailures.length,\n failedIds: finalFailures\n }\n}];"
},
"typeVersion": 2
},
{
"id": "8366baed-c8e2-4973-92db-f6af6e60bf05",
"name": "Check for Errors in Run",
"type": "n8n-nodes-base.if",
"position": [
-432,
848
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "errors-exist",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ $json.totalFailures }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2.2
},
{
"id": "086905df-8fe8-47d2-b12c-5ed7e8e59d2c",
"name": "Remove Duplicate Alerts",
"type": "n8n-nodes-base.code",
"position": [
-160,
816
],
"parameters": {
"jsCode": "const staticData = $getWorkflowStaticData('global');\nconst failedIds = $input.item.json.failedIds || [];\n\n// Initialize rolling memory of failed IDs\nstaticData.knownFailures = staticData.knownFailures || [];\n\n// Find NEW failures not yet alerted on\nconst newFailures = failedIds.filter(id => !staticData.knownFailures.includes(id));\n\nlet shouldAlert = false;\n\nif (newFailures.length > 0) {\n shouldAlert = true;\n const updatedFailures = [...staticData.knownFailures, ...newFailures];\n // Cap at 200 to prevent unbounded memory growth\n // FIX #10: Consider adding { id, ts } TTL objects if stale failure re-alerting is needed.\n staticData.knownFailures = updatedFailures.slice(-200);\n}\n\nreturn [{\n json: {\n ...$input.item.json,\n shouldAlert,\n newFailures\n }\n}];"
},
"typeVersion": 2
},
{
"id": "924129ad-fae1-4c32-86d0-1fed1f718d71",
"name": "Check for New Alert",
"type": "n8n-nodes-base.if",
"position": [
16,
816
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "new-alert-cond",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.shouldAlert }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "e666e919-3165-44e5-9908-e08c8eece43c",
"name": "Send Sync Error Alert",
"type": "n8n-nodes-base.microsoftTeams",
"position": [
256,
752
],
"parameters": {
"teamId": "={{ $('Set Configuration Parameters').first().json.teamsTeamId }}",
"message": "=\u274c **SharePoint Sync Error**\n\n**Time:** {{ $now.toISO() }}\n**Last Run:** {{ $('Fetch Last Run Timestamp').first().json.lastRun }}\n**Error:** Failures detected during batch processing.\n\n**Failed/Throttled PostgreSQL IDs:** {{ $json.newFailures && $json.newFailures.length > 0 ? $json.newFailures.join(', ') : 'Unknown \u2014 check execution log' }}\n\n\u26a0\ufe0f *Watermark has NOT been advanced. Failed rows will be re-queried on the next run.*\n\n*Previously known failures are muted to reduce noise. If failures persist beyond 1 run, add those IDs to a dead-letter table to stop re-querying.*",
"options": {},
"resource": "channelMessage",
"channelId": "={{ $('Set Configuration Parameters').first().json.teamsChannelId }}"
},
"typeVersion": 2,
"continueOnFail": true
},
{
"id": "89fef09f-dd64-49a0-a9b1-21828b9d2782",
"name": "Refresh Last Run Timestamp",
"type": "n8n-nodes-base.code",
"position": [
544,
944
],
"parameters": {
"jsCode": "const staticData = $getWorkflowStaticData('global');\n\n// FIX #4: Guard the cross-node reference so a rename or refactor surfaces immediately.\n// Process Loop Results always executes before any path reaching this node.\nlet loopResults;\ntry {\n loopResults = $('Process Looped Results').first().json;\n} catch(e) {\n // Fallback if Process Loop Results is unavailable due to an unexpected graph error.\n // Should not occur in normal operation \u2014 the no-rows path bypasses this node entirely.\n loopResults = $input.item.json;\n}\n\nconst totalFailures = loopResults.totalFailures ?? 0;\n\n// FIX #3 (CRITICAL): Only advance the watermark if there were zero failures.\n// If rows failed, the watermark stays behind so they are re-queried next run.\n// NOTE: If the same rows fail persistently (e.g. bad field value causing 400),\n// they will be re-queried forever. knownFailures suppresses repeat alerts after\n// the first run. To break the cycle, add those IDs to a dead-letter table in\n// Postgres and exclude them from the query with: AND id NOT IN (SELECT id FROM sync_dead_letter)\nlet newTimestamp;\nlet watermarkAdvanced;\n\nif (totalFailures === 0) {\n newTimestamp = $('Fetch Last Run Timestamp').first().json.currentRunStart;\n staticData.lastSuccessfulRun = newTimestamp;\n watermarkAdvanced = true;\n} else {\n newTimestamp = staticData.lastSuccessfulRun || $('Fetch Last Run Timestamp').first().json.lastRun;\n watermarkAdvanced = false;\n}\n\nreturn [{\n json: {\n lastRunUpdated: newTimestamp,\n watermarkAdvanced,\n syncComplete: true,\n totalSuccess: loopResults.totalSuccess ?? 0,\n totalFailures\n }\n}];"
},
"typeVersion": 2
},
{
"id": "abbdea76-e66a-4af9-be7a-cdab95e6a0ef",
"name": "Dispatch Sync Summary Note",
"type": "n8n-nodes-base.microsoftTeams",
"position": [
848,
1072
],
"parameters": {
"teamId": "={{ $('Set Configuration Parameters').first().json.teamsTeamId }}",
"message": "=\u2705 **SharePoint Sync Complete**\n\n**Run Timestamp:** {{ $json.lastRunUpdated }}\n**Watermark Advanced:** {{ $json.watermarkAdvanced }}\n**Success:** {{ $json.totalSuccess }} rows synced.\n**Failed:** {{ $json.totalFailures }} rows (watermark held back for retry if > 0).\n\nNext sync runs in 15 minutes.",
"options": {},
"resource": "channelMessage",
"channelId": "={{ $('Set Configuration Parameters').first().json.teamsChannelId }}"
},
"typeVersion": 2,
"continueOnFail": true
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"executionOrder": "v1"
},
"versionId": "5a940eb8-b6ed-4a0f-b41e-3b0b7db5e574",
"connections": {
"Check Rows to Sync": {
"main": [
[
{
"node": "Construct Graph Batch Payloads",
"type": "main",
"index": 0
}
],
[
{
"node": "Update Timestamp No Rows",
"type": "main",
"index": 0
}
]
]
},
"Prepare Next Batch": {
"main": [
[
{
"node": "Loop Over Batch Payloads",
"type": "main",
"index": 0
}
]
]
},
"Check for New Alert": {
"main": [
[
{
"node": "Send Sync Error Alert",
"type": "main",
"index": 0
}
],
[
{
"node": "Refresh Last Run Timestamp",
"type": "main",
"index": 0
}
]
]
},
"Send Sync Error Alert": {
"main": [
[
{
"node": "Refresh Last Run Timestamp",
"type": "main",
"index": 0
}
]
]
},
"Process Looped Results": {
"main": [
[
{
"node": "Check for Errors in Run",
"type": "main",
"index": 0
}
]
]
},
"Check for Errors in Run": {
"main": [
[
{
"node": "Remove Duplicate Alerts",
"type": "main",
"index": 0
}
],
[
{
"node": "Refresh Last Run Timestamp",
"type": "main",
"index": 0
}
]
]
},
"Post Batch to Graph API": {
"main": [
[
{
"node": "Verify Batch API Response",
"type": "main",
"index": 0
}
]
]
},
"Remove Duplicate Alerts": {
"main": [
[
{
"node": "Check for New Alert",
"type": "main",
"index": 0
}
]
]
},
"Every 15 Minutes Trigger": {
"main": [
[
{
"node": "Set Configuration Parameters",
"type": "main",
"index": 0
}
]
]
},
"Fetch Last Run Timestamp": {
"main": [
[
{
"node": "Query Updated Rows from Postgres",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Batch Payloads": {
"main": [
[
{
"node": "Post Batch to Graph API",
"type": "main",
"index": 0
}
],
[
{
"node": "Process Looped Results",
"type": "main",
"index": 0
}
]
]
},
"Update Timestamp No Rows": {
"main": [
[
{
"node": "Dispatch Sync Summary Note",
"type": "main",
"index": 0
}
]
]
},
"Verify Batch API Response": {
"main": [
[
{
"node": "Prepare Next Batch",
"type": "main",
"index": 0
}
]
]
},
"Aggregate Postgres Results": {
"main": [
[
{
"node": "Check Rows to Sync",
"type": "main",
"index": 0
}
]
]
},
"Refresh Last Run Timestamp": {
"main": [
[
{
"node": "Dispatch Sync Summary Note",
"type": "main",
"index": 0
}
]
]
},
"Set Configuration Parameters": {
"main": [
[
{
"node": "Fetch Last Run Timestamp",
"type": "main",
"index": 0
}
]
]
},
"Construct Graph Batch Payloads": {
"main": [
[
{
"node": "Loop Over Batch Payloads",
"type": "main",
"index": 0
}
]
]
},
"Query Updated Rows from Postgres": {
"main": [
[
{
"node": "Aggregate Postgres Results",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs every 15 minutes and uses a watermark to incrementally sync recently updated rows from PostgreSQL to a Microsoft SharePoint list via the Microsoft Graph $batch API, posting run summaries and deduplicated error alerts to a Microsoft Teams channel. Runs every 15…
Source: https://n8n.io/workflows/16106/ — 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.
Disparador 1.8. Uses itemLists, postgres, emailSend, httpRequest. Scheduled trigger; 85 nodes.
공유회_알림톡_크론. Uses postgres, httpRequest, n8n-nodes-solapi. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.