Featured image of post Get Missed Call Notifications for Teams Call Queues (Free Community Solution)

Get Missed Call Notifications for Teams Call Queues (Free Community Solution)

In this blog post I'm going to explain how you can add this missing feature to your tenant for free. (Aside from the costs for the Azure resources.)

Intro

If you’re reading this, it will likely mean that you’ve noticed that Teams doesn’t show missed calls on call queues and that you’re desperately looking for a solution which doesn’t cost a fortune or requires that you give a 3rd party access to your Teams call records. If that’s the case, stay with me because I’m going to show you how you can build your own notification mechanism inside your Microsoft 365 tenant.

But first I’d like to point out a couple of things about the history of this issue.

Where Can I See Missed Calls on Call Queues?

The only place where you can actually see if a call was missed on a call queue is in the iOS call history of an iPhone where a call queue agent is signed in on the Teams app on iOS. An iPhone will only show missed calls if the call was offered to the signed in agent. So, this only works with attendant routing (all agents are offered the call at the same time) and presence based routing off.

Limitations Disclaimer

Read this very carefully:

This solution is based on the assumption that there’s always an agent (M365 User) opted into a call queue which doesn’t use presence based routing and uses attendant routing. Any other routing method won’t work since we can’t be sure that the monitoring user got offered the call as well.

This solution is primarily intended for small businesses that have simple, non-presence based attendant routing queues and don’t want to purchase a 3rd party solution to get missed call notifications on call queues. If this isn’t feasible for your scenario, you’re welcome to continue reading (my discoveries might still be very interesting to you nonetheless) but I’m sorry to tell you that this solution won’t work for you.

TL; DR

If you don’t care about all the details and just want to deploy this straight away, feel free to jump directly to Clone Repo and Video Tutorial.

Missed Call Queue Calls on iOS

I actually mentioned using iOS as the closest workaround to seeing missed calls on call queues in one of the many Tech Community threads back in 2020. This gives us a good idea of just how long this issue has been persisting. Sadly, a moderator of this thread just confirmed this as the best response and marked the questions as solved. (LOL?)

I always like to say that not being able to see missed calls on call queues is the Achilles’ heel of Teams Phone and I just don’t understand why Microsoft hasn’t provided a solution yet. It’s such a basic feature after all.

Anyway, let’s look at some examples on how missed calls on call queues are shown on an iPhone. This won’t work on an iPad or an iPod (if that’s still a thing?) since these devices don’t have a phone app. It only works on an iPhone and Show Teams calls in call log must be enabled in the Teams app’s settings.

Unlike normal missed calls, missed calls from call queues won’t display a badge counter on the iPhone’s phone app and they aren’t shown in the notification center or the lock screen either. The badge counter is only shown within the phone app on the call history icon. This is the first reason why this solution is basically useless. You’ll see why I mention it anyway later on.

No Badge Counter on Phone App

That means that someone who’s an agent in the queue that should be monitored would constantly need to go into the phone app and check if there were any missed calls. There’s also no efficient way of extracting call logs from iOS and send them somewhere. Believe me, I have tried everything from getting the data through Siri Shortcuts to getting it from an iCloud or a local backup.

Once you go into the call history, it’s at least possible to filter for missed calls or search for Teams or even the call queue name, despite that being truncated and not visible at all.

Missed Call Queue Call Missed Call Queue Call Details

I do no need to apologize for the German screenshots. This is my main device and I didn’t want to change the language. In case it’s not clear: Verpasster Anruf means Missed Call.

Let’s look at the next example. In this case the call was answered by another agent or by the same agent on another device. In that case, the call history item won’t be red and the details of the entry say Answered on another device (Auf anderem Gerät angenommen).

Answered Call Queue Call Answered Call Queue Call Details (Answered on Another Device)

For a very small company this might be acceptable, at best. But this becomes problematic as soon as you have at least one nested (overflow) queue. In that case, the call on the first queue will be shown as missed, even when it was answered in the overflow queue. This is the second reason why this isn’t a practical solution.

