# Automate customer onboarding

Source: https://developer.nylas.com/docs/cookbook/use-cases/automate/automate-customer-onboarding/

Customer onboarding is the same five steps repeated forever: welcome email, kickoff call, a couple of follow-ups, and someone watching whether the customer actually engaged. In practice, steps get skipped, emails go out late, and kickoff calls slip because nobody scheduled them.

This recipe builds the whole sequence on autopilot. A welcome email goes out with open + click tracking and a self-service Scheduler link. Webhooks fire as the customer engages -- opens, clicks, books -- and a state machine decides what happens next. Same pipeline works for Google, Microsoft, and IMAP customers because Nylas normalizes the underlying providers.

## The pipeline

```
new signup ─▶ create per-customer Scheduler slug ─▶ send welcome email (tracked)
                                                            │
                          ┌──────────────────────────┬──────┴──────────────────┐
                          ▼                          ▼                         ▼
                   message.opened             message.link_clicked      booking.created
                   message.link_clicked       (after 48h timeout)
                          │                          │                         │
                          ▼                          ▼                         ▼
                  state: engaged            state: unresponsive        state: kickoff_booked
                                            ─▶ send follow-up         ─▶ send confirmation
                                                                       ─▶ create prep reminder
```

Three components, one webhook handler, one state machine. Start with the welcome email, add scheduling later, or deploy all three at once.

## Before you begin


Make sure you have the following before starting this tutorial:

- A [Nylas account](https://dashboard-v3.nylas.com/) with an active application
- A valid **API key** from your Nylas Dashboard
- At least one **connected grant** (an authenticated user account) for the provider you want to work with
- **Node.js 18+** or **Python 3.8+** installed (depending on which code samples you follow)

> **Info:** 
> **New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.


You also need:

- A **connected grant** with email send permissions for the account that sends onboarding emails
- A **calendar** on that grant to host kickoff call events
- **Message tracking enabled** on your Nylas application (not available for Sandbox/trial accounts)
- A **publicly accessible HTTPS endpoint** to receive webhook notifications from Nylas

> **Warn:** 
> **Message tracking requires a production application.** Sandbox accounts cannot use tracking features. If you are on a trial plan, you will receive an error when you include `tracking_options` in send requests.

## Send a welcome email sequence

The first touchpoint in onboarding is a welcome email. Use the Nylas [Send Message endpoint](/docs/reference/api/messages/send-message/) with `tracking_options` enabled so you can monitor whether the customer opens the message and clicks any links.

### Send the welcome email with tracking

Include `tracking_options` in your send request to track opens and link clicks. The `label` field helps you identify this message in webhook notifications later.

```bash
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, Jordan!</h2><p>We'\''re excited to have you. Here are your next steps:</p><ol><li><a href=\"https://app.acme.com/setup\">Complete your account setup</a></li><li><a href=\"https://book.nylas.com/acme-kickoff\">Schedule your kickoff call</a></li><li><a href=\"https://docs.acme.com/getting-started\">Read the getting started guide</a></li></ol><p>If you have any questions, reply to this email and we'\''ll get back to you within a few hours.</p></body></html>",
    "tracking_options": {
      "opens": true,
      "links": true,
      "thread_replies": true,
      "label": "onboarding-welcome-jordan@customer.com"
    }
  }'
```

```js [welcomeEmail-Node.js]
const Nylas = require("nylas");

const nylas = new Nylas({
  apiKey: process.env.NYLAS_API_KEY,
});

async function sendWelcomeEmail(customer) {
  const response = await nylas.messages.send({
    identifier: process.env.NYLAS_GRANT_ID,
    requestBody: {
      subject: `Welcome to Acme - Let's get you started`,
      to: [{ name: customer.name, email: customer.email }],
      body: `
        <html><body>
          <h2>Welcome aboard, ${customer.name}!</h2>
          <p>We're excited to have you. Here are your next steps:</p>
          <ol>
            <li><a href="https://app.acme.com/setup">Complete your account setup</a></li>
            <li><a href="${customer.schedulingUrl}">Schedule your kickoff call</a></li>
            <li><a href="https://docs.acme.com/getting-started">Read the getting started guide</a></li>
          </ol>
          <p>If you have any questions, reply to this email.</p>
        </body></html>
      `,
      trackingOptions: {
        opens: true,
        links: true,
        threadReplies: true,
        label: `onboarding-welcome-${customer.email}`,
      },
    },
  });

  console.log(
    `Welcome email sent to ${customer.email}, ID: ${response.data.id}`,
  );
  return response.data;
}
```

```python
from nylas import Client

