Overview
We’ll automate a daily (or hourly) sync so the people in each Talkdesk RingGroup are mirrored into the matching Azure AD group that feeds your Teams Call Queue. This avoids manual edits in a crisis and keeps your emergency backup queues accurate.
- Source of truth: Talkdesk RingGroups
- Sync target: Azure AD groups attached to Teams call queues
- Transport: PowerShell (Microsoft Graph) + Talkdesk APIs
- Security: OAuth2 client credentials for both platforms
Prerequisites
Talkdesk
- API client (client credentials) in your correct region (US/EU/CA).
- Scopes to read users and ring groups (e.g.,
users:read
,ring-groups:read
,user-ring-groups:read
). - Know whether your tenant exposes a Guardian Users list endpoint returning ring_groups per user (preferred), or if you’ll enumerate RingGroups and members.
https://api.talkdeskapp.com
(US), ...eu
(EU), or ...ca.com
(Canada). Confirm your tenant region.Microsoft 365 / Teams
- Azure app registration for app-only access (Graph), with
Group.ReadWrite.All
andUser.Read.All
granted and consented. - One Azure AD group per Teams call queue you want to populate.
- Queues configured to use those groups (Distribution Lists or M365 Groups) as members.
Plan your RingGroup → Group mapping
Create a simple JSON file mapping each Talkdesk RingGroup name to the Azure AD Group ID that feeds its Teams queue.
{
"Sales": "11111111-1111-1111-1111-111111111111",
"Support": "22222222-2222-2222-2222-222222222222",
"Nurses": "33333333-3333-3333-3333-333333333333"
}
C:\TD2Teams\rg-to-groupid.json
(or adjust the path in the script).Set environment variables (secrets)
Store secrets as environment variables on your automation host (jump box, runner, etc.).
# Talkdesk
setx TD_BASEURL "https://api.talkdeskapp.com" # use your region base URL
setx TD_CLIENT_ID "xxxxxxxxxxxxxxxx"
setx TD_CLIENT_SECRET "xxxxxxxxxxxxxxxx"
# Microsoft Graph (app-only)
setx AZ_TENANT_ID "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
setx AZ_APP_ID "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"
setx AZ_APP_SECRET "xxxxxxxxxxxxxxxx"
Restart your PowerShell session so $env:*
values are available.
PowerShell sync script
This script reads users + RingGroups from Talkdesk and ensures the correct Azure AD group membership per mapping. Set $EnforceExactMembership
to $true
if you want removals when people leave a RingGroup.
#requires -Version 7.0
# Talkdesk → Teams DR Sync
# Last updated: 2025-08-29
$ErrorActionPreference = "Stop"
# ================== CONFIG ==================
$TD_BaseUrl = $env:TD_BASEURL
$TD_ClientId = $env:TD_CLIENT_ID
$TD_ClientSecret = $env:TD_CLIENT_SECRET
$TenantId = $env:AZ_TENANT_ID
$ClientId = $env:AZ_APP_ID
$ClientSecret = $env:AZ_APP_SECRET
$MappingPath = "C:\\TD2Teams\\rg-to-groupid.json"
$EnforceExactMembership = $true
# ================== HELPERS ==================
function Get-TalkdeskToken {
param([string]$Scope = "users:read ring-groups:read user-ring-groups:read")
$body = @{ grant_type="client_credentials"; client_id=$TD_ClientId; client_secret=$TD_ClientSecret; scope=$Scope }
$token = Invoke-RestMethod -Method Post -Uri "$TD_BaseUrl/oauth/token" -Body $body
return $token.access_token
}
function Get-TalkdeskUsersWithRingGroups {
param([string]$AccessToken)
$headers = @{ Authorization = "Bearer $AccessToken" }
# Preferred: Guardian Users list endpoint includes ring_groups per user.
$guardianUrlCandidates = @(
"$TD_BaseUrl/guardian/users",
"$TD_BaseUrl/v1/guardian/users"
)
foreach ($u in $guardianUrlCandidates) {
try {
$res = Invoke-RestMethod -Headers $headers -Uri $u -Method Get -ErrorAction Stop
if ($res) { return $res }
} catch { }
}
# Fallback: enumerate RingGroups then members
$rgList = Invoke-RestMethod -Headers $headers -Uri "$TD_BaseUrl/ring-groups" -Method Get
$byUser = @{}
foreach ($rg in $rgList._embedded.ring_groups) {
$rgName = $rg.name
$usersInRg = Invoke-RestMethod -Headers $headers -Uri "$TD_BaseUrl/ring-groups/$($rg.id)/users" -Method Get
foreach ($u in $usersInRg._embedded.users) {
if (-not $byUser.ContainsKey($u.id)) { $byUser[$u.id] = [ordered]@{ email=$u.email; ring_groups=@() } }
$byUser[$u.id].ring_groups += $rgName
}
}
return ($byUser.GetEnumerator() | ForEach-Object { $_.Value })
}
function Connect-GraphApp {
$SecureSecret = (ConvertTo-SecureString $ClientSecret -AsPlainText -Force)
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -ClientSecret $SecureSecret -NoWelcome | Out-Null
Select-MgProfile -Name "v1.0" | Out-Null
}
function Ensure-GroupMembership {
param(
[Parameter(Mandatory=$true)] [string]$GroupId,
[Parameter(Mandatory=$true)] [string[]]$TargetUserObjectIds,
[bool]$Enforce = $false
)
$members = Get-MgGroupMember -GroupId $GroupId -All
$currentIds = $members.Id
$toAdd = $TargetUserObjectIds | Where-Object { $_ -and -not ($currentIds -contains $_) }
foreach ($id in $toAdd) {
New-MgGroupMemberByRef -GroupId $GroupId -BodyParameter @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$id" } | Out-Null
}
if ($Enforce) {
$toRemove = $currentIds | Where-Object { $_ -and -not ($TargetUserObjectIds -contains $_) }
foreach ($id in $toRemove) {
Remove-MgGroupMemberByRef -GroupId $GroupId -DirectoryObjectId $id -Confirm:$false | Out-Null
}
}
}
# ================== MAIN ==================
# Install modules once (uncomment on first run):
# Install-Module Microsoft.Graph -Scope AllUsers -Force
# Import-Module Microsoft.Graph
Write-Host "Acquiring Talkdesk token..."
$tdToken = Get-TalkdeskToken
Write-Host "Fetching users & RingGroups from Talkdesk..."
$tdUsers = Get-TalkdeskUsersWithRingGroups -AccessToken $tdToken # expects .email and .ring_groups
Write-Host "Loading RG→Group mapping..."
$rgMap = Get-Content $MappingPath | ConvertFrom-Json
Write-Host "Connecting to Microsoft Graph (app-only)..."
Connect-GraphApp
# Resolve emails to Azure AD object IDs and bucket by RingGroup
$desired = @{}
foreach ($rgName in $rgMap.PSObject.Properties.Name) { $desired[$rgName] = New-Object System.Collections.Generic.HashSet[string] }
foreach ($u in $tdUsers) {
$email = $u.email
if (-not $email) { continue }
try {
$aadUser = Get-MgUser -Filter "mail eq '$email' or userPrincipalName eq '$email'" -ConsistencyLevel eventual -CountVariable ct
if ($aadUser) {
foreach ($rg in ($u.ring_groups | Where-Object { $_ })) {
if ($desired.ContainsKey($rg)) { $null = $desired[$rg].Add($aadUser.Id) }
}
} else {
Write-Warning "No AAD user for $email"
}
} catch {
Write-Warning "Lookup failed for $email: $($_.Exception.Message)"
}
}
# Apply desired membership
foreach ($rgName in $rgMap.PSObject.Properties.Name) {
$groupId = $rgMap.$rgName
$objectIds = @($desired[$rgName])
Write-Host "Syncing '$rgName' → Group $groupId (target members: $($objectIds.Count))"
Ensure-GroupMembership -GroupId $groupId -TargetUserObjectIds $objectIds -Enforce:$EnforceExactMembership
}
Write-Host "TD → Teams sync complete."
Optional: emit a CSV audit report
Add this after the main block to generate an audit file of Talkdesk → AAD mapping each run.
$audit = foreach ($u in $tdUsers) {
[pscustomobject]@{
Email = $u.email
RingGroups = ($u.ring_groups -join "; ")
}
}
$path = "C:\\TD2Teams\\audit-$(Get-Date -Format yyyyMMdd-HHmm).csv"
$audit | Export-Csv -NoTypeInformation -Encoding UTF8 -Path $path
Write-Host "Wrote audit: $path"
Schedule it (Windows Task Scheduler)
sync.ps1
and rg-to-groupid.json
in C:\TD2Teams\
(or your preferred folder).pwsh.exe
; Arguments: -File "C:\TD2Teams\sync.ps1"
.Alternative schedulers
GitHub Actions (Windows runner)
name: TD-to-Teams-sync
on:
schedule:
- cron: "0 * * * *" # hourly
workflow_dispatch:
jobs:
sync:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set secrets as env
run: |
echo "TD_BASEURL=${{ secrets.TD_BASEURL }}" >> $env:GITHUB_ENV
echo "TD_CLIENT_ID=${{ secrets.TD_CLIENT_ID }}" >> $env:GITHUB_ENV
echo "TD_CLIENT_SECRET=${{ secrets.TD_CLIENT_SECRET }}" >> $env:GITHUB_ENV
echo "AZ_TENANT_ID=${{ secrets.AZ_TENANT_ID }}" >> $env:GITHUB_ENV
echo "AZ_APP_ID=${{ secrets.AZ_APP_ID }}" >> $env:GITHUB_ENV
echo "AZ_APP_SECRET=${{ secrets.AZ_APP_SECRET }}" >> $env:GITHUB_ENV
- name: Install Graph module
run: Install-Module Microsoft.Graph -Force -Scope CurrentUser
shell: pwsh
- name: Run sync
run: pwsh -File .\\sync.ps1
Linux cron (PowerShell 7)
# Install PowerShell 7 and Microsoft.Graph module, then create a cron entry like:
# m h dom mon dow command
0 * * * * /usr/bin/pwsh -File /opt/td2teams/sync.ps1 >> /var/log/td2teams.log 2>&1
Troubleshooting
401 / 403 from Talkdesk
- Wrong region base URL or missing scopes.
- Client ID/secret invalid or expired.
- Using the wrong OAuth flow (this script expects client credentials).
Users not found in AAD
- Email in Talkdesk doesn’t match
mail
oruserPrincipalName
in Microsoft 365. - Add alias logic if you store different addresses between systems.
Queue membership doesn’t update
- Confirm the queue reads from the same group GUIDs you’re updating.
- Allow time for propagation, or touch a queue setting to encourage refresh.
Security & best practices
- Use a service principal with least privilege. Restrict Graph scopes to what you need.
- Store secrets in a secure vault (Windows Credential Manager, Azure Key Vault, GitHub Actions secrets).
- Log activity and keep an audit CSV of each run.
- Consider additive-only mode by setting
$EnforceExactMembership = $false
if removals are risky in your workflow.
FAQ
Can I write members directly to a Teams call queue instead of using groups?
Yes, but groups scale better for DR. By updating group membership, you avoid editing queue configs and gain simpler audits.
What if my tenant doesn’t expose a Guardian Users endpoint?
The script automatically falls back to enumerating RingGroups and their members.
How often should I run the sync?
Daily is common; hourly if your org changes memberships frequently or during incident response windows.