1st Call: Answered in Overflow | 2nd Call: Missed in Top-Level Queue (Top to bottom) 2nd Call in History (Top to Bottom) 1st Call in History (Top to Bottom)

A real solution needs to be able to recognize if a call was answered in an overflow queue and not report calls as missed, if nobody was able to answer in a top-level queue.

Will Microsoft Ever Add This Feature?

I’ve got good news and bad news for you. The good part is: Yes. The bad part is, believe it or not, that this will be part of Teams Premium. This has been confirmed to me by Ilya Bukshteyn - VP, Microsoft Teams Calling and Devices on LinkedIn.

LinkedIn Comment

I’ve said before that I totally understand that Microsoft is charging for Teams Premium features like Intelligent Recap since that requires actual processing power and costs Microsoft Money to operate. But I’m keeping my stance here, something basic as missed call notification should not cost money, regardless of how this is going to be implemented. I really hope that Microsoft realizes this and will re-think that decision, especially since I’ve just proven that iOS already logs missed calls.

The Queues App

This is the official support article for the new Queues app in Teams. There’s a section about View call history which as of the time of writing this article states the following:

To view past calls, including calls or voicemails you may have missed, select Calls under Manage queue.

You can select any call in your history to see more detailed information and call that number back using the number associated with your call queue.

Currently, call history includes the past calls that you have picked up or calls that you have missed that have a voicemail.

― Microsoft Support

In other words, there is no shared call history yet, not even for Teams Premium customers.

What About Teams on Windows?

Even without the Queues app, it’s exactly as the support article says. Agents only see the calls they answered. They don’t see any missed calls or calls answered by other agents, not even if they’re a delegate of the user who answered a call queue call. The call from 21:43 is not visible on the right because it was answered by the user on the left.

Call History for Answered Call Queue Calls for a Specific Agent Call Queue Call History for Another Agent

The fact that the call history does show answered calls and that the iOS call history includes missed calls and calls that were answered by another agent/on another device got me thinking. This data must be available somewhere…

Where Does Teams Fetch the Call History From?

I opened up Teams in the browser and used the Edge Dev Tools to see what’s going on under the hood when I open a user’s call history in Teams. I was hoping that this would be a standard Graph request but of course this had to be an internal, unofficial, undocumented API. Sigh.

1
2
GET
https://teams.microsoft.com/api/csa/emea/api/v1/chats/48%3Acalllogs/messages?messagePageSize=200

JSON Response

Using unofficial APIs isn’t really suited for production but I’m going to show it to you anyway. The browser tools have this neat little feature that lets you copy a request for various CLIs. Of course I’m choosing PowerShell.

Copy Request as PowerShell

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
$session.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0"
Invoke-WebRequest -UseBasicParsing -Uri "https://teams.microsoft.com/api/csa/emea/api/v1/chats/48%3Acalllogs/messages?messagePageSize=200" `
-WebSession $session `
-Headers @{
"x-ms-user-type"="real-user"
  "authorization"="Bearer (Token Removed)"
  "cache-control"="no-store, no-cache"
  "x-ms-client-type"="cdlworker"
  "x-ms-session-id"="22f0ef6c-d8b7-46a8-aea2-58451d1fd722"
  "Referer"="https://teams.microsoft.com/v2/worker/precompiled-web-worker-b686ae686e2a6f80.js"
  "x-ms-migration"="True"
  "x-ms-client-version"="1415/24090101423"
  "x-ms-request-id"="072d879b-e316-4fd6-8c54-b02d82e8f9f6"
  "x-ms-client-caller"=""
  "x-ms-partition"="emea01"
  "x-ms-region"="emea"
  "x-ringoverride"="general"
}

All I need to do is store the outputs of line 3 in a variable to access the raw data after the request has been made in PowerShell:

