{"slug": "customer-onboarding-drip-run-by-an-agent", "title": "Customer Onboarding Drip, Run by an Agent", "summary": "A developer built a customer onboarding automation system using Nylas APIs that treats onboarding as a state machine driven by engagement signals. The system uses tracked welcome emails, self-service booking links, and webhooks to automatically move customers through stages like engaged, unresponsive, and kickoff booked without manual intervention. The tutorial also highlights the use of Nylas Agent Accounts for dedicated sending identities and Scheduler Configurations for zero-frontend booking pages.", "body_md": "How many of the customers you onboarded last month actually booked the kickoff call you emailed them about? If you can't answer that without checking a spreadsheet someone updates by hand, your onboarding sequence is running on hope.\n\nThe fix is to treat onboarding as a state machine driven by real engagement signals: a welcome email with open and click tracking, a self-service booking link for the kickoff, and webhooks that move each customer between states — engaged, unresponsive, kickoff booked — without anyone watching an inbox. The full recipe is in the [customer onboarding automation tutorial](https://developer.nylas.com/docs/cookbook/use-cases/automate/automate-customer-onboarding/), and the whole thing gets more interesting when the sending mailbox is an [Agent Account](https://developer.nylas.com/docs/v3/agent-accounts/) (in beta) — a dedicated `onboarding@`\n\naddress your app owns, so replies route back to the same identity that sent the drip.\n\nThe first send carries `tracking_options`\n\nso you learn whether the customer opened it and what they clicked. The `label`\n\nfield is the trick: it encodes which customer and which stage the event belongs to, so webhook handlers don't need a lookup table.\n\n```\ncurl --request POST \\\n  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \\\n  --header 'Authorization: Bearer <NYLAS_API_KEY>' \\\n  --header 'Content-Type: application/json' \\\n  --data '{\n    \"subject\": \"Welcome to Acme - Let'\\''s get you started\",\n    \"to\": [{ \"name\": \"Jordan Lee\", \"email\": \"jordan@customer.com\" }],\n    \"body\": \"<html><body><h2>Welcome aboard!</h2><ol><li><a href=\\\"https://app.acme.com/setup\\\">Complete setup</a></li><li><a href=\\\"https://book.nylas.com/acme-kickoff\\\">Book your kickoff call</a></li></ol></body></html>\",\n    \"tracking_options\": {\n      \"opens\": true,\n      \"links\": true,\n      \"thread_replies\": true,\n      \"label\": \"onboarding-welcome-jordan@customer.com\"\n    }\n  }'\n```\n\nOne caveat from the docs: message tracking requires a production application — Sandbox and trial accounts get an error when they include `tracking_options`\n\n.\n\nInstead of the back-and-forth of \"does Tuesday work?\", a Scheduler Configuration produces a hosted booking page at `book.nylas.com/<slug>`\n\n:\n\n```\ncurl --request POST \\\n  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/scheduling/configurations' \\\n  --header 'Authorization: Bearer <NYLAS_API_KEY>' \\\n  --header 'Content-Type: application/json' \\\n  --data '{\n    \"requires_session_auth\": false,\n    \"participants\": [{\n      \"name\": \"Acme Onboarding Team\",\n      \"email\": \"onboarding@acme.com\",\n      \"is_organizer\": true,\n      \"availability\": { \"calendar_ids\": [\"primary\"] },\n      \"booking\": { \"calendar_id\": \"primary\" }\n    }],\n    \"availability\": { \"duration_minutes\": 30 },\n    \"event_booking\": {\n      \"title\": \"Kickoff Call - {{invitee_name}}\",\n      \"description\": \"Welcome kickoff call to walk through account setup and align on goals.\"\n    },\n    \"slug\": \"acme-kickoff\"\n  }'\n```\n\nThat's a live 30-minute booking page at `book.nylas.com/acme-kickoff`\n\nwith zero frontend work. You can pre-fill the form via query parameters — `?name=Jordan%20Lee&email__readonly=jordan@customer.com`\n\n— so the customer never re-types their details, and the `__readonly`\n\nsuffix locks the value so attribution stays clean. Better still, create a per-customer Configuration with a unique slug (`acme-kickoff-jordan-lee`\n\n): then you know exactly who booked without trusting form input at all.\n\nWhen they book, a `booking.created`\n\nwebhook fires. Your handler advances the state, sends a confirmation with the agenda, and creates a prep event on your team's calendar — the recipe schedules it from 3600 to 1800 seconds before the call, giving your team a 30-minute review window before they're face to face with the customer.\n\nSubscribe one webhook to `message.opened`\n\n, `message.link_clicked`\n\n, and `booking.created`\n\n, and the state machine writes itself:\n\n`engaged`\n\n`unresponsive`\n\n, send the follow-up`kickoff_booked`\n\n, cancel any pending nudgesThe 48-hour check is the part teams get wrong with naive cron jobs: the follow-up should be conditional on engagement, not just elapsed time. Nobody wants a \"just checking in!\" email two days after they already booked the call.\n\nIn code, the state machine is small. Each state declares its legal transitions, and webhook events drive the moves:\n\n``` js\nconst ONBOARDING_STATES = {\n  new: { nextStates: [\"welcome_sent\"] },\n  welcome_sent: { nextStates: [\"engaged\", \"unresponsive\"] },\n  engaged: { nextStates: [\"kickoff_booked\", \"followup_needed\"] },\n  unresponsive: { nextStates: [\"engaged\", \"escalated\"] },\n  kickoff_booked: { nextStates: [\"kickoff_completed\"] },\n  kickoff_completed: { nextStates: [\"onboarded\"] },\n};\n\nasync function updateOnboardingState(customerEmail, event) {\n  const customer = await getCustomerRecord(customerEmail);\n  switch (event) {\n    case \"email_opened\":\n    case \"link_clicked\":\n      if ([\"welcome_sent\", \"unresponsive\"].includes(customer.onboardingState)) {\n        await transitionTo(customer, \"engaged\");\n      }\n      break;\n    case \"kickoff_booked\":\n      await transitionTo(customer, \"kickoff_booked\");\n      await cancelPendingFollowUps(customerEmail); // no nudges after booking\n      break;\n    case \"followup_timer_expired\":\n      if (customer.onboardingState === \"welcome_sent\") {\n        await transitionTo(customer, \"unresponsive\");\n        await sendFollowUpEmail(customer);\n      }\n      break;\n  }\n}\n```\n\nTwo infrastructure notes that matter more than the state diagram. First, the state lives in a database, not in memory — the webhook handler, the follow-up timer, and the state machine all need the same records across restarts. Second, don't implement the 48-hour delay with `setTimeout`\n\n; it dies with the process. Use a persistent job queue (Bull for Node.js, Celery for Python, or SQS with delayed delivery) so a deploy doesn't silently drop every pending follow-up.\n\nApple Mail Privacy Protection pre-loads tracking pixels at delivery time, which fires a false open for customers who never read the message. Gmail and Outlook can block remote images, which suppresses *real* opens. So: treat opens as a soft signal, and treat link clicks and replies as the signals worth acting on. Also note that `thread_replies`\n\ntracking fires for every reply in the thread — including your own team's — so filter by sender before counting it as customer engagement.\n\nRunning this from a shared human inbox means replies get lost in someone's personal mail. A dedicated agent-owned address keeps the entire conversation — drip sends, customer replies, escalations — in one mailbox your code can read over the same API. Two operational numbers to plan around: webhook delivery is at-least-once, so deduplicate by webhook ID or you'll double-send confirmations; and if you batch-onboard after a launch, provider send limits apply — Google caps most accounts around 500 messages per day, so stagger your sends.\n\nYou don't need the whole state machine on day one. Send the tracked welcome email, subscribe to `message.link_clicked`\n\n, and log the events for a week — that alone tells you what fraction of new customers ever touch your setup link. Then add the 48-hour follow-up branch. The [tutorial](https://developer.nylas.com/docs/cookbook/use-cases/automate/automate-customer-onboarding/) has full Node.js and Python handlers ready to adapt. Which state do most of your customers stall in — and do you currently have any way to know?", "url": "https://wpnews.pro/news/customer-onboarding-drip-run-by-an-agent", "canonical_source": "https://dev.to/qasim157/customer-onboarding-drip-run-by-an-agent-19jc", "published_at": "2026-06-13 11:21:41+00:00", "updated_at": "2026-06-13 11:47:53.328009+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Nylas", "Jordan Lee", "Acme", "Nylas Agent Accounts", "Nylas Scheduler"], "alternates": {"html": "https://wpnews.pro/news/customer-onboarding-drip-run-by-an-agent", "markdown": "https://wpnews.pro/news/customer-onboarding-drip-run-by-an-agent.md", "text": "https://wpnews.pro/news/customer-onboarding-drip-run-by-an-agent.txt", "jsonld": "https://wpnews.pro/news/customer-onboarding-drip-run-by-an-agent.jsonld"}}