Meeting notes are the most valuable artifact a sales team produces. They capture what a prospect said in their own words, what objections came up, and what everyone agreed to do next. But they almost never make it into the CRM. Reps finish a call, jump straight into the next one, and by the end of the day, whatever happened in that 10 AM demo is a blur. The CRM record stays blank, pipeline reviews rely on memory, and managers have no visibility into what was actually discussed.
This tutorial builds a webhook-driven pipeline that fixes that problem. Nylas Notetaker records and transcribes your meetings, then your webhook handler picks up the summary and action items, matches them to the right CRM record, and pushes everything in automatically. No manual entry, no lost context.
What you’ll build
Section titled “What you’ll build”The pipeline follows this flow:
- Notetaker joins a meeting (either manually triggered or auto-scheduled through calendar sync) and records the conversation.
- Nylas processes the recording after the meeting ends, generating a transcript, summary, and action items.
- Nylas fires a
notetaker.mediawebhook when the processed files are ready to download. - Your webhook handler receives the notification, downloads the summary and action items, and fetches the calendar event to identify participants.
- Your handler matches participant emails against contacts or deals in your CRM.
- Your handler pushes a structured meeting record to your CRM’s API with the summary, action items, and transcript attached.
This approach works with any CRM that exposes a REST API: Salesforce, HubSpot, Pipedrive, Close, or a custom internal system. The examples 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 access so you can look up event participants
- Notetaker enabled on your Nylas plan (check your Nylas Dashboard to confirm)
- A CRM (or any external system) with a REST API you can push meeting 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.
Set up webhooks for Notetaker media
Section titled “Set up webhooks for Notetaker media”Subscribe to the notetaker.media trigger so Nylas notifies your endpoint when a recording has been processed and the summary, action items, and transcript are available for download. You can also add notetaker.meeting_state if you want to track when Notetaker joins and leaves calls.
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": [ "notetaker.media", "notetaker.meeting_state" ], "description": "CRM meeting notes sync", "webhook_url": "https://your-server.com/webhooks/nylas", "notification_email_addresses": ["[email protected]"] }'Nylas responds with the webhook ID and a webhook_secret you use to verify incoming notifications. Store that secret securely. After creating 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 the notetaker.media webhook
Section titled “Handle the notetaker.media webhook”When Notetaker finishes processing a recording, Nylas sends a notetaker.media webhook. The payload contains the Notetaker ID and URLs for each available media file. Here is what it looks like:
{ "specversion": "1.0", "type": "notetaker.media", "source": "/nylas/notetaker", "id": "<WEBHOOK_ID>", "time": 1737500935555, "data": { "application_id": "<NYLAS_APPLICATION_ID>", "object": { "id": "<NOTETAKER_ID>", "grant_id": "<NYLAS_GRANT_ID>", "object": "notetaker", "meeting_settings": { "video_recording": true, "audio_recording": true, "transcription": true, "summary": true, "summary_settings": { "custom_instructions": "Focus on action items related to the product launch." }, "action_items": true, "action_items_settings": { "custom_instructions": "Group action items by team member." }, "leave_after_silence_seconds": 300 }, "meeting_provider": "Google Meet", "meeting_link": "https://meet.google.com/abc-defg-hij", "join_time": 1737500936450, "event": { "ical_uid": "<ICAL_UID>", "event_id": "<EVENT_ID>", "master_event_id": "<MASTER_EVENT_ID>" }, "status": "available", "state": "available", "media": { "recording": "<SIGNED_URL>", "recording_duration": "1800", "recording_file_format": "mp4", "thumbnail": "<SIGNED_URL>", "transcript": "<SIGNED_URL>", "summary": "<SIGNED_URL>", "action_items": "<SIGNED_URL>" } } }}Your handler needs to verify the state is available, download the summary and action items, look up the calendar event for participant data, and then push the meeting record to your CRM. Here is a complete Node.js Express handler:
import express from "express";import crypto from "crypto";
const app = express();app.use(express.json());
const NYLAS_API_KEY = process.env.NYLAS_API_KEY;const WEBHOOK_SECRET = process.env.NYLAS_WEBHOOK_SECRET;const BASE_URL = "https://api.us.nylas.com/v3";
// 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) => { return res.status(200).send(req.query.challenge);});
// Process incoming notetaker.media notificationsapp.post("/webhooks/nylas", async (req, res) => { // Respond immediately to avoid retries res.status(200).send("ok");
const { type, data } = req.body;
// Only process media notifications where files are ready if (type !== "notetaker.media" || data.object.state !== "available") { return; }
const notetakerId = data.object.id; const { media } = data.object;
try { // Download summary and action items const [summaryRes, actionItemsRes] = await Promise.all([ fetch(media.summary), fetch(media.action_items), ]);
const summary = await summaryRes.json(); const actionItems = await actionItemsRes.json();
// Get Notetaker details to find the linked calendar event const notetakerRes = await fetch( `${BASE_URL}/grants/${data.object.grant_id}/notetakers/${notetakerId}`, { headers: { Authorization: `Bearer ${NYLAS_API_KEY}` } } ); const notetaker = await notetakerRes.json(); const eventId = notetaker.data.event?.event_id; const calendarId = notetaker.data.calendar_id; const grantId = data.object.grant_id;
// Fetch the calendar event for participant details let participants = []; let meetingTitle = "Meeting";
if (eventId && calendarId) { const eventRes = await fetch( `${BASE_URL}/grants/${grantId}/events/${eventId}?calendar_id=${calendarId}`, { headers: { Authorization: `Bearer ${NYLAS_API_KEY}` } } ); const event = await eventRes.json(); participants = event.data.participants || []; meetingTitle = event.data.title || "Meeting"; }
// Match participants to CRM records and push the data await pushMeetingToCrm({ notetakerId, meetingTitle, summary, actionItems, participants, recordingUrl: media.recording, transcriptUrl: media.transcript, });
console.log(`Logged meeting notes to CRM for notetaker ${notetakerId}`); } catch (error) { console.error("Error processing notetaker media:", error); }});
app.listen(3000, () => console.log("Webhook server running on port 3000"));import hmacimport hashlibimport osimport requestsfrom flask import Flask, request
app = Flask(__name__)
NYLAS_API_KEY = os.environ["NYLAS_API_KEY"]WEBHOOK_SECRET = os.environ["NYLAS_WEBHOOK_SECRET"]BASE_URL = "https://api.us.nylas.com/v3"
headers = {"Authorization": f"Bearer {NYLAS_API_KEY}"}
@app.route("/webhooks/nylas", methods=["GET"])def webhook_challenge(): return request.args.get("challenge"), 200
@app.route("/webhooks/nylas", methods=["POST"])def handle_webhook(): notification = request.get_json()
if ( notification["type"] != "notetaker.media" or notification["data"]["object"]["state"] != "available" ): return "ok", 200
notetaker_id = notification["data"]["object"]["id"] media = notification["data"]["object"]["media"] grant_id = notification["data"]["object"].get("grant_id")
# Download summary and action items summary = requests.get(media["summary"]).json() action_items = requests.get(media["action_items"]).json()
# Get Notetaker details for the linked calendar event notetaker = requests.get( f"{BASE_URL}/grants/{grant_id}/notetakers/{notetaker_id}", headers=headers, ).json()
event_id = notetaker["data"].get("event", {}).get("event_id") calendar_id = notetaker["data"].get("calendar_id")
participants = [] meeting_title = "Meeting"
if event_id and calendar_id: event = requests.get( f"{BASE_URL}/grants/{grant_id}/events/{event_id}", params={"calendar_id": calendar_id}, headers=headers, ).json() participants = event["data"].get("participants", []) meeting_title = event["data"].get("title", "Meeting")
push_meeting_to_crm( notetaker_id=notetaker_id, meeting_title=meeting_title, summary=summary, action_items=action_items, participants=participants, recording_url=media.get("recording"), transcript_url=media.get("transcript"), )
return "ok", 200Media URLs expire after 60 minutes. Download the summary, action items, and transcript as soon as you receive the webhook. If you need to access them later, make a GET /v3/grants/<NYLAS_GRANT_ID>/notetakers/<NOTETAKER_ID>/media request to get fresh URLs.
Match meetings to CRM records
Section titled “Match meetings to CRM records”The calendar event’s participants array is your key to linking a meeting to the right CRM record. Each participant has an email and (usually) a name. The matching strategy depends on your CRM’s data model, but most CRMs let you search contacts by email address.
Here is a practical approach:
- Extract participant emails from the calendar event.
- Filter out internal emails (your own domain) so you only match against external contacts.
- Look up each external email in your CRM’s contacts endpoint.
- If a contact matches, pull the associated deal or opportunity record.
- Attach the meeting notes to both the contact and the deal.
async function matchParticipantsToCrm(participants, internalDomain) { const externalParticipants = participants.filter( (p) => !p.email.endsWith(`@${internalDomain}`) );
const matches = [];
for (const participant of externalParticipants) { const searchRes = await fetch( `https://your-crm.example.com/api/contacts/search?email=${encodeURIComponent(participant.email)}`, { headers: { Authorization: `Bearer ${process.env.CRM_API_KEY}` }, } ); const searchData = await searchRes.json();
if (searchData.results?.length > 0) { const contact = searchData.results[0]; matches.push({ contact_id: contact.id, deal_id: contact.primary_deal_id || null, email: participant.email, name: participant.name || participant.email, }); } }
return matches;}def match_participants_to_crm(participants, internal_domain): external = [ p for p in participants if not p["email"].endswith(f"@{internal_domain}") ]
matches = []
for participant in external: search_res = requests.get( "https://your-crm.example.com/api/contacts/search", params={"email": participant["email"]}, headers={"Authorization": f"Bearer {os.environ['CRM_API_KEY']}"}, ) results = search_res.json().get("results", [])
if results: contact = results[0] matches.append({ "contact_id": contact["id"], "deal_id": contact.get("primary_deal_id"), "email": participant["email"], "name": participant.get("name", participant["email"]), })
return matchesNot every participant will match a CRM contact. That is expected. Log the unmatched emails so your team can decide whether to create new contacts from them. Some teams auto-create contacts for unknown participants; others prefer a manual review step.
A few things to consider when designing your matching logic:
- Email aliases can cause missed matches. A contact might be stored as
[email protected]in your CRM but attend from[email protected]. Fuzzy matching on domain plus first name can help, but it introduces false positives. Start with exact email matching and refine from there. - Multiple contacts from the same company are common in enterprise deals. Match all of them so the meeting record appears on every relevant contact timeline.
- Internal-only meetings (where all participants share your domain) probably should not create CRM records. Filter these out early to avoid cluttering your CRM with standup notes.
Push meeting data to your CRM
Section titled “Push meeting data to your CRM”Once you have matched participants to CRM records, format the meeting data and push it. Most CRMs support a “meeting” or “activity” record type that you can associate with contacts and deals. Adapt the schema below to match your CRM’s API.
async function pushMeetingToCrm({ notetakerId, meetingTitle, summary, actionItems, participants, recordingUrl, transcriptUrl,}) { const crmMatches = await matchParticipantsToCrm( participants, "your-company.com" );
if (crmMatches.length === 0) { console.log("No CRM contacts matched. Skipping CRM update."); return; }
const contactIds = crmMatches.map((m) => m.contact_id); const dealIds = [ ...new Set(crmMatches.map((m) => m.deal_id).filter(Boolean)), ];
const meetingRecord = { subject: meetingTitle, summary: typeof summary === "string" ? summary : JSON.stringify(summary), action_items: Array.isArray(actionItems) ? actionItems : [actionItems], associated_contact_ids: contactIds, associated_deal_id: dealIds[0] || null, recording_url: recordingUrl, transcript_url: transcriptUrl, external_notetaker_id: notetakerId, source: "nylas_notetaker", };
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(meetingRecord), } );
if (!response.ok) { throw new Error(`CRM API error: ${response.status}`); }
const result = await response.json(); console.log(`Created CRM meeting record: ${result.id}`); return result;}def push_meeting_to_crm( notetaker_id, meeting_title, summary, action_items, participants, recording_url, transcript_url,): crm_matches = match_participants_to_crm( participants, "your-company.com" )
if not crm_matches: print("No CRM contacts matched. Skipping CRM update.") return
contact_ids = [m["contact_id"] for m in crm_matches] deal_ids = list( {m["deal_id"] for m in crm_matches if m.get("deal_id")} )
meeting_record = { "subject": meeting_title, "summary": summary if isinstance(summary, str) else str(summary), "action_items": action_items if isinstance(action_items, list) else [action_items], "associated_contact_ids": contact_ids, "associated_deal_id": deal_ids[0] if deal_ids else None, "recording_url": recording_url, "transcript_url": transcript_url, "external_notetaker_id": notetaker_id, "source": "nylas_notetaker", }
response = requests.post( "https://your-crm.example.com/api/meetings", json=meeting_record, headers={"Authorization": f"Bearer {os.environ['CRM_API_KEY']}"}, ) response.raise_for_status()
result = response.json() print(f"Created CRM meeting record: {result['id']}") return resultStore the Notetaker ID as a foreign key in your CRM. This gives you a deduplication key: if you receive the same notetaker.media webhook twice (Nylas guarantees at-least-once delivery), check whether a CRM record with that external_notetaker_id already exists before creating a duplicate.
Automate with calendar sync
Section titled “Automate with calendar sync”Manually sending Notetaker to each meeting works for testing, but the real value comes from full automation. Nylas supports calendar sync rules that automatically schedule a Notetaker for meetings that match your criteria. Combined with the webhook handler you built above, this creates a fully hands-free pipeline.
For example, to have Notetaker auto-join all external meetings (where at least one participant is outside your organization):
curl --request PUT \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/calendars/<CALENDAR_ID>' \ --header 'Accept: application/json, application/gzip' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json' \ --data '{ "notetaker": { "meeting_settings": { "summary": true, "action_items": true, "transcription": true, "audio_recording": true, "video_recording": true }, "name": "Meeting Notetaker", "rules": { "event_selection": ["external"], "participant_filter": { "participants_gte": 2 } } } }'With calendar sync enabled, Nylas evaluates every event on the calendar against your rules. When a meeting qualifies, it automatically schedules a Notetaker. When the meeting ends, your webhook handler picks up the processed media and logs it to your CRM. The entire chain runs without anyone doing anything.
You can customize the rules further. Use "event_selection": ["internal", "external"] to record all meetings, or set "participants_gte": 3 to skip one-on-one calls. See the calendar sync documentation for the full set of rule options.
Things to know
Section titled “Things to know”Here are practical details to keep in mind as you build and run this pipeline in production.
Media URL expiry
Section titled “Media URL expiry”The URLs in the notetaker.media webhook payload are signed and expire after 60 minutes. Always download the files immediately inside your webhook handler. If you miss the window, you can request fresh URLs by calling GET /v3/grants/<NYLAS_GRANT_ID>/notetakers/<NOTETAKER_ID>/media, but the underlying files are only retained for 14 days. After that, they are permanently deleted. If your CRM needs to store meeting recordings or transcripts long-term, download them to your own infrastructure as part of the webhook processing flow.
Processing delay
Section titled “Processing delay”Notetaker does not deliver media instantly after a meeting ends. Nylas needs time to process the recording, generate the transcript, and produce the summary and action items. Expect a delay of a few minutes for short meetings and longer for recordings that run over an hour. Your CRM records will not update in real time, but they will consistently populate well before any human would have gotten around to writing notes manually.
Lobby and waiting rooms
Section titled “Lobby and waiting rooms”Notetaker joins meetings as a non-signed-in user. If the meeting has a lobby or waiting room enabled, someone in the meeting needs to admit the bot manually. If nobody admits it within 10 minutes of the scheduled join time, Notetaker times out and reports a failed_entry state. For fully automated workflows, configure your meeting provider to bypass the lobby for Notetaker, or instruct meeting organizers to admit the bot promptly.
This is the most common failure mode in production. Consider subscribing to notetaker.meeting_state webhooks and alerting your team when a Notetaker gets stuck in the lobby.
Transcript formats
Section titled “Transcript formats”Nylas typically returns speaker-labelled transcripts with timestamps and speaker names. In rare cases, it may return a raw text transcript without speaker attribution. Your code should handle both formats by checking the type field in the transcript JSON: it will be either "speaker_labelled" or "raw". See Handling Notetaker media files for details on both formats.
Deduplication
Section titled “Deduplication”Nylas guarantees at-least-once delivery for webhooks, which means you may receive the same notetaker.media notification more than once. Before creating a CRM record, check whether one already exists with the same Notetaker ID. Use the external_notetaker_id field from the examples above as your deduplication key. If the record already exists, update it instead of creating a duplicate.
Silence detection
Section titled “Silence detection”By default, Notetaker leaves a meeting after 5 minutes of continuous silence. This prevents the bot from lingering in dead calls. You can adjust this threshold with leave_after_silence_seconds in your meeting settings (between 10 and 3600 seconds). Silence detection only activates after at least one participant has spoken, so the bot will not leave immediately on join if nobody has spoken yet.
Summary and action item quality
Section titled “Summary and action item quality”The AI-generated summary and action items work best with clear, structured conversations. For unstructured calls, you can pass custom instructions to improve output quality:
{ "meeting_settings": { "summary": true, "summary_settings": { "custom_instructions": "Focus on decisions made, objections raised, and next steps agreed upon." }, "action_items": true, "action_items_settings": { "custom_instructions": "Assign each action item to the person responsible. Include deadlines if mentioned." } }}Custom instructions are limited to 1,500 characters each.
What’s next
Section titled “What’s next”- Handling Notetaker media files for details on transcript formats, recording specs, and download strategies
- Using calendar sync with Notetaker to automatically schedule Notetaker for meetings matching your rules
- Sync calendar events to a CRM to add event-level sync (times, attendees, status changes) alongside meeting notes
- Automate meeting follow-up emails to send post-meeting summaries directly to attendees
- Webhook notification schemas for the full payload reference for
notetaker.mediaandnotetaker.meeting_statetriggers