1
$callLogs = Invoke-WebRequest -UseBasicParsing -Uri "https://teams.microsoft.com/api/csa/emea/api/v1/chats/48%3Acalllogs/messages?messagePageSize=200" `

Then I needed to convert the JSON response into a PowerShell object:

1
$callLogs = ($callLogs.Content | ConvertFrom-Json).messages

This of course, includes all call logs. To filter for call queues only, I wrote this code:

1
$callLogProperties = ($callLogs.properties.'call-log' | ConvertFrom-Json) | Where-Object { $_.callType -eq "multiParty" }

By inspecting the call log items, I discovered that it even lists the id of the call queue on which the call was received. Yes, the id of the call queue, not the one of the resource account associated with the call queue.

Example of a Single Call Queue Call Log Entry

To get a table of all call queue calls for this user, I came up with this code:

1
2
# Note: This is only displaying the first 6 items
$callLogProperties[0..5] | Select-Object startTime, callDirection, callState, callId, @{Name = "callerNumber"; Expression = {$_.participants[0].replace("4:","")}} | Format-Table

Filtered PowerShell Output of Call Queue Call History for a Specific User

Sadly, this endpoint does not return missed calls. It does however, include calls that were answered by this user, by another user or declined by this user.

So, the call history in iOS remains the only place where missed call queue calls are actually visible in a UI. However, if you search the Teams logs in the cache directory (%APPDATA%\Local\Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams) long enough, you’ll find some client logs of missed call queue calls eventually but this is purely educational.

Missed Call Reference in Teams Client Logs

What’s interesting here is that it says callingEnableMissedCallNotification=false. Dear Teams, why won’t you tell us about missed call queue calls!? (This is a rhetorical question.)

Let’s get back to PowerShell. When you copy that request from the browser dev tools, it copies a JWT (JSON Web Token) with it. Obviously this token won’t live forever and will need to be renewed at some point. The token expires after roughly 24 hours which seems quite long to me. But still, nobody wants to manually refresh the token every day.

Just to prove the concept, I hacked together a small PowerShell script which uses bits of the AADInternals PowerShell module to get a token for the https://chatsvcagg.teams.microsoft.com audience. I was able to get a token using a call queue agent’s username and password but I couldn’t get it to work when MFA is enforced. Since we won’t need this for this solution anyway, I’m not going to publish this script here.

At this point I realized that I need to work with what I had. If I can get a list of all calls that were answered somewhere by someone I can still use that to determine if a call queue call was missed. All I need to do is get a hold of the call id and check if it’s in the call log of a user that’s an agent of the queue I want to monitor for missed calls.

But then there was the problem that all this data was retrieved through an internal API. When you look at the JSON response again, you’ll notice that there’s a property called containerId and it’s value is 48:calllogs. That sounds interesting, doesn’t it?

Fetching a Users Call History Through Graph API

My next step was to see if I can get this information officially from Graph by signing into Graph PowerShell with the credentials of a call queue agent.

1
2
$user = Get-MgUser -UserId "evelyn@nocaptech.ch"
$userCallHistory = Get-MgUserChatMessage -UserId $user.Id -ChatId "48:calllogs"

Bingo. It works.

Get Teams User Call History via Graph PowerShell

But the Graph API returns much less data than the Chat Service Aggregator. For example, I can’t see if the call was accepted, accepted elsewhere or declined by the user.

Example of Single Call Record

But when I access the body property of an object inside the array, I can see the call id.

Body\Content Contains Call Id

So, now I’m able to retrieve a list of all calls that were either answered or declined by this user, or that this user was offered by the call queue but was answered by another agent. This brings us to a very important part about the limitations of this solution.

Limitations of This Solution

You will either need to define a real agent user which will always be opted-in to the queue and you must ensure that this agent will never decline a call. (If an agent declines a call, the call id will show up in their call log but when the call log is retrieved via Graph, the results don’t include the callState like accepted or declined.) Because you can’t really control that, I highly recommend to set up a service account and add it to the queue as an agent instead, even if that adds a small monthly license cost to it.

Your queue must not use presence based routing and the service account must always be signed in on a Teams device/app. I recommend signing the user in on a spare iPhone if you have one. If you don’t have one, you can also use Teams desktop, a Teams desk phone or an iPad or whatever you can spare. It just needs to be a device which is running and connected to the internet 24/7 and where the service account is signed into the Teams app persistently.

I made lots of test calls while my service account was signed in on the Teams app on my iPad and this worked flawlessly. The important thing is that the service user always gets offered the calls but never declines them. Since it’s not a problem if the service account answers calls, this could also be a non-personal account for a CAP (Common Area Phone) you might already have in place. If you decide to use dedicated service account for monitoring your call queues, you can also add the same service account to multiple queues, even if the different queues don’t share the same agents. Just keep in mind that there’s a limit of how many chat messages of the call logs chat Power Automate can fetch through the Graph API. This limit is 50. So, if you expect more than 50 calls in a window of 30 minutes, you’ll likely need a dedicated service account per queue. Again, this solution is intended for small companies which just want to see when they missed a call on their main number.

The service account must be licensed with a license that includes Teams, Teams Phone Standard and SharePoint/OneDrive.

I’ve only tested this with call queues which have Conference Mode enabled.

Securing The Credentials to Access The Graph API

What I did before, to interactively sign into Graph PowerShell using the user’s credentials isn’t an ideal solution. Even if I was using a service account already. I always try to use MFA, even for service accounts. Let’s see if I can get the same result using app only authentication.

App Only Authentication Doesn’t Work

Unfortunately, it’s not possible to query the 48:calllogs chat id using application permissions, even when the app has the appropriate permission Chat.Read.All. That meant that I had to find another way to securely get the messages in the Call Log Chat. At some point I’ll be building a Power Automate Flow to send the Adaptive Cards for the missed call notifications anyway. So why not try to get the chat messages through a Graph Request from Power Automate as well? That would certainly solve the authentication/token issue.

High-Level Solution Architecture

Alright, it’s time to start architecting this whole thing. It might look a little overkill at first but that’s because I’m a perfectionist.

The whole process is kicked off approximately 15-30 minutes after a Teams call has ended. That’s how long it usually takes for Microsoft to make the call record available in your tenant. By using a Graph Subscription I can have Graph send me a notification every time a new record is created or when an existing one is updated. Notifications can be delivered to an endpoint of your choice. I’m using an Azure Function for this.

High-level Diagram

This will kick off a chain of events to determine if a call to a call queue was answered or missed and notify the Team members if it was missed.

How To Build This Solution

Let’s dive into how you can build this solution in your own environment. I’ve invested quite a few additional hours to make this as easy as possible for you by writing some deployment scripts. What I didn’t script is the creation of the service account, it’s licensing or updating the group description. You’ll need to follow the instructions below to prepare everything.

Service Account

Create a new account in Entra ID and assign any combination of licenses that include:

  • Microsoft Teams
  • Skype for Business Online Plan 2
  • Exchange Online (Plan 1 or 2)
  • SharePoint Online
  • Power Automate Free
  • Microsoft Teams Phone Standard

The account doesn’t necessarily need a phone number. If you don’t assign one, the user must be enabled for Enterprise Voice through PowerShell. This is needed so that the account can be added to call queues.

1
Set-CsPhoneNumberAssignment -Identity $userId -EnterpriseVoiceEnabled $true

Example Scenario

Here we have an example call flow. The number is mapped to an auto attendant which then forwards to a call queue which has a nested call queue, in case nobody is able to answer the call in the first queue. Not all agents are opted into the Level 1 call queue because only Evelyn Carter is primarily answering calls. If she fails to answer calls in the Level 1 queue, the calls overflows into the Level 2 queue, where all the agents are opted in. On the left you can see that the service user Q Works hasn’t been added as an agent to the queue yet. On the right, the Q Works user is also an agent, so that I can use the Graph API via Power Automate to check if calls were answered by other agents of the queue.

Call Queue without Service Account Agent Call Queue with Service Account Agent

So far, I have only tested the solution with an auto attendant and a maximum of 2 nested queues or an auto attendant with an IVR which has 2 queues as menu options. In theory, this solution should work with any amount of nested queues or IVR options.

Teams

To receive alerts about missed calls on call queues, you need to map your inbound numbers of top-level call queues or queues which are nested behind a top-level auto attendant to the Teams in which you want to receive the notifications. To map a number to a Team, simply adjust the Team’s description by adding Q.Works Phone Number:<Your Phone Number> at the very beginning of the description. Do not add any other characters after the phone number.

Make sure to paste the phone number exactly as it appears in Teams Admin Center on your resource account, including all the spaces. Do not add any other characters such as hyphens or dots.

Description in Entra ID Admin Center

Q Works Lite expects only one Team to receive notifications per phone number. If you’re an owner of the Team, you can edit the description directly in the Teams App. But you can also edit the group’s description in the M365 Admin or the Entra Admin Center.

When the Azure function runs, it will fetch the phone number that was called and then look for the Team by searching for the matching description, so that it knows in which Team the notifications need to be posted.

Prerequisites

First of all, make sure that you’ve got all these tools and PowerShell modules installed on your machine.

  • PowerShell 7
  • Azure Function Core Tools
  • Azure CLI
  • Python 3.11.9
  • Node.JS
    • M365 CLI
  • PowerShell Modules:
    • Microsoft.Graph.Applications
    • Microsoft.Graph.Users
    • Az.Accounts
    • Az.Functions
    • Az.KeyVault
    • Az.Storage
    • Az.Resources
    • Az.Websites

Clone Repository

Before anything is installed/deployed you’ll need to clone my git repository or download a zip file of the code.

1
git clone https://github.com/mozziemozz/Q-Works-Lite-Public.git

You can use the below scripts to install everything. Make sure that you run both scripts as administrator. After installing the tools, close PowerShell and make sure to run the second script in PowerShell 7.

  1. Run Install-Prerequisites-Tools.ps1
  2. Run Install-Prerequisites-Modules.ps1

Customize For Your Environment

Open the Json file located at .\Deployment\environment.json to customize the deployment for your environment. You can specify a name for the solution, your company shortname and the Azure region.

1
2
3
4
5
6
{
    "SolutionName": "Q Works Lite V3",
    "CompanyShortName": "NCT",
    "RedirectURI": "http://localhost",
    "AzureRegion": "Switzerland North"
}

Run Setup.ps1

Once all the required tools and modules are installed, run the Setup.ps1 script located in the .\Deployment folder of the repo. This will deploy all the resources in Azure. Your account will need global administrator permission and it needs permission to be able to create new Azure resources, including resource groups.

Enable Solution

To enable Q Works Lite, you will need to import the Power Automate Flow to the service user’s Power Automate environment so that you can then copy the trigger URL and paste it into the deployment script. (You’ll be prompted to paste at some point). Don’t just import the Zip file that’s located in .\PowerAutomate. The deployment script will extract this archive, make some changes specific to your environment and repackage a new zip file which then needs to be uploaded. Finally, run the Renew-GraphSubscription PowerShell function once from the portal or wait for it to be 12:15 UTC either on this day or the next day. This function will create a new subscription the first time it runs.

Everything is explained in much more detail in the tutorial video. I highly recommend to watch it either while you’re deploying this yourself or before you’re getting started.

Video Tutorial

Because I felt like this is too much to type, I’m trying something different this time around and I recorded a YouTube video where I run the setup myself and explain everything in detail instead.

What do the Functions and the Flow do?

There are four functions in total. Three of them are PowerShell functions and one of them is a Python function but all of them use the same Linux App Service Plan. One of these functions is solely used to manage and renew the Graph subscription so it doesn’t expire. The Python function uses a Python library called phonenumbers to format the raw E.164 numbers into an international format which is easier to read for humans. It basically inserts spaces at the correct positions for any given phone number in the world. I’ve used and written about this in an Azure Runbook before. Now I’ve created my own Azure Python Function so this can be used by the Analyze-CallRecord PowerShell function. And finally, the Receive-GraphNotifications function is receiving the notifications from Graph. Graph expects a response within 10 seconds so this function is really minimalistic. All it does is save the call Id to a storage queue in the storage account that was created for the PowerShell Function App. A new message in the storage queue will then trigger the Analyze-CallRecord function which does the actual leg-work.

Azure Resources

The authentication to Graph is done through an Entra Application which uses application permissions/app only authentication with a client secret. The client secret is stored securely in an Azure Key Vault and the permissions on the Key Vault secrets are assigned to the managed identity of the PowerShell Function App.

The main function (Analyze-CallRecord) then checks if it was a groupCall (call queue call) and if it was a PSTN or an internal call. The function only processes external calls to call queues, everything else is disregarded. Unfortunately, it’s not possible to create a Graph subscription only for certain types of call records so the function will be called for any new call records. That includes internal calls and Teams meetings as well. However, the free plan includes 1 million executions per month which should be plenty enough for small businesses.

Once the function has gathered all the details about the call, and if it was indeed a PSTN call to a call queue, the function will then trigger the Power Automate Flow.

The flow will fetch the call logs chat history of the service account (which is also a member of the call queues) and checks if the current call id is present in the call logs or not. If the call id is found in the call logs chat history, it means that the call was answered by another agent. If the call id is not present, it means that the call was missed by all agents and it went unanswered.

If the call was missed, the flow will post a new adaptive card to the Teams channel and wait for a response (until a call queue agent has clicked the Call Back Completed button).

End User Experience

This is how it looks like in Teams. Once the Call Completed button was clicked, the card will update for all users so everybody will be able to see, who called the customer/caller back. (Completed By: {User Name})

New Notification Call Back Button Updated Card

Summary

My goal was to deliver a missed call notification as quickly as possible but do it with close to 100% accuracy with V1 of the call records. (I purposely said close to 100% because there could still be occasional outages with the Graph notification service etc.) That’s why I chose to go the route of having an always opted in monitoring agent in the queue. The problem is, especially with complex call flows that have a lot of nested queues, IVRs etc. that it can take multiple hours for the call records to have enough information to determine whether a call was answered or missed. If you analyze the data too soon, you’ll get false positives and if you wait until the data is complete, it will take much longer until the agents receive the notification.

I may extend this solution in the future by also processing updated call records to create a shared call history for call queues in a SharePoint list. But for now, or at least until Microsoft delivers shared call history as a feature of Teams Premium / the Queues app, I believe this is as close as we can get to quick and reliable missed call notifications for call queues today.

The fact that this solution needs a service account, or at least a normal agent who’s opted into the queue all the time and that it can only be used on non-presence based attendant routing queues sure is a bummer but I just can’t see any other way to deliver accurate notifications as fast as they are with this solution.

Even with it’s limitations, I’m very proud of my work and I’m very excited to share it with the community today. I’ve literally spent hundreds of hours developing this solution and I’ve experienced many set-backs and had to iterate through a lot of different approaches to achieve what I’ve published today. The good thing is that in the end, I got much more joy than frustration out of it.

Since I’ve only been using this in my lab so far, I don’t have any insights of what the Azure resources are going to cost per month but since the Function Apps are using a consumption plan (Y1) and nothing is really stored in these storage accounts, the monthly cost should be next to nothing. Of course this is also depending on your call volume since this will impact how many times your function is invoked.

If what I said about costs turns out to be true and you like what I’ve done for the community and Teams Phone customers, I would very much appreciate it, if you consider supporting this project with a small donation on either Buy me a Coffee or GitHub Sponsors. Thank you!

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Hosted on GitHub Pages
Built with Hugo
Theme Stack designed by Jimmy