# Sync calendar events to Notion

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

Notion is where a lot of teams plan their week, but it doesn't pull in calendar events on its own. People copy meetings over by hand, the database drifts, and the "this week" view stops matching reality. You want each meeting to land as a row in a Notion database the moment it's booked or changed, with a real date property you can sort and filter on.

This recipe wires the Nylas Calendar API to a Notion database. You subscribe to event webhooks, fetch the changed event, map it to Notion's page schema, and write a database page through the Notion API. One pipeline covers Google, Outlook, and Exchange, so you don't write provider-specific calendar code. If you're pushing into a sales tool instead, the [sync calendar events to a CRM](/docs/cookbook/use-cases/sync/sync-calendar-events-crm/) recipe is a closer fit, and [two-way calendar sync](/docs/cookbook/use-cases/sync/two-way-calendar-sync/) covers writing changes back to the calendar.

## What you'll build

The pipeline is one webhook-driven flow with four steps. Each calendar change becomes exactly one Notion database page, keyed so a reschedule updates the same row instead of adding a duplicate. The loop touches two APIs and stays under the Notion limit of 3 requests per second.

1. **Subscribe** to the `event.created` and `event.updated` webhook triggers on your Nylas application.
2. **Fetch** the full event from the Nylas Events API when a notification arrives.
3. **Map** Nylas event fields to Notion properties, converting Unix timestamps to ISO dates for a Notion date property.
4. **Write** the page to your Notion database with a create or update call.

## 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 [Notion internal integration](https://developers.notion.com/docs/create-a-notion-integration) and its API token
- A Notion database **shared with that integration**, with a title property and a date property
- A publicly reachable 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 the Notion database

Create a database in Notion with at least two columns: a **Title** property (the default `Name` column works) and a **Date** property. The Notion API can only write to a database that's explicitly shared with your integration, so open the database, click the menu, and add your integration as a connection. Copy the 32-character database ID from the database URL. According to the [Notion API docs](https://developers.notion.com/reference/post-page), property keys in your write requests must match the database's column names exactly, so note the date column's name now.

## How do you sync calendar events to a Notion database?

Subscribe to Nylas event webhooks, then write each change to Notion as a database page. When the `event.created` or `event.updated` trigger fires, Nylas sends a POST to your endpoint, you fetch the full event, and you call the Notion API to create or update a page whose date property holds the meeting time. One subscription covers every connected grant.

The mapping is the part worth getting right. A Nylas event stores its start and end as Unix timestamps inside a `when` object, while a Notion date property expects ISO 8601 strings. You convert once, in your handler, and store the Nylas event `id` somewhere on the Notion page so a later `event.updated` finds the same row. The two systems speak different field names, so here are the 5 fields you'll map across both providers.

| Nylas event field | Notion property type | Notes |
| --- | --- | --- |
| `title` | `title` | The default `Name` column in most databases |
| `when.start_time` / `when.end_time` | `date` | Unix seconds; convert to ISO `start` and `end` |
| `description` | `rich_text` | Optional column for the meeting agenda |
| `location` or `conferencing.details.url` | `url` or `rich_text` | Join link for video meetings |
| `id` | `rich_text` | Store it to match future updates to the same page |

## Subscribe to calendar event webhooks

Create one webhook subscription that listens for both event lifecycle triggers. A single subscription covers every grant in your Nylas application, so you don't register per-user webhooks. The `POST /v3/webhooks` request below registers the 2 event triggers and points them at your callback URL. Nylas sends a challenge request to confirm the endpoint before it goes live.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "trigger_types": ["event.created", "event.updated"],
    "webhook_url": "https://your-app.com/webhooks/nylas",
    "description": "Sync calendar events to Notion",
    "notification_email_addresses": ["alerts@your-app.com"]
  }'
```

```js [createWebhook-Node.js SDK]


const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>" });

const webhook = await nylas.webhooks.create({
  requestBody: {
    triggerTypes: ["event.created", "event.updated"],
    webhookUrl: "https://your-app.com/webhooks/nylas",
    description: "Sync calendar events to Notion",
    notificationEmailAddresses: ["alerts@your-app.com"],
  },
});

console.log("Webhook secret:", webhook.data.webhookSecret);
```

```python [createWebhook-Python SDK]
from nylas import Client

nylas = Client("<NYLAS_API_KEY>")

webhook = nylas.webhooks.create(
    request_body={
        "trigger_types": ["event.created", "event.updated"],
        "webhook_url": "https://your-app.com/webhooks/nylas",
        "description": "Sync calendar events to Notion",
        "notification_email_addresses": ["alerts@your-app.com"],
    }
)

