Skip to content

Teams Phone License Usage

Read-only Teams Phone license-usage report — who holds a Teams Phone license, who has a number, and who never calls — joined to the manager, with a reclaim risk tier per user. Exports a CSV to Blob for Power BI.

Download the script View on GitHub

Read-only

It reads users, licenses and Teams usage reports via Graph (a $batch POST that only reads managers), classifies each user by an observational risk tier, and writes a CSV to your Blob. It changes nothing in the tenant. Scopes are all read: User.Read.All, Organization.Read.All, AuditLog.Read.All, Reports.Read.All, Directory.Read.All + Storage Blob Data Contributor.

1 · The script

Set the CONFIG block (resource group, storage account, container — all @contoso-style placeholders) and confirm your tenant's Teams Phone SKU GUIDs from subscribedSkus, then run.

View the full script
<#
.SYNOPSIS
    Read-only Teams Phone (Voice) license-usage report — who holds a Teams Phone license,
    whether they have a number, and whether they actually use it — with a reclaim risk tier
    per user. Exports a CSV to Blob for Power BI.

.DESCRIPTION
    A Teams Phone license on a disabled account, a guest, a never-signed-in user, or someone
    with no calling activity in 90 days is spend with no return. This runbook joins license
    assignment, sign-in activity, Teams device/activity usage (D90), the directory phone number
    and the user's manager into one row per licensed user, and tags each with an observational
    risk tier so Voice Services / a Service Manager can decide what to reclaim.

    READ-ONLY against Microsoft Graph (GET users, subscribedSkus, usage reports; a $batch POST
    that only *reads* managers). It writes a CSV to Blob — the snapshot the Power BI report reads.
    It changes nothing in the tenant.

    Scopes: User.Read.All, Organization.Read.All, AuditLog.Read.All (for signInActivity),
            Reports.Read.All  +  Storage Blob Data Contributor (Blob write; scope to the container).

    NOTE: CONFIG values below are SYNTHETIC examples. Replace with your own. The Teams Phone
    SKU GUIDs are public Microsoft product identifiers — confirm your tenant's set with the
    subscribedSkus one-liner in the comment. Everything here is reproduced in a personal lab.
#>

# ---------- Step 1 - Initialize Variables ----------
$ResourceGroup  = "<your-resource-group>"          # <- your resource group
$StorageAccount = "<your-storage-account>"         # <- your storage account (lowercase, <=24 chars)
$Container      = "<your-container>"
$ExportLocation = "$env:TEMP"
$ProgressPreference = 'SilentlyContinue'
$VerbosePreference  = 'Continue'

$ReportFileName  = "TeamsVoiceLicenseReport.csv"
$TopWithSignIn   = 120   # Graph caps page size at 120 when signInActivity is in $select (400s above it)

# Teams Phone SKU IDs (public Microsoft product GUIDs — the same across tenants).
# Discover the exact set you own:
#   Invoke-RestMethod "https://graph.microsoft.com/v1.0/subscribedSkus" `
#       -Headers @{'Authorization'="Bearer $accessToken"} |
#       Select -Expand value | Where skuPartNumber -like "*MCOPSTN*" |
#       Select skuPartNumber, skuId
$PhoneSkus = @(
    [guid]"e43b5b99-8dfb-405f-9987-dc307f34bcbd"  # MCOEV        - Teams Phone Standard
    [guid]"5e277f11-3b64-4d34-b6d0-0a8a8e1d4e7c"  # MCOEV_TEAMS  - Teams Phone + Calling Plan bundle
    [guid]"b1511558-69ae-4e3a-89e4-a1e99a559cda"  # MCOEV_SHARED - Teams Shared Devices
    [guid]"f4d50207-d716-4d38-9d31-a632baa4e7db"  # M365_BUSINESS_VOICE
    [guid]"11dee6af-eca8-419f-8061-6864517c1875"  # MCOPSTN Domestic Calling Plan (example)
    # Add your tenant's remaining MCOPSTN* GUIDs, confirmed from subscribedSkus
)

# =====================================================================
# HELPER FUNCTIONS
# =====================================================================

