# Google Meet

> Send Google Meet transcripts to Dimension Labs with the universal API.

This brings in Google Meet transcripts, one conversation per transcript, turn by turn: what your prospects and customers said, and what your own team said. You run it on your side: a small script pulls from the Google Meet REST API and posts each turn to the [universal API](/api/tracker).

:::caution
The Meet REST API deletes transcript entries 30 days after a conference ends. Keep the job running at least once a month (every 15 minutes is the plan below), or those turns are gone from the API. The transcript Google Doc in the organizer's Drive follows your Drive and Vault retention instead.
:::

## What you need

- Transcription turned on in the meetings you want, on a Google Workspace edition that supports it. Only meetings that were transcribed have a transcript.
- A Google Cloud project with the Google Meet REST API and the Admin SDK API turned on, and a service account with a JSON key.
- A Google Workspace super administrator to set up domain-wide delegation for that service account.
- The users whose meetings you want, for example your sales and success teams, and one admin user for directory lookups.
- Optionally, the email domains your company uses, for example `acme.com`, to narrow who counts as your team.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Google Meet

1. In the Google Cloud console, go to **IAM & Admin > Service Accounts**, open your service account, and under **Domain-wide delegation** copy its **Client ID**. Create and download a JSON key for it.
2. In the Google Admin console, as a super administrator, go to **Security > Access and data control > API controls** and click **Manage Domain Wide Delegation**. Click **Add new**, paste the client ID, and enter these scopes: `https://www.googleapis.com/auth/meetings.space.readonly,https://www.googleapis.com/auth/admin.directory.user.readonly`. Click **Authorize**. Google says changes can take up to 24 hours ([create credentials](https://developers.google.com/workspace/guides/create-credentials)).
3. The service account now acts as each user you name. Meet returns the conferences that user owns or took part in ([work with artifacts](https://developers.google.com/workspace/meet/api/guides/artifacts)).

`meetings.space.readonly` lets the app read metadata about any meeting space the user has access to ([scopes](https://developers.google.com/workspace/meet/api/guides/authenticate-authorize)).

## 2. Pull new transcripts

List conferences that ended since your cursor, then each conference's transcripts and their entries:

```bash
curl -G -H "Authorization: Bearer $GOOGLE_TOKEN" "https://meet.googleapis.com/v2/conferenceRecords" \
  --data-urlencode 'filter=end_time>="2026-10-25T00:00:00Z" AND end_time<="2026-10-26T00:00:00Z"'

curl -H "Authorization: Bearer $GOOGLE_TOKEN" "https://meet.googleapis.com/v2/conferenceRecords/RECORD_ID/transcripts"
curl -H "Authorization: Bearer $GOOGLE_TOKEN" "https://meet.googleapis.com/v2/conferenceRecords/RECORD_ID/transcripts/TRANSCRIPT_ID/entries?pageSize=100"
```

- [conferenceRecords.list](https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords/list) filters on `start_time` or `end_time`. Every list returns a `nextPageToken` while there are more pages.
- A transcript's `state` is `STARTED`, `ENDED` (the file isn't generated yet) or `FILE_GENERATED`. The code skips transcripts that are still `STARTED`.
- [transcripts.entries.list](https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords.transcripts.entries/list) returns one entry per stretch of one person's speech: `participant`, `text`, `languageCode`, `startTime` and `endTime`. Entries can differ from the Google Doc if speakers interleave within milliseconds or someone edited the Doc.
- [participants.list](https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords.participants) says who each `participant` is: a `signedinUser` (with a `user` id and `displayName`), an `anonymousUser` or a `phoneUser`.
- The code waits `MEET_DELAY_HOURS` (1 by default) after a conference ends before it reads it, so the transcript has ended.

Quotas, per Google: 6,000 read requests per minute per project, and 600 per minute per user per project. Going over returns `429`; back off and retry ([usage limits](https://developers.google.com/workspace/meet/api/guides/limits)).

## 3. Map the fields

| Dimension Labs | Google Meet | Notes |
|---|---|---|
| `text` | entry `text` | One message per entry. |
| `userId` | `signedinUser.user` id, else a hash of the participant | The user id is stable across meetings and works with the Admin SDK. Anonymous and phone participants get a hash of the participant resource name, never their name or number. |
| `incoming` | `signedinUser.user` looked up in your directory | `false` when the Admin SDK finds the user in your Google Workspace (and, if you set `COMPANY_DOMAINS`, their primary email is on one of those domains). Signed-in users from other organizations, anonymous and phone participants are incoming. |
| `sessionId` | conference record and transcript ids | For example `meet-abc123-def456`. A conference can have more than one transcript session. |
| `conversationId` | conference record id | Keeps several transcripts of one conference together. |
| `dimensionlabs_timestamp` | entry `startTime` | Absolute UTC time. Entries closer than a second apart are spaced to 1,000 ms. |
| `platformJson` | `languageCode`, `space`, `internal` | `space` is the meeting space, shared by every conference held in it. |

Google Meet doesn't mark participants as internal or external, so the code asks your own directory: [users.get](https://developers.google.com/workspace/admin/directory/reference/rest/v1/users/get) accepts the user's unique id, and only finds users in your Google Workspace account.

**Personal data.** Display names, emails and partially redacted phone numbers are never sent. Transcripts hold names, companies and numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

Install the auth library in your project first: `npm install google-auth-library`.

```js
// google-meet.mjs. Environment: MEET_SERVICE_ACCOUNT_KEY (path to the JSON key), MEET_USERS (emails whose meetings to read,
// comma-separated), MEET_ADMIN (an admin email, for directory lookups), COMPANY_DOMAINS (optional), MEET_DELAY_HOURS (default 1), DL_API_KEY.





const HOUR = 60 * 60 * 1000;
const MEET = 'https://meet.googleapis.com/v2';
const MEET_SCOPE = 'https://www.googleapis.com/auth/meetings.space.readonly';
const DIR_SCOPE = 'https://www.googleapis.com/auth/admin.directory.user.readonly';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DELAY = Number(process.env.MEET_DELAY_HOURS ?? 1) * HOUR;
const csv = (s) => (s ?? '').toLowerCase().split(',').map((x) => x.trim()).filter(Boolean);
const DOMAINS = csv(process.env.COMPANY_DOMAINS);
const key = JSON.parse(readFileSync(process.env.MEET_SERVICE_ACCOUNT_KEY, 'utf8'));
const hash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 32);
const clean = (s) => s.replace(/[^A-Za-z0-9_-]/g, '_');
const clients = new Map();

// Domain-wide delegation: the service account acts as `subject`.
function client(subject, scope) {
  const id = `${subject} ${scope}`;
  if (!clients.has(id)) clients.set(id, new JWT({ email: key.client_email, key: key.private_key, scopes: [scope], subject }));
  return clients.get(id);
}

// Retries quota errors (429) and server errors (5xx) with backoff. With optional, other 4xx return null.
async function get(url, subject, scope, optional = false) {
  for (let attempt = 0; ; attempt++) {
    const { token } = await client(subject, scope).getAccessToken();
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (res.ok) return res.json();
    if (optional && res.status !== 429 && res.status < 500) return null;
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Google returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

async function list(path, field, subject, params = {}) {
  const items = [];
  for (let pageToken = ''; ; ) {
    const qs = new URLSearchParams({ ...params, ...(pageToken ? { pageToken } : {}) });
    const page = await get(`${MEET}/${path}?${qs}`, subject, MEET_SCOPE);
    items.push(...(page[field] ?? []));
    pageToken = page.nextPageToken;
    if (!pageToken) return items;
  }
}

// Your team is outgoing: the user is in your Google Workspace directory (and on COMPANY_DOMAINS, if set).
const directory = new Map();
async function isInternal(participant) {
  const user = participant?.signedinUser?.user;
  if (!user) return false;
  if (!directory.has(user)) {
    const u = await get(`https://admin.googleapis.com/admin/directory/v1/users/${user.split('/')[1]}`, process.env.MEET_ADMIN, DIR_SCOPE, true);
    directory.set(user, Boolean(u && (!DOMAINS.length || DOMAINS.includes(u.primaryEmail?.split('@')[1]?.toLowerCase()))));
  }
  return directory.get(user);
}

// Sends every conference that ended at or after `since` (Unix ms) and at least MEET_DELAY_HOURS ago. Returns the new `since`.
export async function syncMeet({ since, historical = false }) {
  const until = Date.now() - DELAY;
  if (until <= since) return since;
  const filter = `end_time>="${new Date(since).toISOString()}" AND end_time<="${new Date(until).toISOString()}"`;
  const records = new Map(); // a conference several users attended is read once
  for (const subject of csv(process.env.MEET_USERS)) {
    for (const r of await list('conferenceRecords', 'conferenceRecords', subject, { filter, pageSize: '100' })) {
      if (!records.has(r.name)) records.set(r.name, { record: r, subject });
    }
  }
  for (const { record, subject } of records.values()) {
    const people = new Map((await list(`${record.name}/participants`, 'participants', subject, { pageSize: '250' })).map((p) => [p.name, p]));
    const conversation = `meet-${clean(record.name.split('/')[1])}`;
    for (const t of await list(`${record.name}/transcripts`, 'transcripts', subject, { pageSize: '100' })) {
      if (t.state === 'STARTED') continue;
      const session = `${conversation}-${clean(t.name.split('/').pop())}`;
      let last = 0;
      for (const e of await list(`${t.name}/entries`, 'transcriptEntries', subject, { pageSize: '100' })) {
        if (!e.text?.trim()) continue;
        const p = people.get(e.participant);
        const internal = await isInternal(p);
        const ts = Math.max(toMillis(e.startTime), last + 1000); // keep entries at least a second apart
        last = ts;
        await sendToDimensionLabs({
          text: e.text,
          userId: p?.signedinUser?.user ? `meet-${clean(p.signedinUser.user.split('/')[1])}` : e.participant ? `meet-${hash(e.participant)}` : session,
          incoming: !internal,
          sessionId: session,
          conversationId: conversation,
          dimensionlabs_timestamp: ts,
          platformJson: { language: e.languageCode, space: record.space, internal },
        }, { historical: historical || ts < Date.now() - HOUR });
      }
    }
  }
  return until;
}
```

## 5. Run it

Save the code as `google-meet.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncMeet as sync } from './google-meet.mjs';`.

- **History first:** `node run.mjs 2026-09-27`, a date within the last 30 days, sends every transcript the API still holds to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab. Entries for older conferences are already deleted from the API.
- **Then every 15 minutes:** the scheduled job sends conferences once they ended `MEET_DELAY_HOURS` ago. Most turns are more than an hour old by then, so they go to `/trackhistorical`: start a backfill whenever you want the latest meetings enriched.
- **Re-runs are safe:** duplicates are removed automatically, including the same conference seen through two of your users.
- **If every turn arrives as incoming,** the directory lookups are failing: check that `MEET_ADMIN` is an admin and that the directory scope is in the domain-wide delegation entry.
- **Never stop for more than a month:** if the job is down longer than 30 days, the API has deleted the missed entries.

Each conference costs a few read requests, well within Google's per-minute quotas. For meetings older than 30 days, export the transcript Google Docs from Drive, turn them into a file (see [Call transcripts](/transform/recipe-call-transcripts)), check it with the [file checker](/data/check-a-file) and [upload it](/data/upload-a-file), then run a backfill.

## Google Meet reference

- [Work with artifacts (transcripts, retention)](https://developers.google.com/workspace/meet/api/guides/artifacts)
- [conferenceRecords.list](https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords/list)
- [Transcript resource](https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords.transcripts)
- [transcripts.entries.list](https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords.transcripts.entries/list)
- [Participant resource](https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords.participants)
- [Meet REST API scopes and domain-wide delegation](https://developers.google.com/workspace/meet/api/guides/authenticate-authorize)
- [Create credentials (domain-wide delegation)](https://developers.google.com/workspace/guides/create-credentials)
- [Admin SDK: users.get](https://developers.google.com/workspace/admin/directory/reference/rest/v1/users/get)
- [Usage limits](https://developers.google.com/workspace/meet/api/guides/limits)

Checked against Google's documentation on 2026-10-26.
