Sync Microsoft 365 profile pictures
Note: These are example scripts. Examples in these pages are samples, not officially supported by Interact Technical Support, and should be reviewed and tested by your development team before use.
Before you start
- You need a General Profile Source in Interact, and its Profile Source ID and Profile Source API Key. See General Profile Sources.
- You need PowerShell. The scripts can be scheduled with Windows Task Scheduler to run at suitable intervals.
Overview
This page describes two ways to sync Microsoft 365 (formerly Office 365) profile pictures into Interact:
- Microsoft Graph API approach (recommended) — uses an Azure application registration and the Microsoft Graph API to find users and download their profile images, with duplicate detection, rate limiting and statistics.
- Exchange Online approach — uses the ExchangeOnlineManagement PowerShell module and
Get-UserPhoto.
Both approaches download images to a folder and then upload them to Interact, matching each picture to a profile by UMI ID (External ID) or username.
Tip: The Microsoft Graph API approach downloads images at 648x648 to align with the Interact profile image size.
Microsoft Graph API approach
Step 1: Register an Azure application
Create an Azure application registration with permission for users to be found via Microsoft Graph and their profile image downloaded.

When you create the application, record the following:
- Client ID
- Client Secret
- Tenant ID
Note: This script depends on the UMI UID (External ID) in a user's Interact profile being the Object ID of the user's profile in Microsoft Entra ID.
Step 2: Create an Interact profile source
Create a General Profile Source in Interact and obtain the Profile Source ID and Profile Source API Key.
How the script works
The script:
- Gets the list of users.
- Downloads profile images (where available) for each user to a directory.
- Checks whether the file is the same as the one in the last-uploaded directory. If it is, it removes it from the download directory.
- Uploads the remaining files to Interact using the UMI ID.
- Moves the uploaded files to the last-uploaded folder.
- Moves failed files to the failed-upload folder.
The complete script
# Input Parameters
$downloadFolder = "c:\O365\DownloadedPictures"
$lastUploadFolder = "c:\O365\LastUploadPictures"
$failedUploadFolder = "c:\O365\FailedUploadPictures"
$logpath = "c:\O365\log.txt"
# Interact Upload Parameters
$interactUrl = "<Interact Instance URL>"
$profileSourceApiKey = "<Profile Source API Key>"
$profileSourceId = "<Profile Source ID>"
$enableUpload = $true # Set to $false to disable upload
Add-Type -AssemblyName System.Net.Http
# Create directories if they don't exist
if (-not (Test-Path -Path $downloadFolder)) {
New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $lastUploadFolder)) {
New-Item -ItemType Directory -Path $lastUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $failedUploadFolder)) {
New-Item -ItemType Directory -Path $failedUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path (Split-Path -Path $logpath -Parent))) {
New-Item -ItemType Directory -Path (Split-Path -Path $logpath -Parent) -Force | Out-Null
}
# Azure AD App Registration Details
$ClientId = "<Client ID>"
$ClientSecret = "<Client Secret>"
$TenantId = "<Tenant ID>"
# Step 1: Get the access token
$TokenBody = @{
client_id = $ClientId
client_secret = $ClientSecret
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method Post -Body $TokenBody
$AccessToken = $TokenResponse.access_token
# Check if the token was retrieved successfully
if ($null -eq $AccessToken) {
Write-Error "Failed to retrieve access token."
exit
}
# Step 2: Create the authorization header with the access token
$headers = @{
"Authorization" = "Bearer $AccessToken"
"Content-Type" = "application/json"
}
# Step 3: Get list of all users (pagination support for large tenant)
$usersUri = "https://graph.microsoft.com/v1.0/users"
$allUsers = @()
do {
$response = Invoke-RestMethod -Uri $usersUri -Method Get -Headers $headers
$allUsers += $response.value
# Get next page of results if present
$usersUri = $response.'@odata.nextLink'
} while ($usersUri)
# Initialize statistics counters
$stats = @{
TotalUsers = $allUsers.Count
Downloaded = 0
NoProfilePic = 0
DownloadErrors = 0
DuplicatesRemoved = 0
PicturesChanged = 0
Uploaded = 0
UploadFailed = 0
}
# Step 4: Download profile pictures
foreach ($user in $allUsers) {
$userId = $user.id
$path = Join-Path $downloadFolder "$userId.jpg"
try {
# Fetch the user photo (use headers without Content-Type for binary data)
$photoHeaders = @{
"Authorization" = "Bearer $AccessToken"
}
$photoUri = "https://graph.microsoft.com/v1.0/users/$userId/photos/648x648/`$value"
# Use -OutFile to directly save binary data to disk
# ContentType parameter forces treating response as binary
Invoke-RestMethod -Uri $photoUri -Method Get -Headers $photoHeaders -OutFile $path -ErrorAction Stop -ContentType "image/jpeg"
$stats.Downloaded++
$message = "$($user.userPrincipalName) profile picture downloaded"
Write-Output $message
Add-Content -Path $logpath -Value $message
}
catch {
if ($_.Exception.Response.StatusCode.Value__ -eq 404) {
$stats.NoProfilePic++
$message = "$($user.userPrincipalName) has no profile picture"
Write-Output $message
Add-Content -Path $logpath -Value $message
}
else {
$stats.DownloadErrors++
$errorMessage = "Error downloading profile picture for $($user.userPrincipalName): $($_.Exception.Message)"
Write-Error $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
}
# Step 4.5: Remove already uploaded files from download folder (comparing file hashes)
Write-Output "`nChecking for already uploaded files..."
Add-Content -Path $logpath -Value "`n=== Checking for Duplicates ==="
$removedCount = 0
$changedCount = 0
$downloadedFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"
foreach ($downloadedFile in $downloadedFiles) {
$lastUploadedFilePath = Join-Path $lastUploadFolder $downloadedFile.Name
if (Test-Path $lastUploadedFilePath) {
try {
# Compare file hashes to detect if content has changed
$downloadedHash = (Get-FileHash -Path $downloadedFile.FullName -Algorithm SHA256).Hash
$uploadedHash = (Get-FileHash -Path $lastUploadedFilePath -Algorithm SHA256).Hash
if ($downloadedHash -eq $uploadedHash) {
# Files are identical, remove from download folder
Remove-Item -Path $downloadedFile.FullName -Force
$message = "Removed duplicate file (unchanged): $($downloadedFile.Name)"
Write-Output $message
Add-Content -Path $logpath -Value $message
$removedCount++
}
else {
# File content has changed, keep for upload
$message = "Picture changed for: $($downloadedFile.Name) - will be uploaded"
Write-Output $message
Add-Content -Path $logpath -Value $message
$changedCount++
}
}
catch {
$errorMessage = "Failed to compare file $($downloadedFile.Name): $($_.Exception.Message)"
Write-Warning $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
}
$stats.DuplicatesRemoved = $removedCount
$stats.PicturesChanged = $changedCount
$duplicateMessage = "Removed $removedCount duplicate files (unchanged), Found $changedCount pictures that changed"
Write-Output $duplicateMessage
Add-Content -Path $logpath -Value $duplicateMessage
# Upload Functions
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function ProcessFiles() {
Write-Output "`nStarting upload process..."
Add-Content -Path $logpath -Value "`n=== Upload Process Started ==="
$filesPerMinute = 6
$uploadCounter = 0
$allFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"
$totalFiles = $allFiles.Count
$allFiles | Foreach-Object {
$umiid = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $umiid + "/picture"
Write-Output "Uploading $($_.FullName) to $baseUri"
$success = SendFile $baseUri $_.FullName
if ($success) {
$stats.Uploaded++
} else {
$stats.UploadFailed++
}
$uploadCounter++
# Rate limiting: Wait after every 6 uploads to avoid Error 429
if ($uploadCounter -eq $filesPerMinute -and ($stats.Uploaded + $stats.UploadFailed) -lt $totalFiles) {
$rateLimitMessage = "Rate limit: Uploaded $uploadCounter files. Waiting 60 seconds before continuing..."
Write-Output $rateLimitMessage
Add-Content -Path $logpath -Value $rateLimitMessage
Start-Sleep -Seconds 60
$uploadCounter = 0
}
}
$uploadMessage = "Upload complete: $($stats.Uploaded) successful, $($stats.UploadFailed) failed"
Write-Output $uploadMessage
Add-Content -Path $logpath -Value $uploadMessage
}
function SendFile([string]$uri, [string]$path) {
$httpClientHandler = New-Object System.Net.Http.HttpClientHandler
$httpClient = New-Object System.Net.Http.Httpclient $httpClientHandler
$response = $null
$packageFileStream = $null
$success = $false
try {
$packageFileStream = New-Object System.IO.FileStream @($path, [System.IO.FileMode]::Open)
$contentDispositionHeaderValue = New-Object System.Net.Http.Headers.ContentDispositionHeaderValue "form-data"
$contentDispositionHeaderValue.Name = "fileData"
$contentDispositionHeaderValue.FileName = (Split-Path $path -leaf)
$streamContent = New-Object System.Net.Http.StreamContent $packageFileStream
$streamContent.Headers.ContentDisposition = $contentDispositionHeaderValue
$streamContent.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue "image/jpeg"
$content = New-Object System.Net.Http.MultipartFormDataContent
$content.Add($streamContent)
$httpClient.DefaultRequestHeaders.Add("X-ApiKey", $profileSourceApiKey)
$response = $httpClient.PostAsync($uri, $content).Result
if ($response.IsSuccessStatusCode) {
$message = "Successfully uploaded: $(Split-Path $path -leaf)"
Write-Output $message
Add-Content -Path $logpath -Value $message
$success = $true
} else {
$errorMessage = "Failed to upload $(Split-Path $path -leaf): $($response.StatusCode)"
Write-Warning $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
catch [Exception] {
$errorMessage = "Error uploading $(Split-Path $path -leaf): $($_.Exception.Message)"
Write-Error $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
finally {
if ($null -ne $packageFileStream) {
$packageFileStream.Dispose()
}
if ($null -ne $httpClient) {
$httpClient.Dispose()
}
if ($null -ne $response) {
$response.Dispose()
}
}
# Move file to lastUploadFolder after successful upload, or to failedUploadFolder if failed
if ($success) {
try {
$fileName = Split-Path $path -Leaf
$destinationPath = Join-Path $lastUploadFolder $fileName
Move-Item -Path $path -Destination $destinationPath -Force
$moveMessage = "Moved $(Split-Path $path -leaf) to $lastUploadFolder"
Write-Output $moveMessage
Add-Content -Path $logpath -Value $moveMessage
}
catch {
$moveErrorMessage = "Failed to move $(Split-Path $path -leaf): $($_.Exception.Message)"
Write-Warning $moveErrorMessage
Add-Content -Path $logpath -Value $moveErrorMessage
}
} else {
try {
$fileName = Split-Path $path -Leaf
$destinationPath = Join-Path $failedUploadFolder $fileName
Move-Item -Path $path -Destination $destinationPath -Force
$moveMessage = "Moved failed upload $(Split-Path $path -leaf) to $failedUploadFolder"
Write-Output $moveMessage
Add-Content -Path $logpath -Value $moveMessage
}
catch {
$moveErrorMessage = "Failed to move $(Split-Path $path -leaf) to failed folder: $($_.Exception.Message)"
Write-Warning $moveErrorMessage
Add-Content -Path $logpath -Value $moveErrorMessage
}
}
return $success
}
# Step 5: Upload profile pictures to Interact
if ($enableUpload) {
ProcessFiles
} else {
Write-Output "`nUpload disabled. Set `$enableUpload = `$true to enable."
}
# Display Final Statistics
Write-Output "`n==========================================="
Write-Output " FINAL STATISTICS"
Write-Output "==========================================="
Write-Output "Total Users Found: $($stats.TotalUsers)"
Write-Output "Images Downloaded: $($stats.Downloaded)"
Write-Output "No Profile Picture: $($stats.NoProfilePic)"
Write-Output "Download Errors: $($stats.DownloadErrors)"
Write-Output "Duplicates Removed: $($stats.DuplicatesRemoved)"
Write-Output "Pictures Changed: $($stats.PicturesChanged)"
Write-Output "Images Uploaded: $($stats.Uploaded)"
Write-Output "Upload Failed: $($stats.UploadFailed)"
Write-Output "==========================================="
# Write statistics to log
Add-Content -Path $logpath -Value "`n==========================================="
Add-Content -Path $logpath -Value " FINAL STATISTICS"
Add-Content -Path $logpath -Value "==========================================="
Add-Content -Path $logpath -Value "Total Users Found: $($stats.TotalUsers)"
Add-Content -Path $logpath -Value "Images Downloaded: $($stats.Downloaded)"
Add-Content -Path $logpath -Value "No Profile Picture: $($stats.NoProfilePic)"
Add-Content -Path $logpath -Value "Download Errors: $($stats.DownloadErrors)"
Add-Content -Path $logpath -Value "Duplicates Removed: $($stats.DuplicatesRemoved)"
Add-Content -Path $logpath -Value "Pictures Changed: $($stats.PicturesChanged)"
Add-Content -Path $logpath -Value "Images Uploaded: $($stats.Uploaded)"
Add-Content -Path $logpath -Value "Upload Failed: $($stats.UploadFailed)"
Add-Content -Path $logpath -Value "==========================================="
Using username instead of UMI ID
The script above matches users by UMI ID, which is expected to match the Entra ID Object ID of the profile. You can change it to download files by User Principal Name (UPN) when that matches the username in Interact.
Change the download lines from:
$userId = $user.id
$path = Join-Path $downloadFolder "$userId.jpg"
To:
$userId = $user.userPrincipalName
$path = Join-Path $downloadFolder "$userId.jpg"
Then change the upload URL from:
$umiid = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $umiid + "/picture"
To:
$username = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/username/" + $username + "/picture"
Exchange Online approach
This approach uses the ExchangeOnlineManagement PowerShell module instead of the Microsoft Graph API.
Note: This is a legacy approach. It relies on
Get-UserPhotoand basic authentication, both of which Microsoft has retired or deprecated, so it may not work on tenants where basic authentication is disabled. For new setups, use the Microsoft Graph API approach above.
Prerequisites
- A General Profile Source in Interact. See General Profile Sources.
- The ExchangeOnlineManagement PowerShell module, installed and imported:
Import-Module ExchangeOnlineManagement
Note: You may have to set the PowerShell execution policy to
RemoteSignedorUnrestrictedwithSet-ExecutionPolicy RemoteSignedorSet-ExecutionPolicy Unrestricted.
Microsoft 365 permissions
- Global administrator or Exchange administrator role in Microsoft 365.
- ApplicationImpersonation role in Exchange Online.
The script
This script downloads all users' profile pictures, stores them in C:\O365\AllUserProfilePictures, then uploads them to Interact. It uses the ExternalDirectoryObjectId field as the filename, for example 14f90941-8aab-49d7-963b-841962dea5e9.jpg. Interact uses this GUID to associate the picture with the correct user profile by mapping it against the UMI ID. See Profile pictures for other ways to map exported pictures to the Interact profile, for example by username.
#Input Parameters:
$folderpath = "c:\O365\AllUserProfilePictures"
$logpath = "c:\O365\log.txt"
#Connect to Exchange Online:
$UPN = "name@example.com"
$Password = ConvertTo-SecureString "password" -AsPlainText -Force
$Credential = New-Object System.Management.Automation.PSCredential ($UPN, $Password)
Connect-ExchangeOnline -Credential $Credential
#Interact variables
$interactUrl = "https://example.interactgo.com"
$profileSourceApiKey = "1234"
$profileSourceId = "1000"
#Download all user profile pictures from Microsoft 365:
New-Item -ItemType directory -Path $folderpath -Force
$allUsers = Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize Unlimited | Select-Object UserPrincipalName, Alias, ExternalDirectoryObjectId
foreach ($user in $allUsers) {
$path = Join-Path $folderpath "$($user.ExternalDirectoryObjectId).jpg"
try {
$photo = Get-UserPhoto -Identity $user.UserPrincipalName -ErrorAction continue
if ($photo.PictureData -ne $null) {
[io.file]::WriteAllBytes($path, $photo.PictureData)
$message = "$($user.UserPrincipalName) profile picture downloaded"
Write-Output $message
Add-Content -Path $logpath -Value $message
}
else {
$message = "$($user.UserPrincipalName) has no profile picture"
Write-Output $message
Add-Content -Path $logpath -Value $message
}
}
catch {
$errorMessage = "Error downloading profile picture for $($user.UserPrincipalName): $($_.Exception.Message)"
Write-Error $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
#UPLOAD TO INTERACT
Add-Type -AssemblyName System.Net.Http
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function ProcessFiles(){
Get-ChildItem $folderpath |
Foreach-Object {
$personId = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $personId + "/picture"
SendFile $baseUri $_.FullName
}
}
function SendFile([string]$uri, [string]$path){
$httpClientHandler = New-Object System.Net.Http.HttpClientHandler
$httpClient = New-Object System.Net.Http.Httpclient $httpClientHandler
$packageFileStream = New-Object System.IO.FileStream @($path, [System.IO.FileMode]::Open)
$contentDispositionHeaderValue = New-Object System.Net.Http.Headers.ContentDispositionHeaderValue "form-data"
$contentDispositionHeaderValue.Name = "fileData"
$contentDispositionHeaderValue.FileName = (Split-Path $path -leaf)
$streamContent = New-Object System.Net.Http.StreamContent $packageFileStream
$streamContent.Headers.ContentDisposition = $contentDispositionHeaderValue
$streamContent.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue "image/jpeg"
$content = New-Object System.Net.Http.MultipartFormDataContent
$content.Add($streamContent)
$httpClient.DefaultRequestHeaders.Add("X-ApiKey", $profileSourceApiKey)
try
{
$response = $httpClient.PostAsync($uri, $content).Result
}
catch [Exception]
{
# log it
}
finally
{
if($null -ne $httpClient)
{
$httpClient.Dispose()
}
if($null -ne $response)
{
$response.Dispose()
}
}
}
ProcessFiles