# ---------- Helper: Write CSV bytes safely (PS 5.1 / 7.x / SMA) -----
# Untyped $Response param — the SMA runbook sandbox uses a different
# concrete type than the console; a strict type constraint throws
# "cannot convert" before the body runs. Also strips the UTF-8 BOM
# (EF BB BF) Graph report endpoints emit — without it, Import-Csv
# prefixes the first column header with "???".
Function Save-CsvBytes {
    Param([Parameter(Mandatory)]$Response,[Parameter(Mandatory)][string]$Path)
    if ($Response.RawContentStream -and $Response.RawContentStream.Length -gt 0) {
        $bytes = $Response.RawContentStream.ToArray()
        if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
            $bytes = $bytes[3..($bytes.Length - 1)]
        }
        [System.IO.File]::WriteAllBytes($Path, $bytes); return
    }
    if ($Response.Content -is [string] -and $Response.Content.Length -gt 0) {
        [System.IO.File]::WriteAllText($Path, $Response.Content.TrimStart([char]0xFEFF), [System.Text.Encoding]::UTF8); return
    }
    if ($Response.Content -is [byte[]]) {
        $bytes = $Response.Content
        if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
            $bytes = $bytes[3..($bytes.Length - 1)]
        }
        [System.IO.File]::WriteAllBytes($Path, $bytes); return
    }
    Write-Warning "Save-CsvBytes: could not extract content for: $Path"
}

# ---------- Helper: Graph GET with pagination + 429 retry ------------
Function Invoke-MyGraphGetRequest {
    Param([Parameter(Mandatory)][string]$URL,[switch]$AdvancedQuery,[int]$MaxRetries = 5)
    $ReqHeaders = $script:Headers.Clone()
    if ($AdvancedQuery) { $ReqHeaders['ConsistencyLevel'] = 'eventual' }
    $AllResults = @(); $RetryCount = 0
    try {
        do {
            $Success = $false
            while (-not $Success) {
                try { $Resp = Invoke-WebRequest -Uri $URL -Method GET -Headers $ReqHeaders -UseBasicParsing -ErrorAction Stop; $Success = $true }
                catch {
                    $Code = $null; try { $Code = $_.Exception.Response.StatusCode.value__ } catch {}
                    if ($Code -eq 429) {
                        $RetryCount++
                        if ($RetryCount -gt $MaxRetries) { Write-Error "Max retries on 429 for: $URL"; return $null }
                        $Wait = $null; try { $Wait = [int]$_.Exception.Response.Headers['Retry-After'] } catch {}
                        if (-not $Wait) { $Wait = [math]::Pow(2, $RetryCount) * 5 }
                        Write-Verbose "429 - waiting ${Wait}s (retry $RetryCount/$MaxRetries)"; Start-Sleep -Seconds $Wait
                    } else { throw }
                }
            }
            $Data = $Resp.Content | ConvertFrom-Json
            if ($Data.value) { $AllResults += $Data.value } else { return $Data }
            $URL = $Data.'@odata.nextLink'
        } while ($URL)
        return $AllResults
    } catch { Write-Error "Graph GET failed for ${URL}: $_"; return $null }
}

