# Customer Onboarding Drip, Run by an Agent

> Source: <https://dev.to/qasim157/customer-onboarding-drip-run-by-an-agent-19jc>
> Published: 2026-06-13 11:21:41+00:00

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](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@`

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-up`kickoff_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:

``` js
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](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?
