~/2025/08/20/powershell-connectwise-sync-configuration-items-from-discovery-scans.md

PowerShell: ConnectWise – Sync Configurations from Discovery Scans

---
author: 
date: 
read: 4 min
in:   [ps, scripts]
tags: [powershell, connectwise, discovery]
---

$ grep -n '^#' post.md

Discovery tools are good at finding what's actually on a network. PSA tools like ConnectWise PSA (Manage) are good at billing, ticketing and reporting against what's supposed to be there. The gap between the two is where configuration items go stale: a switch gets replaced, nobody updates the CI, and six months later a report says you're managing hardware that's in a dumpster. This script takes a CSV export from a discovery scan and reconciles it against a company's configurations through the ConnectWise REST API. It loads the company's existing configurations once, matches each discovered device by serial number, then MAC address, then name, sends a JSON Patch for fields that changed, and creates a configuration for anything it can't match.

Requirements

  • Windows PowerShell 5.1 or PowerShell 7.
  • An API member in ConnectWise PSA (System > Members > API Members) with a public/private key pair, assigned a security role that can inquire on companies and add/edit configurations. The private key is only shown when it's generated, so store it straight away.
  • A clientId from the ConnectWise Developer Network (developer.connectwise.com). Every request must carry it in a clientId header.
  • Your ConnectWise company ID (the login company code) and the API host for your region, for example api-na.myconnectwise.net.
  • Configuration types (for example Server, Network Device, Printer, Workstation) and manufacturers already created in PSA. The script looks them up by name and doesn't create reference data.
  • A discovery CSV with Hostname, IPAddress, MacAddress, DeviceType, Manufacturer, Model and SerialNumber columns.

Parameters

NameTypeRequiredDescription
CsvPathStringYesPath to the discovery export CSV.
CompanyIdentifierStringYesIdentifier (short name) of the customer company the configurations belong to.
CWServerStringYesAPI host name, for example api-na.myconnectwise.net.
CWCompanyIdStringYesYour ConnectWise login company ID, used in authentication.
CredentialPSCredentialYesAPI member keys: user name = public key, password = private key.
ClientIdStringYesThe clientId issued on the Developer Network.
DelayMillisecondsIntNoPause between write calls, to stay under API throttling on large imports. Defaults to 200.

The script supports -WhatIf, which reports what would be created or updated without calling the write endpoints.

Usage

If the scan came from the Python subnet fingerprint script, reshape its columns into the sync's schema first. DeviceType is a guess from the vendor, so review it before importing:

powershell
Import-Csv -Path ".\site-a-fingerprint.csv" | ForEach-Object {
    [PSCustomObject]@{
        Hostname     = if ($_.reverse_dns) { ($_.reverse_dns -split '\.')[0].ToUpper() } else { "" }
        IPAddress    = $_.ip_address
        MacAddress   = $_.mac_address
        DeviceType   = if ($_.vendor -match "Cisco|Ubiquiti|Aruba|Juniper") { "switch" } elseif ($_.vendor -match "HP|Brother|Lexmark") { "printer" } else { "workstation" }
        Manufacturer = $_.vendor
        Model        = ""
        SerialNumber = ""
    }
} | Export-Csv -Path "C:\Discovery\site-a-scan.csv" -NoTypeInformation

Store the API keys once as a credential file readable only by you (DPAPI-protected, so it only decrypts for the same user on the same machine):

powershell
Get-Credential -Message "User name = public key, password = private key" | Export-Clixml -Path "$env:USERPROFILE\cw-api.xml"

Run with -WhatIf first:

powershell
$cred = Import-Clixml -Path "$env:USERPROFILE\cw-api.xml"
.\Sync-CWConfigurationsFromDiscovery.ps1 -CsvPath "C:\Discovery\site-a-scan.csv" -CompanyIdentifier "ACMECORP" -CWServer "api-na.myconnectwise.net" -CWCompanyId "<company-id>" -Credential $cred -ClientId "<client-id>" -WhatIf
text
Codebase: v4_6_release/  Company: ACMECORP (id 250)  Existing configurations: 412
What if: Performing the operation "PATCH ipAddress (10.10.1.1 -> 10.10.1.2)" on target "SW-CORE-01 (config 4821, matched on serialNumber)".
What if: Performing the operation "Create configuration (type Network Device)" on target "AP-FLOOR3-07".
38 discovered device(s): 1 updated, 1 created, 35 unchanged, 1 skipped.

