# Two-way calendar sync without duplicates

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

A one-way mirror copies calendar changes into another system and stops there. Two-way sync is harder: a meeting your user creates in your app has to land on their Google or Outlook calendar, and a meeting they create on that calendar has to flow back into your app, all without the same meeting showing up twice. The trap is the echo. Your write triggers a webhook, the webhook looks like a new change, and your handler writes it again.

This recipe builds bidirectional sync with one stable idea: every event carries an external ID that ties the row in your database to the event on the calendar. That single mapping powers deduplication in both directions and breaks the echo loop. If you only need to copy events into a CRM in one direction, the [sync calendar events to a CRM](/docs/cookbook/use-cases/sync/sync-calendar-events-crm/) recipe is simpler and a better fit.

## How do you sync a calendar in two directions without duplicates?

Store an external ID on both sides of the sync. When your app writes an event to the calendar, save the Nylas `metadata` with your record ID and keep the returned event ID locally. On every inbound webhook, look up that external ID before writing. If a match exists, you update the linked row instead of creating a second one.

The mapping table is the heart of the design. Each row links your internal record ID to the event ID and grant ID, plus a content hash and a timestamp. The Nylas Calendar API supports a `metadata` object on events, so you can stamp your own identifier onto a reserved key (`key1` through `key5` are the only keys `metadata_pair` can filter on) and query it back with the `metadata_pair` filter. That gives you two independent ways to match an incoming change to a local row: the event ID you stored, or the metadata you wrote. In practice you want both, because the second one survives a database restore where local IDs drift but the calendar copy does not. A single mapping row is roughly 200 bytes, so a million synced events fits in well under 1 GB of storage.

```sql
CREATE TABLE event_sync_map (
  internal_id   TEXT PRIMARY KEY,
  nylas_event_id TEXT,                 -- null until the create call returns
  grant_id      TEXT NOT NULL,
  calendar_id   TEXT NOT NULL,
  content_hash  TEXT NOT NULL,
  source        TEXT NOT NULL,        -- 'app' or 'calendar'
  updated_at    BIGINT NOT NULL,
  UNIQUE (grant_id, nylas_event_id)
);
```

## Push events from your app to the calendar

When a user creates or edits an event inside your app, write it to the calendar with the Nylas event endpoints and stamp your record ID into `metadata`. Creating an event is a `POST /v3/grants/{grant_id}/events?calendar_id=...`, and editing one is a `PUT` to the same path with the event ID appended. The metadata you set survives across providers, so the same identifier comes back on every future read.

The order matters here. Compute a content hash of the fields you sync, save the mapping row first with that hash, then make the API call. If the call fails you retry from a known row rather than guessing. About 95 percent of outbound writes finish in under 1 second against Google and Microsoft, so a synchronous call inside the request is usually fine for single events.

```js [pushEvent-Node.js]


function contentHash(fields) {
  return crypto.createHash("sha256")
    .update(JSON.stringify(fields)).digest("hex");
}

async function pushToCalendar(nylas, grantId, calendarId, record) {
  const fields = {
    title: record.title,
    when: { startTime: record.start, endTime: record.end },
    participants: record.attendees.map((email) => ({ email })),
  };
  const hash = contentHash(fields);

  // Save the mapping first so a crash after the API call is recoverable.
  await saveMapping({
    internal_id: record.id,
    nylas_event_id: null,
    grant_id: grantId,
    calendar_id: calendarId,
    content_hash: hash,
    source: "app",
    updated_at: Date.now(),
  });

  const created = await nylas.events.create({
    identifier: grantId,
    queryParams: { calendarId },
    requestBody: { ...fields, metadata: { key1: record.id } },
  });

  // Fill in the event ID now that the create succeeded.
  await saveMapping({ internal_id: record.id, nylas_event_id: created.data.id });
  return created.data.id;
}
```

## Pull calendar changes back into your app

Inbound changes arrive as `event.created`, `event.updated`, and `event.deleted` webhook notifications. This recipe assumes you already have a verified endpoint. If you do not, set it up first with the [calendar webhooks](/docs/cookbook/calendar/calendar-webhooks/) recipe, then return here for the dedup logic that the generic setup does not cover.

The handler is short because the mapping table does the work. Read the event ID and grant ID from the payload, look up the mapping row, and branch on whether a row exists. A missing row means a genuinely new calendar event, so you create a local record. An existing row means an update, so you patch the row you already have. Always answer the webhook within the 10 second limit and process after responding, since the typical inbound notification arrives within 5 seconds of the change on the calendar. Look up each event metadata first, checking `metadata.key1` (the reserved key you stamped the record ID onto) before falling back to a `(grant_id, event_id)` lookup (the `findMappingByInternalId` and `findMapping` calls in the handler below), so an echo that lands before the pending mapping is filled still matches on `metadata.key1` instead of creating a duplicate. The `else` branch here is the naive version; the next section replaces it with `applyInbound`, which adds the echo-guard hash check before writing.

