# How to sync calendar events to a CRM

Source: https://developer.nylas.com/docs/cookbook/use-cases/sync/sync-calendar-events-crm/

Sales teams, recruiters, and account managers live in their calendars. Every meeting with a prospect, candidate, or client is a data point your CRM should capture. But CRM records fall behind because nobody manually logs every meeting, rescheduled call, or cancelled demo. The data drifts, pipeline reports get stale, and managers lose visibility into what's actually happening.

This tutorial builds a sync pipeline that automatically mirrors calendar events to your CRM whenever something changes. You subscribe to Nylas webhooks for event changes, receive real-time notifications, fetch the full event details, and push structured records to your CRM's API. One integration handles Google Calendar, Outlook, and Exchange without any provider-specific code.

## What you'll build

The pipeline follows a straightforward webhook-driven architecture:

1. **Subscribe** to `event.created`, `event.updated`, and `event.deleted` webhook triggers on your Nylas application.
2. **Receive** real-time POST notifications from Nylas whenever a calendar event changes on any connected grant.
3. **Fetch** the full event details from the Nylas Events API (for created and updated events).
4. **Map** Nylas event fields to your CRM's record schema.
5. **Push** the transformed record to your CRM's API to create, update, or soft-delete the corresponding entry.

This approach works with any CRM that has a REST API: Salesforce, HubSpot, Pipedrive, or a custom internal system. The examples in this tutorial use generic REST endpoints so you can adapt them to your specific CRM.

## 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 **calendar read access** for at least one user
- A CRM (or any external system) with a REST API you can push records to
- A publicly accessible HTTPS endpoint to receive webhook notifications