Drop -WhatIf to apply, and schedule it after each recurring discovery scan:

powershell
.\Sync-CWConfigurationsFromDiscovery.ps1 -CsvPath "C:\Discovery\site-a-scan.csv" -CompanyIdentifier "ACMECORP" -CWServer "api-na.myconnectwise.net" -CWCompanyId "<company-id>" -Credential $cred -ClientId "<client-id>"

Script

powershell
<#
.SYNOPSIS
    Reconciles a discovery scan CSV against ConnectWise PSA configuration items.
.DESCRIPTION
    Reads a discovery export CSV (Hostname, IPAddress, MacAddress, DeviceType, Manufacturer, Model,
    SerialNumber). Resolves the API codebase, looks up the target company by identifier, and loads all
    of that company's configurations with paging, plus configuration types and manufacturers. Each
    discovered device is matched to an existing configuration by serial number, then MAC address, then
    name. A match gets a JSON Patch for any of ipAddress, macAddress, serialNumber or modelNumber that
    changed; no match creates a new configuration of the mapped type. Uses the ConnectWise PSA REST API
    (apis/3.0) with API member Basic authentication and the clientId header.
.PARAMETER CsvPath
    Path to the discovery export CSV.
.PARAMETER CompanyIdentifier
    Identifier (short name) of the customer company the configurations belong to.
.PARAMETER CWServer
    API host name, for example api-na.myconnectwise.net.
.PARAMETER CWCompanyId
    Your ConnectWise login company ID, used in authentication.
.PARAMETER Credential
    API member keys: user name = public key, password = private key.
.PARAMETER ClientId
    The clientId issued on the Developer Network.
.PARAMETER DelayMilliseconds
    Pause between write calls, to stay under API throttling on large imports. Defaults to 200.
.EXAMPLE
    .\Sync-CWConfigurationsFromDiscovery.ps1 -CsvPath "C:\Discovery\site-a-scan.csv" -CompanyIdentifier "ACMECORP" -CWServer "api-na.myconnectwise.net" -CWCompanyId "<company-id>" -Credential $cred -ClientId "<client-id>" -WhatIf
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2025-08-20)
    Requires: PowerShell 5.1 or 7, a ConnectWise PSA API member with company inquire and configuration add/edit rights
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $true)]
    [string]$CsvPath,

    [Parameter(Mandatory = $true)]
    [string]$CompanyIdentifier,

    [Parameter(Mandatory = $true)]
    [string]$CWServer,

    [Parameter(Mandatory = $true)]
    [string]$CWCompanyId,

    [Parameter(Mandatory = $true)]
    [System.Management.Automation.PSCredential]$Credential,

    [Parameter(Mandatory = $true)]
    [string]$ClientId,

    [Parameter(Mandatory = $false)]
    [int]$DelayMilliseconds = 200
)

$ErrorActionPreference = 'Stop'

# Basic auth: base64("<companyId>+<publicKey>:<privateKey>"). The braces stop PowerShell reading "$name:" as a scope specifier.
$publicKey = $Credential.UserName
$privateKey = $Credential.GetNetworkCredential().Password
$authBytes = [System.Text.Encoding]::UTF8.GetBytes("${CWCompanyId}+${publicKey}:${privateKey}")
$headers = @{
    Authorization = "Basic " + [System.Convert]::ToBase64String($authBytes)
    clientId      = $ClientId
    Accept        = "application/json"
}

function Invoke-CWApi {
    param(
        [string]$Method = 'Get',
        [string]$Path,
        [hashtable]$Query,
        $Body
    )

    $uri = "$script:apiBase/$Path"
    if ($Query) {
        $pairs = foreach ($key in $Query.Keys) {
            "{0}={1}" -f $key, [uri]::EscapeDataString([string]$Query[$key])
        }
        $uri += "?" + ($pairs -join "&")
    }

    $params = @{ Uri = $uri; Method = $Method; Headers = $headers }
    if ($null -ne $Body) {
        # -InputObject (not the pipeline) so a one-element patch array stays a JSON array.
        $params['Body'] = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $Body -Depth 6))
        $params['ContentType'] = 'application/json'
    }
    return Invoke-RestMethod @params
}

function Get-CWAll {
    param([string]$Path, [string]$Conditions)

    # pageSize is capped at 1000; keep asking until a short page comes back.
    $page = 1
    $results = New-Object -TypeName System.Collections.Generic.List[object]
    do {
        $query = @{ page = $page; pageSize = 1000 }
        if ($Conditions) {
            $query['conditions'] = $Conditions
        }
        $batch = @(Invoke-CWApi -Path $Path -Query $query)
        foreach ($item in $batch) {
            $results.Add($item)
        }
        $page++
    } while ($batch.Count -eq 1000)
    return $results
}

