Engineering
Engineering

Adding Zoom to a Multi-Tenant SaaS: Meeting Recording to CRM

Guilherme Barroso · 2026.8.10 · 7 min read

A recorded Zoom meeting is only useful in Introzy once it becomes CRM context: the right people, companies, and existing opportunities. Getting that attachment wrong is worse than waiting.

That is the design problem we solved when we wired Zoom into Introzy. The product surface is simple: recording link, private transcript, Kai summary, timeline entry, in-app notification. The interesting part is the pipeline underneath, and the places where we refuse to guess.

The Pipeline From Webhook to Notification

After a cloud-recorded meeting ends on a connected Zoom account, the path looks like this:

Zoom webhook
  -> resolve org from Zoom account_id
  -> upsert ZoomMeetingRecording (processing)
  -> enqueue worker
  -> download recording metadata + transcript
  -> upload VTT to private R2
  -> match Nylas calendar event
  -> link existing CRM contacts/companies
  -> Kai summary (transcript + CRM context)
  -> in-app notification (once, if linked)

The webhook handler stays thin: verify the signature, resolve the org installation, upsert a processing row, enqueue work, return quickly. Zoom can send recording.completed and recording.transcript_completed close together, and it retries. We treat those arrivals as idempotent upserts plus a Redis dedupe key, then let the worker do the heavy lifting.

If we cannot map account_id to an active org Zoom install, we acknowledge the webhook and skip the upsert. Tenant resolution happens before any write.

Why Calendar Is the Match Anchor

A Zoom recording includes a meeting id, a host account, and recording files. It does not know which Introzy contact, company, or opportunity should own the context.

The calendar is the bridge between a Zoom recording and CRM context.

Google and Outlook events, synced through Nylas, give us the owner (syncedByUserId), attendees, start time, title, and Zoom join URL. That is what turns a recording into a CRM activity: who to notify, which emails to resolve against existing contacts and companies, and whether an existing opportunity already sits on that relationship graph.

We do not create contacts, companies, or opportunities from a Zoom invite. Existing records only. Ambiguous relationships stay unlinked.

The Two-Stage Matcher

Calendar matching is intentionally conservative. Stage 1 looks for a unique Zoom meeting id. Stage 2 falls back to a ±15-minute window plus a shared attendee email. The constant and result shape look like this:

const TIME_WINDOW_MS = 15 * 60 * 1000

type ZoomCalendarMatchResult =
  | { matched: true; stage: 'meeting_id' | 'time_attendee'; nylasCalendarEventId: string }
  | { matched: false; reason: 'no_candidates' | 'ambiguous'; candidateCount: number }

Stage 1 prefers a unique org-scoped Zoom meeting id. We look for that id in conferencingUrl, location, and description, because different calendar providers put the join URL in different fields.

// Stage 1: unique meeting id inside the org
const idMatches = events.filter(
  event => zoomMeetingIdFromCalendarFields(event) === zoomMeetingId
)

if (idMatches.length === 1) {
  return { matched: true, stage: 'meeting_id', nylasCalendarEventId: idMatches[0].id }
}

if (idMatches.length >= 2) {
  return { matched: false, reason: 'ambiguous', candidateCount: idMatches.length }
}

Stage 2 only runs if stage 1 finds nothing. It requires a start time within ±15 minutes (TIME_WINDOW_MS) and at least one shared attendee email, including the organizer.

// Stage 2: ±15 minutes and ≥1 shared email
const windowStart = new Date(meetingStartTime.getTime() - TIME_WINDOW_MS)
const windowEnd = new Date(meetingStartTime.getTime() + TIME_WINDOW_MS)

const overlapMatches = timeCandidates.filter(event =>
  hasEmailOverlap(eventParticipantEmails(event), seedEmails)
)

if (overlapMatches.length === 1) {
  return { matched: true, stage: 'time_attendee', nylasCalendarEventId: overlapMatches[0].id }
}

