Skip to content

Two-way calendar sync with Google Calendar and Outlook

Your app has its own schedule, and each user wants it to match their Google Calendar or Outlook in both directions. The calls themselves are simple. What breaks a sync is that your own writes come back as changes, deletions arrive as cancelled entries, a recurring series changes as a whole or one occurrence at a time, and notifications get dropped. With Pyas you write to both providers through one events API, get a signed webhook naming each event that changed, skip the ones your own requests caused, and keep a sync token to catch anything a webhook missed.

Start freeRead the quickstartFree for 3 connected accounts
How it works

What your app does, step by step

  1. 09:00

    Connect each user's calendar

    Put PyasConnect where users manage integrations. Your server returns a session token for the signed-in user, and their id is saved on the account as subjectId. Store the accountId you get back next to the user.

    connect.tsxTSX
    // server.ts
    app.post('/api/pyas-session', pyas.connect.sessionHandler((req) => req.user.id))
    
    // CalendarSettings.tsx
    <PyasConnect
        getSessionToken={getSessionToken}
        onConnected={(account) => saveCalendarAccount(user.id, account.accountId)}
    />
  2. 09:30

    Take the first snapshot and keep the sync token

    List the window you sync with sync: true. Link each event to your records by its id and store its version and the fields you sync. Save the sync token after the last page.

    firstSync.tsTypeScript
    let page = await pyas.events.list({ accountId, from, to, sync: true, limit: 250 })
    while (true) {
        for (const event of page.data) await links.upsertFromCalendar(accountId, event)
        const next = await page.next()
        if (!next) break
        page = next
    }
    await cursors.save(accountId, page.response.meta.syncToken)
  3. 10:00

    Write your changes and store the provider's event ID

    Use your record's id as the idempotency key, so a retried create never makes a second event. Send the stored version with updates, so an edit made in the calendar since your last read is refused instead of overwritten.

    pushChange.tsTypeScript
    if (!link) {
        const created = await pyas.events.create(
            { accountId, title: appt.title, startsAt: appt.startsAt, endsAt: appt.endsAt },
            { idempotencyKey: `appt-${appt.id}` },
        )
        await links.insert({ appointmentId: appt.id, accountId, eventId: created.id, version: created.version })
    } else {
        const updated = await pyas.events.update(
            { accountId, eventId: link.eventId, startsAt: appt.startsAt, endsAt: appt.endsAt },
            { ifMatch: link.version },
        )
        await links.update(link.id, { version: updated.version })
    }
  4. 10:30

    Handle webhooks, skipping your own writes

    A change made through your own API call carries request.traceId; one made in a calendar app does not. For the rest, read the event and compare it with your copy, because the webhook carries IDs only.

    webhooks.tsTypeScript
    app.post('/webhooks/pyas', express.raw({ type: 'application/json' }), async (req, res) => {
        let event
        try { event = await pyas.webhooks.constructEvent(req.body, req.header('Pyas-Signature'), secret) }
        catch { return res.sendStatus(400) }
        res.sendStatus(200)
        if (event.request?.traceId) return // your own write coming back
        const { id, calendarId } = event.data.object
        if (event.type === 'event.deleted') return links.markDeleted(event.accountId, id)
        const latest = await pyas.events.get({ accountId: event.accountId, eventId: id, calendarRef: calendarId })
        await links.applyIfChanged(event.accountId, latest)
    })
  5. 11:00

    Catch up from the sync token, and start over on 410

    Run an incremental sync on a timer, so a change whose notification was lost still arrives. Deleted events come in meta.deletedIds, and the new token on the last page. An expired token returns RESOURCE_SYNC_TOKEN_EXPIRED (410): list the window again and match the events to your existing links.

    catchUp.tsTypeScript
    try {
        let page = await pyas.events.list({ accountId, syncToken: await cursors.get(accountId) })
        while (true) {
            for (const event of page.data) await links.applyIfChanged(accountId, event)
            for (const id of page.response.meta.deletedIds ?? []) await links.markDeleted(accountId, id)
            const next = await page.next()
            if (!next) break
            page = next
        }
        await cursors.save(accountId, page.response.meta.syncToken)
    } catch (err) {
        if (err.code !== 'RESOURCE_SYNC_TOKEN_EXPIRED') throw err
        await firstSync(accountId) // step 2 again, keeping your links
    }

