Skip to content

CRM calendar sync with Google Calendar and Outlook

A CRM calendar sync logs every meeting a rep has with a customer on the right contact and deal, and puts meetings booked in the CRM on the rep's own calendar. The hard parts are matching attendees to contacts, recognizing one meeting on two reps' calendars, and supporting reps on Google and Outlook at once. With Pyas you connect each rep once, backfill from one event list, keep it current from webhooks, and book with a Meet, Teams or Zoom link in the same call.

Start freeRead the quickstartFree for 3 connected accounts
How it works

What your app does, step by step

  1. 09:00

    Connect each rep's calendar

    Put PyasConnect on the rep's settings page. Your server returns the rep's user id, which is saved on the account as subjectId, so every calendar maps back to a rep. Reps approve on Google's, Microsoft's and Zoom's own consent screens through Pyas Auth.

    connect.tsxTSX
    // Server: the rep's user id becomes the account's subjectId
    app.post('/api/pyas-session', pyas.connect.sessionHandler((req) => req.user.id))
    
    // Rep settings page
    <PyasConnect
        getSessionToken={getSessionToken}
        onConnected={(account) => saveRepCalendar(account.accountId)}
    />
  2. 09:30

    Backfill recent history

    On first connect, list a past window so the CRM isn't empty on day one, plus the weeks ahead so upcoming meetings show on the deal. expand: true returns each occurrence of a recurring meeting as its own event. Run it in a background job.

    backfill.tsTypeScript
    const oneDay = 24 * 60 * 60 * 1000
    const from = new Date(Date.now() - 90 * oneDay).toISOString()
    const to = new Date(Date.now() + 30 * oneDay).toISOString()
    
    for await (const event of pyas.events.list({ accountId, from, to, expand: true })) {
        await logMeeting(rep, accountId, event)
    }
  3. 10:00

    Match attendees to contacts

    Check the event's attendees. Drop anyone on your customer's own domains; if nobody is left, it's an internal meeting or a personal event. Look the rest up by email, and key the activity on the iCalUID and start time so two reps on one call make one activity.

    match.tsTypeScript
    async function logMeeting(rep, accountId, event) {
        const outside = event.attendees.filter((guest) => !rep.companyDomains.includes(domainOf(guest.email)))
        if (outside.length === 0) return // internal meeting or personal event
    
        const contacts = await crm.contacts.findByEmails(outside.map((guest) => guest.email))
        if (contacts.length === 0) return // not a contact yet: skip, or create one
    
        await crm.activities.upsert({
            key: event.iCalUID ? `${event.iCalUID}:${event.startsAt}` : `${accountId}:${event.id}`,
            contactIds: contacts.map((contact) => contact.id),
            participantRepId: rep.id,
            startsAt: event.startsAt,
        })
    }
  4. 10:30

    Keep it current with webhooks

    Subscribe once with pyas.webhooks.create({ url, events: ['event.*', 'connection.broken'] }). A delivery carries the event's IDs, not its details, so read the event and run the same matching code. Pyas keeps Google's push channels and Microsoft's subscriptions renewed.

    webhooks.tsTypeScript
    app.post('/webhooks/pyas', express.raw({ type: 'application/json' }), async (req, res) => {
        const delivery = await pyas.webhooks.constructEvent(req.body, req.get('Pyas-Signature'), webhookSecret)
        const { id, calendarId } = delivery.data.object
    
        if (delivery.type === 'event.deleted') {
            await crm.activities.markCancelled(delivery.accountId, id)
        } else if (delivery.type === 'event.created' || delivery.type === 'event.updated') {
            const event = await pyas.events.get({ accountId: delivery.accountId, eventId: id, calendarRef: calendarId })
            await logMeeting(await repForAccount(delivery.accountId), delivery.accountId, event)
        }
        res.sendStatus(200)
    })
  5. 11:00

    Book from the CRM onto the rep's calendar

    Create the event on the rep's account with the contact as an attendee and a meeting link: Meet on Google, Teams on a Microsoft 365 work account, Zoom on either when the rep's Zoom account is connected. Use your booking id as the idempotency key so a retry never makes a second meeting. The webhook for this change carries request.traceId, so you can skip changes you made yourself.

    book.tsTypeScript
    const meeting = await pyas.events.create(
        {
            accountId: rep.calendarAccountId,
            title: `Pricing review with ${company.name}`,
            startsAt: slot.startsAt,
            endsAt: slot.endsAt,
            attendees: [{ email: contact.email, name: contact.name }],
            conferencing: { provider: rep.calendarProvider === 'google' ? 'meet' : 'teams' },
        },
        { idempotencyKey: `crm-meeting-${booking.id}` },
    )
    await crm.activities.linkEvent(booking.id, meeting.id)

What you use

Which meetings belong on a contact

The rule most CRMs settle on: log a meeting when at least one attendee's email belongs to a contact, and skip it otherwise. Match on attendee emails, not titles. A title like "Sync w/ Dana" tells you nothing reliable. Google documents the attendee email as "the attendee's email address, if available" (events reference), so your code should cope with an attendee that has none.

The attendee check also settles the cases that come up in the first week of support tickets:

  • Internal meetings. When every attendee is on your customer's own domains, it's a pipeline review or a one-on-one. Skip it. Store the customer's domains as a workspace setting, because companies often have more than one and the rep's address alone won't tell you.
  • Personal events. A dentist appointment has no attendees, so there is nobody to match and nothing gets logged.
  • Private events. Both providers let a rep mark an event private. Google describes a private event as one where "only event attendees may view event details", and Outlook has a sensitivity of normal, personal, private or confidential (Graph event resource). Don't build the logging rule on that flag. Apply the same attendee test, and let reps remove a logged meeting by hand, since a private meeting with an outside guest is the one most likely to be sensitive.
  • Rooms. Rooms can appear in the attendee list. Google flags them with resource, and Graph gives them the attendee type resource (attendee resource). Don't count a room as a person.
  • Hidden guest lists. When the rep is a guest on someone else's meeting, the guest list may be hidden. Google has guestsCanSeeOtherGuests, "Whether attendees other than the organizer can see who the event's attendees are", and Outlook has hideAttendees, where "each attendee only sees themselves". The organizer is then your remaining clue.

