# Microsoft Teams meetings

> Send Microsoft Teams meeting transcripts to Dimension Labs with the universal API.

This brings in Microsoft Teams meeting transcripts through Microsoft Graph, 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 Graph API and posts each turn to the [universal API](/api/tracker).

## What you need

- Transcription turned on for the Teams meetings you want: only meetings that were transcribed have a transcript.
- A Microsoft Entra admin who can register an app and grant admin consent, and a Teams admin who can run PowerShell to create an application access policy.
- Graph API access to meeting transcripts turned on for your tenant. A tenant admin controls it; while it is off, every transcript request returns `403` with `GraphAccessToTranscriptsDisabled`.
- 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 Microsoft Graph

1. In the Microsoft Entra admin center, register an app. Note its **Application (client) ID** and your **Directory (tenant) ID**, and create a client secret.
2. Add the **application** permissions `OnlineMeetingTranscript.Read.All` (to read transcripts) and `User.Read.All` (to list your users), and grant admin consent.
3. Create an application access policy with the app's client ID, and grant it to the users whose meetings you want, or to the whole tenant. Microsoft's steps are in [Configure application access to online meetings](https://learn.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy):

   ```powershell
   New-CsApplicationAccessPolicy -Identity DL-transcripts -AppIds "YOUR_CLIENT_ID" -Description "Dimension Labs sync"
   Grant-CsApplicationAccessPolicy -PolicyName DL-transcripts -Global
   ```

   Policy changes can take up to 30 minutes to reach Graph.
4. Get a token with the client credentials grant: `POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` with `client_id`, `client_secret`, `scope=https://graph.microsoft.com/.default` and `grant_type=client_credentials` ([get access without a user](https://learn.microsoft.com/en-us/graph/auth-v2-service)). Send it as `Authorization: Bearer YOUR_TOKEN`.

## 2. Pull new transcripts

Transcripts are listed per meeting organizer. List your users, then each organizer's transcripts created in a time window:

```bash
curl -H "Authorization: Bearer $GRAPH_TOKEN" \
  "https://graph.microsoft.com/v1.0/users/ORGANIZER_ID/onlineMeetings/getAllTranscripts(meetingOrganizerUserId='ORGANIZER_ID',startDateTime=2026-10-25T00:00:00Z,endDateTime=2026-10-26T00:00:00Z)"
```

- [getAllTranscripts](https://learn.microsoft.com/en-us/graph/api/onlinemeeting-getalltranscripts) returns `callTranscript` objects (`id`, `meetingId`, `createdDateTime`) for scheduled meetings the user organized. It fails without `meetingOrganizerUserId`. `startDateTime` and `endDateTime` filter on when the transcript was created. Channel meetings aren't supported.
- Follow `@odata.nextLink` until it is missing.
- For each transcript, `GET /users/{organizerId}/onlineMeetings/{meetingId}/transcripts/{transcriptId}/metadataContent` returns one JSON line per utterance with `speakerName`, `spokenText`, `spokenLanguage` and an absolute `startDateTime` ([get callTranscript](https://learn.microsoft.com/en-us/graph/api/calltranscript-get)).
- A transcript is created when transcription starts, so the code waits `TEAMS_DELAY_HOURS` (3 by default) before it reads one, to let the meeting end. If long meetings arrive cut short, raise the delay.

Graph returns `429` with a `Retry-After` header when it throttles you ([throttling](https://learn.microsoft.com/en-us/graph/throttling-limits)).

## 3. Map the fields

| Dimension Labs | Microsoft Teams | Notes |
|---|---|---|
| `text` | `spokenText` | One message per utterance. |
| `userId` | your directory user `id`, else a hash of `speakerName` | Speakers who match one of your users get that user's Entra object id. Everyone else gets a hash of the name, never the name itself. |
| `incoming` | `speakerName` against your directory | `false` when the name matches a member (`userType` `Member`) of your tenant, limited to `COMPANY_DOMAINS` if you set it. Guests, external and anonymous speakers are incoming. |
| `sessionId` | transcript `id` | Hashed, because the id is long and can contain `=`: `teams-...`. One transcript is one session. |
| `conversationId` | transcript `id` | Same value as `sessionId`. A recurring meeting keeps one `meetingId`, so `meetingId` goes in `platformJson` instead. |
| `dimensionlabs_timestamp` | `startDateTime` | Absolute UTC time. Utterances closer than a second apart are spaced to 1,000 ms. |
| `platformJson` | `meetingId`, `spokenLanguage`, `internal` | |

Teams transcripts carry only the speaker's display name. The code tells your team apart by matching that name against the members of your tenant directory, and narrows it to your email domains when `COMPANY_DOMAINS` is set. Two people with the same display name count as one.

**Personal data.** Speaker names are used only to match and hash, and are never sent. Transcripts hold names, companies and numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// teams-meetings.mjs. Environment: TEAMS_TENANT_ID, TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, COMPANY_DOMAINS (optional),
// TEAMS_ORGANIZERS (optional: user principal names whose meetings to read, comma-separated), TEAMS_DELAY_HOURS (default 3), DL_API_KEY.



const HOUR = 60 * 60 * 1000;
const GRAPH = 'https://graph.microsoft.com/v1.0';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DELAY = Number(process.env.TEAMS_DELAY_HOURS ?? 3) * HOUR;
const csv = (s) => (s ?? '').toLowerCase().split(',').map((x) => x.trim()).filter(Boolean);
const DOMAINS = csv(process.env.COMPANY_DOMAINS);
const ORGANIZERS = csv(process.env.TEAMS_ORGANIZERS);
const hash = (s) => createHash('sha256').update(s.trim().toLowerCase()).digest('hex').slice(0, 32);
let token, tokenExpires = 0;

async function bearer() {
  if (Date.now() < tokenExpires) return token;
  const body = new URLSearchParams({
    client_id: process.env.TEAMS_CLIENT_ID, client_secret: process.env.TEAMS_CLIENT_SECRET,
    scope: 'https://graph.microsoft.com/.default', grant_type: 'client_credentials',
  });
  const res = await fetch(`https://login.microsoftonline.com/${process.env.TEAMS_TENANT_ID}/oauth2/v2.0/token`, { method: 'POST', body });
  if (!res.ok) throw new Error(`Microsoft token request returned ${res.status}: ${await res.text()}`);
  const t = await res.json();
  token = t.access_token;
  tokenExpires = Date.now() + (t.expires_in - 60) * 1000;
  return token;
}

// Retries Graph throttling (429) and server errors (5xx), honoring Retry-After. A 404 returns null.
async function get(url, { text = false } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { Authorization: `Bearer ${await bearer()}` } });
    if (res.ok) return text ? res.text() : res.json();
    if (res.status === 404) return null;
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Microsoft Graph returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

async function list(url) {
  const items = [];
  for (let next = url; next; ) {
    const page = await get(next);
    items.push(...(page?.value ?? []));
    next = page?.['@odata.nextLink'];
  }
  return items;
}

// Sends every transcript created at or after `since` (Unix ms) and at least TEAMS_DELAY_HOURS ago. Returns the new `since`.
export async function syncTeams({ since, historical = false }) {
  const until = Date.now() - DELAY;
  if (until <= since) return since;
  const users = await list(`${GRAPH}/users?$select=id,displayName,userPrincipalName,userType&$top=999`);
  const members = users.filter((u) => u.userType === 'Member'
    && (!DOMAINS.length || DOMAINS.includes(u.userPrincipalName?.split('@')[1]?.toLowerCase())));
  const team = new Map(members.filter((u) => u.displayName).map((u) => [u.displayName.trim().toLowerCase(), u.id]));
  const organizers = ORGANIZERS.length ? members.filter((u) => ORGANIZERS.includes(u.userPrincipalName?.toLowerCase())) : members;
  const window = `startDateTime=${new Date(since).toISOString()},endDateTime=${new Date(until).toISOString()}`;
  for (const org of organizers) {
    const transcripts = await list(`${GRAPH}/users/${org.id}/onlineMeetings/getAllTranscripts(meetingOrganizerUserId='${org.id}',${window})`);
    for (const t of transcripts) {
      const lines = await get(`${GRAPH}/users/${org.id}/onlineMeetings/${t.meetingId}/transcripts/${t.id}/metadataContent`, { text: true });
      const session = `teams-${hash(t.id)}`;
      let last = 0;
      for (const line of (lines ?? '').split('\n')) {
        if (!line.trim().startsWith('{')) continue; // skip WEBVTT and cue timing lines
        const u = JSON.parse(line);
        if (!u.spokenText?.trim()) continue;
        const memberId = team.get(u.speakerName?.trim().toLowerCase());
        const ts = Math.max(toMillis(u.startDateTime), last + 1000); // keep utterances at least a second apart
        last = ts;
        await sendToDimensionLabs({
          text: u.spokenText,
          userId: memberId ? `teams-${memberId}` : u.speakerName ? `teams-${hash(u.speakerName)}` : session,
          incoming: !memberId,
          sessionId: session,
          conversationId: session,
          dimensionlabs_timestamp: ts,
          platformJson: { meetingId: t.meetingId, language: u.spokenLanguage, internal: Boolean(memberId) },
        }, { historical: historical || ts < Date.now() - HOUR });
      }
    }
  }
  return until;
}
```

## 5. Run it

Save the code as `teams-meetings.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 { syncTeams as sync } from './teams-meetings.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every transcript created since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends transcripts once they are `TEAMS_DELAY_HOURS` old. By then every utterance is more than an hour old, so it goes to `/trackhistorical`: start a backfill whenever you want the latest meetings enriched.
- **Re-runs are safe:** duplicates are removed automatically.

Each run makes one call per organizer, so for a large tenant set `TEAMS_ORGANIZERS` to the people whose calls you want, for example your sales and success teams. The application access policy must cover those users too. The code reads scheduled meetings only; ad hoc calls have their own endpoints and permissions. If the run stops with `403`, check the tenant's transcript API setting and the access policy. You can also [upload a file](/data/upload-a-file) of downloaded transcripts (see [Call transcripts](/transform/recipe-call-transcripts), and check it with the [file checker](/data/check-a-file)), then run a backfill.

## Microsoft Teams reference

- [Meeting transcripts and recordings overview](https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/meeting-transcripts/overview-transcripts)
- [onlineMeeting: getAllTranscripts](https://learn.microsoft.com/en-us/graph/api/onlinemeeting-getalltranscripts)
- [Get callTranscript (content and metadataContent)](https://learn.microsoft.com/en-us/graph/api/calltranscript-get)
- [callTranscript resource type](https://learn.microsoft.com/en-us/graph/api/resources/calltranscript)
- [Configure application access to online meetings](https://learn.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy)
- [Get access without a user (client credentials)](https://learn.microsoft.com/en-us/graph/auth-v2-service)
- [List users](https://learn.microsoft.com/en-us/graph/api/user-list)
- [Microsoft Graph throttling](https://learn.microsoft.com/en-us/graph/throttling-limits)

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