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": "Calendly to CRM Sync (Pipedrive / HubSpot / Salesforce)",
"nodes": [
{
"parameters": {
"content": "## Calendly to CRM Sync\n\nCalendly v2 webhook lands here, payload is HMAC-verified, normalized, classified by event type, and routed to the configured CRM (Pipedrive default, HubSpot, or Salesforce).\n\n**Production patterns wired:**\n- HMAC verify with Calendly v2 signature format (opt-in, `CALENDLY_SIGNING_SECRET`)\n- Rate limit (opt-in, `RATE_LIMIT_ENABLED=1`)\n- Idempotency on `event.uri` (opt-in, `IDEMPOTENCY_ENABLED=1`)\n- Error branches with structured fallback\n\nSee `README.md` for setup, env vars, and extension recipes.",
"height": 320,
"width": 400,
"color": 6
},
"id": "note-intro",
"name": "Sticky Note - Intro",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
-100
]
},
{
"parameters": {
"content": "### >> SET ME <<\n\n1. Create a Calendly v2 webhook subscription via the Calendly API or the UI. Subscribe to `invitee.created`, `invitee.canceled`, optionally `invitee.no_show`.\n2. Set `CALENDLY_SIGNING_SECRET` to the same `signing_key` you configured in the subscription.\n3. Set `CRM_TARGET=pipedrive` (or `hubspot` or `salesforce`) in your n8n env.\n4. Set CRM stage IDs: `CRM_PIPELINE_BOOKED_ID`, `CRM_PIPELINE_CANCELED_ID`.\n5. Configure Slack webhook URL in `SLACK_OPS_WEBHOOK` for ops notifications.\n6. Self-hosted n8n: set `NODE_FUNCTION_ALLOW_BUILTIN=crypto`.",
"height": 320,
"width": 380,
"color": 5
},
"id": "note-setup",
"name": "Sticky Note - Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
240
]
},
{
"parameters": {
"content": "## Production Patterns\n\nFour patterns wired as opt-in nodes. Default-off via env vars so import boots clean.\n\n- **HMAC verify:** `CALENDLY_SIGNING_SECRET` + `WEBHOOK_INTEGRITY_CHECK_ENABLED=1` (Calendly v2 format: `t=<ts>,v1=<hmac>`)\n- **Rate limit:** `RATE_LIMIT_ENABLED=1` (60 req / 5 min / IP)\n- **Idempotency:** `IDEMPOTENCY_ENABLED=1` (5-min window on `event.uri`)\n- **Error branch:** always on. Every HTTP node has `onError: continueErrorOutput` wired to the Error Fallback Code node.\n\n- **Respond-duplicate gateway:** when the Idempotency Check detects a duplicate it emits a `{ skipped: true }` sentinel that the `Skip If Duplicate` IF node routes to the dedicated `Respond Duplicate` `respondToWebhook` node (200 OK + `{ ok: true, deduped: true }`). This avoids the 30s connection-hang that would otherwise occur on `responseMode: responseNode` if the duplicate path returned `[]` and never reached a respond node.\n\nFor clustered n8n, swap the in-memory dedup for Redis SET NX EX 300. Snippet in each opt-in node's comments.",
"height": 320,
"width": 380,
"color": 7
},
"id": "note-production-patterns",
"name": "Sticky Note - Production Patterns",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
840,
-260
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "calendly-webhook",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "cal-1-trigger",
"name": "Calendly Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
60
]
},
{
"parameters": {
"jsCode": "// Calendly v2 HMAC signature verification, opt-in.\n// Header: Calendly-Webhook-Signature with format `t=<unix-ts>,v1=<hmac-sha256>`\n// HMAC payload: `<timestamp>.<rawBody>` (Stripe-like, not just rawBody).\n//\n// Enable by setting CALENDLY_SIGNING_SECRET + WEBHOOK_INTEGRITY_CHECK_ENABLED=1.\n// Pass-through when unset.\n\nconst secret = $env.CALENDLY_SIGNING_SECRET;\nconst integrityCheckEnabled = $env.WEBHOOK_INTEGRITY_CHECK_ENABLED === '1';\n\nif (!secret || !integrityCheckEnabled) {\n return [$input.first()];\n}\n\nconst crypto = require('crypto');\nconst item = $input.first();\nconst rawBody = item.json.rawBody || (typeof item.json.body === 'string' ? item.json.body : JSON.stringify(item.json.body || {}));\nconst headers = item.json.headers || {};\nconst sigHeader = headers['calendly-webhook-signature'] || headers['Calendly-Webhook-Signature'];\n\nif (!sigHeader || typeof sigHeader !== 'string') {\n throw new Error('UNAUTHORIZED: missing Calendly-Webhook-Signature header');\n}\n\n// Parse `t=<ts>,v1=<hmac>` into a map\nconst parts = {};\nfor (const segment of sigHeader.split(',')) {\n const [k, v] = segment.split('=');\n if (k && v) parts[k.trim()] = v.trim();\n}\nconst ts = parts.t;\nconst v1 = parts.v1;\n\nif (!ts || !v1) {\n throw new Error('UNAUTHORIZED: malformed Calendly signature header');\n}\n\n// Replay-window check: 5 minutes\nconst tsNum = parseInt(ts, 10);\nif (!Number.isFinite(tsNum)) {\n throw new Error('UNAUTHORIZED: invalid timestamp');\n}\nconst nowSec = Math.floor(Date.now() / 1000);\nconst REPLAY_WINDOW_S = 5 * 60;\nif (Math.abs(nowSec - tsNum) > REPLAY_WINDOW_S) {\n throw new Error('UNAUTHORIZED: signature timestamp outside replay window');\n}\n\nconst signedPayload = ts + '.' + rawBody;\nconst expected = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');\n\n// Length-guard before constant-time compare\nif (v1.length !== expected.length) {\n throw new Error('UNAUTHORIZED: signature length mismatch');\n}\n\nconst expBuf = Buffer.from(expected, 'utf8');\nconst v1Buf = Buffer.from(v1, 'utf8');\nif (!crypto.timingSafeEqual(expBuf, v1Buf)) {\n throw new Error('UNAUTHORIZED: invalid signature');\n}\n\nreturn [$input.first()];"
},
"id": "cal-pp-1-verify",
"name": "Verify Webhook (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
60
]
},
{
"parameters": {
"jsCode": "// Per-IP sliding-window rate limit, opt-in.\n// Enable by setting RATE_LIMIT_ENABLED=1.\n// 60 requests per 5 minutes per IP.\n//\n// $getWorkflowStaticData is per-instance, not cluster-aware. Replace with\n// Redis INCR + EXPIRE for multi-worker n8n. Snippet at the bottom.\n\nif ($env.RATE_LIMIT_ENABLED !== '1') {\n return [$input.first()];\n}\n\nconst LIMIT = 60;\nconst WINDOW_MS = 5 * 60 * 1000;\nconst MAX_KEYS = 5000;\n\nconst item = $input.first();\nconst headers = item.json.headers || {};\nconst rawIp = headers['x-forwarded-for'] || headers['x-real-ip'] || 'unknown';\nconst key = String(rawIp).split(',')[0].trim();\n\nconst data = $getWorkflowStaticData('global');\ndata.rateBuckets = data.rateBuckets || {};\nconst buckets = data.rateBuckets;\nconst now = Date.now();\n\nfor (const k of Object.keys(buckets)) {\n buckets[k] = (buckets[k] || []).filter(t => now - t < WINDOW_MS);\n if (buckets[k].length === 0) delete buckets[k];\n}\nif (Object.keys(buckets).length > MAX_KEYS) {\n const oldest = Object.entries(buckets).sort((a, b) => (a[1][0] || 0) - (b[1][0] || 0)).slice(0, 100);\n for (const [k] of oldest) delete buckets[k];\n}\n\nconst hits = buckets[key] || [];\nif (hits.length >= LIMIT) {\n throw new Error('RATE_LIMIT_EXCEEDED: ' + LIMIT + ' requests per ' + Math.round(WINDOW_MS / 60000) + ' minutes for ' + key);\n}\nbuckets[key] = [...hits, now];\n\nreturn [$input.first()];\n\n// Redis variant for clustered n8n:\n// const Redis = require('ioredis');\n// const redis = new Redis(process.env.REDIS_URL);\n// const cnt = await redis.incr('rl:' + key);\n// if (cnt === 1) await redis.expire('rl:' + key, 300);\n// await redis.quit();\n// if (cnt > LIMIT) throw new Error('RATE_LIMIT_EXCEEDED');"
},
"id": "cal-pp-2-ratelimit",
"name": "Rate Limit (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
60
]
},
{
"parameters": {
"jsCode": "// 5-minute idempotency window on Calendly event.uri, opt-in.\n// Enable by setting IDEMPOTENCY_ENABLED=1.\n// Calendly retries on 5xx for up to 24 hours, but a 5-minute window catches\n// the storm of retries that follow a recovery. The longer-tail retries\n// usually arrive after the first successful processing.\n\nif ($env.IDEMPOTENCY_ENABLED !== '1') {\n return [$input.first()];\n}\n\nconst WINDOW_MS = 5 * 60 * 1000;\nconst MAX_KEYS = 10000;\n\nconst item = $input.first();\nconst body = item.json.body || {};\n// Calendly v2 payload: { event: 'invitee.created', payload: { event: { uri }, invitee: { uri } } }\nconst eventUri = body.payload && body.payload.event && body.payload.event.uri;\nconst inviteeUri = body.payload && body.payload.invitee && body.payload.invitee.uri;\nconst evtName = body.event || 'unknown';\nconst idempotencyKey = (eventUri && inviteeUri) ? (evtName + ':' + eventUri + ':' + inviteeUri) : null;\n\nif (!idempotencyKey) {\n return [$input.first()];\n}\n\nconst data = $getWorkflowStaticData('global');\ndata.seenKeys = data.seenKeys || {};\nconst seen = data.seenKeys;\nconst now = Date.now();\n\nfor (const k of Object.keys(seen)) {\n if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\nif (Object.keys(seen).length > MAX_KEYS) {\n const oldest = Object.entries(seen).sort((a, b) => a[1] - b[1]).slice(0, 1000);\n for (const [k] of oldest) delete seen[k];\n}\n\nif (seen[idempotencyKey]) {\n // Duplicate detected. Emit a sentinel item that the\n // 'Skip If Duplicate' IF node routes to 'Respond Duplicate'\n // (200 OK + { deduped: true }). Without that 200 the source\n // provider would hold the HTTP connection until n8n's webhook\n // timeout (default 30s) and mark delivery failed.\n return [{ json: { skipped: true, reason: 'duplicate', dedupKey: String(idempotencyKey) } }];\n}\nseen[idempotencyKey] = now;\n\nreturn [$input.first()];\n\n// Redis variant:\n// const result = await redis.set('idem:' + idempotencyKey, '1', 'EX', 300, 'NX');\n// if (result === null) return [];"
},
"id": "cal-pp-3-idempotency",
"name": "Idempotency Check (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
840,
60
]
},
{
"parameters": {
"jsCode": "// Normalize Calendly v2 nested webhook payload into a stable schema.\n// Calendly v2 ships everything inside body.payload with `invitee` and `event` sub-objects.\n\nconst body = $input.first().json.body || {};\nconst eventName = body.event || 'unknown';\nconst payload = body.payload || {};\nconst event = payload.event || {};\nconst invitee = payload.invitee || {};\nconst scheduled = payload.scheduled_event || event;\n\nconst questionAnswers = Array.isArray(invitee.questions_and_answers) ? invitee.questions_and_answers : [];\nconst answersMap = {};\nfor (const qa of questionAnswers) {\n if (qa && qa.question && qa.answer != null) answersMap[String(qa.question).toLowerCase().trim()] = String(qa.answer).trim();\n}\n\nreturn [{\n json: {\n eventName,\n eventUri: event.uri || scheduled.uri || null,\n inviteeUri: invitee.uri || null,\n inviteeEmail: (invitee.email || '').toLowerCase(),\n inviteeName: invitee.name || invitee.first_name || 'Unknown',\n inviteeTimezone: invitee.timezone || null,\n eventTypeName: scheduled.name || event.name || 'Calendly booking',\n eventStartTime: scheduled.start_time || event.start_time || null,\n eventEndTime: scheduled.end_time || event.end_time || null,\n cancelReason: invitee.cancellation && invitee.cancellation.reason ? invitee.cancellation.reason : null,\n canceler: invitee.cancellation && invitee.cancellation.canceled_by ? invitee.cancellation.canceled_by : null,\n questionAnswers: answersMap,\n receivedAt: new Date().toISOString(),\n },\n}];"
},
"id": "cal-1-normalize",
"name": "Normalize Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1040,
60
]
},
{
"parameters": {
"jsCode": "// Classify the booking lifecycle and pick a CRM stage.\n// Three states: booked (invitee.created), canceled (invitee.canceled), no_show (invitee.no_show).\n\nconst input = $input.first().json;\nconst evt = String(input.eventName || '').toLowerCase();\n\nlet lifecycle = 'unknown';\nlet stageEnv = 'CRM_PIPELINE_CANCELED_ID';\n\nif (evt === 'invitee.created') {\n lifecycle = 'booked';\n stageEnv = 'CRM_PIPELINE_BOOKED_ID';\n} else if (evt === 'invitee.canceled') {\n lifecycle = 'canceled';\n stageEnv = 'CRM_PIPELINE_CANCELED_ID';\n} else if (evt === 'invitee.no_show') {\n lifecycle = 'no_show';\n stageEnv = 'CRM_PIPELINE_CANCELED_ID';\n}\n\nconst stageId = $env[stageEnv] || '1';\n\nreturn [{\n json: {\n ...input,\n lifecycle,\n stageId,\n },\n}];"
},
"id": "cal-2-classify",
"name": "Classify Event",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1240,
60
]
},
{
"parameters": {
"jsCode": "// Pick CRM target from env. Default pipedrive.\n\nconst input = $input.first().json;\nconst crmTarget = ($env.CRM_TARGET || 'pipedrive').toLowerCase();\n\nreturn [{\n json: {\n ...input,\n crmTarget,\n },\n}];"
},
"id": "cal-3-set-routing",
"name": "Set CRM Target",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1440,
60
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": false,
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.crmTarget }}",
"rightValue": "pipedrive",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "pipedrive"
},
{
"conditions": {
"options": {
"caseSensitive": false,
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.crmTarget }}",
"rightValue": "hubspot",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "hubspot"
},
{
"conditions": {
"options": {
"caseSensitive": false,
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.crmTarget }}",
"rightValue": "salesforce",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "salesforce"
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": "cal-4-route",
"name": "Route by CRM",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1640,
60
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.pipedrive.com/v1/deals",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "api_token",
"value": "={{ $credentials.pipedriveApi.apiToken }}"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "title",
"value": "={{ $json.eventTypeName }} - {{ $json.inviteeName }} ({{ $json.inviteeEmail }}) [{{ $json.lifecycle }}]"
},
{
"name": "value",
"value": "0"
},
{
"name": "currency",
"value": "EUR"
},
{
"name": "stage_id",
"value": "={{ $json.stageId }}"
}
]
},
"options": {}
},
"id": "cal-5a-pipedrive",
"name": "Pipedrive Upsert Deal",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1840,
-100
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "POST",
"url": "https://api.hubapi.com/crm/v3/objects/deals",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $credentials.hubspotApi.accessToken }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ properties: { dealname: $json.eventTypeName + ' - ' + $json.inviteeName + ' (' + $json.inviteeEmail + ') [' + $json.lifecycle + ']', amount: '0', dealstage: $json.stageId } }) }}",
"options": {}
},
"id": "cal-5b-hubspot",
"name": "HubSpot Upsert Deal",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1840,
60
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "POST",
"url": "https://your-instance.my.salesforce.com/services/data/v60.0/sobjects/Opportunity",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $credentials.salesforceApi.accessToken }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ Name: $json.eventTypeName + ' - ' + $json.inviteeName + ' (' + $json.inviteeEmail + ') [' + $json.lifecycle + ']', StageName: $json.stageId, Amount: 0, CloseDate: ($json.eventStartTime || new Date(Date.now() + 30 * 86400000).toISOString()).slice(0, 10) }) }}",
"options": {}
},
"id": "cal-5c-salesforce",
"name": "Salesforce Upsert Deal",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1840,
220
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// Normalize CRM response so downstream does not care which CRM fired.\n\nconst input = $input.first();\nconst body = input.json || {};\n\nlet dealId = null;\nif (body.data && body.data.id) dealId = body.data.id;\nelse if (body.id) dealId = body.id;\n\nreturn [{\n json: {\n ...body,\n dealId,\n crmSuccess: !!dealId,\n },\n}];"
},
"id": "cal-6-normalize-crm",
"name": "Normalize CRM Output",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2040,
60
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_OPS_WEBHOOK }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: ':calendar: ' + ($('Set CRM Target').first().json.lifecycle || 'unknown').toUpperCase() + ': ' + ($('Set CRM Target').first().json.eventTypeName || 'booking') + ' for ' + ($('Set CRM Target').first().json.inviteeEmail || 'unknown') + ' starting ' + ($('Set CRM Target').first().json.eventStartTime || 'unknown') }) }}",
"options": {}
},
"id": "cal-7-slack",
"name": "Slack Ops Notification",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2240,
60
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, lifecycle: $('Set CRM Target').first().json.lifecycle, dealId: $json.dealId }) }}",
"options": {
"responseCode": 200
}
},
"id": "cal-8-respond",
"name": "Respond to Calendly",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2440,
60
]
},
{
"parameters": {
"jsCode": "// Fallback for downstream API failures (Pipedrive / HubSpot / Salesforce / Slack).\n// Builds a structured error log and a graceful response so Calendly sees a 200\n// instead of triggering a 24-hour retry storm.\n\nconst input = $input.first();\nconst err = (input.json && input.json.error) || input.error || {};\nconst orig = ($('Set CRM Target').first() && $('Set CRM Target').first().json) || {};\n\nreturn [{\n json: {\n ok: false,\n fallback: true,\n eventUri: orig.eventUri || 'unknown',\n inviteeEmail: orig.inviteeEmail || 'unknown',\n crmTarget: orig.crmTarget || 'unknown',\n lifecycle: orig.lifecycle || 'unknown',\n error: {\n message: err.message || 'unknown error',\n name: err.name || 'CalendlyCrmError',\n },\n receivedAt: orig.receivedAt || new Date().toISOString(),\n },\n}];"
},
"id": "cal-err-fallback",
"name": "Error Fallback",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2240,
380
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_OPS_WEBHOOK }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: ':warning: Calendly-CRM error ' + ($json.error.name || 'Error') + ': ' + ($json.error.message || '') + ' (event ' + ($json.eventUri || 'unknown') + ', target ' + ($json.crmTarget || 'unknown') + ')' }) }}",
"options": {}
},
"id": "cal-err-slack-alert",
"name": "Error Slack Alert",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2440,
380
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: false, message: 'Booking received, downstream sync deferred' }) }}",
"options": {
"responseCode": 200
}
},
"id": "cal-err-respond",
"name": "Error Respond to Calendly",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2640,
380
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-06-calendly-to-crm-sync-skipped",
"leftValue": "={{ $json.skipped }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "06-cal-if-skip-dup",
"name": "Skip If Duplicate",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1060,
60
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, deduped: true, reason: \"duplicate\" }) }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "X-Dedup",
"value": "1"
}
]
}
}
},
"id": "06-cal-respond-duplicate",
"name": "Respond Duplicate",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1280,
-120
]
}
],
"connections": {
"Calendly Webhook": {
"main": [
[
{
"node": "Verify Webhook (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Verify Webhook (opt-in)": {
"main": [
[
{
"node": "Rate Limit (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Rate Limit (opt-in)": {
"main": [
[
{
"node": "Idempotency Check (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Idempotency Check (opt-in)": {
"main": [
[
{
"node": "Skip If Duplicate",
"type": "main",
"index": 0
}
]
]
},
"Normalize Payload": {
"main": [
[
{
"node": "Classify Event",
"type": "main",
"index": 0
}
]
]
},
"Classify Event": {
"main": [
[
{
"node": "Set CRM Target",
"type": "main",
"index": 0
}
]
]
},
"Set CRM Target": {
"main": [
[
{
"node": "Route by CRM",
"type": "main",
"index": 0
}
]
]
},
"Route by CRM": {
"main": [
[
{
"node": "Pipedrive Upsert Deal",
"type": "main",
"index": 0
}
],
[
{
"node": "HubSpot Upsert Deal",
"type": "main",
"index": 0
}
],
[
{
"node": "Salesforce Upsert Deal",
"type": "main",
"index": 0
}
]
]
},
"Pipedrive Upsert Deal": {
"main": [
[
{
"node": "Normalize CRM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"HubSpot Upsert Deal": {
"main": [
[
{
"node": "Normalize CRM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Salesforce Upsert Deal": {
"main": [
[
{
"node": "Normalize CRM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Normalize CRM Output": {
"main": [
[
{
"node": "Slack Ops Notification",
"type": "main",
"index": 0
}
]
]
},
"Slack Ops Notification": {
"main": [
[
{
"node": "Respond to Calendly",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Error Fallback": {
"main": [
[
{
"node": "Error Slack Alert",
"type": "main",
"index": 0
}
]
]
},
"Error Slack Alert": {
"main": [
[
{
"node": "Error Respond to Calendly",
"type": "main",
"index": 0
}
]
]
},
"Skip If Duplicate": {
"main": [
[
{
"node": "Respond Duplicate",
"type": "main",
"index": 0
}
],
[
{
"node": "Normalize Payload",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
Effortlessly synchronise new Calendly bookings directly into your CRM, such as Pipedrive, HubSpot, or Salesforce, to keep your sales pipeline updated without manual data entry. This saves time for sales teams and administrators who juggle scheduling and follow-ups, ensuring no leads slip through the cracks. The key step involves a webhook trigger that captures Calendly events and normalises the payload before routing it to your chosen CRM via HTTP requests, handling optional verifications like rate limiting and idempotency for reliability.
Use this workflow when you need real-time updates from Calendly to enrich CRM records with meeting details, particularly in sales-driven environments with high booking volumes. Avoid it for one-off events or if your CRM lacks API support, as it relies on robust integrations. Common variations include adding email notifications post-sync or filtering specific event types like initial consultations before CRM entry.
About this workflow
Calendly to CRM Sync (Pipedrive / HubSpot / Salesforce). Uses stickyNote, httpRequest, respondToWebhook. Webhook trigger; 22 nodes.
Source: https://github.com/studiomeyer-io/n8n-workflows/blob/main/templates/06-calendly-to-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.
Portfolio Orchestrator. Uses httpRequest. Webhook trigger; 59 nodes.
jump-section: Comment Fix Pipeline. Uses httpRequest. Webhook trigger; 24 nodes.
GitHub Issues Router (Linear / Jira / ClickUp). Uses stickyNote, httpRequest, respondToWebhook. Webhook trigger; 23 nodes.
Form to CRM Lead Router (Pipedrive / HubSpot / Salesforce). Uses stickyNote, httpRequest, respondToWebhook. Webhook trigger; 22 nodes.
Reputation Engine — Technical SEO Implementer. Uses httpRequest. Webhook trigger; 22 nodes.