Contacts are the easy part. Deals are harder, because one contact can sit on several. A rule that holds up: always attach the meeting to the contacts and their company, attach it to a deal only when exactly one open deal matches, and otherwise ask the rep.

One meeting on two reps' calendars

An account executive and a sales engineer join the same call. Each calendar holds its own copy of the meeting, with its own event ID, so logging per event gives the contact two activities. The copies share an iCalUID, which Google describes as the RFC 5545 identifier "used to uniquely identify events" across calendaring systems.

Recurring meetings need care, and the two providers disagree. Google says all occurrences of a recurring event "share the same icalUIDs", while Microsoft says its iCalUId "is different for each occurrence in a recurring series". Key each logged occurrence on the iCalUID plus its start time, which works on Google. Don't assume a Google rep and an Outlook rep see the same identifier for one occurrence of a weekly check-in; when the keys don't line up, fall back to the start time plus the set of outside attendees. Record every rep who attended on the one activity.

Reps on Google, others on Outlook

You don't choose your customers' email provider, and some sales teams run both after an acquisition. The APIs differ in field names (an attendee's address is email on Google and emailAddress on Graph), in how recurring events are stored, in how change notifications expire, and in who has to approve access. A developer weighing a calendar API put the motive plainly: "I could build for EVERYONE and not just a gsuite specific app" (Hacker News).

Pyas returns Google and Outlook events in one shape, with startsAt and endsAt in UTC, the local start time and an IANA time zone, so your matching code runs once for both.

Company Microsoft accounts need an administrator

Sales teams at larger customers are on Microsoft 365 work accounts, and those tenants decide what a user may approve. Microsoft's setting "Let Microsoft manage your consent settings", which "is also the default for a new tenant", excludes Calendars.Read, Calendars.ReadWrite and every other calendar permission from what end users can approve (app consent policies). The first rep at a new customer gets an approval screen instead of a consent screen.

Plan for it in onboarding. When a rep hits it, show a page they can forward to their IT administrator. Once the administrator approves for the organization, the rest of the team connects normally. This applies with Pyas Auth as well. The details are in the admin approval guide.

When a rep's sync stops

A silent gap is the worst failure a CRM sync can have. A manager sees a rep with no meetings last week and can't tell whether the rep was idle or the connection broke. Google lists the ways a refresh token stops working, including "The user has revoked your app's access", "The refresh token has not been used for six months" and an admin restricting the service the app uses (Google OAuth 2.0). Reps also leave, and their company shuts the account down.

Show each rep's sync state where managers look, not only in settings. With Pyas, the connection.broken webhook tells you when an account stops working, pyas.accounts.status.get({ accountId }) says why and what fixes it, and pyas.connect.sessions.create({ accountId }) opens the reconnect.

Cancelled, moved and recurring meetings

  • Cancelled. Don't delete the activity when the event is deleted. Mark it cancelled. A first call that gets cancelled is information about the deal. Google keeps deleted events as cancelled entries rather than removing them outright.
  • Moved. Pyas webhooks don't include previous values. To log "rescheduled from Tuesday", compare the event you just read with the start time you stored.
  • Whole series. A change to a recurring series arrives as one webhook with isSeries: true. List its occurrences again with pyas.events.instances over the window you keep, and update the logged ones.

Email is a separate integration

Pyas doesn't read or log email. A CRM that logs emails next to meetings needs its own Gmail and Microsoft mail integration.

Plan that review on its own schedule. On Google, full Gmail access is a restricted scope, which adds a security assessment that calendar scopes don't need (our guide to Google's review).

Questions

How does a CRM calendar integration with Google Calendar work?
Each rep connects their Google account once through OAuth. The CRM reads the rep's events over a time window, then listens for changes, and logs each meeting whose attendees include a contact's email on that contact. Meetings booked in the CRM are created as the rep's own events, so they appear in Google Calendar like any other meeting.
Can a CRM sync with Outlook and Google Calendar at the same time?
Yes, as long as you treat each rep's calendar separately and normalize what comes back. With Pyas each rep connects whichever provider they use and events arrive in one shape, so the matching code is written once. Reps on Microsoft 365 work accounts may need an administrator to approve calendar access first.
What do users expect when they ask for Salesforce or HubSpot calendar sync?
Most of those searches come from people using an existing CRM. They expect customer meetings to land on the contact and deal without manual logging, and meetings booked in the CRM to appear on their calendar with a video link. If you're building a CRM, your customers will ask for the same thing.
Does Pyas log emails to the CRM too?
No. Pyas doesn't read or log email. It covers calendars, events and meeting links, so email logging needs its own Gmail and Microsoft mail integration, with its own permission review.
How much calendar history should a CRM import on first connect?
Enough to cover the deals that are open today. A 90-day window is a common starting point, with a few weeks ahead for upcoming meetings. With Pyas it's one pyas.events.list call over whatever window you choose; for a large team, run it one rep at a time in the background and keep Google's quotas in mind.
Why does a rep see "Need admin approval" when connecting Outlook?
Microsoft's default consent policy keeps calendar permissions off the list users can approve themselves, so an administrator in the rep's company has to approve the app once for everyone. It happens with Pyas Auth too. Personal Outlook.com accounts have no administrator and aren't affected. The fix is in our admin approval guide.

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.