Onboarding a new customer usually involves a welcome email, a kickoff call, a handful of follow-ups, and someone keeping track of whether the customer actually engaged with any of it. In practice, steps get skipped, emails go out late, and kickoff calls slip through the cracks because nobody scheduled them.
This tutorial builds an automated onboarding pipeline that handles the full sequence. You send a personalized welcome email with open and click tracking, give the customer a self-service scheduling page for their kickoff call, and use webhook notifications to trigger follow-ups based on real engagement signals. The whole system runs across Google, Microsoft, and IMAP providers without any provider-specific code.
What you’ll build
Section titled “What you’ll build”The pipeline has three components that work together to move customers through onboarding:
- Welcome email sequence — Send a personalized welcome email with tracking enabled, then schedule timed follow-ups. Nylas tracks opens and link clicks so you know who engaged and who needs a nudge.
- Self-service kickoff scheduling — Create a Scheduler Configuration that lets customers book their own kickoff call. No back-and-forth emails, no calendar coordination.
- Engagement-driven follow-ups — Use webhook notifications to detect email opens, link clicks, and completed bookings. Your orchestration layer decides what happens next based on the customer’s actual behavior.
Each component works independently. Start with the welcome email, add scheduling later, or deploy all three at once.
Before you begin
Section titled “Before you begin”Make sure you have the following before starting this tutorial:
- A Nylas account 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)
New to Nylas? Start with the quickstart guide 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
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
Section titled “Send a welcome email sequence”The first touchpoint in onboarding is a welcome email. Use the Nylas Send Message endpoint with tracking_options enabled so you can monitor whether the customer opens the message and clicks any links.
Send the welcome email with tracking
Section titled “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.
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": "[email protected]" } ], "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": "[email protected]" } }'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;}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.dataSchedule follow-up emails
Section titled “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, you will see how to make this conditional on actual engagement.
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 emailscheduleFollowUp(customer, 48);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
Section titled “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
Section titled “Create a Scheduler Configuration”Use the Create Configuration endpoint to set up a booking page for kickoff calls.
curl --request POST \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/scheduling/configurations' \ --header 'Accept: application/json, application/gzip' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json' \ --data '{ "requires_session_auth": false, "participants": [{ "name": "Acme Onboarding Team", "email": "[email protected]", "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" }'async function createKickoffScheduler() { const response = await nylas.scheduling.configurations.create({ identifier: process.env.NYLAS_GRANT_ID, requestBody: { requiresSessionAuth: false, participants: [ { name: "Acme Onboarding Team", 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;}def create_kickoff_scheduler(): response = nylas.scheduling.configurations.create( identifier=os.environ["NYLAS_GRANT_ID"], request_body={ "requires_session_auth": False, "participants": [ { "name": "Acme Onboarding Team", "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.dataThis 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
Section titled “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 protected]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 protected]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
Section titled “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
Section titled “Set up tracking webhooks”Create a webhook subscription for message.opened and message.link_clicked events:
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": [ ] }'Handle tracking webhooks
Section titled “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.
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 verificationapp.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: "[email protected]" 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(), }); } }});import hashlibimport hmacimport jsonfrom 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", 200Apple 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
Section titled “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.
// 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
Section titled “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.
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": "[email protected]" } ] }'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, }, }, });}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, }, }, )Build the orchestration layer
Section titled “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
Section titled “Define onboarding states”Each customer progresses through a series of states. Transitions happen when webhook events arrive or when timed checks run.
// Onboarding states and their allowed transitionsconst 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 handlerasync 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
Section titled “Start the onboarding flow”When a new customer signs up, kick off the full sequence:
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);}import time
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)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
Section titled “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 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.openedorbooking.creatednotification 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_repliestracking 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.
What’s next
Section titled “What’s next”- Sending messages for the full Send Message API reference and delivery best practices
- Message tracking for details on open, click, and reply tracking configuration
- Using Nylas Scheduler for the complete Scheduler setup guide, including hosted and embedded options
- Hosted Scheduling Pages for customization options, pre-filling, and styling
- Webhook notifications for the full list of triggers and payload schemas
- Calendar events for creating and managing calendar events through the Nylas API