> **Warn:** 
> **Nylas blocks requests to ngrok URLs.** Use [VS Code port forwarding](https://code.visualstudio.com/docs/editor/port-forwarding) or [Hookdeck](https://hookdeck.com/) to expose your local server during development.

## Subscribe to calendar event webhooks

Start by creating a webhook subscription that listens for all three event lifecycle triggers. This single subscription covers every grant in your Nylas application, so you don't need to set up per-user webhooks.

```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": ["event.created", "event.updated", "event.deleted"],
    "description": "CRM calendar sync",
    "webhook_url": "<YOUR_WEBHOOK_URL>",
    "notification_email_addresses": ["you@example.com"]
  }'
```

Nylas responds with the webhook ID and a `webhook_secret` you'll use to verify incoming notifications. Store that secret securely.

```json
{
  "request_id": "abc-123",
  "data": {
    "id": "wh_abc123",
    "description": "CRM calendar sync",
    "trigger_types": ["event.created", "event.updated", "event.deleted"],
    "webhook_url": "https://your-app.example.com/webhooks/nylas",
    "webhook_secret": "your-webhook-secret",
    "status": "active",
    "notification_email_addresses": ["you@example.com"],
    "created_at": 1700000000,
    "updated_at": 1700000000
  }
}
```

After you create the webhook, Nylas sends a verification GET request with a `challenge` query parameter to your endpoint. Your server must return the exact challenge value in a `200 OK` response. See [Using webhooks with Nylas](/docs/v3/notifications/) for the full verification flow.

> **Warn:** 
> **Your endpoint must respond within 10 seconds.** If verification fails, Nylas marks the webhook as inactive. Make sure your server is running and accessible before creating the webhook.

## Handle event notifications

When a calendar event changes on any connected grant, Nylas sends a POST request to your webhook URL. Each notification includes the trigger type and the event data. Here's a Node.js Express handler that routes each event type to the appropriate CRM operation:

```js [handleWebhook-Node.js]


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

const WEBHOOK_SECRET = process.env.NYLAS_WEBHOOK_SECRET;

// Verify the webhook signature
function verifyWebhookSignature(req) {
  const signature = req.headers["x-nylas-signature"];
  const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET);
  hmac.update(req.body);
  const digest = hmac.digest("hex");
  return signature === digest;
}

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

// Process incoming webhook notifications
app.post("/webhooks/nylas", async (req, res) => {
  // Always respond 200 quickly to avoid retries
  res.status(200).send("ok");

  const notification = req.body;
  const eventData = notification.data.object;
  const grantId = eventData.grant_id;
  const eventId = eventData.id;

  switch (notification.type) {
    case "event.created":
      await handleEventCreated(grantId, eventId, eventData);
      break;
    case "event.updated":
      await handleEventUpdated(grantId, eventId, eventData);
      break;
    case "event.deleted":
      await handleEventDeleted(grantId, eventId);
      break;
  }
});

async function handleEventCreated(grantId, eventId, eventData) {
  const crmRecord = mapEventToCrmRecord(eventData);
  await createCrmMeeting(crmRecord);
  console.log(`Created CRM record for event ${eventId}`);
}

async function handleEventUpdated(grantId, eventId, eventData) {
  const crmRecord = mapEventToCrmRecord(eventData);
  await updateCrmMeeting(eventId, crmRecord);
  console.log(`Updated CRM record for event ${eventId}`);
}

async function handleEventDeleted(grantId, eventId) {
  await deleteCrmMeeting(eventId);
  console.log(`Deleted CRM record for event ${eventId}`);
}

app.listen(3000, () => console.log("Webhook server running on port 3000"));
```

```python
from flask import Flask, request, jsonify

app = Flask(__name__)

WEBHOOK_SECRET = os.environ["NYLAS_WEBHOOK_SECRET"]

def verify_webhook_signature(request_data, signature):
    digest = hmac.new(
        WEBHOOK_SECRET.encode(),
        request_data,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(digest, signature)

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

@app.route("/webhooks/nylas", methods=["POST"])
def handle_webhook():
    notification = request.get_json()
    event_data = notification["data"]["object"]
    grant_id = event_data["grant_id"]
    event_id = event_data["id"]

    if notification["type"] == "event.created":
        crm_record = map_event_to_crm_record(event_data)
        create_crm_meeting(crm_record)

    elif notification["type"] == "event.updated":
        crm_record = map_event_to_crm_record(event_data)
        update_crm_meeting(event_id, crm_record)

    elif notification["type"] == "event.deleted":
        delete_crm_meeting(event_id)

    return "ok", 200
```

> **Info:** 
> **Respond with 200 immediately.** Do your processing after sending the response, or push the notification onto a queue. If your endpoint takes too long, Nylas retries the delivery and you end up with duplicates.

### Webhook payload structure

Each notification follows the [CloudEvents](https://cloudevents.io/) spec. Here's what an `event.created` payload looks like:

```json [payloadStructure-event.created]
{
  "specversion": "1.0",
  "type": "event.created",
  "source": "/google/events/realtime",
  "id": "<WEBHOOK_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": "One-on-one",
      "description": "Weekly sync with the team lead.",
      "location": "Room 103",
      "when": {
        "start_time": 1680796800,
        "end_time": 1680800100,
        "start_timezone": "America/Los_Angeles",
        "end_timezone": "America/Los_Angeles"
      },
      "participants": [{ "email": "nyla@example.com", "status": "yes" }],
      "conferencing": {
        "provider": "Google Meet",
        "details": { "url": "https://meet.google.com/abc-defg-hij" }
      },
      "status": "confirmed",
      "busy": true,
      "object": "event"
    }
  }
}
```

For `event.deleted`, the payload is minimal. You only get the event ID, grant ID, calendar ID, and `master_event_id` (if it was part of a recurring series). The full event details are gone, which is why your sync logic needs to store the Nylas event ID as a foreign key in your CRM.

## Fetch full event details

The webhook payload for `event.created` and `event.updated` includes the full event object by default. But if you've configured [field customizations](/docs/v3/notifications/#specify-fields-for-webhook-notifications) in your Nylas Dashboard, the payload may only include a subset of fields. In that case, fetch the complete event using the Events API:

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/events/<EVENT_ID>?calendar_id=<CALENDAR_ID>' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

```js [fetchEvent-Node.js]


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

async function fetchEventDetails(grantId, eventId, calendarId) {
  const event = await nylas.events.find({
    identifier: grantId,
    eventId: eventId,
    queryParams: {
      calendarId: calendarId,
    },
  });

  return event.data;
}
```

```python
from nylas import Client

nylas = Client(
    api_key="NYLAS_API_KEY",
    api_uri="https://api.us.nylas.com",
)

def fetch_event_details(grant_id, event_id, calendar_id):
    event, _ = nylas.events.find(
        identifier=grant_id,
        event_id=event_id,
        query_params={"calendar_id": calendar_id},
    )

    return event
```

The key fields you'll want for CRM sync:

- **`title`** - The event subject line
- **`when.start_time` / `when.end_time`** - Unix timestamps for the meeting window
- **`participants`** - Array of attendee objects with `email`, `name`, and `status`
- **`location`** - Physical meeting location, if set
- **`conferencing.details.url`** - Video call link (Zoom, Google Meet, Teams)
- **`description`** - Meeting notes or agenda text
- **`organizer`** - Who created the event
- **`status`** - `confirmed`, `tentative`, or `cancelled`

## Map events to CRM records

Transform the Nylas event object into whatever schema your CRM expects. Here's a reference mapping:

| Nylas event field          | CRM field           | Notes                                                   |
| -------------------------- | ------------------- | ------------------------------------------------------- |
| `title`                    | `meeting_subject`   | Use as the CRM record title                             |
| `when.start_time`          | `meeting_date`      | Convert from Unix timestamp to your CRM's date format   |
| `when.end_time`            | `meeting_end_date`  | Calculate duration if your CRM stores it that way       |
| `participants`             | `attendees`         | Array of `{ email, name }` objects                      |
| `location`                 | `meeting_location`  | Physical room or address                                |
| `conferencing.details.url` | `meeting_link`      | Video call URL (Zoom, Meet, Teams)                      |
| `description`              | `notes`             | May contain HTML from some providers                    |
| `organizer.email`          | `organizer_email`   | The person who created the event                        |
| `id`                       | `external_event_id` | Store this as your dedup key                            |
| `status`                   | `meeting_status`    | Map `confirmed`/`tentative`/`cancelled` to CRM statuses |

Here's a mapping function you can adapt:

```js [mapEvent-Node.js]
function mapEventToCrmRecord(eventData) {
  const startTime = eventData.when?.start_time;
  const endTime = eventData.when?.end_time;

  return {
    external_event_id: eventData.id,
    meeting_subject: eventData.title || "Untitled meeting",
    meeting_date: startTime ? new Date(startTime * 1000).toISOString() : null,
    meeting_end_date: endTime ? new Date(endTime * 1000).toISOString() : null,
    duration_minutes:
      startTime && endTime ? Math.round((endTime - startTime) / 60) : null,
    meeting_location: eventData.location || null,
    meeting_link: eventData.conferencing?.details?.url || null,
    notes: eventData.description || "",
    organizer_email: eventData.organizer?.email || null,
    attendees: (eventData.participants || []).map((p) => ({
      email: p.email,
      name: p.name || null,
      rsvp_status: p.status,
    })),
    meeting_status: eventData.status || "confirmed",
    provider_calendar_id: eventData.calendar_id,
    grant_id: eventData.grant_id,
  };
}
```

```python
from datetime import datetime, timezone

def map_event_to_crm_record(event_data):
    start_time = event_data.get("when", {}).get("start_time")
    end_time = event_data.get("when", {}).get("end_time")

    return {
        "external_event_id": event_data["id"],
        "meeting_subject": event_data.get("title", "Untitled meeting"),
        "meeting_date": (
            datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat()
            if start_time else None
        ),
        "meeting_end_date": (
            datetime.fromtimestamp(end_time, tz=timezone.utc).isoformat()
            if end_time else None
        ),
        "duration_minutes": (
            round((end_time - start_time) / 60)
            if start_time and end_time else None
        ),
        "meeting_location": event_data.get("location"),
        "meeting_link": (
            event_data.get("conferencing", {})
            .get("details", {})
            .get("url")
        ),
        "notes": event_data.get("description", ""),
        "organizer_email": (
            event_data.get("organizer", {}).get("email")
        ),
        "attendees": [
            {
                "email": p["email"],
                "name": p.get("name"),
                "rsvp_status": p.get("status"),
            }
            for p in event_data.get("participants", [])
        ],
        "meeting_status": event_data.get("status", "confirmed"),
        "provider_calendar_id": event_data.get("calendar_id"),
        "grant_id": event_data.get("grant_id"),
    }
```

Then push the record to your CRM:

```js [pushToCrm-Node.js]
async function createCrmMeeting(crmRecord) {
  const response = await fetch("https://your-crm.example.com/api/meetings", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.CRM_API_KEY}`,
    },
    body: JSON.stringify(crmRecord),
  });

  if (!response.ok) {
    console.error(`CRM create failed: ${response.status}`);
    throw new Error(`CRM API error: ${response.statusText}`);
  }

  return response.json();
}

