Sales reps spend hours logging activity in their CRM. Emails go untracked, meetings disappear from records, and contact details fall out of date. This tutorial builds an automated pipeline that uses Nylas to monitor email threads with prospects, log calendar meetings, and sync contact details, keeping your CRM current without manual data entry.
The pipeline works across Google, Microsoft, and IMAP providers. Because Nylas normalizes the data, your integration code stays the same regardless of which email or calendar provider your sales team uses.
What you’ll build
Section titled “What you’ll build”This tutorial creates a multi-signal automation pipeline with three components that feed activity into your CRM in real time:
- Email tracking — Subscribe to
message.createdwebhooks to detect prospect replies, extract thread context, and log email activity as it happens. - Meeting logging — Subscribe to
event.createdandevent.updatedwebhooks to capture scheduled meetings, pull participant lists, and record meeting details automatically. - Contact sync — Periodically fetch contact records from the Nylas Contacts API and push updated phone numbers, job titles, and company names into your CRM.
Each component works independently. You can deploy all three together or start with one and add the others later.
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:
- Connected grants for each sales rep whose email and calendar you want to monitor
- A CRM with an API (Salesforce, HubSpot, Pipedrive, or any system that accepts HTTP requests)
- A publicly accessible HTTPS endpoint to receive webhook notifications from Nylas
Set up webhooks for email and calendar
Section titled “Set up webhooks for email and calendar”Start by creating a webhook subscription that listens for new messages and calendar events. This single subscription covers all grants in your Nylas application, so every connected sales rep is automatically included.
curl --request POST \ --url 'https://api.us.nylas.com/v3/webhooks/' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --data-raw '{ "trigger_types": [ "message.created", "event.created", "event.updated" ], "description": "Sales pipeline automation", "webhook_url": "https://your-app.example.com/webhooks/nylas", "notification_email_addresses": [ ] }'Nylas sends a GET request to your endpoint with a challenge query parameter to verify it. Your server must return the exact challenge value in a 200 response.
// Express.js challenge handlerapp.get("/webhooks/nylas", (req, res) => { const challenge = req.query.challenge; res.status(200).send(challenge);});Once verified, Nylas begins delivering webhook notifications as POST requests to your endpoint.
Webhook notifications are delivered at least once. Nylas guarantees delivery but may send duplicates, especially when providers like Google or Microsoft send upsert-style updates. Your handler should be idempotent. See Filter noise from the pipeline for de-duplication strategies.
Track email activity with prospects
Section titled “Track email activity with prospects”When a new email arrives for any connected sales rep, Nylas sends a message.created webhook. Your handler receives the full message object, including sender, recipients, subject, snippet, and thread ID. The goal is to check whether the message involves a known prospect and, if so, log it to your CRM.
Here is the structure of a message.created notification payload:
{ "specversion": "1.0", "type": "message.created", "source": "/google/emails/realtime", "id": "webhook-notification-id", "time": 1723821985, "webhook_delivery_attempt": 1, "data": { "application_id": "<NYLAS_APPLICATION_ID>", "object": { "id": "<MESSAGE_ID>", "grant_id": "<NYLAS_GRANT_ID>", "thread_id": "<THREAD_ID>", "subject": "Re: Q3 proposal review", "snippet": "Thanks for sending the proposal. I had a few questions...", "date": 1723821981, "folders": ["INBOX"], "unread": true } }}Build the email webhook handler
Section titled “Build the email webhook handler”The handler below receives the webhook, extracts the sender and recipients, checks them against your CRM’s prospect list, and logs a new activity if there is a match.
const express = require("express");const crypto = require("crypto");
const app = express();app.use(express.json());
// Verify the webhook signature to confirm it came from Nylasfunction 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;}
// Check if an email address belongs to a known prospect in your CRMasync function findProspectInCrm(email) { // Replace with your CRM's API call const response = await fetch( `https://your-crm.example.com/api/contacts?email=${encodeURIComponent(email)}` ); if (!response.ok) return null; const data = await response.json(); return data.contacts?.[0] || null;}
// Log an email activity to your CRMasync function logEmailActivity(prospectId, messageData) { await fetch("https://your-crm.example.com/api/activities", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "email", prospect_id: prospectId, subject: messageData.subject, snippet: messageData.snippet, thread_id: messageData.thread_id, direction: messageData.folders.includes("SENT") ? "outbound" : "inbound", timestamp: new Date(messageData.date * 1000).toISOString(), }), });}
app.post("/webhooks/nylas", async (req, res) => { // Always respond quickly to avoid Nylas retries 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.created") { const message = data.object;
// Collect all email addresses from the message const emailAddresses = [ ...message.from.map((f) => f.email), ...message.to.map((t) => t.email), ...(message.cc || []).map((c) => c.email), ];
// Check each address against your CRM for (const email of emailAddresses) { const prospect = await findProspectInCrm(email); if (prospect) { await logEmailActivity(prospect.id, message); break; // Log once per message, not once per matching address } } }});The thread_id field is especially useful here. It groups related messages into a single conversation, so your CRM can display the full email thread on a deal record instead of showing isolated messages.
Use the folders field to determine direction. If the message is in the SENT folder, the sales rep sent it. If it is in INBOX, the prospect replied. This gives you inbound vs. outbound tracking without comparing email addresses to your team roster.
Log calendar meetings automatically
Section titled “Log calendar meetings automatically”Meeting activity is one of the strongest signals in a sales pipeline. When a rep schedules a call with a prospect, you want that logged immediately. The event.created webhook fires whenever a new calendar event appears for a connected grant.
Here is the structure of an event.created notification:
{ "specversion": "1.0", "type": "event.created", "source": "/google/events/realtime", "id": "webhook-notification-id", "time": 1695415185, "webhook_delivery_attempt": 1, "data": { "application_id": "<NYLAS_APPLICATION_ID>", "object": { "id": "<EVENT_ID>", "grant_id": "<NYLAS_GRANT_ID>", "calendar_id": "<CALENDAR_ID>", "title": "Q3 Deal Review - Acme Corp", "status": "confirmed", "participants": [ ], "when": { "start_time": 1680796800, "end_time": 1680800100, "start_timezone": "America/Los_Angeles", "end_timezone": "America/Los_Angeles" }, "conferencing": { "provider": "Zoom Meeting", "details": { "url": "https://zoom.us/j/123456789" } }, "location": "Zoom", "description": "Review Q3 proposal and discuss next steps." } }}Build the meeting webhook handler
Section titled “Build the meeting webhook handler”This handler checks whether any event participant is a known prospect. If so, it creates a meeting activity in your CRM with the title, time, duration, and conferencing link.
// Add this to the same Express app from the email handler
async function logMeetingActivity(prospectId, eventData) { const startTime = new Date(eventData.when.start_time * 1000); const endTime = new Date(eventData.when.end_time * 1000); const durationMinutes = Math.round((endTime - startTime) / 60000);
await fetch("https://your-crm.example.com/api/activities", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "meeting", prospect_id: prospectId, title: eventData.title, scheduled_at: startTime.toISOString(), duration_minutes: durationMinutes, conferencing_url: eventData.conferencing?.details?.url || null, participants: eventData.participants.map((p) => p.email), status: eventData.status, }), });}
// Inside the existing POST handler, add event handlingapp.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;
if (type === "message.created") { // ... email handler from previous section }
if (type === "event.created" || type === "event.updated") { const event = data.object;
// Skip events without participants (personal blocks, reminders) if (!event.participants?.length) return;
for (const participant of event.participants) { const prospect = await findProspectInCrm(participant.email); if (prospect) { await logMeetingActivity(prospect.id, event); break; } } }});Handle both event.created and event.updated. Meetings get rescheduled constantly. By listening to event.updated, you catch time changes, added participants, and cancellations. Use the event id as a unique key in your CRM so updates overwrite the original record instead of creating duplicates.
Sync contact details
Section titled “Sync contact details”Webhooks handle real-time email and calendar signals. Contact data, on the other hand, works better with periodic sync. Run a scheduled job (every few hours or once a day) that pulls contacts from each grant and updates your CRM with fresh phone numbers, job titles, and company names.
Fetch contacts from the Nylas Contacts API:
curl --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/contacts?limit=50' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'The response includes structured contact fields you can map directly to your CRM:
{ "request_id": "1", "data": [ { "id": "<CONTACT_ID>", "grant_id": "<NYLAS_GRANT_ID>", "given_name": "Dana", "surname": "Chen", "company_name": "Acme Corp", "job_title": "VP of Engineering", "emails": [ ], "phone_numbers": [ { "type": "work", "number": "+1-555-867-5309" } ] } ], "next_cursor": "eyJhbGciOi..."}Build the contact sync job
Section titled “Build the contact sync job”This script iterates through all grants, paginates through their contacts, and upserts matching records in your CRM.
const NYLAS_API_KEY = process.env.NYLAS_API_KEY;const NYLAS_BASE_URL = "https://api.us.nylas.com/v3";
// Fetch all contacts for a single grant, handling paginationasync function fetchAllContacts(grantId) { const contacts = []; let cursor = null;
do { const url = new URL(`${NYLAS_BASE_URL}/grants/${grantId}/contacts`); url.searchParams.set("limit", "50"); if (cursor) url.searchParams.set("page_token", cursor);
const response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${NYLAS_API_KEY}` }, });
const result = await response.json(); contacts.push(...result.data); cursor = result.next_cursor || null; } while (cursor);
return contacts;}
// Match a Nylas contact to a CRM record and update itasync function upsertContactInCrm(contact) { const workEmail = contact.emails?.find((e) => e.type === "work")?.email; if (!workEmail) return;
const prospect = await findProspectInCrm(workEmail); if (!prospect) return;
// Only update fields that have values in the Nylas contact const updates = {}; if (contact.phone_numbers?.length) { updates.phone = contact.phone_numbers[0].number; } if (contact.job_title) { updates.job_title = contact.job_title; } if (contact.company_name) { updates.company = contact.company_name; }
if (Object.keys(updates).length === 0) return;
await fetch(`https://your-crm.example.com/api/contacts/${prospect.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), });}
// Run the sync for all grantsasync function syncContacts(grantIds) { for (const grantId of grantIds) { const contacts = await fetchAllContacts(grantId); console.log(`Fetched ${contacts.length} contacts for grant ${grantId}`);
for (const contact of contacts) { await upsertContactInCrm(contact); } }}
// Schedule this to run periodically (e.g., every 6 hours)syncContacts(["grant-id-1", "grant-id-2", "grant-id-3"]);Respect rate limits. The Nylas API enforces per-application rate limits. If you are syncing contacts for hundreds of grants, add a short delay between requests or process grants in parallel with a concurrency limit. Check the rate limits documentation for current thresholds.
Filter noise from the pipeline
Section titled “Filter noise from the pipeline”A raw webhook feed is noisy. Not every email or calendar event is a sales interaction. Here are four filtering strategies you should implement before pushing data to your CRM.
Skip internal emails
Section titled “Skip internal emails”Emails between colleagues are not prospect activity. Compare the sender and recipient domains to your company’s domain and skip the message if they match.
function isInternalEmail(message, companyDomains) { const allAddresses = [ ...message.from.map((f) => f.email), ...message.to.map((t) => t.email), ];
const domains = allAddresses.map((email) => email.split("@")[1]); const uniqueDomains = [...new Set(domains)];
// If every address is on a company domain, it is internal return uniqueDomains.every((domain) => companyDomains.includes(domain));}
// Usageconst companyDomains = ["your-company.com", "your-subsidiary.com"];if (isInternalEmail(message, companyDomains)) return;Ignore automated and marketing emails
Section titled “Ignore automated and marketing emails”Automated emails clutter the pipeline. Look for the List-Unsubscribe header, bulk sender patterns, and common no-reply addresses.
function isAutomatedEmail(message) { const senderEmail = message.from?.[0]?.email || "";
// Common no-reply patterns const noReplyPatterns = [ /^no-?reply@/i, /^notifications?@/i, /^mailer-daemon@/i, /^postmaster@/i, /^bounce@/i, ];
if (noReplyPatterns.some((pattern) => pattern.test(senderEmail))) { return true; }
// Check for marketing/bulk sender indicators in headers // You can request specific headers via webhook field customization const subject = message.subject || ""; if (subject.match(/unsubscribe/i)) return true;
return false;}De-duplicate webhook deliveries
Section titled “De-duplicate webhook deliveries”Nylas guarantees at-least-once delivery, which means you may receive the same notification more than once. Track processed webhook IDs to avoid duplicate CRM entries.
// Use a cache (Redis, in-memory Set, or database) to track processed IDsconst processedWebhooks = new Set();
function isDuplicate(webhookId) { if (processedWebhooks.has(webhookId)) return true; processedWebhooks.add(webhookId);
// Clean up old entries periodically to prevent memory growth if (processedWebhooks.size > 10000) { const entries = [...processedWebhooks]; entries.slice(0, 5000).forEach((id) => processedWebhooks.delete(id)); }
return false;}
// In your webhook handlerapp.post("/webhooks/nylas", async (req, res) => { res.status(200).send("OK");
if (isDuplicate(req.body.id)) return; // ... rest of handler});For production systems, use Redis or a database table with a TTL instead of an in-memory Set. The in-memory approach works for development but does not survive restarts.
Handle out-of-office auto-replies
Section titled “Handle out-of-office auto-replies”Out-of-office messages trigger message.created webhooks just like real replies. Detect them by checking for common OOO patterns in the subject line or body snippet.
function isOutOfOffice(message) { const subject = (message.subject || "").toLowerCase(); const snippet = (message.snippet || "").toLowerCase();
const oooPatterns = [ "out of office", "out of the office", "automatic reply", "auto-reply", "autoreply", "i am currently out", "i'm currently out", "on vacation", "on leave", ];
return oooPatterns.some( (pattern) => subject.includes(pattern) || snippet.includes(pattern) );}Architecture overview
Section titled “Architecture overview”The three components fit together around a central webhook handler that acts as the routing layer between Nylas and your CRM.
Webhook-driven path (real-time): Nylas monitors every connected grant for new messages and calendar events. When a trigger fires, Nylas sends a POST request to your webhook endpoint. The handler validates the signature, checks the notification type, and routes it to the appropriate processor. The email processor matches sender/recipient addresses against your CRM’s contact and deal records. The meeting processor does the same with event participants. Both push activity records into your CRM through its API.
Scheduled sync path (periodic): A cron job or scheduled task runs on a fixed interval. It iterates through each grant, fetches contacts from the Nylas Contacts API, and compares them against existing CRM records. When it finds updated phone numbers, job titles, or company names, it patches the CRM record.
Shared CRM lookup layer: Both paths share the same function for matching email addresses to CRM prospects. This is the single point where you define what counts as a “known prospect.” You could match on email domain, on a specific deal stage, or on a tag in your CRM. Keep this logic centralized so you can tune it without updating multiple handlers.
The result is a pipeline where every meaningful email, every meeting, and every contact change flows into your CRM automatically. Sales reps see a complete activity timeline on each deal record without logging anything manually.
What’s next
Section titled “What’s next”Now that your pipeline is tracking email, meetings, and contacts, consider extending it with these capabilities:
- Send email to automate outreach sequences when a deal reaches a specific stage
- Message tracking to track email opens and link clicks for engagement scoring
- Calendar availability to suggest meeting times and let prospects book directly
- Contacts API reference for advanced contact queries, filtering, and group management
- Webhook notifications for the complete list of available triggers and payload schemas