# Automate a sales pipeline

Source: https://developer.nylas.com/docs/cookbook/use-cases/automate/automate-sales-pipeline/

Sales reps spend hours logging activity in their CRM. Emails go untracked, meetings disappear from records, contact details rot. The reps know it's busywork. They skip it whenever they can, which is most of the time.

This recipe replaces the busywork with three webhook-and-cron pipelines that keep your CRM honest without anyone touching it. Email threads with prospects log automatically when `message.created` fires. Meetings log when `event.created` fires. Contact phone numbers, job titles, and companies refresh on a nightly sync. The same code works against Google, Microsoft, and IMAP -- Nylas normalizes the providers underneath.

## The pipeline

```
                ┌─────────────────────────────────────────────────────────┐
                │                                                         │
inbound email ──▶ message.created webhook ──▶ match sender to CRM ──▶ log activity
                                                         │
calendar event ──▶ event.created/updated webhook ────────┘
                                                         │
                                              shared CRM lookup function
                                                         │
                                                         ▼
nightly cron ──▶ list Nylas contacts ──▶ patch CRM with fresh phone/title/company
```

Three components, one shared CRM lookup function. Each component works independently -- start with email tracking and add the others later.

## 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:

- **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

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.

```bash
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": [
      "dev-team@your-company.com"
    ]
  }'
```

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.

```js [setupWebhooks-Challenge handler]
// Express.js challenge handler
app.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.

> **Info:** 
> **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](#filter-noise-from-the-pipeline) for de-duplication strategies.

## 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:

```json [emailTracking-Webhook 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",
      "from": [{ "email": "prospect@acme.com", "name": "Dana Chen" }],
      "to": [{ "email": "rep@your-company.com" }],
      "snippet": "Thanks for sending the proposal. I had a few questions...",
      "date": 1723821981,
      "folders": ["INBOX"],
      "unread": true
    }
  }
}
```

### 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.

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

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

// Verify the webhook signature to confirm it came from Nylas
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;
}

// Check if an email address belongs to a known prospect in your CRM
async 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 CRM
async 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.

> **Success:** 
> **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

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:

```json [meetingLogging-Webhook payload]
{
  "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": [
        { "email": "rep@your-company.com", "status": "yes" },
        { "email": "prospect@acme.com", "status": "noreply" }
      ],
      "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

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.

```js [meetingLogging-Node.js handler]
// 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 handling
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;

  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;
      }
    }
  }
});
```

> **Info:** 
> **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

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:

```bash
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:

```json
{
  "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": [{ "type": "work", "email": "dana.chen@acme.com" }],
      "phone_numbers": [{ "type": "work", "number": "+1-555-867-5309" }]
    }
  ],
  "next_cursor": "eyJhbGciOi..."
}
```

### Build the contact sync job

This script iterates through all grants, paginates through their contacts, and upserts matching records in your CRM.

```js [contactSync-Node.js sync job]
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 pagination
async 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 it
async 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 grants
async 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"]);
```

> **Warn:** 
> **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](/docs/dev-guide/best-practices/rate-limits/) for current thresholds.

## 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

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.

```js [filterNoise-Internal email filter]
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));
}

// Usage
const companyDomains = ["your-company.com", "your-subsidiary.com"];
if (isInternalEmail(message, companyDomains)) return;
```

### Ignore automated and marketing emails

Automated emails clutter the pipeline. Look for the `List-Unsubscribe` header, bulk sender patterns, and common no-reply addresses.

```js [filterNoise-Automated email filter]
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

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.

```js
// Use a cache (Redis, in-memory Set, or database) to track processed IDs
const 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 handler
app.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

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.

```js [filterNoise-OOO detection]
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

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.

## Things to know

- **Don't ship without dedup.** At-least-once delivery means the same `message.created` notification can land twice, especially if your handler was slow. Track processed webhook IDs in Redis or a database table with a TTL -- don't trust an in-memory `Set` past development.
- **Filter internal email aggressively.** A pipeline that logs every "lunch?" thread between colleagues becomes useless fast. Compare sender + recipient domains against your company's domains and skip when every address is internal.
- **Use the `folders` field for direction.** `SENT` means the rep wrote it; `INBOX` means the prospect replied. Don't try to compare addresses against a roster -- that breaks when reps change roles.
- **Out-of-office replies fire `message.created` like any other message.** Detect them by subject patterns (`out of office`, `automatic reply`, `auto-reply`) and skip. Logging an OOO as prospect engagement is worse than missing a real reply.
- **Meeting updates create duplicates if you don't key on event ID.** Meetings get rescheduled constantly. Use the event `id` as the unique key in your CRM so updates overwrite the original record instead of creating a new row.
- **Per-application rate limits matter at scale.** If you're syncing contacts for hundreds of grants on a nightly cron, sequence the requests with a concurrency cap. Burst sync jobs are how you trip your own rate limit.

## Next steps

- [Sync email contacts to a CRM](/docs/cookbook/use-cases/sync/sync-email-crm/) -- pull new senders out of email and push them into the CRM
- [Sync calendar events to a CRM](/docs/cookbook/use-cases/sync/sync-calendar-events-crm/) -- the calendar-only counterpart
- [Export to Salesforce](/docs/cookbook/use-cases/sync/export-to-salesforce/), [HubSpot](/docs/cookbook/use-cases/sync/export-to-hubspot/), or [Pipedrive](/docs/cookbook/use-cases/sync/export-to-pipedrive/) -- CRM-specific mappings
- [Automate customer onboarding](/docs/cookbook/use-cases/automate/automate-customer-onboarding/) -- the post-close counterpart to a sales pipeline
- [Map communication patterns between organizations](/docs/cookbook/agents/communication-patterns/) -- analyze who talks to whom across companies
- [Message tracking](/docs/v3/email/message-tracking/) -- opens and click tracking for engagement scoring