function ConvertTo-NormalMac {
    param([string]$Mac)
    if ([string]::IsNullOrWhiteSpace($Mac)) {
        return ""
    }
    return ($Mac -replace '[^0-9A-Fa-f]', '').ToUpper()
}

function Get-DeviceTypeName {
    param([string]$DeviceType)

    switch -Wildcard ($DeviceType) {
        "*server*" { return "Server" }
        "*switch*" { return "Network Device" }
        "*router*" { return "Network Device" }
        "*firewall*" { return "Network Device" }
        "*access point*" { return "Network Device" }
        "*printer*" { return "Printer" }
        default { return "Workstation" }
    }
}

# The codebase (for example "v4_6_release/") is published per company at /login/companyinfo/<companyId>.
$companyInfo = Invoke-RestMethod -Uri "https://$CWServer/login/companyinfo/$CWCompanyId" -Method Get -Headers $headers
$codebase = $companyInfo.Codebase.Trim('/')
$script:apiBase = "https://$CWServer/$codebase/apis/3.0"

$company = @(Invoke-CWApi -Path "company/companies" -Query @{ conditions = "identifier=`"$CompanyIdentifier`"" })
if ($company.Count -ne 1) {
    throw "Expected one company with identifier '$CompanyIdentifier', found $($company.Count)."
}
$companyId = $company[0].id

$existing = Get-CWAll -Path "company/configurations" -Conditions "company/id=$companyId"
$types = @{}
foreach ($type in (Get-CWAll -Path "company/configurations/types")) {
    $types[$type.name] = $type.id
}
$manufacturers = @{}
foreach ($manufacturer in (Get-CWAll -Path "procurement/manufacturers")) {
    $manufacturers[$manufacturer.name] = $manufacturer.id
}

Write-Host "Codebase: $codebase/  Company: $CompanyIdentifier (id $companyId)  Existing configurations: $($existing.Count)"

# Index existing configurations for matching. Hashtables are case-insensitive by default.
$bySerial = @{}
$byMac = @{}
$byName = @{}
foreach ($config in $existing) {
    if ($config.serialNumber) { $bySerial[$config.serialNumber.Trim()] = $config }
    $mac = ConvertTo-NormalMac -Mac $config.macAddress
    if ($mac) { $byMac[$mac] = $config }
    if ($config.name) { $byName[$config.name.Trim()] = $config }
}

$devices = @(Import-Csv -Path $CsvPath)
$created = 0
$updated = 0
$unchanged = 0
$skipped = 0

foreach ($device in $devices) {
    $hostname = "$($device.Hostname)".Trim()
    $serial = "$($device.SerialNumber)".Trim()
    $mac = ConvertTo-NormalMac -Mac $device.MacAddress

    if (-not $hostname -and -not $serial -and -not $mac) {
        Write-Warning "Skipping a row with no hostname, serial number or MAC address."
        $skipped++
        continue
    }

    $match = $null
    $matchedOn = $null
    if ($serial -and $bySerial.ContainsKey($serial)) {
        $match = $bySerial[$serial]
        $matchedOn = 'serialNumber'
    } elseif ($mac -and $byMac.ContainsKey($mac)) {
        $match = $byMac[$mac]
        $matchedOn = 'macAddress'
    } elseif ($hostname -and $byName.ContainsKey($hostname)) {
        $match = $byName[$hostname]
        $matchedOn = 'name'
    }

    if ($match) {
        # Only fields the scan actually has a value for are compared, so blanks never wipe PSA data.
        $operations = New-Object -TypeName System.Collections.Generic.List[object]
        $changes = New-Object -TypeName System.Collections.Generic.List[string]
        $fields = [ordered]@{
            ipAddress    = "$($device.IPAddress)".Trim()
            macAddress   = "$($device.MacAddress)".Trim()
            serialNumber = $serial
            modelNumber  = "$($device.Model)".Trim()
        }
        foreach ($field in $fields.Keys) {
            $newValue = $fields[$field]
            $oldValue = "$($match.$field)".Trim()
            $same = if ($field -eq 'macAddress') { (ConvertTo-NormalMac -Mac $oldValue) -eq $mac } else { $oldValue -eq $newValue }
            if ($newValue -and -not $same) {
                $operations.Add([PSCustomObject]@{ op = "replace"; path = "/$field"; value = $newValue })
                $changes.Add("$field ($oldValue -> $newValue)")
            }
        }

        if ($operations.Count -eq 0) {
            $unchanged++
            continue
        }

        if ($PSCmdlet.ShouldProcess("$($match.name) (config $($match.id), matched on $matchedOn)", "PATCH $($changes -join ', ')")) {
            Invoke-CWApi -Method Patch -Path "company/configurations/$($match.id)" -Body $operations.ToArray() | Out-Null
            Write-Host "Updated $($match.name): $($changes -join ', ')"
            Start-Sleep -Milliseconds $DelayMilliseconds
        }
        $updated++
        continue
    }

    if (-not $hostname) {
        Write-Warning "No match for serial '$serial' / MAC '$mac' and no hostname to create it with - skipped."
        $skipped++
        continue
    }

    $typeName = Get-DeviceTypeName -DeviceType $device.DeviceType
    if (-not $types.ContainsKey($typeName)) {
        Write-Warning "Configuration type '$typeName' doesn't exist in PSA - skipped $hostname."
        $skipped++
        continue
    }

    if ($PSCmdlet.ShouldProcess($hostname, "Create configuration (type $typeName)")) {
        $newConfig = [ordered]@{
            name         = $hostname
            type         = @{ id = $types[$typeName] }
            company      = @{ id = $companyId }
            ipAddress    = "$($device.IPAddress)".Trim()
            macAddress   = "$($device.MacAddress)".Trim()
            serialNumber = $serial
            modelNumber  = "$($device.Model)".Trim()
        }
        if ($device.Manufacturer -and $manufacturers.ContainsKey($device.Manufacturer.Trim())) {
            $newConfig['manufacturer'] = @{ id = $manufacturers[$device.Manufacturer.Trim()] }
        }
        $result = Invoke-CWApi -Method Post -Path "company/configurations" -Body $newConfig
        Write-Host "Created $hostname (config $($result.id), type $typeName)"
        Start-Sleep -Milliseconds $DelayMilliseconds
    }
    $created++
}

Write-Host "$($devices.Count) discovered device(s): $updated updated, $created created, $unchanged unchanged, $skipped skipped."

Notes

  • The original draft had two real bugs. It built the auth string as "$CWCompanyId+$CWPublicKey:$CWPrivateKey". Microsoft's quoting rules spell out why that fails: PowerShell treats everything between the $ and a following : as a scope specifier, so the string throws instead of expanding. The fix is braces around each name, as in "${CWPublicKey}:${CWPrivateKey}". And it wrapped ConvertTo-Json output in [...], which produced a nested array whenever more than one field changed. The script now builds the patch as an array and serialises it once. It also sets the content type with -ContentType rather than a Content-Type entry in -Headers.
  • Codebase. The v4_6_release path segment is the codebase, and it's published per company at https://<server>/login/companyinfo/<companyId> in the Codebase field. Reading it at runtime is how the maintained client libraries do it, and it saves editing the script when your instance moves.
  • Matching order matters. Name-only matching (what the first draft did) creates duplicates when a device is renamed and silently merges two devices that reuse a name. Serial number is the most stable key, MAC next; name is the fallback. Blank values in the scan never overwrite populated fields in PSA.
  • Conditions syntax. String values in conditions are wrapped in double quotes (identifier="ACMECORP") and numbers are not (company/id=250). Invoke-CWApi URL-encodes the whole value.
  • Paging. List endpoints return one page at a time; pageSize is capped at 1,000. Get-CWAll pages until it gets a short page. Loading the company's configurations once, instead of one lookup per device, is the biggest single saving on API calls.
  • Reference data. type and manufacturer are references, sent as @{ id = ... } after the script resolves names to IDs from company/configurations/types and procurement/manufacturers. Create those records in PSA once, up front; devices whose type doesn't exist are skipped with a warning.
  • Decommissioning is out of scope. The script doesn't retire configurations for devices that disappeared from a scan. An offline laptop isn't the same as a device that's gone; pair this with a report of configurations not seen in the last few scans and retire those by hand.
  • Secrets. Export-Clixml protects the credential with Windows DPAPI for the current user and machine. For an unattended run under a service account, export the credential while logged on as that account, or use a vault through the Microsoft.PowerShell.SecretManagement module. Keep the clientId out of public repositories too; it identifies your integration to ConnectWise.

Source