nylas = Client(
    api_key=os.environ["NYLAS_API_KEY"],
)

def send_welcome_email(customer):
    message = nylas.messages.send(
        identifier=os.environ["NYLAS_GRANT_ID"],
        request_body={
            "subject": "Welcome to Acme - Let's get you started",
            "to": [{"name": customer["name"], "email": customer["email"]}],
            "body": f"""
                <html><body>
                <h2>Welcome aboard, {customer["name"]}!</h2>
                <p>We're excited to have you. Here are your next steps:</p>
                <ol>
                    <li><a href="https://app.acme.com/setup">Complete your account setup</a></li>
                    <li><a href="{customer["scheduling_url"]}">Schedule your kickoff call</a></li>
                    <li><a href="https://docs.acme.com/getting-started">Read the getting started guide</a></li>
                </ol>
                <p>If you have any questions, reply to this email.</p>
                </body></html>
            """,
            "tracking_options": {
                "opens": True,
                "links": True,
                "thread_replies": True,
                "label": f"onboarding-welcome-{customer['email']}",
            },
        },
    )

    print(f"Welcome email sent to {customer['email']}, ID: {message.data.id}")
    return message.data
```

### Schedule follow-up emails

After sending the welcome email, schedule a follow-up for customers who have not engaged. This example uses a simple delay-based approach. In the [orchestration section](#build-the-orchestration-layer), you will see how to make this conditional on actual engagement.

```js [followUpEmail-Node.js]
async function scheduleFollowUp(customer, delayHours) {
  // In production, use a job queue (Bull, Celery, etc.) instead of setTimeout
  setTimeout(
    async () => {
      // Check if customer already engaged before sending
      const engaged = await checkCustomerEngagement(customer.email);

      if (!engaged) {
        await nylas.messages.send({
          identifier: process.env.NYLAS_GRANT_ID,
          requestBody: {
            subject: "Quick check-in - Need help getting started?",
            to: [{ name: customer.name, email: customer.email }],
            body: `
            <html><body>
              <p>Hi ${customer.name},</p>
              <p>Just checking in to see if you need any help getting started.
              If you haven't had a chance yet, here are two things that
              will make the biggest difference:</p>
              <ul>
                <li><a href="https://app.acme.com/setup">Complete your account setup</a> (takes about 5 minutes)</li>
                <li><a href="${customer.schedulingUrl}">Book your kickoff call</a> with our team</li>
              </ul>
              <p>Reply any time if you have questions.</p>
            </body></html>
          `,
            trackingOptions: {
              opens: true,
              links: true,
              label: `onboarding-followup-${customer.email}`,
            },
          },
        });
      }
    },
    delayHours * 60 * 60 * 1000,
  );
}

// Schedule a follow-up 48 hours after the welcome email
scheduleFollowUp(customer, 48);
```

> **Info:** 
> **Use a proper job queue for production follow-ups.** The `setTimeout` example above works for demonstration, but it does not survive server restarts. Use a persistent job scheduler like Bull (Node.js), Celery (Python), or a managed service like AWS SQS with delayed delivery.

## Create a self-service scheduling page

Instead of coordinating kickoff calls over email, create a Scheduler Configuration that gives each customer a booking link. They pick a time that works for them, and the event appears on your team's calendar automatically.

### Create a Scheduler Configuration

Use the [Create Configuration endpoint](/docs/reference/api/configurations/post-configurations/) to set up a booking page for kickoff calls.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/scheduling/configurations' \
  --header 'Accept: application/json' \
  --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, answer questions, and align on goals.",
      "hide_participants": false
    },
    "slug": "acme-kickoff"
  }'
```

```js [createScheduler-Node.js]
async function createKickoffScheduler() {
  const response = await nylas.scheduling.configurations.create({
    identifier: process.env.NYLAS_GRANT_ID,
    requestBody: {
      requiresSessionAuth: false,
      participants: [
        {
          name: "Acme Onboarding Team",
          email: "onboarding@acme.com",
          isOrganizer: true,
          availability: { calendarIds: ["primary"] },
          booking: { calendarId: "primary" },
        },
      ],
      availability: { durationMinutes: 30 },
      eventBooking: {
        title: "Kickoff Call",
        description:
          "Welcome kickoff to walk through setup and align on goals.",
        hideParticipants: false,
      },
      slug: "acme-kickoff",
    },
  });

  console.log(
    `Scheduler created: https://book.nylas.com/${response.data.slug}`,
  );
  return response.data;
}
```

```python
def create_kickoff_scheduler():
    response = nylas.scheduler.configurations.create(
        identifier=os.environ["NYLAS_GRANT_ID"],
        request_body={
            "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",
                "description": "Welcome kickoff to walk through setup and align on goals.",
                "hide_participants": False,
            },
            "slug": "acme-kickoff",
        },
    )

    print(f"Scheduler created: https://book.nylas.com/{response.data.slug}")
    return response.data
