cd /news/developer-tools/customer-onboarding-drip-run-by-an-a… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-26142] src=dev.to pub= topic=developer-tools verified=true sentiment=↑ positive

Customer Onboarding Drip, Run by an Agent

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.

read5 min publishedJun 13, 2026

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.

The 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, and the whole thing gets more interesting when the sending mailbox is an Agent Account (in beta) β€” a dedicated onboarding@

address your app owns, so replies route back to the same identity that sent the drip.

The first send carries tracking_options

so you learn whether the customer opened it and what they clicked. The label

field is the trick: it encodes which customer and which stage the event belongs to, so webhook handlers don't need a lookup table.

curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "subject": "Welcome to Acme - Let'\''s get you started",
    "to": [{ "name": "Jordan Lee", "email": "jordan@customer.com" }],
    "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>",
    "tracking_options": {
      "opens": true,
      "links": true,
      "thread_replies": true,
      "label": "onboarding-welcome-jordan@customer.com"
    }
  }'

One caveat from the docs: message tracking requires a production application β€” Sandbox and trial accounts get an error when they include tracking_options

.

Instead of the back-and-forth of "does Tuesday work?", a Scheduler Configuration produces a hosted booking page at book.nylas.com/<slug>

:

curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/scheduling/configurations' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "requires_session_auth": false,
    "participants": [{
      "name": "Acme Onboarding Team",
      "email": "onboarding@acme.com",
      "is_organizer": true,
      "availability": { "calendar_ids": ["primary"] },
      "booking": { "calendar_id": "primary" }
    }],
    "availability": { "duration_minutes": 30 },
    "event_booking": {
      "title": "Kickoff Call - {{invitee_name}}",
      "description": "Welcome kickoff call to walk through account setup and align on goals."
    },
    "slug": "acme-kickoff"
  }'

That's a live 30-minute booking page at book.nylas.com/acme-kickoff

with zero frontend work. You can pre-fill the form via query parameters β€” ?name=Jordan%20Lee&email__readonly=jordan@customer.com

β€” so the customer never re-types their details, and the __readonly

suffix locks the value so attribution stays clean. Better still, create a per-customer Configuration with a unique slug (acme-kickoff-jordan-lee

): then you know exactly who booked without trusting form input at all.

When they book, a booking.created

webhook 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.

Subscribe one webhook to message.opened

, message.link_clicked

, and booking.created

, and the state machine writes itself:

engaged

unresponsive

, send the follow-upkickoff_booked

, 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.

In code, the state machine is small. Each state declares its legal transitions, and webhook events drive the moves:

const ONBOARDING_STATES = {
  new: { nextStates: ["welcome_sent"] },
  welcome_sent: { nextStates: ["engaged", "unresponsive"] },
  engaged: { nextStates: ["kickoff_booked", "followup_needed"] },
  unresponsive: { nextStates: ["engaged", "escalated"] },
  kickoff_booked: { nextStates: ["kickoff_completed"] },
  kickoff_completed: { nextStates: ["onboarded"] },
};

async function updateOnboardingState(customerEmail, event) {
  const customer = await getCustomerRecord(customerEmail);
  switch (event) {
    case "email_opened":
    case "link_clicked":
      if (["welcome_sent", "unresponsive"].includes(customer.onboardingState)) {
        await transitionTo(customer, "engaged");
      }
      break;
    case "kickoff_booked":
      await transitionTo(customer, "kickoff_booked");
      await cancelPendingFollowUps(customerEmail); // no nudges after booking
      break;
    case "followup_timer_expired":
      if (customer.onboardingState === "welcome_sent") {
        await transitionTo(customer, "unresponsive");
        await sendFollowUpEmail(customer);
      }
      break;
  }
}

Two 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

; 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.

Apple 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

tracking fires for every reply in the thread β€” including your own team's β€” so filter by sender before counting it as customer engagement.

Running 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.

You don't need the whole state machine on day one. Send the tracked welcome email, subscribe to message.link_clicked

, 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 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?

── more in #developer-tools 4 stories Β· sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/customer-onboarding-…] indexed:0 read:5min 2026-06-13 Β· β€”