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": "CRM Sync Demo",
"nodes": [
{
"id": "cron-trigger",
"name": "Schedule",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
300
],
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 1
}
]
}
}
},
{
"id": "read-cursor",
"name": "Read Sync Cursor",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
300
],
"parameters": {
"language": "javaScript",
"jsCode": "// Cursor de sincronizacion incremental.\n//\n// La version anterior leia LAST_SYNC_TIMESTAMP de $env. Un workflow no puede\n// escribir en su propio entorno, asi que el cursor nunca avanzaba: cada ejecucion\n// repetia la misma ventana. El estado de una sincronizacion incremental tiene que\n// vivir donde el workflow pueda actualizarlo.\n//\n// Aqui se usa la clave estatica del workflow (getWorkflowStaticData), que n8n\n// persiste entre ejecuciones. En produccion, con varios workers, conviene una\n// tabla en PostgreSQL para evitar carreras.\nconst state = $getWorkflowStaticData('global');\n\nconst since = state.lastSyncedAt || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();\n\nreturn [{ json: { since: since, page: 1, startedAt: new Date().toISOString() } }];\n",
"mode": "runOnceForAllItems"
}
},
{
"id": "fetch-contacts",
"name": "Fetch Contacts (Source)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
300
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"parameters": {
"method": "GET",
"url": "={{ $env.SOURCE_API_URL }}/contacts",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $env.SOURCE_API_TOKEN }}"
}
]
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "updatedSince",
"value": "={{ $json.since }}"
},
{
"name": "page",
"value": "={{ $json.page }}"
},
{
"name": "pageSize",
"value": "100"
}
]
},
"options": {
"timeout": 30000,
"pagination": {
"pagination": {
"paginationMode": "responseContainsNextURL",
"nextURL": "={{ $response.body.next_page_url }}",
"paginationCompleteWhen": "other",
"completeExpression": "={{ !$response.body.next_page_url }}",
"limitPagesFetched": true,
"maxRequestsAmount": 50
}
}
}
}
},
{
"id": "transform-code",
"name": "Transform Fields",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
],
"parameters": {
"language": "javaScript",
"jsCode": "// Mapeo de campos entre los dos esquemas.\n//\n// Se descartan los registros sin email: es la identidad de negocio con la que se\n// deduplica en destino. Sin ella, el upsert crearia duplicados en cada pasada.\nconst mapped = [];\n\nfor (const item of $input.all()) {\n const c = item.json;\n const email = String(c.email || '').trim().toLowerCase();\n if (!email) continue;\n\n mapped.push({\n json: {\n email: email,\n firstName: c.first_name || c.givenName || '',\n lastName: c.last_name || c.surname || '',\n phone: c.phone || c.telephone || '',\n company: c.company || c.organization || '',\n lastModified: new Date().toISOString()\n }\n });\n}\n\nreturn mapped;\n",
"mode": "runOnceForAllItems"
}
},
{
"id": "upsert-destination",
"name": "Upsert Contacts (Destination)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
300
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"onError": "continueRegularOutput",
"parameters": {
"method": "POST",
"url": "={{ $env.DESTINATION_API_URL }}/contacts/batch",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $env.DESTINATION_API_TOKEN }}"
},
{
"name": "Idempotency-Key",
"value": "={{ $json.email }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ contacts: [$json] }) }}",
"options": {
"timeout": 30000,
"batching": {
"batch": {
"batchSize": 20,
"batchInterval": 1000
}
}
}
}
},
{
"id": "commit-cursor",
"name": "Commit Cursor & Summarize",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
300
],
"parameters": {
"language": "javaScript",
"jsCode": "// El cursor solo avanza si la pasada termino: si se avanzase antes, un fallo a\n// mitad dejaria un hueco de registros que ya nadie volveria a mirar.\nconst state = $getWorkflowStaticData('global');\nconst started = $('Read Sync Cursor').first().json.startedAt;\n\nconst items = $input.all();\nconst failed = items.filter(function (i) { return i.json && i.json.error; }).length;\nconst synced = items.length - failed;\n\nif (failed === 0) {\n state.lastSyncedAt = started;\n}\n\nreturn [{\n json: {\n syncTimestamp: new Date().toISOString(),\n contactsSynced: synced,\n contactsFailed: failed,\n cursorAdvanced: failed === 0,\n status: failed === 0 ? 'completed' : 'partial'\n }\n}];\n",
"mode": "runOnceForAllItems"
}
},
{
"id": "notify-slack",
"name": "Notify Summary",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1320,
300
],
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 2000,
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_WEBHOOK_URL }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: ':arrows_counterclockwise: Sincronizacion CRM ' + $json.status + ' \u2014 ' + $json.contactsSynced + ' contactos, ' + $json.contactsFailed + ' fallidos' }) }}",
"options": {
"timeout": 10000
}
}
}
],
"connections": {
"Schedule": {
"main": [
[
{
"node": "Read Sync Cursor",
"type": "main",
"index": 0
}
]
]
},
"Read Sync Cursor": {
"main": [
[
{
"node": "Fetch Contacts (Source)",
"type": "main",
"index": 0
}
]
]
},
"Fetch Contacts (Source)": {
"main": [
[
{
"node": "Transform Fields",
"type": "main",
"index": 0
}
]
]
},
"Transform Fields": {
"main": [
[
{
"node": "Upsert Contacts (Destination)",
"type": "main",
"index": 0
}
]
]
},
"Upsert Contacts (Destination)": {
"main": [
[
{
"node": "Commit Cursor & Summarize",
"type": "main",
"index": 0
}
]
]
},
"Commit Cursor & Summarize": {
"main": [
[
{
"node": "Notify Summary",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"saveDataErrorExecution": "all",
"saveDataSuccessExecution": "none",
"saveManualExecutions": true,
"executionTimeout": 900
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
CRM Sync Demo. Uses httpRequest. Scheduled trigger; 7 nodes.
Source: https://github.com/BhrayanM/Portafolio/blob/main/examples/crm-sync-demo.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.
Birthday Automation - Production (Fixed). Uses stopAndError, httpRequest, emailSend, bannerbear. Scheduled trigger; 86 nodes.
This template runs two scheduled workflows to govern Microsoft Entra ID (Azure AD) guest accounts by detecting stale users via Microsoft Graph, staging deletions in SharePoint with a 72-hour window, n
Jira-Allure-Auto-Qa. Uses httpRequest, jira. Scheduled trigger; 68 nodes.
Spotify-Sync-Surrealdb-V1. Uses httpRequest, n8n-nodes-surrealdb, spotify. Scheduled trigger; 62 nodes.
As n8n instances scale, teams often lose track of sub-workflows—who uses them, where they are referenced, and whether they can be safely updated. This leads to inefficiencies like unnecessary copies o