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.
What your app does, step by step
- 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.// 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)} /> - 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: truereturns each occurrence of a recurring meeting as its own event. Run it in a background job.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) } - 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.
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, }) } - 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.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) }) - 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.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
- Calendar APIRead and write events and calendars on Google Calendar and Outlook, in one shape.
- WebhooksSigned webhooks that name the exact event that changed, so you read only that.
- PyasConnectThe dialog your users connect Google Calendar, Outlook and Zoom in, inside your app.
- Meeting linksGoogle Meet, Microsoft Teams or Zoom on the event, from one field.
- Account healthEach connected account's status and why it broke, so your app can ask the user to reconnect.
- Pyas AuthSign-in Google, Microsoft and Zoom have already verified, so you skip your own OAuth and app reviews.
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,privateorconfidential(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 typeresource(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 hashideAttendees, 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 withpyas.events.instancesover 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.listcall 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.