```

This creates a public scheduling page at `https://book.nylas.com/acme-kickoff`. Include this URL in your welcome email so customers can book their kickoff call directly.

### Pre-fill the booking form

You can pass customer information through URL query parameters so they do not have to type their name and email again. This reduces friction and increases booking rates.

```
https://book.nylas.com/acme-kickoff?name=Jordan%20Lee&email=jordan@customer.com
```

Add `__readonly` to a parameter to prevent the customer from editing a pre-filled value:

```
https://book.nylas.com/acme-kickoff?name=Jordan%20Lee&email__readonly=jordan@customer.com
```

> **Success:** 
> **Generate unique scheduling URLs per customer.** Instead of using a single slug, create per-customer Configurations with unique slugs (like `acme-kickoff-jordan-lee`). This lets you track which customer booked without relying on form input, and you can customize the event title per customer.

## Track email engagement

With tracking enabled on your welcome emails, Nylas fires webhook notifications when customers open messages or click links. Subscribe to these triggers to get real-time engagement signals.

### Set up tracking webhooks

Create a webhook subscription for `message.opened` and `message.link_clicked` events:

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks/' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --data '{
    "trigger_types": [
      "message.opened",
      "message.link_clicked",
      "booking.created"
    ],
    "description": "Customer onboarding engagement tracking",
    "webhook_url": "https://your-app.example.com/webhooks/nylas",
    "notification_email_addresses": [
      "dev-team@your-company.com"
    ]
  }'
```

### Handle tracking webhooks

When a customer opens the welcome email or clicks a link, Nylas sends a notification with the `label` you set when sending the message. Use this label to identify which customer and which onboarding stage the event belongs to.

```js [trackingHandler-Node.js]
const express = require("express");
const crypto = require("crypto");

const app = express();
app.use(express.json());

function verifyWebhookSignature(req, webhookSecret) {
  const signature = req.headers["x-nylas-signature"];
  const hmac = crypto.createHmac("sha256", webhookSecret);
  const digest = hmac.update(JSON.stringify(req.body)).digest("hex");
  return signature === digest;
}

// Challenge handler for webhook verification
app.get("/webhooks/nylas", (req, res) => {
  res.status(200).send(req.query.challenge);
});

app.post("/webhooks/nylas", async (req, res) => {
  res.status(200).send("OK");

  if (!verifyWebhookSignature(req, process.env.NYLAS_WEBHOOK_SECRET)) {
    console.error("Invalid webhook signature");
    return;
  }

  const { type, data } = req.body;

  if (type === "message.opened") {
    const label = data.object.label;
    const messageId = data.object.message_id;

    console.log(`Email opened: ${label}`);

    // Extract customer email from the label
    // Label format: "onboarding-welcome-jordan@customer.com"
    const customerEmail = label
      ?.replace("onboarding-welcome-", "")
      .replace("onboarding-followup-", "");

    if (customerEmail) {
      await updateOnboardingState(customerEmail, "email_opened", {
        messageId,
        openedAt: new Date().toISOString(),
      });
    }
  }

  if (type === "message.link_clicked") {
    const label = data.object.label;
    const link = data.object.url;

    console.log(`Link clicked: ${link} (label: ${label})`);

    const customerEmail = label
      ?.replace("onboarding-welcome-", "")
      .replace("onboarding-followup-", "");

    if (customerEmail) {
      await updateOnboardingState(customerEmail, "link_clicked", {
        url: link,
        clickedAt: new Date().toISOString(),
      });
    }
  }
});
```

```python
from flask import Flask, request

app = Flask(__name__)

def verify_webhook_signature(payload, signature, secret):
    digest = hmac.new(
        secret.encode(), json.dumps(payload).encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, digest)

@app.route("/webhooks/nylas", methods=["GET"])
def challenge():
    return request.args.get("challenge", ""), 200