```js [pullEvent-Node.js]
async function handleNotification(notification) {
  const obj = notification.data.object;
  // Resolve metadata first so an echo that beats the pending-row update still matches.
  const mapping = obj.metadata?.key1
    ? await findMappingByInternalId(obj.metadata.key1)
    : await findMapping(obj.grant_id, obj.id);

  if (notification.type === "event.deleted") {
    if (mapping) await deleteLocalRecord(mapping.internal_id);
    return;
  }

  if (!mapping) {
    const localId = await createLocalRecord(obj); // new on the calendar
    await saveMapping({
      internal_id: localId,
      nylas_event_id: obj.id,
      grant_id: obj.grant_id,
      calendar_id: obj.calendar_id,
      content_hash: contentHash({
        title: obj.title,
        when: obj.when,
        participants: obj.participants,
      }),
      source: "calendar",
      updated_at: Date.now(),
    });
  } else {
    await updateLocalRecord(mapping.internal_id, obj);
  }
}
```

## How do you prevent an echo loop between app and calendar?

An echo happens when your own write to the calendar fires a webhook that your handler treats as a fresh change and writes again. Break it by comparing content. Compute the hash of the incoming event and compare it to the stored hash for that mapping. If they match, the change originated from your side and you skip it. Only a real difference triggers a write.

Hash comparison is more reliable than time windows. A naive guard ignores any webhook that arrives within, say, 2 seconds of your write, but provider latency varies and a legitimate edit can land in that window. The hash compares actual content, so it never drops a real change. The cost is tiny: a SHA-256 over a handful of fields runs in well under 1 millisecond. Store the hash on every write from both directions and you get loop protection plus free idempotency, because Nylas guarantees at-least-once delivery and the same notification can arrive more than once.

```js [echoGuard-Node.js]
async function applyInbound(mapping, incoming) {
  const incomingHash = contentHash({
    title: incoming.title,
    when: incoming.when,
    participants: incoming.participants,
  });

  if (mapping && mapping.content_hash === incomingHash) {
    return; // echo of our own write, or a duplicate delivery
  }

  if (!mapping) {
    const localId = await createLocalRecord(incoming); // new calendar-side event
    await saveMapping({
      internal_id: localId,
      nylas_event_id: incoming.id,
      grant_id: incoming.grant_id,
      calendar_id: incoming.calendar_id,
      content_hash: incomingHash,
      source: "calendar",
      updated_at: Date.now(),
    });
    return;
  }

  await updateLocalRecord(mapping.internal_id, incoming);
  await saveHash(mapping.internal_id, incomingHash, "calendar");
}
```

## How do you match an inbound event to the right local record?

Use the external ID as the primary key for matching, with two lookups in priority order. First check `metadata.key1` on the inbound event, the reserved key you wrote the record ID onto, because it is the most direct link. If it is absent, the event was created directly on the calendar, so fall back to matching by Nylas event ID in your mapping table.

This two-tier lookup is what keeps the two directions from colliding. Events your app created always carry metadata, so they resolve on the first check and never get duplicated when their webhook echoes back. Events the user created on the calendar have no metadata, so they resolve on the second check or, when neither matches, become a new local record with a fresh mapping row. You can query the calendar for your own events at any time with `GET /v3/grants/{grant_id}/events?calendar_id=<CALENDAR_ID>&metadata_pair=key1:abc123`, which is useful for repair jobs that reconcile drift. A reconciliation pass over 10,000 events typically completes in under 30 seconds when you page at the 200 event limit.

```js [matchRecord-Node.js]
async function resolveLocalId(obj) {
  const fromMeta = obj.metadata?.key1;
  if (fromMeta) return fromMeta;

  const mapping = await findMapping(obj.grant_id, obj.id);
  return mapping ? mapping.internal_id : null; // null => create new
}
```

## What's next

- [Sync calendar events to a CRM](/docs/cookbook/use-cases/sync/sync-calendar-events-crm/) for the simpler one-way mirror this recipe builds on
- [Calendar webhooks](/docs/cookbook/calendar/calendar-webhooks/) for the webhook subscription and challenge setup
- [Using webhooks with Nylas](/docs/v3/notifications/) for signature verification, retries, and failure handling
- [Calendar events API](/docs/v3/calendar/using-the-events-api/) for the full reference on creating and updating events