async function updateCrmMeeting(eventId, crmRecord) {
  const response = await fetch(
    `https://your-crm.example.com/api/meetings?external_event_id=${eventId}`,
    {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.CRM_API_KEY}`,
      },
      body: JSON.stringify(crmRecord),
    },
  );

  if (!response.ok) {
    console.error(`CRM update failed: ${response.status}`);
  }
}

async function deleteCrmMeeting(eventId) {
  const response = await fetch(
    `https://your-crm.example.com/api/meetings?external_event_id=${eventId}`,
    {
      method: "DELETE",
      headers: {
        Authorization: `Bearer ${process.env.CRM_API_KEY}`,
      },
    },
  );

  if (!response.ok) {
    console.error(`CRM delete failed: ${response.status}`);
  }
}
```

> **Info:** 
> **Match attendees to CRM contacts.** Most CRMs let you associate meetings with contact or deal records. After creating the meeting record, loop through the attendees and link each email address to an existing CRM contact. This is where the real value shows up in pipeline reporting.

## Handle recurring events

Recurring events add a layer of complexity. Nylas fires separate webhook notifications for each occurrence of a recurring series, not just one notification for the parent event. Each occurrence has its own unique `id`, but they all share the same `master_event_id` that points back to the parent.

Your sync logic should:

- **Store `master_event_id`** alongside each CRM record so you can group occurrences in the UI
- **Treat each occurrence as its own CRM record**, since each one may have different participants, times, or statuses (for example, when someone reschedules a single occurrence)
- **Check for the `master_event_id` field** to distinguish standalone events from recurring occurrences

```js [recurringCheck-Node.js]
function isRecurringOccurrence(eventData) {
  return eventData.master_event_id != null;
}

