HomeUser provisioningSync Microsoft 365 profile pictures

Sync Microsoft 365 profile pictures

Download profile pictures from Microsoft 365 and upload them into Interact using a scheduled PowerShell script.

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.

Azure application registration with Microsoft Graph permissions

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:

  1. Gets the list of users.
  2. Downloads profile images (where available) for each user to a directory.
  3. 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.
  4. Uploads the remaining files to Interact using the UMI ID.
  5. Moves the uploaded files to the last-uploaded folder.
  6. 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-UserPhoto and 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

  1. A General Profile Source in Interact. See General Profile Sources.
  2. The ExchangeOnlineManagement PowerShell module, installed and imported:
Import-Module ExchangeOnlineManagement

Note: You may have to set the PowerShell execution policy to RemoteSigned or Unrestricted with Set-ExecutionPolicy RemoteSigned or Set-ExecutionPolicy Unrestricted.

Microsoft 365 permissions

  1. Global administrator or Exchange administrator role in Microsoft 365.
  2. 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
Section: User provisioning