DEV Community

Qasim Muhammad
Qasim Muhammad

Posted on

Customer Onboarding Drip, Run by an Agent

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.

Start with a tracked welcome email

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"
    }
  }'
Enter fullscreen mode Exit fullscreen mode

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

Let customers book their own kickoff

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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.

React to behavior, not a calendar schedule

Subscribe one webhook to message.opened, message.link_clicked, and booking.created, and the state machine writes itself:

  • Open or click → engaged
  • 48 hours of silence → unresponsive, send the follow-up
  • Booking → kickoff_booked, cancel any pending nudges

The 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Why opens lie to you

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.

Where the agent mailbox earns its keep

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.

Wire up one transition this week

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?

Top comments (0)