Skip to content

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"
  }
}