Client Onboarding Pipeline
Module 16 · business · intermediate
starter.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "16 - Client Onboarding Pipeline (starter)",
"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": {
"content": "## Your task\nThe plan (folder, channel, dated tasks, welcome email) is already built. Try `?dry_run=true`.\nOn the Switch's **run** output, chain the side effects, each with **Settings → On Error → Continue**:\n1. **Google Drive**: create folder `folder_name` in your clients folder.\n2. **Slack**: create private channel `channel_name`.\n3. **Code** → one item per task → **ClickUp**: create each task with its due date.\n4. **Aggregate** (so the next step runs once) → **Gmail**: send the welcome email.\n5. **Onboarding Report** (Code): a step is OK only if it returned an `id`; save the status in static data.\n\nTest: `./test/test.sh`",
"height": 260,
"width": 420
},
"id": "starter-task-note",
"name": "Your Task",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
-340
]
}
],
"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
}
],
[]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
}
}