The Evolution of Call Queues
Call queues for Microsoft Teams have come a long way since Microsoft’s initial offering in 2017. At the time, administrators configured call queues in the Skype for Business Online Admin Center. Since then, Microsoft has added many new features to help Teams Phone become a legitimate replacement for traditional phone systems.
Recent additions include call handling when no agents are signed in, automated call back offers, and the ability to link a Microsoft Shift to a call queue.
The Problem Persists
However, one major drawback of call queues still exists: a lack of notifications for missed or abandoned calls received in call queues. This is a genuine issue for organizations that take customer service seriously and want to call their customers back in case they couldn’t answer a call.
Microsoft introduced the Queues App as part of Teams Premium at the end of 2024. While the Queues App provides some reporting features and is a welcome addition to Teams Phone, it still doesn’t quite cover everything, especially the issue around missed calls (Figure 1).

The app reports how many calls were answered or abandoned for a specific queue, or which agent answered how many calls. Unfortunately, the story is quite similar to the Microsoft auto attendant and call queue historical Power BI reports. The reports offer many insights that focus on statistics over a certain amount of time, but lack a simple missed/answered call log.
The only solution to get a report of missed and answered calls, including caller numbers and agent names, is to either buy a third-party reporting tool or build one yourself.
Microsoft Graph to the Rescue
The first thing I do whenever I’m writing a new PowerShell script that leverages the Microsoft Graph API is to figure out what permissions are needed for what I want to achieve. In this case, I want to fetch call records and PSTN calls.
Configuring the Prerequisites
Permissions to read call records and PSTN calls are only available as application permissions. This means that I cannot use a user account signed in with delegated credentials. Before I can query any call record data, I must register an application for the purpose.
To automate the process, I wrote a script to handle the creation and configuration of the app registration. The app registration is created using delegated permissions. So, this step requires an account with at least Cloud Application Administrator and Privileged Role Administrator or Global Administrator. If you have not previously granted admin consent for the Application.ReadWrite.All permission for the Microsoft Graph Command Line Tools, you’ll be asked to grant admin consent during sign-in.
#Requires -Module Microsoft.Graph.Applications Connect-MgGraph -Scopes "Application.ReadWrite.All" $appDisplayName = "Call Queue Missed Call Reporting" $appRegistration = New-MgApplication -DisplayName $appDisplayName -SignInAudience "AzureADMyOrg" -Web @{ RedirectUris = @("https://localhost") } -RequiredResourceAccess @( @{ ResourceAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph ResourceAccess = @( @{ Id = "df021288-bdef-4463-88db-98f22de89214" # User.Read.All Type = "Role" } @{ Id = "a2611786-80b3-417e-adaa-707d4261a5f0" # CallRecord-PstnCalls.Read.All Type = "Role" } @{ Id = "45bbb07e-7321-4fd7-a8f6-3ff27e6a81c8 " # CallRecords.Read.All Type = "Role" } ) }
The next piece of code creates a service principal for the app registration. The service principal is what shows up under Enterprise applications in Entra ID. The script then adds a role assignment for each of the app registration’s required permissions. This is the equivalent of granting admin consent to the application permissions on the app registration in the Entra ID portal.
$servicePrincipal = New-MgServicePrincipal -AppId $appRegistration.AppId $graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" # Microsoft Graph Service foreach ($permission in $appRegistration.RequiredResourceAccess.ResourceAccess) { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id -PrincipalId $servicePrincipal.Id ` -ResourceId $graphServicePrincipal.Id ` -AppRoleId $permission.Id }
The final piece of the puzzle is to create a client secret using PowerShell. I’m still in the test phase and want to run the reporting script from my computer. Because I don’t want to re-enter the secret every time I authenticate, I’m using the code below to store the secret in an encrypted string on my PC. I can now use PowerShell to import and decrypt the secret from the local hard drive as long as the process is run by the same Windows user account and the same machine that encrypted the secret.
$clientSecret = Add-MgApplicationPassword -ApplicationId $appRegistration.Id -PasswordCredential @{ DisplayName = "Graph PowerShell" EndDateTime = (Get-Date).AddMonths(3) } $encryptedSecret = $clientSecret.SecretText | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString Set-Content -Path ".\Scripts\Teams\CallRecords\auth.json" -Value ` (@{ appId = $appRegistration.AppId; tenantId = (Get-MgContext).TenantId; secret = $encryptedSecret } ` | ConvertTo-Json) -Force -Encoding UTF8
For production workloads, it’s always recommend to use an X.509 certificate or System Assigned Managed Identities if your resources are on Azure.

Figure 2 shows the app registration in Entra ID. It looks just like I created it using the Portal. All the permissions are Application permissions, which means that the app itself can perform actions without a signed-in user.
Fetching Call Records
Now that I have the authentication piece sorted out, I can start querying the Graph API for some call records. The code below reads and decrypts the client secret from my hard drive and connects to the Microsoft Graph API using the application created beforehand.
$auth = Get-Content -Path ".\Scripts\Teams\CallRecords\auth.json" | ConvertFrom-Json $clientSecret = $auth.secret | ConvertTo-SecureString $clientSecretCredentials = New-Object System.Management.Automation.PSCredential($auth.appId, $clientSecret) Connect-MgGraph -TenantId $auth.tenantId -Credential $clientSecretCredentials Get-MgContext | Format-List AppName, ClientId, TenantId, AuthType, TokenCredentialType, Scopes
The Graph endpoint for call records is https://graph.microsoft.com/v1.0/communications/callRecords. Luckily, this endpoint also supports filtering so I can limit the results to call records that include a specific user ID. In my case, the user ID belongs to a Microsoft Teams Resource Account linked to an auto attendant.