@app.route("/webhooks/nylas", methods=["POST"])
def handle_webhook():
    payload = request.json
    signature = request.headers.get("X-Nylas-Signature", "")

    if not verify_webhook_signature(
        payload, signature, os.environ["NYLAS_WEBHOOK_SECRET"]
    ):
        return "Invalid signature", 401

    event_type = payload.get("type")
    data = payload.get("data", {}).get("object", {})

    if event_type == "message.opened":
        label = data.get("label", "")
        customer_email = (
            label.replace("onboarding-welcome-", "")
            .replace("onboarding-followup-", "")
        )
        print(f"Email opened by {customer_email}")
        update_onboarding_state(customer_email, "email_opened")

    elif event_type == "message.link_clicked":
        label = data.get("label", "")
        url = data.get("url", "")
        customer_email = (
            label.replace("onboarding-welcome-", "")
            .replace("onboarding-followup-", "")
        )
        print(f"Link clicked by {customer_email}: {url}")
        update_onboarding_state(customer_email, "link_clicked", url=url)

    return "OK", 200
```

> **Warn:** 
> **Apple Mail Privacy Protection pre-loads tracking pixels.** When a customer uses Apple Mail with privacy features enabled, Nylas may report the email as opened even if the customer never read it. Apple's mail proxy downloads remote content (including tracking pixels) at delivery time, which triggers a false open event. Do not treat a single open as definitive proof of engagement. Look for link clicks or replies as stronger signals.

## Handle booking confirmations

When a customer books their kickoff call through the scheduling page, Nylas fires a `booking.created` webhook. Use this to advance the onboarding state, create follow-up calendar events, and send a confirmation with the agenda.

```js [bookingHandler-Node.js]
// Add this to the same webhook handler from above

app.post("/webhooks/nylas", async (req, res) => {
  res.status(200).send("OK");

  if (!verifyWebhookSignature(req, process.env.NYLAS_WEBHOOK_SECRET)) {
    return;
  }

  const { type, data } = req.body;

  // ... existing tracking handlers ...

  if (type === "booking.created") {
    const booking = data.object;
    const guestEmail = booking.guest?.email;
    const eventId = booking.event_id;
    const startTime = booking.start_time;

    console.log(
      `Kickoff booked by ${guestEmail} at ${new Date(startTime * 1000)}`,
    );

    // Update onboarding state
    await updateOnboardingState(guestEmail, "kickoff_booked", {
      eventId,
      bookedAt: new Date().toISOString(),
      scheduledFor: new Date(startTime * 1000).toISOString(),
    });

    // Send a confirmation email with the agenda
    await sendBookingConfirmation(guestEmail, booking);

    // Create a prep reminder for your team 1 hour before the call
    await createPrepReminder(booking);
  }
});

async function sendBookingConfirmation(customerEmail, booking) {
  const startDate = new Date(booking.start_time * 1000);
  const formattedDate = startDate.toLocaleDateString("en-US", {
    weekday: "long",
    month: "long",
    day: "numeric",
  });
  const formattedTime = startDate.toLocaleTimeString("en-US", {
    hour: "numeric",
    minute: "2-digit",
    timeZoneName: "short",
  });

  await nylas.messages.send({
    identifier: process.env.NYLAS_GRANT_ID,
    requestBody: {
      subject: `Your kickoff call is confirmed - ${formattedDate}`,
      to: [{ email: customerEmail }],
      body: `
        <html><body>
          <p>Your kickoff call is confirmed for <strong>${formattedDate} at ${formattedTime}</strong>.</p>
          <h3>What we'll cover:</h3>
          <ol>
            <li>Review your account setup and configuration</li>
            <li>Walk through core features for your use case</li>
            <li>Answer any questions from your team</li>
            <li>Set up next steps and success milestones</li>
          </ol>
          <p>If you need to reschedule, use the link in your calendar invitation.</p>
        </body></html>
      `,
      trackingOptions: {
        opens: true,
        label: `onboarding-confirmation-${customerEmail}`,
      },
    },
  });
}
```

### Create a prep reminder for your team

Create a calendar event before the kickoff call so your onboarding team has time to review the customer's account.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/events?calendar_id=primary' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "title": "Prep: Kickoff call with Jordan Lee",
    "description": "Review account setup status, recent support tickets, and engagement signals before the kickoff call.",
    "when": {
      "start_time": 1700000000,
      "end_time": 1700001800
    },
    "participants": [
      { "email": "onboarding@acme.com" }
    ]
  }'
```