What you use

Your own writes come back as changes

Every event your app creates or moves is a change on that calendar, and the provider reports it like any other. If your handler copies it back into your schedule, and your schedule's save hook writes it to the calendar again, the two sides echo each other. At best that doubles the work. At worst a write that lands between two edits restores an old value.

On the providers directly, you have to recognize your own writes. Google lets you set hidden key-value pairs on an event as extended properties, and Graph events take extensions and extended properties. A tag says your app created the event, though, not that your app made the latest change. Comparing versions gets closer. Google's etag "changes every time the resource changes" (Google), so an etag in the sync that matches the one your write returned means nothing happened since. Outlook breaks that shortcut: on Outlook.com, a new event's change key moves on its own seconds after the create.

With Pyas, the webhook for a change your API call made carries request.traceId. Skip those, provided you updated your own record when you made the call.

Map IDs in both directions

Keep a table linking each of your records to a provider event: your id, the connected account, the calendar, the provider's event id, the event's last version and the fields you sync. You look it up from your side when a user edits a record, and from the event id when a webhook or sync names an event.

Two provider details shape that table:

  • Google lets you choose an event's id at creation. It must use base32hex characters ("lowercase letters a-v and digits 0-9"), be 5 to 1024 characters long and be unique per calendar (events reference). Otherwise Google generates one.
  • An Outlook event's id "changes when the item is moved from one container (such as a folder or calendar) to another", unless you request immutable IDs with Prefer: IdType="ImmutableId" (event resource).

Notifications say when, the sync says what

Neither provider promises delivery. Google: "Notifications are not 100% reliable. Expect a small percentage of messages to get dropped under normal working conditions" (push guide). Microsoft: if your endpoint answers slowly, "the change notification service might begin to drop notifications. Dropped notifications can't be recovered" (webhooks guide).

So a notification is a trigger, and the incremental sync from a stored cursor is the source of truth. Run that sync on a timer as well, so a lost notification costs minutes instead of a missing appointment. The webhooks guide covers Google channels and Graph subscriptions in detail.

Pyas keeps the channels and subscriptions alive and sends one kind of webhook for both providers. It names the changed event in data.object as { id, calendarId, seriesId, isSeries }, never its title or times, and never the values before the change. You read the event and compare it with your copy.

Pyas does not store copies of your users' events. Your database is the only earlier version you can compare against, so keep the fields you sync, not only the IDs.

Sync tokens expire

From Google's sync guide: the nextSyncToken "is present only on the very last page", "each list request should use the same set of query parameters, including the initial request", and tokens can be invalidated "for various reasons including token expiration or changes in related ACLs". Google then answers 410, which "should trigger a full wipe of the client's store and a new full sync." Graph's equivalent is the delta link, and a calendar view delta covers one calendar and one date range: "To track the changes in multiple calendars, you need to track each calendar individually" (delta query for events).

In a two-way sync, "wipe the store" can't mean deleting your own records, because they are the other half of the sync. Wipe the cursor and the cached event fields, list the window again, and match each event to your records through the ID map. A linked event missing from the new listing counts as deleted only if it falls inside the window you listed.

Deleted events come back as cancelled

Google keeps deleted events. In the events reference, status cancelled means "The event is cancelled (deleted)", and a deleted event is "only guaranteed to have the id field populated". Your ID map is how you know which record it was. Reading a deleted event by its ID returns the whole event with status cancelled, not a 404 (field note). Graph's delta query marks deletions with "@removed": {"reason": "deleted"}.

Decide what a deletion means in your product before you apply it. In a practice-management tool, a session deleted from someone's Google Calendar may need to become "cancelled" with a note on your side, not disappear. Also expect a race: a webhook for an update can arrive after the event was deleted, so a read that finds nothing is a deletion, not an error.

