CSV to SCIM example
Before you start
- You need a SCIM profile source and authentication token in Interact. See SCIM v2.0.
- You need PowerShell and a CSV file of user data.
Overview
This PowerShell script automates creating and updating user accounts via the SCIM API from CSV data. It includes performance options, error handling and manager assignment.
Features:
- Bulk user processing — creates new users and updates existing users from CSV data.
- Performance options — configurable delays between API calls.
- Manager assignment — assigns managers based on CSV data.
- Error handling — error reporting and validation.
- Progress tracking — real-time progress reporting and performance metrics.
The script
The full script follows. Substitute the parameters (CSV path, base URL and authentication token) when you run it.
Warning: The authentication token is visible in the script parameters. Consider secure storage and never expose your token in shared or client-side code.
# Create and update Interact users via the SCIM API from a CSV file.
#
# This is an illustrative example. It shows a two-phase flow — create or update
# each user, then assign managers in a second pass once all users exist — using a
# trimmed set of profile fields. See SCIM field mapping and SCIM package examples
# for the full set of supported fields and payloads.
param(
[string]$CsvPath = "",
[string]$BaseUrl = "",
[string]$BearerToken = "",
[int]$DelayMs = 50 # Delay between API calls, in milliseconds
)
$headers = @{
"Authorization" = "Bearer $BearerToken"
"Content-Type" = "application/scim+json"
}
# --- JSON templates (trimmed to core fields; add more as needed) ---
$PostTemplate = @'
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
],
"externalId": "{ExternalId}",
"userName": "{Username}",
"active": {Active},
"password": "{Password}",
"title": "{JobTitle}",
"userType": "{ProfileType}",
"emails": [
{ "primary": true, "type": "work", "value": "{Email}" }
],
"name": { "familyName": "{FamilyName}", "givenName": "{GivenName}" },
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
"department": "{Department}",
"organization": "{Company}"
},
"urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User": {
"location": "{Location}",
"loginType": "{AuthenticationType}",
"jobStartDate": "{JobStartDate}"
}
}
'@
$PatchTemplate = @'
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{ "op": "Replace", "path": "active", "value": {Active} },
{ "op": "Replace", "path": "title", "value": "{JobTitle}" },
{ "op": "Replace", "path": "userType", "value": "{ProfileType}" },
{ "op": "Replace", "path": "emails[type eq \"work\"].value", "value": "{Email}" },
{ "op": "Replace", "path": "name.familyName", "value": "{FamilyName}" },
{ "op": "Replace", "path": "name.givenName", "value": "{GivenName}" },
{ "op": "Replace", "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", "value": "{Department}" },
{ "op": "Replace", "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization", "value": "{Company}" },
{ "op": "Replace", "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:location", "value": "{Location}" },
{ "op": "Replace", "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:loginType", "value": "{AuthenticationType}" }
]
}
'@
$ManagerTemplate = @'
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "Replace",
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager",
"value": "{ManagerId}"
}
]
}
'@
# Convert a date to the ISO 8601 format SCIM expects (yyyy-MM-ddTHH:mm:ssZ).
# This example handles date-only and ISO datetime input; extend it for other formats.
function Convert-DateToScimFormat {
param([string]$DateString)
if ([string]::IsNullOrWhiteSpace($DateString)) { return "" }
if ($DateString -match '^\d{4}-\d{2}-\d{2}$') { return $DateString + "T00:00:00Z" }
try { return ([datetime]$DateString).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") }
catch { Write-Host "Warning: could not parse date '$DateString', leaving empty"; return "" }
}
# Replace {Placeholder} tokens in a JSON template with escaped values.
function Set-JsonPlaceholders {
param([string]$JsonTemplate, [hashtable]$ReplacementValues)
$result = $JsonTemplate
foreach ($placeholder in $ReplacementValues.Keys) {
$value = $ReplacementValues[$placeholder]
if ([string]::IsNullOrWhiteSpace($value)) {
$escaped = ""
} elseif ($placeholder -eq "Active" -and ($value -eq "true" -or $value -eq "false")) {
$escaped = $value # boolean, no quotes
} else {
$escaped = $value -replace '\\', '\\' -replace '"', '\"' -replace "`n", '\n' -replace "`r", '\r' -replace "`t", '\t'
}
$result = $result -replace [regex]::Escape("{$placeholder}"), $escaped
}
return $result
}
# Drop PATCH operations whose value is empty (keeps booleans and non-empty strings).
function Remove-NullPatchOperations {
param([string]$PatchJson)
try {
$patch = $PatchJson | ConvertFrom-Json
$patch.Operations = @($patch.Operations | Where-Object {
$null -ne $_.value -and ($_.value -is [bool] -or -not [string]::IsNullOrWhiteSpace($_.value.ToString()))
})
return $patch | ConvertTo-Json -Depth 10
}
catch { return $PatchJson }
}
# Look up a user by userName; returns the SCIM id if found.
function Test-UserExists {
param([string]$UserName)
try {
$resp = Invoke-RestMethod -Uri ("$BaseUrl" + "?filter=userName eq `"$UserName`"") -Method GET -Headers $headers
if ($resp.Resources -and $resp.Resources.Count -gt 0) {
return @{ Exists = $true; UserId = $resp.Resources[0].id }
}
}
catch { Write-Host "Error checking user '$UserName': $($_.Exception.Message)" }
return @{ Exists = $false; UserId = $null }
}
# --- Phase 1: create or update each user ---
if (-not (Test-Path $CsvPath)) { throw "CSV file not found at path: $CsvPath" }
$csvData = Import-Csv -Path $CsvPath
Write-Host "Found $($csvData.Count) users to process"
$createdCount = 0; $updatedCount = 0; $failureCount = 0
$failedUsers = @()
foreach ($row in $csvData) {
if ([string]::IsNullOrWhiteSpace($row.UserName)) {
Write-Host "Skipping row with missing username"
$failureCount++; continue
}
# Map CSV columns to template placeholders
$replacements = @{
"ExternalId" = $row.ExternalId
"Username" = $row.UserName
"Active" = if ($row.Active -in @("true", "1")) { "true" } else { "false" }
"Password" = $row.Password
"FamilyName" = $row.FamilyName
"GivenName" = $row.GivenName
"Email" = $row.EmailAddress
"JobTitle" = $row.JobTitle
"ProfileType" = $row.ProfileType
"AuthenticationType" = $row.AuthenticationType
"Department" = $row.PrimaryDepartment
"Company" = $row.PrimaryCompany
"Location" = $row.PrimaryLocation
"JobStartDate" = Convert-DateToScimFormat -DateString $row.JobStartDate
}
$user = Test-UserExists -UserName $row.UserName
try {
if ($user.Exists) {
$payload = Set-JsonPlaceholders -JsonTemplate $PatchTemplate -ReplacementValues $replacements
$payload = Remove-NullPatchOperations -PatchJson $payload
Invoke-RestMethod -Uri "$BaseUrl/$($user.UserId)" -Method PATCH -Body $payload -Headers $headers | Out-Null
Write-Host "Updated $($row.UserName)"
$updatedCount++
}
else {
$payload = Set-JsonPlaceholders -JsonTemplate $PostTemplate -ReplacementValues $replacements
Invoke-RestMethod -Uri $BaseUrl -Method POST -Body $payload -Headers $headers | Out-Null
Write-Host "Created $($row.UserName)"
$createdCount++
}
}
catch {
Write-Host "Failed for $($row.UserName): $($_.Exception.Message)"
$failureCount++; $failedUsers += $row.UserName
}
if ($DelayMs -gt 0) { Start-Sleep -Milliseconds $DelayMs }
}
Write-Host ""
Write-Host "Created: $createdCount Updated: $updatedCount Failed: $failureCount"
if ($failedUsers.Count -gt 0) { Write-Host "Failed users: $($failedUsers -join ', ')" }
# --- Phase 2: assign managers (run after all users exist) ---
Write-Host ""
Write-Host "Assigning managers..."
$managerSuccess = 0; $managerMissing = 0; $managerFailed = 0
foreach ($row in ($csvData | Where-Object { -not [string]::IsNullOrWhiteSpace($_."Manager Email") })) {
try {
$user = Test-UserExists -UserName $row.UserName
if (-not $user.Exists) { Write-Host "User not found: $($row.UserName)"; $managerFailed++; continue }
$manager = Test-UserExists -UserName $row."Manager Email"
if (-not $manager.Exists) { Write-Host "Manager not found: $($row.'Manager Email')"; $managerMissing++; continue }
$payload = $ManagerTemplate -replace '\{ManagerId\}', $manager.UserId
Invoke-RestMethod -Uri "$BaseUrl/$($user.UserId)" -Method PATCH -Body $payload -Headers $headers | Out-Null
Write-Host "Assigned manager to $($row.UserName)"
$managerSuccess++
}
catch {
Write-Host "Failed manager assignment for $($row.UserName): $($_.Exception.Message)"
$managerFailed++
}
if ($DelayMs -gt 0) { Start-Sleep -Milliseconds $DelayMs }
}
Write-Host ""
Write-Host "Managers assigned: $managerSuccess Not found: $managerMissing Failed: $managerFailed"
Parameters
| Parameter | Type | Description |
|---|---|---|
CsvPath |
String | Path to the CSV file of user data |
BaseUrl |
String | The SCIM Users endpoint, for example https://{{intranet_url}}/api/v2/scim/v2/Users |
BearerToken |
String | The SCIM authentication token |
DelayMs |
Integer | Delay between API calls in milliseconds (default 50) |
Usage examples
Run the script with your CSV path, SCIM endpoint and authentication token:
.\Load-Users.ps1 -CsvPath ".\users.csv" -BaseUrl "https://{{intranet_url}}/api/v2/scim/v2/Users" -BearerToken "{{auth_token}}"
Increase the delay between calls to respect API rate limits on large imports:
.\Load-Users.ps1 -CsvPath ".\users.csv" -BaseUrl "https://{{intranet_url}}/api/v2/scim/v2/Users" -BearerToken "{{auth_token}}" -DelayMs 200
CSV file format
The example above reads a core subset of these columns. The full set of recognised profile columns is listed below — extend the templates and the column mapping in the script to use more of them.
Required fields
UserName— primary identifier for the userEmailAddress— primary email addressExternalId— external system identifierFamilyName— last nameGivenName— first nameActive— account status (true/false)Password— initial password (can be blank if AuthenticationType is SAML)AuthenticationType— authentication methodProfileType— user profile type (can be blank and defaults to Intranet User)ForcePasswordReset— force password reset on first login (true/false)PrimaryDepartment— department namePrimaryLocation— work locationPrimaryCompany— company name
Contact information
AlternativeEmailAddress— secondary emailHomePhoneNumber— home phoneWorkPhoneNumber— work phoneMobilePhoneNumber— mobile phoneAddress— physical address
Localisation
Locale— user locale (default: en-US)PreferredLanguage— preferred languageTimezone— user timezone
Personal information
Title— name title (Mr., Ms., Dr., etc.)Initials— name initialsPronouns— preferred pronounsDateOfBirth— birth date (ISO 8601)Bio— user biography
Employment information
JobTitle— job titleJobStartDate— employment start dateJobEndDate— employment end datePrimaryDepartment— department namePrimaryLocation— work locationPrimaryCompany— company name
Manager information
Manager Email— manager's email address for relationship assignment
Social media
LinkedInId— LinkedIn profile IDFacebookId— Facebook profile IDInstagramTag— Instagram handleTwitterTag— Twitter handle
How the script works
The script runs in two phases.
Phase 1: user creation and update
- Reads and validates the CSV file.
- Queries the SCIM API to determine whether each user exists.
- Uses the POST template for new users and the PATCH template for updates.
- Maps CSV columns to SCIM schema fields.
- Validates required fields and JSON syntax.
- Makes the appropriate REST call (POST or PATCH).
- Updates counters and displays progress.
Phase 2: manager assignment
- Identifies users with manager assignments.
- Resolves user and manager IDs via SCIM search.
- Updates the user record with the manager relationship.
- Handles missing users or managers gracefully.
SCIM schema support
The script supports the core Interact fields and can be amended to include additional fields. It uses the following schemas.
- Core SCIM 2.0 schema:
urn:ietf:params:scim:schemas:core:2.0:User - Enterprise extension (
urn:ietf:params:scim:schemas:extension:enterprise:2.0:User): department, organization, manager relationship. - Custom extension (
urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User): location, authentication type, job start date and more. See SCIM field mapping for the full set.
Functions reference
The script uses several functions.
Convert-DateToScimFormat— converts date-only or ISO 8601 datetime input to the SCIM format (yyyy-MM-ddTHH:mm:ssZ); extend it if your CSV uses other date formats. It returns an empty string for unparseable dates and emits a warning.Set-JsonPlaceholders— replaces placeholder tokens such as{Username}in a JSON template with actual values. It handles null and empty values, treatsActiveas a boolean (no quotes), and escapes special characters (\,", newlines, tabs).Remove-NullPatchOperations— filters out PATCH operations whose value is null, empty or whitespace, while keeping boolean values. Returns the original JSON if filtering fails.Test-UserExists— checks whether a user exists by filtering onuserName, returning the user's SCIM ID if found.Invoke-UserUpdate— sends a SCIM PATCH request to{BaseUrl}/{UserId}withContent-Type: application/scim+json.Invoke-UserCreate— sends a SCIM POST request to create a user.
Error handling
This script is offered as an example. It provides the following error handling.
- Validation errors: rows with a missing username are skipped with a warning, and unparseable date fields fall back to empty with a warning.
- API errors: authentication failures (token validation and clear messages), network connectivity (timeout and connection handling), SCIM specification violations (detailed API response parsing), and rate limiting (configurable delays).
- Recovery: individual user failures do not stop batch processing, errors are logged with specific messages, and optional features such as manager assignment are handled separately.
Performance
The script makes one existence-check call plus one create or update call per user, then a further call per manager assignment, with a configurable delay (-DelayMs, default 50ms) between calls. For large imports, increase the delay to respect API rate limits and process in batches.
Approximate timings at the default delay:
| User count | Approximate time |
|---|---|
| 50 users | ~18 seconds |
| 100 users | ~36 seconds |
| 500 users | ~3 minutes |
Security considerations
- Authentication: bearer authentication token-based API authentication. The token is visible in the script parameters, so consider secure storage. Tokens are never logged or exposed in output.
- Data handling: passwords are transmitted over HTTPS only, no sensitive information is written to error logs, and error messages are sanitised.
- Network: all API communication is over HTTPS, with correct SCIM content types and authentication headers, and safe parsing of API responses.
Troubleshooting
- "CSV file not found" — verify the file path (use absolute paths if needed), check file permissions, and ensure the file exists at the specified location.
- "User creation/update failed" — verify the SCIM API endpoint URL format, check the authentication token's validity and expiry, validate that required CSV fields are populated, and review the API response details in the error output.
- "Date parsing warnings" — use ISO 8601 format (
2024-01-15T00:00:00Z), check the supported formats inConvert-DateToScimFormat, leave date fields empty if the format is unknown, and check for invalid characters or malformed date strings. - "Manager assignment failed" — the manager user may not exist yet, the manager email may not match the username field, or the manager lookup may fail due to an incorrect address. Ensure manager users are processed before employees, verify manager addresses exist, and check that the manager email matches the SCIM
userNamefield.
For performance tuning, start with the default 50ms delay and increase it if you hit rate limiting. You can reduce it for faster processing on high-throughput APIs, monitoring for errors. Test with small batches first, and consider off-peak processing for large imports.