if (overlapMatches.length >= 2) {
  return { matched: false, reason: 'ambiguous', candidateCount: overlapMatches.length }
}
If the match is ambiguous, leave it unlinked. Wrong CRM context is worse than waiting.

When either stage returns more than one candidate, we keep candidateCount and leave the recording unlinked. An unmatched recording can exist in the database. It does not appear on person, company, or deal timelines, and it does not notify anyone, until matching is unique.

Storage, TTL, and Tenant Boundaries

Recording media stays in Zoom. Introzy stores the Zoom-hosted recording link, not a second copy of the video.

Transcripts are different. We download the VTT in the worker and upload it to a private R2 bucket under org context. At ingest we set transcriptExpiresAt to now plus 90 days:

export const TRANSCRIPT_TTL_MS = 90 * 24 * 60 * 60 * 1000

The browser never receives the R2 object key. Authorized downloads go through our API after org-scoped auth checks, then we stream the file.

Every write in this path is org-scoped: installation lookup, recording upsert, calendar match, CRM link, summary, notification. Useful CRM context is only useful inside the right tenant.

Notification Rules

We notify once, and only when the assets are actually useful.

Ready means:

  • recordingStatus === 'ready'
  • and (transcriptStatus === 'ready' or summaryStatus === 'ready')

The recipient is the calendar owner: NylasCalendarEvent.syncedByUserId. Not the Zoom installer. Not every attendee.

Unlinked recordings do not notify. No navigable person or company destination means no notification either.

Retries are normal, so we do not rely on “send if we haven’t already” in application memory. We claim the notification with a conditional update on assetsNotifiedAt:

const claimed = await tx.zoomMeetingRecording.updateMany({
  where: {
    id: recordingId,
    organizationId,
    assetsNotifiedAt: null,
  },
  data: { assetsNotifiedAt: notifiedAt },
})

if (claimed.count !== 1) {
  return { notified: false, reason: 'already_notified' }
}

await tx.notification.create({ /* ZOOM_ASSETS_READY + deep link */ })

The claim and create run in the same transaction, so a crash cannot mark the recording notified without creating the row.

Why Kai's Summary Is Different

Zoom can summarize what was said. Kai summarizes why it matters.

Zoom can summarize a transcript. Kai gets the transcript plus bounded Introzy CRM context: meeting title and time, linked contacts and roles, companies already in the org, a primary company when one is clear, opportunity and relationship context when that graph is available, and attendees we could not link yet.

The output is an operational summary for the workflow: what changed, what matters, what should be followed up on. It should not invent participants, companies, opportunities, or decisions the transcript and context do not support.

What Surprised Us

The Zoom recording webhook can arrive before Nylas has the calendar event. If we only matched at ingest time, those recordings would stay orphaned forever. So after calendar upserts, we run an orphan repair: if a newly synced event now has a unique Zoom meeting id, attach the unmatched recording and continue CRM linking.

That race is normal. Calendar sync and Zoom recording completion are independent systems. Matching has to be re-entrant.

Two other details that shaped the implementation:

  • recording.completed and recording.transcript_completed can arrive close together or duplicated. The webhook path has to be idempotent before the worker runs.
  • Zoom join URLs show up in location, description, or conferencingUrl depending on the calendar provider. Stage 1 searches all three.

Where This Goes Next

Not built yet:

  • Email notify (and a settings toggle) when assets are ready
  • Multi-attendee notification recipients, not only the calendar owner
  • An unmatched-recordings inbox for humans to resolve ambiguous matches
  • Manual participant linking when auto-match is wrong or incomplete
  • Kai action proposals from the call: follow-up tasks, CRM field updates, opportunity next steps, risks, objections, relationship notes

Today the foundation is the boring part that has to be right first: resolve the org, fail closed on ambiguous calendar matches, keep transcripts private with a 90-day TTL, notify once, and summarize with CRM context instead of a raw transcript alone.

Try Introzy: Connect your calendar and Zoom, then let meeting context land where your relationships already live. Get started free →

We ship in public. Come build with us.

Introzy is free to start. No card required.