A series is not an occurrence

A recurring meeting changes in two ways: the whole series, or one occurrence. Google marks an occurrence with recurringEventId and originalStartTime, which "uniquely identifies the instance within the recurring event series even if the instance was moved" (recurring events). Outlook gives each event a type of singleInstance, occurrence, exception or seriesMaster, with seriesMasterId on the parts of a series, and a series' instances "doesn't include occurrences canceled from the series" (event resource).

Key your occurrences by series and original start, not by current start, since moving one occurrence changes its start. In Pyas, a change to the whole series is one webhook with isSeries: true, and one changed occurrence has seriesId set:

series.tsTypeScript
const { id, calendarId, seriesId, isSeries } = event.data.object
if (isSeries) {
    for await (const occurrence of pyas.events.instances({ accountId: event.accountId, eventId: id, calendarRef: calendarId, from, to })) {
        await links.upsertOccurrence(id, occurrence)
    }
} else {
    const changed = await pyas.events.get({ accountId: event.accountId, eventId: id, calendarRef: calendarId })
    await links.upsertOccurrence(seriesId, changed) // seriesId is null for a one-off event
}

Two people edit the same event

Your user reschedules an appointment in your app while their assistant moves the same meeting in Outlook. Without a check, whichever write lands last wins and nobody is told. Google supports conditional writes: send If-Match with the etag you read, and if the event changed since, "you will get a 412 (Precondition failed) response code" (Google). Outlook's changeKey "identifies the version of the event object", but on Outlook.com it moves after a create with nothing changed, as the field note above shows.

In Pyas, ifMatch: event.version refuses the update if the event changed since you read it, on either provider. When it is refused, read the event again, compare it with your copy, and resolve field by field. A rule that holds up: your app owns the fields only it has, such as the client or the notes, and for times and titles the newer edit wins, decided after the re-read and not before.

Questions

Does Google Calendar have webhooks?
Yes, as push notifications. You call events.watch on a calendar and Google posts to your HTTPS address when its events change. The message has no body, so a sync still has to tell you what changed. Channels expire, and "there's no automatic way to renew a notification channel", so you watch again before each one runs out.
How long does an Outlook calendar webhook subscription last?
A Microsoft Graph subscription on Outlook events lasts at most 10,080 minutes, "under seven days", or 1,440 minutes when notifications include resource data, per the subscription resource. An expiry under 45 minutes is set to 45. Renew with PATCH /subscriptions/{id} before it lapses, or Graph deletes it and stops sending. Pyas renews Graph subscriptions and Google channels for you.
Should I copy users' events into my database or read them live from the API?
For two-way sync, keep a copy of the events in the window you sync. A Stack Overflow asker building exactly this wondered whether importing them is "even technically possible"; it is, and reading live only works for display. Without a copy you can't tell what changed, since Google's notifications have no body and Pyas webhooks carry IDs without previous values. Pyas does not keep copies of your users' events, so yours is the one to compare against.
What happens when a user's calendar connection breaks?
Pyas sends a connection.broken webhook, and pyas.accounts.status.get({ accountId }) says why and what fixes it. pyas.connect.sessions.create({ accountId }) starts a reconnect for that same account. After the reconnect, sync from your stored token, or list the window again if the token has expired.
Can I build and test a calendar sync without real calendars?
Yes. Every Pyas app has a sandbox with simulated Google, Outlook and Zoom accounts and sample people with real-looking calendars, reached with test keys that never touch a real calendar. npx @pyas/cli webhooks listen --forward http://localhost:3000/webhooks/pyas relays the sandbox's webhooks to your laptop, signed as in production.
Does this work with iCloud or on-premises Exchange?
No. Pyas covers Google Calendar and Outlook (Microsoft 365 and Outlook.com), plus Zoom for meeting links. It does not connect iCloud, CalDAV servers or on-premises Exchange.

Read next

Build it against the sandbox today

Every app gets sample people with real-looking calendars, so you can build this before anyone connects an account.