This workflow follows the Execute Workflow Trigger → 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 →
{
"id": "crmsync000000001",
"name": "02-crm-sync",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "days",
"triggerAtHour": 2,
"triggerAtMinute": 15
}
]
}
},
"id": "20000000-0000-4000-8000-000000000001",
"name": "Nightly Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
-220,
220
]
},
{
"parameters": {
"inputSource": "passthrough"
},
"id": "20000000-0000-4000-8000-000000000023",
"name": "On-demand Re-run",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.2,
"position": [
-220,
400
]
},
{
"parameters": {
"mode": "manual",
"includeOtherFields": true,
"assignments": {
"assignments": [
{
"id": "20000000-0000-4000-8000-000000000024-cfg1",
"name": "CRM_DELTA_URL",
"type": "string",
"value": "http://mock-api:3000/crm/contacts"
},
{
"id": "20000000-0000-4000-8000-000000000024-cfg2",
"name": "CRM_SYNC_URL",
"type": "string",
"value": "http://mock-api:3000/crm/sync"
}
]
},
"options": {}
},
"id": "20000000-0000-4000-8000-000000000024",
"name": "Workflow Config",
"type": "n8n-nodes-base.set",
"typeVersion": 3.5,
"position": [
-110,
300
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO sync_watermark (workflow, cursor_value) VALUES ($1, $2) ON CONFLICT (workflow) DO UPDATE SET workflow = EXCLUDED.workflow RETURNING cursor_value;",
"options": {
"queryReplacement": "={{ ['02-crm-sync', '1970-01-01T00:00:00.000Z'] }}"
}
},
"id": "20000000-0000-4000-8000-000000000002",
"name": "Read Watermark",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.7,
"position": [
0,
300
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO sync_audit (workflow, execution_id, cursor_before, status) VALUES ($1, $2, $3, 'running') RETURNING id;",
"options": {
"queryReplacement": "={{ ['02-crm-sync', $execution.id, $('Read Watermark').first().json.cursor_value] }}"
}
},
"id": "20000000-0000-4000-8000-000000000003",
"name": "Start Run Audit",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.7,
"position": [
220,
300
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"method": "GET",
"url": "={{ $('Workflow Config').first().json.CRM_DELTA_URL }}",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "since",
"value": "={{ $('Read Watermark').first().json.cursor_value }}"
},
{
"name": "limit",
"value": "250"
}
]
},
"options": {
"timeout": 10000
}
},
"id": "20000000-0000-4000-8000-000000000004",
"name": "Fetch Delta",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
440,
300
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 500,
"onError": "continueErrorOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "20000000-0000-4000-8000-000000000005-cond",
"leftValue": "={{ $json.count > 0 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "20000000-0000-4000-8000-000000000005",
"name": "Any Changes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
660,
220
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "// One item per changed contact, carrying its cursor alongside the payload.\n// The cursor travels with the item all the way to the watermark decision, so\n// that decision never has to guess which record a result belonged to.\nconst res = $input.first().json;\nconst items = Array.isArray(res.items) ? res.items : [];\nreturn items.map((c) => ({ json: { id: c.id, cursor: c.updated_at, contact: c } }));"
},
"id": "20000000-0000-4000-8000-000000000006",
"name": "Prepare Contacts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
140
]
},
{
"parameters": {
"batchSize": 1,
"options": {}
},
"id": "20000000-0000-4000-8000-000000000007",
"name": "Loop Over Contacts",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
1100,
140
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Workflow Config').first().json.CRM_SYNC_URL }}",
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.contact) }}",
"options": {
"timeout": 10000
}
},
"id": "20000000-0000-4000-8000-000000000008",
"name": "Sync Contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1320,
240
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 1000,
"onError": "continueErrorOutput"
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "// Re-emit the contact as a RESULT record. Batch size is 1, so the loop node\n// holds exactly the contact this response belongs to \u2014 no positional\n// guesswork, and no dependence on paired-item plumbing surviving an error.\nconst src = $(\"Loop Over Contacts\").first().json;\nreturn [{ json: { id: src.id, cursor: src.cursor, ok: true } }];"
},
"id": "20000000-0000-4000-8000-000000000009",
"name": "Mark Synced",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
160
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "// The error branch carries n8n's error item; the contact context comes from\n// the loop node. Both are captured here once, so the dead-letter row and the\n// result record cannot disagree about what failed or why.\nconst src = $(\"Loop Over Contacts\").first().json;\nconst e = ($input.first().json || {}).error || {};\nconst reason = String(e.message || e.description || \"contact sync failed after retries\");\nreturn [{ json: { id: src.id, cursor: src.cursor, ok: false, reason, contact: src.contact } }];"
},
"id": "20000000-0000-4000-8000-000000000010",
"name": "Describe Failure",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
340
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO dead_letter (workflow, execution_id, order_id, reason, payload) VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id;",
"options": {
"queryReplacement": "={{ ['02-crm-sync', $execution.id, $json.id, $json.reason, JSON.stringify($json.contact)] }}"
}
},
"id": "20000000-0000-4000-8000-000000000011",
"name": "Dead-letter Contact",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.7,
"position": [
1760,
340
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "// Emit the failure as a RESULT record so it re-enters the loop alongside the\n// successes. The watermark decision needs to see failures \u2014 a failure that\n// never reaches it is exactly how a cursor jumps over unprocessed records.\nconst f = $(\"Describe Failure\").first().json;\nreturn [{ json: { id: f.id, cursor: f.cursor, ok: false, reason: f.reason } }];"
},
"id": "20000000-0000-4000-8000-000000000012",
"name": "Confirm Dead-lettered",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1980,
340
]
},
{
"parameters": {
"amount": 1,
"unit": "seconds"
},
"id": "20000000-0000-4000-8000-000000000013",
"name": "Pace Between Batches",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
2200,
240
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "// Nothing changed since the last cursor. This is a healthy outcome, not an\n// absence of one: it still records a run and still refreshes the heartbeat,\n// so an idle sync is never mistaken for a stalled one.\nreturn [{ json: { outcome: \"no_changes\" } }];"
},
"id": "20000000-0000-4000-8000-000000000014",
"name": "No Changes",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
340
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "// The delta fetch itself failed after retries. No contact was read, so the\n// watermark must not move by even one record.\nconst e = ($input.first().json || {}).error || {};\nreturn [{ json: { outcome: \"fetch_failed\", reason: String(e.message || e.description || \"delta fetch failed after retries\") } }];"
},
"id": "20000000-0000-4000-8000-000000000015",
"name": "Fetch Failed",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
460
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "// THE WATERMARK DECISION.\n//\n// The cursor advances to the last contact that succeeded with NO failure\n// before it \u2014 not to the highest successful cursor, and never to the end of\n// the fetched page. The loop below therefore STOPS at the first failure\n// rather than skipping past it.\n//\n// Why the distinction matters: if contact 3 fails and contact 4 succeeds,\n// advancing to 4 means contact 3 is never fetched again. Nothing errors,\n// nothing is logged, and the record is simply gone from the sync forever.\n// Holding at 2 costs one re-read of contact 4 on the next run, which is\n// harmless because the write is idempotent.\nconst items = $input.all().map((i) => i.json || {});\nconst cursorBefore = $(\"Read Watermark\").first().json.cursor_value;\nconst first = items[0] || {};\n\nif (first.outcome === \"fetch_failed\") {\n return [{ json: { outcome: \"fetch_failed\", items_read: 0, items_synced: 0, items_failed: 0,\n cursor_before: cursorBefore, cursor_after: cursorBefore, status: \"failed\",\n detail: String(first.reason || \"delta fetch failed\") } }];\n}\n\nif (first.outcome === \"no_changes\") {\n return [{ json: { outcome: \"no_changes\", items_read: 0, items_synced: 0, items_failed: 0,\n cursor_before: cursorBefore, cursor_after: cursorBefore, status: \"ok\",\n detail: \"no changes since the last run\" } }];\n}\n\nconst results = items\n .filter((r) => r && r.cursor)\n .sort((a, b) => String(a.cursor).localeCompare(String(b.cursor)));\n\nlet lastGood = null;\nfor (const r of results) {\n if (!r.ok) break; // <-- stop at the first failure; do NOT skip over it\n lastGood = r.cursor;\n}\n\nconst synced = results.filter((r) => r.ok).length;\nconst failed = results.length - synced;\nreturn [{ json: {\n outcome: \"processed\",\n items_read: results.length,\n items_synced: synced,\n items_failed: failed,\n cursor_before: cursorBefore,\n cursor_after: lastGood || cursorBefore,\n status: failed === 0 ? \"ok\" : \"partial\",\n detail: failed === 0\n ? \"all contacts synced\"\n : failed + \" contact(s) dead-lettered; watermark held at the last good cursor\",\n} }];"
},
"id": "20000000-0000-4000-8000-000000000016",
"name": "Summarize Run",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2420,
300
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "UPDATE sync_watermark SET cursor_value = CASE WHEN cursor_value IS NULL OR $2 > cursor_value THEN $2 ELSE cursor_value END, updated_at = now() WHERE workflow = $1 RETURNING cursor_value;",
"options": {
"queryReplacement": "={{ ['02-crm-sync', $('Summarize Run').first().json.cursor_after] }}"
}
},
"id": "20000000-0000-4000-8000-000000000017",
"name": "Advance Watermark",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.7,
"position": [
2640,
300
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "UPDATE sync_audit SET finished_at = now(), items_read = $2::int, items_synced = $3::int, items_failed = $4::int, cursor_after = $5, status = $6 WHERE id = $1::bigint RETURNING id, status;",
"options": {
"queryReplacement": "={{ (() => { const s = $('Summarize Run').first().json; return [$('Start Run Audit').first().json.id, s.items_read, s.items_synced, s.items_failed, s.cursor_after, s.status]; })() }}"
}
},
"id": "20000000-0000-4000-8000-000000000018",
"name": "Complete Run Audit",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.7,
"position": [
2860,
300
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO sync_heartbeat (workflow, last_run_at, last_success_at, status, detail) VALUES ($1, now(), CASE WHEN $2 = 'ok' THEN now() ELSE NULL END, $2, $3) ON CONFLICT (workflow) DO UPDATE SET last_run_at = now(), last_success_at = CASE WHEN $2 = 'ok' THEN now() ELSE sync_heartbeat.last_success_at END, status = $2, detail = $3 RETURNING status;",
"options": {
"queryReplacement": "={{ (() => { const s = $('Summarize Run').first().json; return ['02-crm-sync', s.status, s.detail]; })() }}"
}
},
"id": "20000000-0000-4000-8000-000000000019",
"name": "Update Heartbeat",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.7,
"position": [
3080,
300
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "20000000-0000-4000-8000-000000000020-cond",
"leftValue": "={{ $json.status === 'ok' }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "20000000-0000-4000-8000-000000000020",
"name": "Run OK?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
3300,
300
]
},
{
"parameters": {},
"id": "20000000-0000-4000-8000-000000000021",
"name": "Sync Complete",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
3520,
220
]
},
{
"parameters": {
"errorType": "errorMessage",
"errorMessage": "={{ '02-crm-sync: ' + $('Summarize Run').first().json.status + ' \u2014 ' + $('Summarize Run').first().json.detail }}"
},
"id": "20000000-0000-4000-8000-000000000022",
"name": "Stop And Error",
"type": "n8n-nodes-base.stopAndError",
"typeVersion": 1,
"position": [
3520,
400
]
}
],
"connections": {
"Nightly Trigger": {
"main": [
[
{
"node": "Workflow Config",
"type": "main",
"index": 0
}
]
]
},
"On-demand Re-run": {
"main": [
[
{
"node": "Workflow Config",
"type": "main",
"index": 0
}
]
]
},
"Workflow Config": {
"main": [
[
{
"node": "Read Watermark",
"type": "main",
"index": 0
}
]
]
},
"Read Watermark": {
"main": [
[
{
"node": "Start Run Audit",
"type": "main",
"index": 0
}
]
]
},
"Start Run Audit": {
"main": [
[
{
"node": "Fetch Delta",
"type": "main",
"index": 0
}
]
]
},
"Fetch Delta": {
"main": [
[
{
"node": "Any Changes?",
"type": "main",
"index": 0
}
],
[
{
"node": "Fetch Failed",
"type": "main",
"index": 0
}
]
]
},
"Any Changes?": {
"main": [
[
{
"node": "Prepare Contacts",
"type": "main",
"index": 0
}
],
[
{
"node": "No Changes",
"type": "main",
"index": 0
}
]
]
},
"Prepare Contacts": {
"main": [
[
{
"node": "Loop Over Contacts",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Contacts": {
"main": [
[
{
"node": "Summarize Run",
"type": "main",
"index": 0
}
],
[
{
"node": "Sync Contact",
"type": "main",
"index": 0
}
]
]
},
"Sync Contact": {
"main": [
[
{
"node": "Mark Synced",
"type": "main",
"index": 0
}
],
[
{
"node": "Describe Failure",
"type": "main",
"index": 0
}
]
]
},
"Mark Synced": {
"main": [
[
{
"node": "Pace Between Batches",
"type": "main",
"index": 0
}
]
]
},
"Describe Failure": {
"main": [
[
{
"node": "Dead-letter Contact",
"type": "main",
"index": 0
}
]
]
},
"Dead-letter Contact": {
"main": [
[
{
"node": "Confirm Dead-lettered",
"type": "main",
"index": 0
}
]
]
},
"Confirm Dead-lettered": {
"main": [
[
{
"node": "Pace Between Batches",
"type": "main",
"index": 0
}
]
]
},
"Pace Between Batches": {
"main": [
[
{
"node": "Loop Over Contacts",
"type": "main",
"index": 0
}
]
]
},
"No Changes": {
"main": [
[
{
"node": "Summarize Run",
"type": "main",
"index": 0
}
]
]
},
"Fetch Failed": {
"main": [
[
{
"node": "Summarize Run",
"type": "main",
"index": 0
}
]
]
},
"Summarize Run": {
"main": [
[
{
"node": "Advance Watermark",
"type": "main",
"index": 0
}
]
]
},
"Advance Watermark": {
"main": [
[
{
"node": "Complete Run Audit",
"type": "main",
"index": 0
}
]
]
},
"Complete Run Audit": {
"main": [
[
{
"node": "Update Heartbeat",
"type": "main",
"index": 0
}
]
]
},
"Update Heartbeat": {
"main": [
[
{
"node": "Run OK?",
"type": "main",
"index": 0
}
]
]
},
"Run OK?": {
"main": [
[
{
"node": "Sync Complete",
"type": "main",
"index": 0
}
],
[
{
"node": "Stop And Error",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"errorWorkflow": "globalerrhandler",
"timezone": "Australia/Brisbane"
},
"active": false
}
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.
postgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
02-crm-sync. Uses executeWorkflowTrigger, postgres, httpRequest, stopAndError. Scheduled trigger; 24 nodes.
Source: https://github.com/jarrod-wright/n8n-workflow-hardening/blob/main/02-crm-sync/workflow.json — 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.
sync-watchdog. Uses executeWorkflowTrigger, postgres, httpRequest. Scheduled trigger; 8 nodes.
Reagendamiento_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 89 nodes.
Disparador 1.8. Uses itemLists, postgres, emailSend, httpRequest. Scheduled trigger; 85 nodes.
Agendamiento_v2. Uses n8n-nodes-evolution-api, redis, httpRequest, executeWorkflowTrigger. Event-driven trigger; 59 nodes.
Cancelacion_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 46 nodes.