02 Get your data in /Integrations
Microsoft Teams meetings
Send Microsoft Teams meeting transcripts to Dimension Labs with the universal API.
- MT Microsoft Teams meetingsTickets, chats, calls, reviews
- ⟳ Your jobPull new · map fields · every 15 min
- { } Universal APIPOST /track · /trackhistorical
- Dimension LabsRead in full · enriched daily
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.
What you need
Section titled “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
403withGraphAccessToTranscriptsDisabled. - 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.
1. Get API access in Microsoft Graph
Section titled “1. Get API access in Microsoft Graph”-
In the Microsoft Entra admin center, register an app. Note its Application (client) ID and your Directory (tenant) ID, and create a client secret.
-
Add the application permissions
OnlineMeetingTranscript.Read.All(to read transcripts) andUser.Read.All(to list your users), and grant admin consent. -
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:
Terminal window New-CsApplicationAccessPolicy -Identity DL-transcripts -AppIds "YOUR_CLIENT_ID" -Description "Dimension Labs sync"Grant-CsApplicationAccessPolicy -PolicyName DL-transcripts -GlobalPolicy changes can take up to 30 minutes to reach Graph.
-
Get a token with the client credentials grant:
POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/tokenwithclient_id,client_secret,scope=https://graph.microsoft.com/.defaultandgrant_type=client_credentials(get access without a user). Send it asAuthorization: Bearer YOUR_TOKEN.
2. Pull new transcripts
Section titled “2. Pull new transcripts”Transcripts are listed per meeting organizer. List your users, then each organizer’s transcripts created in a time window:
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 returns
callTranscriptobjects (id,meetingId,createdDateTime) for scheduled meetings the user organized. It fails withoutmeetingOrganizerUserId.startDateTimeandendDateTimefilter on when the transcript was created. Channel meetings aren’t supported. - Follow
@odata.nextLinkuntil it is missing. - For each transcript,
GET /users/{organizerId}/onlineMeetings/{meetingId}/transcripts/{transcriptId}/metadataContentreturns one JSON line per utterance withspeakerName,spokenText,spokenLanguageand an absolutestartDateTime(get callTranscript). - 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).
3. Map the fields
Section titled “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.
4. Send it
Section titled “4. Send it”// 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.import { createHash } from 'node:crypto';import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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
Section titled “5. Run it”Save the code as teams-meetings.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncTeams as sync } from './teams-meetings.mjs';.
- History first:
node run.mjs 2025-01-01sends every transcript created since that date to/trackhistorical. Then start a backfill from the Backfill tab. - Then every 15 minutes: the scheduled job sends transcripts once they are
TEAMS_DELAY_HOURSold. 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 of downloaded transcripts (see Call transcripts, and check it with the file checker), then run a backfill.
Microsoft Teams reference
Section titled “Microsoft Teams reference”- Meeting transcripts and recordings overview
- onlineMeeting: getAllTranscripts
- Get callTranscript (content and metadataContent)
- callTranscript resource type
- Configure application access to online meetings
- Get access without a user (client credentials)
- List users
- Microsoft Graph throttling
Checked against Microsoft’s documentation on 2026-10-26.