Client Onboarding Pipeline
Module 16 · business · intermediate
learner.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "16 - Client Onboarding Pipeline",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "onboarding/new-client",
"responseMode": "responseNode",
"options": {}
},
"id": "m16-webhook",
"name": "New Client",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
200
],
"webhookId": "course-16-new-client"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "agency_name",
"value": "Northstar Digital",
"type": "string"
},
{
"id": "s2",
"name": "sender_name",
"value": "Ian",
"type": "string"
},
{
"id": "s3",
"name": "kickoff_link",
"value": "https://cal.com/your-name/kickoff",
"type": "string"
},
{
"id": "s4",
"name": "slack_channel_prefix",
"value": "client-",
"type": "string"
},
{
"id": "s5",
"name": "drive_parent_folder_id",
"value": "YOUR_CLIENTS_FOLDER_ID",
"type": "string"
},
{
"id": "s6",
"name": "clickup",
"value": "={{ { team: 'YOUR_TEAM_ID', space: 'YOUR_SPACE_ID', list: 'YOUR_ONBOARDING_LIST_ID' } }}",
"type": "object"
},
{
"id": "s7",
"name": "packages",
"type": "object",
"value": "={{ {\n starter: {\n label: 'Starter Website',\n kickoff_within_days: 3,\n access_needed: ['domain registrar', 'current website'],\n tasks: [\n { name: 'Send contract countersignature + invoice receipt', offset_days: 0, owner_role: 'account manager' },\n { name: 'Kickoff call', offset_days: 3, owner_role: 'account manager' },\n { name: 'Collect brand assets and access', offset_days: 5, owner_role: 'project manager' },\n { name: 'First design draft', offset_days: 12, owner_role: 'designer' }\n ]\n },\n growth: {\n label: 'Growth (Website + Google Ads)',\n kickoff_within_days: 2,\n access_needed: ['domain registrar', 'current website', 'Google Ads account', 'Google Analytics'],\n tasks: [\n { name: 'Send contract countersignature + invoice receipt', offset_days: 0, owner_role: 'account manager' },\n { name: 'Kickoff call', offset_days: 2, owner_role: 'account manager' },\n { name: 'Collect brand assets and access', offset_days: 4, owner_role: 'project manager' },\n { name: 'Audit Google Ads + Analytics', offset_days: 6, owner_role: 'ads specialist' },\n { name: 'First design draft', offset_days: 10, owner_role: 'designer' },\n { name: 'Launch first campaign', offset_days: 15, owner_role: 'ads specialist' }\n ]\n }\n} }}"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m16-settings",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
220,
200
]
},
{
"parameters": {
"jsCode": "// Validate the new client and turn the package template into a concrete onboarding plan:\n// names for the folder/channel, dated tasks, and the welcome email.\nconst s = $('Settings (edit me)').first().json;\nconst req = $('New Client').first().json;\nconst b = req.body ?? {};\n\nconst errors = [];\nconst clientName = String(b.client_name ?? '').trim();\nconst contactName = String(b.contact_name ?? '').trim();\nconst contactEmail = String(b.contact_email ?? '').trim().toLowerCase();\nconst pkgKey = String(b.package ?? '').trim().toLowerCase();\nconst pkg = s.packages[pkgKey];\nif (!clientName) errors.push('client_name is required');\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(contactEmail)) errors.push('contact_email must be a valid email');\nif (!pkg) errors.push(`package must be one of: ${Object.keys(s.packages).join(', ')}`);\n\nlet start = b.start_date ? DateTime.fromISO(b.start_date) : DateTime.now().plus({ days: 1 });\nif (b.start_date && !start.isValid) errors.push('start_date must be YYYY-MM-DD');\nif (errors.length) return [{ json: { valid: false, errors } }];\n\n// Business days only (Mon–Fri)\nconst addBusinessDays = (d, n) => {\n let x = d;\n while (x.weekday > 5) x = x.plus({ days: 1 });\n for (let i = 0; i < n; i++) { x = x.plus({ days: 1 }); while (x.weekday > 5) x = x.plus({ days: 1 }); }\n return x;\n};\nstart = addBusinessDays(start.startOf('day'), 0);\n\nconst slug = clientName.toLowerCase().normalize('NFKD').replace(/[^\\w\\s-]/g, '').trim().replace(/[\\s_]+/g, '-').replace(/-+/g, '-').slice(0, 60);\nconst first = contactName.split(' ')[0] || 'there';\nconst kickoffBy = addBusinessDays(start, pkg.kickoff_within_days);\n\nconst tasks = pkg.tasks.map((t, i) => ({\n n: i + 1,\n name: `[${clientName}] ${t.name}`,\n owner_role: t.owner_role,\n due: addBusinessDays(start, t.offset_days).toISODate(),\n}));\n\nreturn [{\n json: {\n valid: true,\n dry_run: req.query?.dry_run === 'true',\n force: b.force === true,\n client_name: clientName,\n contact_name: contactName,\n contact_email: contactEmail,\n package: pkgKey,\n package_label: pkg.label,\n slug,\n start_date: start.toISODate(),\n kickoff_by: kickoffBy.toISODate(),\n folder_name: `${clientName} (${start.toFormat('yyyy-LL')})`,\n channel_name: `${s.slack_channel_prefix}${slug}`.slice(0, 80),\n tasks,\n email: {\n to: contactEmail,\n subject: `Welcome to ${s.agency_name}, ${first}! Your next steps`,\n body:\n `Hi ${first},\\n\\n` +\n `Welcome aboard! We're excited to start your ${pkg.label} engagement on ${start.toFormat('cccc, LLLL d')}.\\n\\n` +\n `Here's what happens next:\\n` +\n `1. Book your kickoff call (ideally before ${kickoffBy.toFormat('LLLL d')}): ${s.kickoff_link}\\n` +\n `2. You'll get access to a shared folder for files and assets.\\n` +\n `3. Before the kickoff, please send logins/access for: ${pkg.access_needed.join(', ')}.\\n\\n` +\n `Any questions, just reply to this email.\\n\\n` +\n `Talk soon,\\n${s.sender_name}\\n${s.agency_name}`,\n },\n },\n}];\n"
},
"id": "m16-plan",
"name": "Validate & Build Plan",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "v1",
"leftValue": "={{ $json.valid }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m16-valid",
"name": "Valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
660,
200
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { errors: $json.errors } }}",
"options": {
"responseCode": 400
}
},
"id": "m16-400",
"name": "Respond Invalid",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
880,
380
]
},
{
"parameters": {
"jsCode": "// Idempotency: never onboard the same client twice by accident (payment webhooks retry!).\n// A completed onboarding blocks re-runs unless \"force\": true; a partial one may be re-run to finish.\nconst plan = $input.first().json;\nconst db = $getWorkflowStaticData('global');\ndb.onboarded = db.onboarded ?? {};\nconst prev = db.onboarded[plan.slug];\nconst blocked = !plan.dry_run && prev?.status === 'complete' && !plan.force;\nreturn [{ json: { ...plan, already_onboarded: blocked, previous: prev ?? null } }];\n"
},
"id": "m16-registry",
"name": "Check Registry",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
120
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "w1",
"leftValue": "={{ $json.dry_run }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "dry run"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "w2",
"leftValue": "={{ $json.already_onboarded }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "already onboarded"
}
]
},
"options": {
"fallbackOutput": "extra",
"renameFallbackOutput": "run"
}
},
"id": "m16-mode",
"name": "Mode",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1100,
120
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { status: 'dry_run', plan: { client_name: $json.client_name, slug: $json.slug, package: $json.package, start_date: $json.start_date, kickoff_by: $json.kickoff_by, folder_name: $json.folder_name, channel_name: $json.channel_name, tasks: $json.tasks, email: $json.email } } }}",
"options": {}
},
"id": "m16-respond-dry",
"name": "Respond Plan",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1320,
-60
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { status: 'already_onboarded', slug: $json.slug, previous: $json.previous, hint: 'send \"force\": true to run again' } }}",
"options": {
"responseCode": 409
}
},
"id": "m16-409",
"name": "Respond Already Onboarded",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1320,
100
]
},
{
"parameters": {
"resource": "folder",
"name": "={{ $json.folder_name }}",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"folderId": {
"__rl": true,
"mode": "id",
"value": "={{ $('Settings (edit me)').first().json.drive_parent_folder_id }}"
},
"options": {}
},
"id": "m16-drive",
"name": "Create Drive Folder",
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
1320,
300
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"resource": "channel",
"operation": "create",
"channelId": "={{ $('Check Registry').first().json.channel_name }}",
"channelVisibility": "private"
},
"id": "m16-slack",
"name": "Create Slack Channel",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.3,
"position": [
1540,
300
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// One item per checklist task, so the ClickUp node creates one task each.\nconst plan = $('Check Registry').first().json;\nreturn plan.tasks.map((t) => ({ json: t }));\n"
},
"id": "m16-tasks",
"name": "Task Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1760,
300
]
},
{
"parameters": {
"team": "={{ $('Settings (edit me)').first().json.clickup.team }}",
"space": "={{ $('Settings (edit me)').first().json.clickup.space }}",
"folderless": true,
"list": "={{ $('Settings (edit me)').first().json.clickup.list }}",
"name": "={{ $json.name }}",
"additionalFields": {
"dueDate": "={{ $json.due }}",
"content": "=Owner: {{ $json.owner_role }}\nClient: {{ $('Check Registry').first().json.client_name }} ({{ $('Check Registry').first().json.contact_email }})\nCreated by the onboarding workflow."
}
},
"id": "m16-clickup",
"name": "Create ClickUp Tasks",
"type": "n8n-nodes-base.clickUp",
"typeVersion": 1,
"position": [
1980,
300
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "task_results",
"options": {}
},
"id": "m16-agg",
"name": "Tasks Done",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
2200,
300
]
},
{
"parameters": {
"sendTo": "={{ $('Check Registry').first().json.email.to }}",
"subject": "={{ $('Check Registry').first().json.email.subject }}",
"emailType": "text",
"message": "={{ $('Check Registry').first().json.email.body }}",
"options": {
"appendAttribution": false
}
},
"id": "m16-gmail",
"name": "Send Welcome Email",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
2420,
300
],
"webhookId": "course-16-gmail",
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Collect what each step did. Every step runs with \"On Error: Continue\", so a failed step\n// shows up here as { error } instead of stopping the whole onboarding.\n// A step only counts as done if it returned an id: some nodes return an empty item instead of an error\n// (ClickUp did, without credentials).\nconst plan = $('Check Registry').first().json;\nconst errText = (j) => (typeof j.error === 'string' ? j.error : j.error?.message ?? (j.error ? JSON.stringify(j.error) : null));\nconst done = (j) => !j.error && Boolean(j.id);\nconst why = (j) => errText(j) ?? 'no id returned (check the credential and the ids in Settings)';\n\nconst drive = $('Create Drive Folder').first().json;\nconst slack = $('Create Slack Channel').first().json;\nconst tasks = $('Create ClickUp Tasks').all().map((i) => i.json);\nconst email = $('Send Welcome Email').first().json;\n\nconst steps = [\n { step: 'drive_folder', ok: done(drive), detail: done(drive) ? drive.webViewLink ?? drive.id : why(drive) },\n { step: 'slack_channel', ok: done(slack), detail: done(slack) ? `#${slack.name ?? plan.channel_name}` : why(slack) },\n {\n step: 'clickup_tasks',\n ok: tasks.length > 0 && tasks.every(done),\n detail: `${tasks.filter(done).length}/${tasks.length} tasks created` + (tasks.find((t) => !done(t)) ? `; first problem: ${why(tasks.find((t) => !done(t)))}` : ''),\n },\n { step: 'welcome_email', ok: done(email), detail: done(email) ? `sent to ${plan.contact_email}` : why(email) },\n];\nconst status = steps.every((s) => s.ok) ? 'complete' : 'partial';\n\nconst db = $getWorkflowStaticData('global');\ndb.onboarded = db.onboarded ?? {};\ndb.onboarded[plan.slug] = { status, at: DateTime.now().toISO(), steps: steps.map((s) => `${s.step}:${s.ok ? 'ok' : 'failed'}`) };\n\nreturn [{\n json: {\n status,\n client_name: plan.client_name,\n slug: plan.slug,\n start_date: plan.start_date,\n failed_steps: steps.filter((s) => !s.ok).map((s) => s.step),\n steps,\n },\n}];\n"
},
"id": "m16-report",
"name": "Onboarding Report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2640,
300
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "m16-respond",
"name": "Respond Report",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2860,
220
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "p1",
"leftValue": "={{ $json.status }}",
"rightValue": "partial",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m16-partial",
"name": "Any Step Failed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2860,
400
]
},
{
"parameters": {},
"id": "m16-alert",
"name": "Alert Team (Exercise 1)",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
3080,
400
]
}
],
"connections": {
"New Client": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Validate & Build Plan",
"type": "main",
"index": 0
}
]
]
},
"Validate & Build Plan": {
"main": [
[
{
"node": "Valid?",
"type": "main",
"index": 0
}
]
]
},
"Valid?": {
"main": [
[
{
"node": "Check Registry",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond Invalid",
"type": "main",
"index": 0
}
]
]
},
"Check Registry": {
"main": [
[
{
"node": "Mode",
"type": "main",
"index": 0
}
]
]
},
"Mode": {
"main": [
[
{
"node": "Respond Plan",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond Already Onboarded",
"type": "main",
"index": 0
}
],
[
{
"node": "Create Drive Folder",
"type": "main",
"index": 0
}
]
]
},
"Create Drive Folder": {
"main": [
[
{
"node": "Create Slack Channel",
"type": "main",
"index": 0
}
]
]
},
"Create Slack Channel": {
"main": [
[
{
"node": "Task Items",
"type": "main",
"index": 0
}
]
]
},
"Task Items": {
"main": [
[
{
"node": "Create ClickUp Tasks",
"type": "main",
"index": 0
}
]
]
},
"Create ClickUp Tasks": {
"main": [
[
{
"node": "Tasks Done",
"type": "main",
"index": 0
}
]
]
},
"Tasks Done": {
"main": [
[
{
"node": "Send Welcome Email",
"type": "main",
"index": 0
}
]
]
},
"Send Welcome Email": {
"main": [
[
{
"node": "Onboarding Report",
"type": "main",
"index": 0
}
]
]
},
"Onboarding Report": {
"main": [
[
{
"node": "Respond Report",
"type": "main",
"index": 0
},
{
"node": "Any Step Failed?",
"type": "main",
"index": 0
}
]
]
},
"Any Step Failed?": {
"main": [
[
{
"node": "Alert Team (Exercise 1)",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
}
}