print("Webhook secret:", webhook.data.webhook_secret)
```

Save the `webhook_secret` from the response. You use it to verify the `X-Nylas-Signature` header on every incoming notification so an attacker can't forge calendar changes into your Notion workspace. For the full subscription lifecycle and signature verification, see [sync calendar events with webhooks](/docs/cookbook/calendar/calendar-webhooks/).

## Fetch the changed event from Nylas

A webhook notification carries the event `id`, `grant_id`, and `calendar_id`, but not the full event body. Fetch the complete record with `GET /v3/grants/{grant_id}/events/{event_id}`, which takes the grant ID and event ID positionally in the SDKs and needs the `calendar_id` from the webhook payload as a query parameter. The same endpoint covers all 3 providers and returns the 4 fields you'll map into Notion: the `when` timestamps, `title`, `description`, and `participants`.

```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 SDK]
const { data: event } = await nylas.events.find({
  identifier: grantId,
  eventId: eventId,
  queryParams: { calendarId: calendarId },
});
```

```python [fetchEvent-Python SDK]
event = nylas.events.find(
    grant_id,
    event_id,
    query_params={"calendar_id": calendar_id},
).data
```

The `when` object reports `start_time` and `end_time` as Unix seconds (for example, `1701977400`), with separate `start_timezone` and `end_timezone` strings. Convert the 2 timestamps to ISO 8601 before you hand them to Notion, because the Notion date property rejects raw epoch integers.

## Write the event to a Notion database page

Create the Notion page with `POST /v1/pages`, the [Notion create-page endpoint](https://developers.notion.com/reference/post-page). Set `parent.database_id` to your database ID, then fill `properties` with a `title` and a `date`. The Notion date property takes ISO 8601 strings in `start` and `end`, which is why you converted the Unix timestamps in the previous step. Every Notion request needs the `Notion-Version` header; the current version is `2026-03-11` per the [Notion versioning docs](https://developers.notion.com/reference/versioning).

```bash
curl --request POST \
  --url 'https://api.notion.com/v1/pages' \
  --header 'Authorization: Bearer <NOTION_API_TOKEN>' \
  --header 'Notion-Version: 2026-03-11' \
  --header 'Content-Type: application/json' \
  --data '{
    "parent": { "database_id": "<NOTION_DATABASE_ID>" },
    "properties": {
      "Name": { "title": [{ "text": { "content": "Quarterly planning sync" } }] },
      "Date": { "date": { "start": "2025-11-12T09:30:00-08:00", "end": "2025-11-12T10:30:00-08:00" } },
      "Nylas Event ID": { "rich_text": [{ "text": { "content": "6aaaaaaame8kpgcid6hvd" } }] }
    }
  }'
```

```js [createNotionPage-Node.js SDK]
const startIso = new Date(event.when.startTime * 1000).toISOString();
const endIso = new Date(event.when.endTime * 1000).toISOString();

await fetch("https://api.notion.com/v1/pages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NOTION_API_TOKEN}`,
    "Notion-Version": "2026-03-11",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    parent: { database_id: process.env.NOTION_DATABASE_ID },
    properties: {
      Name: { title: [{ text: { content: event.title ?? "Untitled event" } }] },
      Date: { date: { start: startIso, end: endIso } },
      "Nylas Event ID": { rich_text: [{ text: { content: event.id } }] },
    },
  }),
});
```

```python [createNotionPage-Python SDK]


from datetime import datetime, timezone

start_iso = datetime.fromtimestamp(event.when.start_time, tz=timezone.utc).isoformat()
end_iso = datetime.fromtimestamp(event.when.end_time, tz=timezone.utc).isoformat()

requests.post(
    "https://api.notion.com/v1/pages",
    headers={
        "Authorization": f"Bearer {os.environ['NOTION_API_TOKEN']}",
        "Notion-Version": "2026-03-11",
        "Content-Type": "application/json",
    },
    json={
        "parent": {"database_id": os.environ["NOTION_DATABASE_ID"]},
        "properties": {
            "Name": {"title": [{"text": {"content": event.title or "Untitled event"}}]},
            "Date": {"date": {"start": start_iso, "end": end_iso}},
            "Nylas Event ID": {"rich_text": [{"text": {"content": event.id}}]},
        },
    },
)
```

For an `event.updated` trigger, you patch the existing page instead of creating a second one. Query the database with `POST /v1/databases/{database_id}/query`, filtering the `Nylas Event ID` column for the event you stored, then send the new times to `PATCH /v1/pages/{page_id}`. This keeps one Notion row per meeting even after three or four reschedules.

## Things to know about the Notion API

A few Notion-specific limits shape this integration, and they differ from how the Nylas Calendar API behaves. The Notion API rate-limits each integration to an average of **3 requests per second** and returns HTTP `429` with a `Retry-After` header when you exceed it, so a burst of calendar changes needs a small queue between your webhook handler and the Notion write.

- **Sharing is mandatory.** The integration can only read or write a database that a workspace member has explicitly shared with it. A `404` from `/v1/pages` almost always means the database isn't connected to the integration, not that the ID is wrong.
- **Property keys are case-sensitive.** The keys in your `properties` object must match the database column names exactly, including capitalization. A typo drops that property without returning an error.
- **Date properties take ISO 8601, not epochs.** Notion's `date` type expects strings like `2025-11-12T09:30:00-08:00`. Pass the `start_timezone` from the Nylas `when` object through to the offset so the meeting shows at the right local time.
- **Title is required.** Every Notion page needs a non-empty `title` property. Calendar events occasionally have a blank `title`, so fall back to a placeholder like `Untitled event` before you write.
- **Versioning is pinned by header.** Notion ties breaking changes to the `Notion-Version` date you send. Pin it explicitly rather than relying on a default, and review the [Notion changelog](https://developers.notion.com/page/changelog) before bumping it.

For the deletion path, Notion's API archives pages rather than hard-deleting them. Handle a future `event.deleted` trigger by patching the page with `"archived": true`.

## What's next

- [Sync calendar events with webhooks](/docs/cookbook/calendar/calendar-webhooks/) for the full webhook subscription and signature verification flow
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for the complete event schema and query parameters
- [Two-way calendar sync](/docs/cookbook/use-cases/sync/two-way-calendar-sync/) to write Notion changes back to the calendar
- [Notion API create-page reference](https://developers.notion.com/reference/post-page) for the full Notion page and property schema