Skip to content

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