# ---------- Helper: Manager lookup via $batch (20 per call) ----------
# Avoids hundreds of individual /users/{id}/manager calls; Graph JSON batch
# accepts up to 20 requests per call. Returns userId(lower) -> managerName.
Function Get-ManagersBatch {
    Param([array]$Users)
    $ManagerMap = @{}; $BatchSize = 20; $Total = $Users.Count
    for ($i = 0; $i -lt $Total; $i += $BatchSize) {
        $Slice = $Users[$i..([math]::Min($i + $BatchSize - 1, $Total - 1))]
        $Requests = @()
        foreach ($u in $Slice) {
            if ([string]::IsNullOrEmpty($u.id)) { continue }
            $Requests += @{ id = $u.id; method = "GET"; url = "/users/$($u.id)/manager?`$select=displayName,userPrincipalName" }
        }
        if ($Requests.Count -eq 0) { continue }
        $BatchBody = @{ requests = $Requests } | ConvertTo-Json -Depth 5
        $RetryCount = 0; $Success = $false; $BatchResp = $null
        while (-not $Success -and $RetryCount -le 5) {
            try {
                $BatchResp = Invoke-WebRequest -Uri "https://graph.microsoft.com/v1.0/`$batch" -Method POST -Headers $script:Headers -Body $BatchBody -UseBasicParsing -ErrorAction Stop
                $Success = $true
            } catch {
                $Code = $null; try { $Code = $_.Exception.Response.StatusCode.value__ } catch {}
                if ($Code -eq 429) { $RetryCount++; $Wait = [math]::Pow(2, $RetryCount) * 5; Write-Verbose "Batch 429 - waiting ${Wait}s"; Start-Sleep -Seconds $Wait }
                else { Write-Warning "Batch manager request failed: $_"; $Success = $true }
            }
        }
        if (-not $BatchResp) { continue }
        $BatchData = $BatchResp.Content | ConvertFrom-Json
        foreach ($r in $BatchData.responses) {
            if ($r.status -eq 200 -and $r.body.displayName) { $ManagerMap[$r.id.ToLower()] = $r.body.displayName }
        }
        Start-Sleep -Milliseconds 300   # stay under per-app rate limits
    }
    return $ManagerMap
}

# =====================================================================
# MAIN
# =====================================================================

# ---------- Step 2 - Authenticate via Managed Identity ----------
$miUrl  = $env:IDENTITY_ENDPOINT
$miHdrs = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
$miHdrs.Add("X-IDENTITY-HEADER", $env:IDENTITY_HEADER); $miHdrs.Add("Metadata", "True")
$accessToken = (Invoke-RestMethod $miUrl -Method POST -Headers $miHdrs -ContentType 'application/x-www-form-urlencoded' -Body @{ resource = 'https://graph.microsoft.com/' }).access_token
if ([string]::IsNullOrEmpty($accessToken)) { throw "Failed to acquire access token." }
$script:Headers = @{ 'Authorization' = "Bearer $accessToken"; 'Content-Type' = 'application/json' }

# ---------- Step 3 - SKU metadata (GUID -> skuPartNumber) ----------
$AllSkus = Invoke-MyGraphGetRequest -URL "https://graph.microsoft.com/v1.0/subscribedSkus"
if (-not $AllSkus) { throw "Unable to retrieve subscribedSkus." }
$SkuHash = @{}
foreach ($s in $AllSkus) { if ($s.skuId -and $s.skuPartNumber) { $SkuHash[[guid]$s.skuId] = $s.skuPartNumber } }

# ---------- Step 4 - Licensed users (single-pass, all properties) ----------
# assignedLicenses/any() filter — no ConsistencyLevel needed. NOTE: businessPhones[0] is the
# DIRECTORY phone attribute, NOT the authoritative Teams line assignment (that lives in Teams —
# Get-CsPhoneNumberAssignment). Treat it as a hint, not proof of provisioning. signInActivity needs P1/P2.
$AllLicensedUsers = @()
foreach ($SkuId in $PhoneSkus) {
    $Uri = "https://graph.microsoft.com/v1.0/users?`$filter=assignedLicenses/any(x:x/skuId eq $SkuId)" +
        "&`$select=id,userPrincipalName,displayName,jobTitle,department,city,country,officeLocation,usageLocation," +
        "businessPhones,mobilePhone,accountEnabled,employeeType,userType,createdDateTime,signInActivity,assignedLicenses&`$top=$TopWithSignIn"
    $UsersForSku = Invoke-MyGraphGetRequest -URL $Uri
    if ($UsersForSku) { $AllLicensedUsers += $UsersForSku }
}

# Deduplicate (a user may hold multiple Phone SKUs); guard null UPN (service accounts)
$SeenUPNs = @{}; $UserDetails = @()
foreach ($u in $AllLicensedUsers) {
    if ([string]::IsNullOrEmpty($u.userPrincipalName)) { continue }
    $key = $u.userPrincipalName.ToLower()
    if (-not $SeenUPNs.ContainsKey($key)) { $SeenUPNs[$key] = $true; $UserDetails += $u }
}
$UserCount = $UserDetails.Count
if ($UserCount -eq 0) { Write-Warning "No users found. Verify SKU GUIDs and permissions."; return }
Write-Verbose "Unique licensed users: $UserCount"