```js [prepReminder-Node.js]
async function createPrepReminder(booking) {
  // Schedule prep 1 hour before the kickoff call
  const prepStartTime = booking.start_time - 3600;
  const prepEndTime = booking.start_time - 1800;

  await nylas.events.create({
    identifier: process.env.NYLAS_GRANT_ID,
    queryParams: { calendarId: "primary" },
    requestBody: {
      title: `Prep: Kickoff call with ${booking.guest?.name || booking.guest?.email}`,
      description:
        "Review account setup status and engagement signals before the kickoff.",
      when: {
        startTime: prepStartTime,
        endTime: prepEndTime,
      },
      participants: [{ email: "onboarding@acme.com" }],
    },
  });
}
```

```python
def create_prep_reminder(booking):
    # Schedule prep 1 hour before the kickoff call
    prep_start_time = booking["start_time"] - 3600
    prep_end_time = booking["start_time"] - 1800

    nylas.events.create(
        identifier=os.environ["NYLAS_GRANT_ID"],
        query_params={"calendar_id": "primary"},
        request_body={
            "title": f"Prep: Kickoff call with {booking['guest'].get('name', booking['guest']['email'])}",
            "description": "Review account setup status and engagement signals before the kickoff.",
            "when": {
                "start_time": prep_start_time,
                "end_time": prep_end_time,
            },
            "participants": [{"email": "onboarding@acme.com"}],
        },
    )
```

## Build the orchestration layer

The individual components above handle sending, scheduling, and tracking. The orchestration layer ties them together into a state machine that moves each customer through onboarding stages based on their behavior.

### Define onboarding states

Each customer progresses through a series of states. Transitions happen when webhook events arrive or when timed checks run.

```js [orchestration-Node.js]
// Onboarding states and their allowed transitions
const ONBOARDING_STATES = {
  new: {
    description: "Customer record created, no emails sent yet",
    nextStates: ["welcome_sent"],
  },
  welcome_sent: {
    description: "Welcome email delivered",
    nextStates: ["engaged", "unresponsive"],
  },
  engaged: {
    description: "Customer opened email or clicked a link",
    nextStates: ["kickoff_booked", "followup_needed"],
  },
  kickoff_booked: {
    description: "Customer booked their kickoff call",
    nextStates: ["kickoff_completed"],
  },
  followup_needed: {
    description: "Customer engaged but did not book a kickoff",
    nextStates: ["kickoff_booked", "unresponsive"],
  },
  unresponsive: {
    description: "No engagement after follow-up period",
    nextStates: ["engaged", "escalated"],
  },
  kickoff_completed: {
    description: "Kickoff call happened",
    nextStates: ["onboarded"],
  },
  onboarded: {
    description: "Customer completed onboarding",
    nextStates: [],
  },
};

// State transition handler
async function updateOnboardingState(customerEmail, event, metadata = {}) {
  const customer = await getCustomerRecord(customerEmail);
  if (!customer) return;

  const currentState = customer.onboardingState;

  switch (event) {
    case "email_opened":
    case "link_clicked":
      if (currentState === "welcome_sent" || currentState === "unresponsive") {
        await transitionTo(customer, "engaged", metadata);
      }
      break;

    case "kickoff_booked":
      await transitionTo(customer, "kickoff_booked", metadata);
      // Cancel any pending follow-up emails
      await cancelPendingFollowUps(customerEmail);
      break;

    case "followup_timer_expired":
      if (currentState === "welcome_sent") {
        await transitionTo(customer, "unresponsive", metadata);
        // Send the follow-up email
        await sendFollowUpEmail(customer);
      }
      break;

    case "kickoff_completed":
      await transitionTo(customer, "kickoff_completed", metadata);
      // Send post-kickoff resources email
      await sendPostKickoffEmail(customer);
      break;
  }
}

async function transitionTo(customer, newState, metadata) {
  console.log(`[${customer.email}] ${customer.onboardingState} -> ${newState}`);

  await saveCustomerRecord(customer.email, {
    onboardingState: newState,
    lastTransition: new Date().toISOString(),
    ...metadata,
  });
}
```

### Start the onboarding flow

When a new customer signs up, kick off the full sequence:

