Find every recording for one Teams meeting
This is a step-by-step example, not a high-level pattern. It starts from a single Microsoft Teams recording and ends with every recording that belongs to the same meeting — plus their transcripts and audio.
Every step is a curl call you can paste into a terminal. $BASE_URL is your regional API host (for example https://uk.focus.subphonic.ai/api — see Base URLs), and $TOKEN is a bearer token from POST /authWithApiKey.
Two things group recordings here, and both matter: an Event ID ties together every participant's recording of the same conversation, and a linked interaction ties together recordings around a single leg — a transfer, a conference leg, or the same person rejoining. You filter on the first; the second comes back in your results automatically (Step 4).
Use it whenever "one recording" is not the unit you actually care about. Compliance review, eDiscovery export and QA of a multi-party meeting all need the conversation, not one participant's leg of it.
Why Teams needs this
Focus records Microsoft Teams per participant. A meeting with four recorded participants produces four separate recordings, each with its own interactionId, its own thirdPartyId, its own media file and its own transcript.
Search by person, endpoint or date range and you get one of those legs. What ties them together is the Event ID — a single value carried by every recording in the conversation.
The same identifier appears under three names:
| Where you see it | Name |
|---|---|
Focus UI, and the ?eventId= deep link | Event ID |
Focus REST API, and the attributes bag on a recording | scenarioId |
| Microsoft Teams | Call Chain ID |
They are the same value. The API uses scenarioId.
Step 1 — Authenticate
TOKEN=$(curl -s -X POST "$BASE_URL/authWithApiKey" \
-H "Content-Type: application/json" \
-d '{"clientId":1234567,"apiKey":"YOUR-API-KEY-XYZ","username":"YOUR-INTEGRATION-USERNAME"}' \
| jq -r .token)
The service user needs Recording.View for the searches below, plus Recording.Transcription.View and Recording.Play for steps 5 and 6.
Step 2 — Find one leg of the meeting
Start from whatever you already know — here, Teams communications for one person on the day of the meeting:
curl -X POST "$BASE_URL/client/recording.search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"personIds": { "include": [42] },
"communicationTypes": { "include": ["teams"] },
"dateTimeRange": {
"from": "2026-09-08T00:00:00Z",
"to": "2026-09-09T00:00:00Z"
},
"startTimeDirection": "DESC",
"page": 1,
"pageSize": 100
}'
Each result carries its Event ID in the attributes bag:
{
"recordings": [
{
"interactionId": 4815162,
"thirdPartyId": "REC-00123",
"startDatetimeUTC": "2026-09-08T14:02:11Z",
"durationInSeconds": 742,
"type": "teams",
"attributes": {
"scenarioId": "cc0da036-2f1e-4b7a-9d33-8a1c5e0f4b62",
"platform": "teams"
}
}
],
"total": 1,
"page": 1,
"pageSize": 100
}
Step 3 — Read the Event ID
attributes is omitted entirely when a recording has none, so read it defensively rather than assuming the key is there:
EVENT_ID=$(curl -s -X POST "$BASE_URL/client/recording.search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "recordingIds": { "include": [4815162] } }' \
| jq -r '.recordings[0].attributes.scenarioId // empty')
echo "$EVENT_ID"
# cc0da036-2f1e-4b7a-9d33-8a1c5e0f4b62
An empty $EVENT_ID means this recording has no Event ID — treat it as a standalone communication rather than retrying with a partial value.
Step 4 — Retrieve every leg of the conversation
Filter on the Event ID and you get the whole meeting back:
curl -X POST "$BASE_URL/client/recording.search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"attributes\": { \"scenarioId\": \"$EVENT_ID\" },
\"startTimeDirection\": \"ASC\",
\"page\": 1,
\"pageSize\": 50
}"
{
"recordings": [
{
"interactionId": 4815162,
"thirdPartyId": "REC-00123",
"startDatetimeUTC": "2026-09-08T14:02:11Z",
"durationInSeconds": 742,
"attributes": {
"scenarioId": "cc0da036-2f1e-4b7a-9d33-8a1c5e0f4b62",
"platform": "teams"
}
}
],
"total": 4,
"page": 1,
"pageSize": 50
}
total: 4 — one recording per recorded participant. The response above is abridged to a single entry; the other three legs have the same shape, the same scenarioId, and their own interactionId and thirdPartyId.
The search is still filtered by your service user's roles and rights. A four-participant meeting can legitimately return fewer than four rows if some participants are outside what that user may access — so treat total as "legs visible to me", not "legs recorded".
You don't have to ask for linked recordings. Where a matched recording is part of a linked interaction — a transfer, a conference leg, or the same person leaving and rejoining the meeting — the other members are returned in the same result set. Any row added that way carries "includedAsSibling": true, meaning it didn't match your filter itself:
{
"interactionId": 4815166,
"includedAsSibling": true,
"interaction": { "groupType": "rejoin", "groupId": "a1b2c3d4" }
}
interaction.groupType is one of transfer, conference, rejoin, sms_thread, cross_channel or manual — a participant who rejoined is the rejoin case. groupId is an opaque id shared by the members of that group.
Two different groupings are in play here, and they answer different questions:
| Field | Groups | Example |
|---|---|---|
attributes.scenarioId (Event ID) | every recording in one conversation | four participants in one meeting |
interaction.groupId | linked recordings around one leg | one participant who dropped out and rejoined |
interaction is populated only when the recording is part of a linked interaction and at least one other member is visible to you — so treat a missing interaction as "no linked legs I can see", not "no linked legs".
Collect the ids for the next two steps:
LEGS=$(curl -s -X POST "$BASE_URL/client/recording.search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{ \"attributes\": { \"scenarioId\": \"$EVENT_ID\" }, \"page\": 1, \"pageSize\": 50 }")
echo "$LEGS" | jq '[.recordings[].interactionId]' # [4815162, 4815163, 4815164, 4815165]
echo "$LEGS" | jq -r '.recordings[].thirdPartyId' # REC-00123, REC-00124, …
Step 5 — Fetch the transcript for every leg
Transcription takes the numeric ids, and accepts all of them in one call:
curl -X POST "$BASE_URL/client/recording.transcription.get" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "interactionIds": [4815162, 4815163, 4815164, 4815165] }'
Each leg is transcribed independently, so you get one entry per recording — a per-participant view of the same meeting. An empty or null analysis means that leg has not been transcribed.
Step 6 — Download the media for a leg
Media download is keyed on thirdPartyId, one call per leg:
curl -X POST "$BASE_URL/client/recording.playBackByThirdPartyId" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "thirdPartyId": "REC-00123", "format": "mp4" }' \
--output leg-1.mp4
Use mp4 for Teams meetings with video and mp3 for audio only. If your account has forced comments enabled, include a comment explaining the access or the call returns 400.
Step 7 — Open the same conversation in the UI
The portal takes the same value as a deep link, which is the quickest way to sanity-check what your integration retrieved:
https://uk.focus.subphonic.ai/communications?eventId=cc0da036-2f1e-4b7a-9d33-8a1c5e0f4b62
How attribute matching behaves
The attributes filter is deliberately strict:
- Exact match only — no partial or prefix matching.
- Multiple keys are ANDed.
- Types are preserved — numeric
3does not match string"3". - One value per key — to search several Event IDs, issue one request each.
- Ten keys maximum.
Arrays, objects and null return 400 rather than being quietly ignored, as does an eleventh key. An empty string is a real filter, so {"scenarioId": ""} matches nothing rather than everything.
Practical guidance
- Read
attributesdefensively. The bag is omitted when a recording has none, andscenarioIdis absent for sources that don't supply one. Fall back to treating the recording as standalone. - Store the Event ID alongside your own records. If you sync recordings into your own store, persisting
scenarioIdlets you group legs locally without re-querying Focus. - One request per Event ID. The filter takes a single value per key, so a batch of conversations is a loop, not one call. Space the requests out to stay inside your rate limit.
- Set
pageSizeexplicitly. Large meetings and long transfer chains can produce more legs than you expect; page through them rather than assuming one page is the whole conversation. - Don't build the id yourself. Always take
scenarioIdfrom a recording or an Event ID a user gave you. It is opaque, and partial values match nothing.
Related
- Find a communication by Event ID — the endpoint reference for this filter.
- Search recordings — full schema and an interactive "Try it" console.
- A simple third-party workflow — surfacing Focus data in context inside another tool.
- Teams troubleshooting — when recordings for a meeting are missing rather than simply split across legs.