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
Section titled “What you’ll build”The pipeline follows a straightforward webhook-driven architecture:
- Subscribe to
event.created,event.updated, andevent.deletedwebhook triggers on your Nylas application. - Receive real-time POST notifications from Nylas whenever a calendar event changes on any connected grant.
- Fetch the full event details from the Nylas Events API (for created and updated events).
- Map Nylas event fields to your CRM’s record schema.
- 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
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 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
Nylas blocks requests to ngrok URLs. Use VS Code port forwarding or Hookdeck to expose your local server during development.
Subscribe to calendar event webhooks
Section titled “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.
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": ["[email protected]"] }'Nylas responds with the webhook ID and a webhook_secret you’ll use to verify incoming notifications. Store that secret securely.
{ "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", "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 for the full verification flow.
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
Section titled “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:
import express from "express";import crypto from "crypto";
const app = express();app.use(express.json());
const WEBHOOK_SECRET = process.env.NYLAS_WEBHOOK_SECRET;
// Verify the webhook signaturefunction 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 verificationapp.get("/webhooks/nylas", (req, res) => { const challenge = req.query.challenge; return res.status(200).send(challenge);});
// Process incoming webhook notificationsapp.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"));import osimport hmacimport hashlibfrom 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", 200Respond 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
Section titled “Webhook payload structure”Each notification follows the CloudEvents spec. Here’s what an event.created payload looks like:
{ "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": [ ], "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
Section titled “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 in your Nylas Dashboard, the payload may only include a subset of fields. In that case, fetch the complete event using the Events API:
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>'import Nylas from "nylas";
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;}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 eventThe key fields you’ll want for CRM sync:
title- The event subject linewhen.start_time/when.end_time- Unix timestamps for the meeting windowparticipants- Array of attendee objects withemail,name, andstatuslocation- Physical meeting location, if setconferencing.details.url- Video call link (Zoom, Google Meet, Teams)description- Meeting notes or agenda textorganizer- Who created the eventstatus-confirmed,tentative, orcancelled
Map events to CRM records
Section titled “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:
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, };}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:
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}`); }}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
Section titled “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_idalongside 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_idfield to distinguish standalone events from recurring occurrences
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, };}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
Section titled “Handle edge cases”A production-quality sync pipeline needs to account for several things that don’t show up in the happy path.
Idempotency
Section titled “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
Section titled “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.
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
Section titled “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
cancelledstatus 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
Section titled “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
Section titled “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
Section titled “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 with date range filtering to pull historical data:
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>'import Nylas from "nylas";
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}`);}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}")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
Section titled “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:
- Calendar events API for the full reference on reading, creating, and updating events
- Recurring events for details on RRULE handling and recurring series behavior
- Webhook notification schemas for the complete payload reference for all event triggers
- Free/busy to add availability-aware features, like showing whether a sales rep is free before a deal review
- Webhooks overview for webhook verification, retry logic, and status management