Tutorial • Talkdesk → Microsoft Teams DR Sync

Automate Talkdesk RingGroups → Teams Call Queue Backup

This guide shows how to export Talkdesk users and the RingGroups they belong to, then automatically sync those memberships into Microsoft Teams call queues (via Azure AD groups) for a clean, reliable emergency backup.

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.
Base URLs typically look like 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 and User.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"
}
Save this as 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)

1
Place sync.ps1 and rg-to-groupid.json in C:\TD2Teams\ (or your preferred folder).
2
Open Task SchedulerCreate TaskRun whether user is logged on or not, run as a service account.
3
Trigger: Daily or Hourly, as needed.
4
Action: Program/script: pwsh.exe; Arguments: -File "C:\TD2Teams\sync.ps1".
5
Set Conditions and Settings appropriate for a server (e.g., run on AC power, retry on failure).
Tip: If your Teams queues read from groups, membership refresh may not be instant. For urgent DR, you can touch a queue setting or wait a few minutes for propagation.

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 or userPrincipalName 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.