# ---------- Step 5 - Manager lookup (batch) ----------
$ManagerMap = Get-ManagersBatch -Users $UserDetails

# ---------- Step 6 - Teams device usage (D90) ----------
$DeviceUsagePath = Join-Path $ExportLocation "TeamsDeviceUsageTemp.csv"; $DeviceUsageHash = @{}
try {
    $DevResp = Invoke-WebRequest -Uri "https://graph.microsoft.com/v1.0/reports/getTeamsDeviceUsageUserDetail(period='D90')" -Method GET -Headers $script:Headers -UseBasicParsing -ErrorAction Stop
    Save-CsvBytes -Response $DevResp -Path $DeviceUsagePath
    if (Test-Path $DeviceUsagePath) {
        Import-Csv $DeviceUsagePath | ForEach-Object {
            $upn = ($_.'User Principal Name').Trim()   # .Trim() critical: Graph CSVs embed trailing spaces
            if (-not [string]::IsNullOrEmpty($upn)) { $DeviceUsageHash[$upn.ToLower()] = $_ }
        }
        Remove-Item $DeviceUsagePath -Force -ErrorAction SilentlyContinue
    }
} catch { Write-Warning "Teams device usage report unavailable: $_" }

# ---------- Step 7 - Teams activity / call count (D90) ----------
$ActivityPath = Join-Path $ExportLocation "TeamsActivityTemp.csv"; $TeamsCallHash = @{}
try {
    $ActResp = Invoke-WebRequest -Uri "https://graph.microsoft.com/v1.0/reports/getTeamsUserActivityUserDetail(period='D90')" -Method GET -Headers $script:Headers -UseBasicParsing -ErrorAction Stop
    Save-CsvBytes -Response $ActResp -Path $ActivityPath
    if (Test-Path $ActivityPath) {
        Import-Csv $ActivityPath | ForEach-Object {
            $upn = ($_.'User Principal Name').Trim()
            if (-not [string]::IsNullOrEmpty($upn)) {
                $callCount = 0; [int]::TryParse($_.'Call Count', [ref]$callCount) | Out-Null
                $TeamsCallHash[$upn.ToLower()] = $callCount
            }
        }
        Remove-Item $ActivityPath -Force -ErrorAction SilentlyContinue
    }
} catch { Write-Warning "Teams user activity report unavailable: $_" }