function mapEventToCrmRecordWithRecurrence(eventData) {
  const baseRecord = mapEventToCrmRecord(eventData);

  return {
    ...baseRecord,
    is_recurring: isRecurringOccurrence(eventData),
    master_event_id: eventData.master_event_id || null,
    series_label: eventData.master_event_id
      ? `Series: ${eventData.title}`
      : null,
  };
}
```

> **Warn:** 
> **Do not assume recurring events stay consistent.** A user might change the time, add a participant, or cancel a single occurrence. Each modification triggers an `event.updated` webhook for that specific occurrence. Your CRM records should reflect these per-occurrence changes.

## Handle edge cases

A production-quality sync pipeline needs to account for several things that don't show up in the happy path.

### Idempotency

Use the Nylas event `id` as your deduplication key. Before creating a CRM record, check whether one already exists with that `external_event_id`. If it does, update instead of creating a duplicate. This single check prevents the most common sync bug.

### At-least-once delivery

Nylas guarantees at-least-once delivery, which means you may receive the same notification more than once. Your handler should be idempotent by design. If you receive an `event.created` notification for an event that already exists in your CRM, treat it as an update.

```js [idempotency-Node.js]
async function handleEventCreated(grantId, eventId, eventData) {
  const crmRecord = mapEventToCrmRecord(eventData);
  const existing = await findCrmMeetingByEventId(eventId);

  if (existing) {
    // Already exists, treat as update
    await updateCrmMeeting(eventId, crmRecord);
  } else {
    await createCrmMeeting(crmRecord);
  }
}
```

### Provider differences

Nylas normalizes most provider behavior, but a few differences are worth knowing:

- **Google Calendar** sends real-time notifications with very low latency (usually under 30 seconds)
- **Microsoft Outlook/Exchange** may have slightly higher latency for webhook delivery
- **Google** uses `cancelled` status for deleted single occurrences of recurring events, while Microsoft removes them entirely
- **Event descriptions** may contain HTML from some providers and plain text from others

You don't need provider-specific code branches, but your mapping function should handle both HTML and plain-text descriptions gracefully.

### Deleted events

The `event.deleted` webhook payload only includes the event `id`, `grant_id`, `calendar_id`, and `master_event_id`. It does not include the full event object, because the event no longer exists. This is why storing the Nylas event ID as a foreign key in your CRM is critical. Without it, you can't look up which CRM record to remove.

### Batch processing

If you're syncing calendars for hundreds or thousands of users, a single webhook endpoint can get noisy. Consider:

- **Queue incoming webhooks** with a message broker (SQS, RabbitMQ, Redis) and process them asynchronously
- **Rate-limit your CRM API calls** to avoid hitting your CRM's rate limits during burst activity (Monday morning calendar updates, for example)
- **Log every notification** with the Nylas webhook ID for debugging and replay

## Initial sync with existing events

Webhooks only fire for changes that happen after you create the subscription. For your first deployment, you need to backfill existing events from each connected grant. Use the [List Events endpoint](/docs/reference/api/events/get-all-events/) with date range filtering to pull historical data:

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/events?calendar_id=<CALENDAR_ID>&start=1704067200&end=1735689600&limit=50' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

```js [backfill-Node.js]


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