```js [startOnboarding-Node.js]
async function startOnboarding(customer) {
  // 1. Create the customer record
  await saveCustomerRecord(customer.email, {
    name: customer.name,
    email: customer.email,
    onboardingState: "new",
    createdAt: new Date().toISOString(),
  });

  // 2. Create a per-customer scheduling page
  const scheduler = await createKickoffScheduler();
  const schedulingUrl = `https://book.nylas.com/${scheduler.slug}?name=${encodeURIComponent(customer.name)}&email__readonly=${encodeURIComponent(customer.email)}`;

  // 3. Send the welcome email
  customer.schedulingUrl = schedulingUrl;
  await sendWelcomeEmail(customer);

  // 4. Update state
  await transitionTo(
    { email: customer.email, onboardingState: "new" },
    "welcome_sent",
    { welcomeSentAt: new Date().toISOString() },
  );

  // 5. Schedule a follow-up check after 48 hours
  await scheduleFollowUpCheck(customer.email, 48);
}
```

```python
def start_onboarding(customer):
    # 1. Create the customer record
    save_customer_record(customer["email"], {
        "name": customer["name"],
        "email": customer["email"],
        "onboarding_state": "new",
        "created_at": time.time(),
    })

    # 2. Create a per-customer scheduling page
    scheduler = create_kickoff_scheduler()
    scheduling_url = (
        f"https://book.nylas.com/{scheduler.slug}"
        f"?name={customer['name']}&email__readonly={customer['email']}"
    )

    # 3. Send the welcome email
    customer["scheduling_url"] = scheduling_url
    send_welcome_email(customer)

    # 4. Update state
    transition_to(customer["email"], "welcome_sent")

    # 5. Schedule a follow-up check after 48 hours
    schedule_followup_check(customer["email"], delay_hours=48)
```

> **Info:** 
> **The orchestration layer needs persistent storage.** Store customer onboarding state in a database (PostgreSQL, MongoDB, Redis) rather than in memory. The webhook handler, the follow-up scheduler, and the state machine all need access to the same customer records across restarts.

## Things to know

A few practical details that affect how well this pipeline works in production:

- **Tracking pixels are not reliable for open detection.** Apple Mail Privacy Protection, Outlook's optional privacy settings, and some corporate email gateways preload tracking pixels automatically. This means open events may fire for customers who never actually read your email. Treat opens as a soft signal and rely on link clicks or replies for stronger engagement evidence.

- **Some email clients block external images by default.** Gmail, Outlook, and Thunderbird can be configured to block remote images until the recipient allows them. Since Nylas tracking uses a pixel image, these customers will not generate open events even if they read the email. Do not assume silence means disengagement.

- **Scheduler timezone handling matters.** Nylas Scheduler shows availability in the guest's local timezone by default, which is usually what you want. But if your onboarding team is in a specific timezone and you want to restrict booking hours, set the timezone explicitly in your availability configuration. Customers in very different timezones may see limited or no availability if your window is too narrow.

- **Rate limits apply to bulk onboarding.** If you onboard many customers at once (for example, after a launch or batch import), you will hit Nylas API rate limits. The [Send Message endpoint](/docs/reference/api/messages/send-message/) is subject to both Nylas rate limits and provider sending limits. Google limits most accounts to 500 messages per day, and Microsoft has similar thresholds. Stagger your sends or use a dedicated sending service for high-volume sequences.

- **Webhook deduplication is your responsibility.** Nylas guarantees at-least-once delivery, so you may receive the same `message.opened` or `booking.created` notification more than once. Track processed webhook IDs and skip duplicates to avoid sending double confirmation emails or triggering duplicate state transitions.

- **Thread replies tracking counts all replies.** The `thread_replies` tracking option fires for every reply in the thread, including your own. If your onboarding team replies to a customer's response, that reply triggers another webhook. Filter by the sender's email address to avoid counting your own team's messages as customer engagement.

- **Scheduling page links should be unique per customer.** A shared scheduling slug works, but per-customer slugs or pre-filled query parameters give you cleaner attribution. When a booking comes in through a shared slug, you rely entirely on the guest's form input to identify who booked.

## Next steps

- [Automate sales pipeline](/docs/cookbook/use-cases/automate/automate-sales-pipeline/) -- the same webhook + CRM pattern for sales activity
- [Scheduling with notetaking](/docs/cookbook/use-cases/build/scheduling-with-notetaking/) -- attach Notetaker to every kickoff call
- [Automate meeting follow-ups](/docs/cookbook/use-cases/act/automate-meeting-follow-ups/) -- close the loop with a recap after each kickoff
- [How to list Microsoft email messages](/docs/cookbook/email/messages/list-messages-microsoft/) -- if you need to read inbound replies during onboarding
- [Message tracking](/docs/v3/email/message-tracking/) -- open, click, and reply tracking configuration
- [Hosted Scheduling Pages](/docs/v3/scheduler/hosted-scheduling-pages/) -- customization, pre-filling, and styling options