Figure 3 shows that calls are first received by an auto attendant before they’re forwarded to the call queues. To get the full picture of how a call flowed, the report needs to target the resource account of the voice app that first receives the calls, even when the actual reporting is for the nested call queues.
The code below lists any available call records in which the user ID of the auto attendant is present as one of the call participants.
$resourceAccountId = "483c7f8d-446c-4e32-b9ec-3129ada9c044" # Resource account associated with the top-level auto attendant or call queue $callRecords = (Invoke-MgGraphRequest -Method Get "https://graph.microsoft.com/v1.0/communications/callRecords?`$filter=participants_v2/any(p:p/id eq '$($resourceAccountId)')" -ContentType "application/json" -OutputType PSObject).value
Optionally, it’s also possible to add a time range to the filter. In this example, I fetch calls which are at least four hours old.
$timeFilter = (Get-Date).ToUniversalTime().AddHours(-4).ToString("yyyy-MM-ddTHH:mm:ssZ") $callRecords = (Invoke-MgGraphRequest -Method Get "https://graph.microsoft.com/v1.0/communications/callRecords?`$filter=participants_v2/any(p:p/id eq '$($resourceAccountId)') and startDateTime lt $timeFilter" -ContentType "application/json" -OutputType PSObject).value

In the example of Figure 4, five call records are returned that match the user ID. The type groupCall indicates that these calls involved call queues. I will need their IDs to query additional data later on. Note: The caller number is fully visible in the output but has been anonymized.
Analyzing the Data
If you look at the start and end times of the call records in Figure 4, it’s clear that they all last at least a couple of seconds. However, some of these calls are missed. Unlike normal Teams calls, calls to a voice app do not ring and wait for somebody to answer. An auto attendant or a call queue answers calls immediately. The time during which an auto attendant routes calls or a call queue searches for an available agent counts towards the call duration. Therefore, the duration of a call can’t be used as an indicator of whether a call was answered or missed.
Call Record Sessions
To learn if a call to a call queue was missed or answered, I need to use a call record ID to retrieve its associated sessions. Sessions include data about each individual participant of a call.
$call = $callRecords[0] $sessions = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/communications/callRecords/$($call.id)/sessions" -OutputType PSObject).value
By filtering the sessions for non-empty display names and subtracting the session start time from its end time, I can calculate the duration of each session that involved a call queue agent. If an agent didn’t answer a call, the duration will be zero seconds.
$agentSessions = $sessions | Where-Object { $_.caller.identity.user.displayName -ne $null -and $_.caller.identity.user.id -ne $callOrganizerId } $agentSessions | Sort-Object -Property startDateTime | ft startDateTime, endDateTime, @{Name="duration";Expression={($_.endDateTime - $_.startDateTime).TotalSeconds}}, ` @{Name="CallerSideUsers";Expression={$_.caller.identity.user.displayName}}
Example Output for a missed call:
startDateTime endDateTime duration CallerSideUsers ------------- ----------- -------- --------------- 03.09.2025 19:15:52 03.09.2025 19:15:52 0.00 Evelyn Carter 03.09.2025 19:16:05 03.09.2025 19:16:05 0.00 Evelyn Carter
And here’s an example of an answered session. In this case, one of the session durations is greater than zero seconds.
startDateTime endDateTime duration CallerSideUsers ------------- ----------- -------- --------------- 03.09.2025 19:17:42 03.09.2025 19:17:42 0.00 Evelyn Carter 03.09.2025 19:17:51 03.09.2025 19:18:03 12.09 Evelyn Carter
Both examples include two sessions for the same agent because the call traversed from the first queue to the second queue as depicted in Figure 4. In the second example, the call was missed in the first queue, but was ultimately answered in the second queue. That’s why it’s important to filter for the resource account ID of the top-level voice app and not just target a single call queue’s resource account. The resource account of the top-level voice app is referenced throughout the entire call record and sessions, and thus returns all the required data.
Putting Everything Together
You can get the full scripts for both the creation of the Entra ID Application and the reporting script here. The reporting script does some other clever things under the hood as well.
For example, it uses the PSTN call records endpoint to enrich the report with additional data, such as the called number (number of your auto attendant or call queue), because that’s missing from the call records themselves.
The report also includes the agent’s name and platform used (Windows/Mac OS/iOS/Android etc.) on answered calls.

The output of the script is a very basic, yet informative overview of all calls received on a specific auto attendant/call queue, as shown in Figure 5. It includes all important details like the caller number, called number/voice app, and the last involved voice app (where a call was answered or missed). If your call flow has escalation queues, you can see how many calls overflow into another queue. Furthermore, the report also tells you how long a caller was waiting in the queue before an agent answered the call and how long the actual call was.
Caveats
Call records are published to Microsoft 365 tenants in versions. The general rule of thumb is that the more participants for a call, like the number of call queue agents or the number of call queues and auto attendants a call traverses, the more versions you will get. The problem is that early versions often don’t include all the data necessary to determine if a call was answered or missed, and there is no way to say that any version is the final version. For example, version 2 of a call record might still not include a session of an agent that answered a call and thus look like a call that was missed. Versions 3 or 4 might then include this additional information, which makes it very hard to do accurate reporting.
In my experience, it’s best to wait at least four hours before you use the data for reporting. That’s why the script includes a variable called $reportingDelay = -4. This will only process call records of calls that are older than 4 hours. You might have to adjust this value to suit your own environment.
Summary
Using the Graph API to create your own report is definitely helpful and gets the job done, but due to delayed updates in call record versions, it’s still not suited for true real-time reporting on missed call queue calls – something any other phone system provides out of the box.
While I appreciate Microsoft giving us tools like the Graph API and PowerShell to build a reporting script like this, I’d still very much like to see them introduce shared call history as a native feature for call queues.