async function backfillEvents(
  grantId,
  calendarId,
  startTimestamp,
  endTimestamp,
) {
  let pageToken = undefined;
  const allEvents = [];

  do {
    const response = await nylas.events.list({
      identifier: grantId,
      queryParams: {
        calendarId: calendarId,
        start: startTimestamp,
        end: endTimestamp,
        limit: 50,
        pageToken: pageToken,
      },
    });

    for (const event of response.data) {
      const crmRecord = mapEventToCrmRecord(event);
      const existing = await findCrmMeetingByEventId(event.id);

      if (!existing) {
        await createCrmMeeting(crmRecord);
        allEvents.push(event.id);
      }
    }

    pageToken = response.nextCursor;
  } while (pageToken);

  console.log(`Backfilled ${allEvents.length} events for grant ${grantId}`);
}
```

```python
from nylas import Client

nylas = Client(
    api_key="NYLAS_API_KEY",
    api_uri="https://api.us.nylas.com",
)

def backfill_events(grant_id, calendar_id, start_timestamp, end_timestamp):
    page_token = None
    total_synced = 0

    while True:
        query_params = {
            "calendar_id": calendar_id,
            "start": start_timestamp,
            "end": end_timestamp,
            "limit": 50,
        }

        if page_token:
            query_params["page_token"] = page_token

        events, request_id, next_cursor = nylas.events.list(
            identifier=grant_id,
            query_params=query_params,
        )

        for event in events:
            crm_record = map_event_to_crm_record(event)
            existing = find_crm_meeting_by_event_id(event.id)

            if not existing:
                create_crm_meeting(crm_record)
                total_synced += 1

        if not next_cursor:
            break

        page_token = next_cursor

    print(f"Backfilled {total_synced} events for grant {grant_id}")
```

> **Success:** 
> **Run the backfill before enabling webhooks.** This way, when webhooks start firing, your idempotency logic handles any overlap between the backfill window and the first real-time notifications.

A practical approach: backfill the last 90 days of events, then let webhooks handle everything going forward. Adjust the window based on how far back your sales team needs historical meeting data.

## What's next

You now have a working pipeline that keeps your CRM in sync with calendar events across Google, Microsoft, and Exchange accounts. Here are some next steps to expand the integration:

- **[Sync email contacts to a CRM](/docs/cookbook/use-cases/sync/sync-email-crm/)** for the email side of the same pipeline
- **[Log meeting notes to a CRM automatically](/docs/cookbook/use-cases/sync/auto-log-meeting-notes-crm/)** to capture meeting notes alongside calendar events
- **[Export email data to Salesforce](/docs/cookbook/use-cases/sync/export-to-salesforce/)** for a provider-specific export
- **[Calendar events API](/docs/v3/calendar/using-the-events-api/)** for the full reference on reading, creating, and updating events
- **[Recurring events](/docs/v3/calendar/recurring-events/)** for details on RRULE handling and recurring series behavior
- **[Webhook notification schemas](/docs/reference/notifications/)** for the complete payload reference for all event triggers
- **[Free/busy](/docs/v3/calendar/check-free-busy/)** to add availability-aware features, like showing whether a sales rep is free before a deal review
- **[Webhooks overview](/docs/v3/notifications/)** for webhook verification, retry logic, and status management