# How to auto-log meeting notes to your CRM

Source: https://developer.nylas.com/docs/cookbook/use-cases/sync/auto-log-meeting-notes-crm/

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

The pipeline follows this flow:

1. **Notetaker joins a meeting** (either manually triggered or auto-scheduled through calendar sync) and records the conversation.
2. **Nylas processes the recording** after the meeting ends, generating a transcript, summary, and action items.
3. **Nylas fires a `notetaker.media` webhook** when the processed files are ready to download.
4. **Your webhook handler** receives the notification, downloads the summary and action items, and fetches the calendar event to identify participants.
5. **Your handler matches participant emails** against contacts or deals in your CRM.
6. **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


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 access** so you can look up event participants
- **Notetaker enabled** on your Nylas plan (check your [Nylas Dashboard](https://dashboard-v3.nylas.com/) 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

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

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

```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": [
      "notetaker.media",
      "notetaker.meeting_state"
    ],
    "description": "CRM meeting notes sync",
    "webhook_url": "https://your-server.com/webhooks/nylas",
    "notification_email_addresses": ["you@example.com"]
  }'
```

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](/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 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:

```json
{
  "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,
        "transcription_settings": {
          "expected_languages": ["en", "es"],
          "fallback_language": "en"
        },
        "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:

```js [mediaHandler-Node.js]


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 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) => {
  return res.status(200).send(req.query.challenge);
});

// Process incoming notetaker.media notifications
app.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"));
```

```python
from 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", 200
```

> **Warn:** 
> **Media 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`](/docs/reference/api/notetaker/get-notetaker-media/) request to get fresh URLs.

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

1. **Extract participant emails** from the calendar event.
2. **Filter out internal emails** (your own domain) so you only match against external contacts.
3. **Look up each external email** in your CRM's contacts endpoint.
4. **If a contact matches**, pull the associated deal or opportunity record.
5. **Attach the meeting notes** to both the contact and the deal.

```js [matchCrm-Node.js]
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;
}
```

```python
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 matches
```

> **Info:** 
> **Not 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 `jane@acme.com` in your CRM but attend from `jane.smith@acme.com`. 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

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.

```js [pushCrm-Node.js]
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;
}
```

```python
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 result
```

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

Manually sending Notetaker to each meeting works for testing, but the real value comes from full automation. Nylas supports [calendar sync rules](/docs/v3/notetaker/calendar-sync/) 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):

```bash
curl --request PUT \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/calendars/<CALENDAR_ID>' \
  --header 'Accept: application/json' \
  --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](/docs/v3/notetaker/calendar-sync/) for the full set of rule options.

## Things to know

Here are practical details to keep in mind as you build and run this pipeline in production.

### 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`](/docs/reference/api/notetaker/get-notetaker-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

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

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

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](/docs/v3/notetaker/media-handling/) for details on both formats.

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

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

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:

```json
{
  "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

- [Handling Notetaker media files](/docs/v3/notetaker/media-handling/) for details on transcript formats, recording specs, and download strategies
- [Using calendar sync with Notetaker](/docs/v3/notetaker/calendar-sync/) to automatically schedule Notetaker for meetings matching your rules
- [Sync calendar events to a CRM](/docs/cookbook/use-cases/sync/sync-calendar-events-crm/) to add event-level sync (times, attendees, status changes) alongside meeting notes
- [Automate meeting follow-up emails](/docs/cookbook/use-cases/act/automate-meeting-follow-ups/) to send post-meeting summaries directly to attendees
- [Webhook notification schemas](/docs/reference/notifications/) for the full payload reference for `notetaker.media` and `notetaker.meeting_state` triggers