# ---------- Step 8 - Build report with observational risk tiers ----------
$FinalReport = foreach ($User in $UserDetails) {
    $UPNLower    = $User.userPrincipalName.ToLower()
    $LastSignIn  = $User.signInActivity.lastSignInDateTime
    $SignInEmpty = [string]::IsNullOrEmpty($LastSignIn)
    $DaysSince   = if ($SignInEmpty) { "Never" } else { [math]::Round((New-TimeSpan -Start ([datetime]$LastSignIn) -End (Get-Date)).TotalDays) }

    $UsageRecord    = $DeviceUsageHash[$UPNLower]
    $LastDeviceDate = if ($UsageRecord) { $UsageRecord.'Last Activity Date'.Trim() } else { "None" }
    $TeamsCallCount = if ($TeamsCallHash.ContainsKey($UPNLower)) { $TeamsCallHash[$UPNLower] } else { 0 }

    $PhoneNumber = ""
    if ($User.businessPhones -and $User.businessPhones.Count -gt 0) { $PhoneNumber = $User.businessPhones[0] }

    $UserPhoneLicenses = @()
    if ($User.assignedLicenses) { $UserPhoneLicenses = $User.assignedLicenses | Where-Object { [guid]$_.skuId -in $PhoneSkus } }
    $AllPhoneSkuNames = if ($UserPhoneLicenses) {
        ($UserPhoneLicenses | ForEach-Object { $id = [guid]$_.skuId; if ($SkuHash.ContainsKey($id)) { $SkuHash[$id] } else { $id.ToString() } }) -join ' + '
    } else { "Unknown" }
    $PrimarySkuName = "Unknown"
    if ($UserPhoneLicenses) { $firstId = [guid]$UserPhoneLicenses[0].skuId; if ($SkuHash.ContainsKey($firstId)) { $PrimarySkuName = $SkuHash[$firstId] } }

    # Risk tiers are OBSERVATIONAL — no action prescribed. Ordered by priority
    # so the most severe tier wins when several conditions are true at once.
    $UsageRisk = ""; $RiskReason = ""
    if     ($User.accountEnabled -eq $false) { $UsageRisk = "Tier 1 - Disabled Account"; $RiskReason = "Account disabled but a Teams Phone license is still assigned" }
    elseif ($SignInEmpty)                    { $UsageRisk = "Tier 2 - Never Signed In";  $RiskReason = "No sign-in activity on record" }
    elseif ([int]$DaysSince -gt 90)          { $UsageRisk = "Tier 3 - Inactive >90 Days"; $RiskReason = "Last sign-in was $DaysSince days ago" }
    elseif (-not $UsageRecord)               { $UsageRisk = "Tier 4 - No Teams Activity"; $RiskReason = "Signed in recently but no Teams device activity in 90 days" }
    else                                     { $UsageRisk = "Tier 5 - Active";            $RiskReason = "Active sign-in and Teams activity in 90 days" }

    $ManagerName = ""
    if ($User.id -and $ManagerMap.ContainsKey($User.id.ToLower())) { $ManagerName = $ManagerMap[$User.id.ToLower()] }
    $AcctEnabled = if ($User.accountEnabled -eq $false) { "Disabled" } else { "Enabled" }

    [PSCustomObject]@{
        UserPrincipalName      = $User.userPrincipalName
        DisplayName            = $User.displayName
        JobTitle               = if ($User.jobTitle){$User.jobTitle}else{"Not Set"}
        Department             = if ($User.department){$User.department}else{"Not Set"}
        Manager                = if ($ManagerName){$ManagerName}else{"Not Set"}
        City                   = if ($User.city){$User.city}else{"Not Set"}
        Country                = if ($User.country){$User.country}else{"Not Set"}
        OfficeLocation         = if ($User.officeLocation){$User.officeLocation}else{"Not Set"}
        UsageLocation          = if ($User.usageLocation){$User.usageLocation}else{"Not Set"}
        AccountStatus          = $AcctEnabled
        EmployeeType           = if ($User.employeeType){$User.employeeType}else{"Not Set"}
        AccountCreated         = $User.createdDateTime
        PrimaryLicenseSku      = $PrimarySkuName
        AllPhoneSkus           = $AllPhoneSkuNames
        DirectoryPhoneNumber   = $PhoneNumber
        LastSignInDateTime     = if ($SignInEmpty){"Never"}else{$LastSignIn}
        DaysSinceLastSignIn    = $DaysSince
        LastTeamsActivityDate  = $LastDeviceDate
        TeamsCallCount_D90     = $TeamsCallCount
        UsageRisk              = $UsageRisk
        RiskReason             = $RiskReason
    }
}

# ---------- Step 9 - Summary to verbose log ----------
$FinalReport | Group-Object UsageRisk | Sort-Object Name | ForEach-Object { Write-Verbose "$($_.Name) : $($_.Count) users" }
Write-Verbose "Total users in report: $($FinalReport.Count)"

# ---------- Step 10 - Export & upload to Blob ----------
if (-not (Test-Path $ExportLocation)) { New-Item -ItemType Directory -Path $ExportLocation -Force | Out-Null }
$OutputFilePath = Join-Path $ExportLocation $ReportFileName
$FinalReport | Export-Csv -Path $OutputFilePath -NoTypeInformation -Force -Encoding UTF8

$StorageContext = (Get-AzStorageAccount -ResourceGroupName $ResourceGroup -Name $StorageAccount).Context
Set-AzStorageBlobContent -File $OutputFilePath -Container $Container -Blob $ReportFileName -Context $StorageContext -Force | Out-Null
Write-Verbose "Done. Report uploaded: $ReportFileName"

2 · The risk tiers

The report tags each licensed user — Tier 1 Disabled account · Tier 2 Never signed in · Tier 3 Inactive >90 days · Tier 4 No Teams activity · Tier 5 Active — as observational signals. A human decides what to reclaim.