How The ConnectWise Manage API Handles Pagination with PowerShell
Table of Contents
If you have ever worked with the Microsoft Graph API, you may be familiar with how it handles pagination, which is returning an @odata.nextLink
property in the response containing a URL to the following results page.
I was initially confused when I started working with the ConnectWise PSA API. Although I could specify a pageSize
and page
parameter, there never seemed to be an indication of where to go for the next page of results. This write-up will show you several ways to do pagination within the API and how to form your headers and requests for each type.
The documentation for pagination from Connectwise can be found here.
Query Parameters
The first item to discuss is query parameters. Query parameters are key-value pairs that filter, sort, or paginate data an API returns. Below is a table of the query parameters we will use when paginating data and what each one does.
Query Parameter | Description |
Page | Specifies which page of results you are querying. |
PageSize | How many items are returned per page. The max is 1,000 |
PageID | Used for Forward-Only pagination. Treated like an additional condition of Id > pageId |
Pagination
Navigable
The first type of pagination is Navigable. Navigable pagination is a method in APIs where the response includes links (usually in the headers or the body) to navigate to other pages of data. These links typically include URLs to the next, previous, first, and last pages, allowing clients to move through large sets of results easily.
Below is what the response will look like
Key Value
--- -----
Cache-Control {no-cache}
Pragma {no-cache}
Strict-Transport-Security {max-age=31536000; includeSubDomains}
x-server-name {SERVCWAPI01}
x-cw-request-id {c923476-8265-4a343-231-4a1c2340b}
X-Frame-Options {SAMEORIGIN}
Link {<https://SERVERNAME/v4_6_release/apis/3.0/company/companies?pageSize=1000&page=2>; rel="next", <https://SERVERNAME…
api-current-version {2024.1}
X-Content-Type-Options {nosniff}
X-XSS-Protection {1; mode=block}
Content-Security-Policy {frame-ancestors 'self' blob: *.myconnectwise.net *.connectwisedev.com; default-src 'self' 'unsafe-inline' 'u…
Referrer-Policy {strict-origin-when-cross-origin}
Date {Fri, 07 Jun 2024 01:02:41 GMT}
Content-Length {445381}
Content-Type {application/json; charset=utf-8}
Expires {-1}
With ConnectWise, the next link will be returned with the headers under “Link.” PowerShell’s Invoke-Restmethod
does not include the header response by default, but we can get them if we include the ResponseHeadersVariable
parameter.
$Url = "https://SERVERNAME/v4_6_release/apis/3.0/company/companies?page=1&pageSize=1000"
$InvokeRestMethodParams = @{
Method = "Get"
URI = $Url
ErrorAction = "SilentlyContinue"
Verbose = $false
ResponseHeadersVariable = "responseHeaders"
Headers = @{
"Authorization" = "Basic [BASE64 AUTHSTRING]"
'clientId' = '[CLIENTID]'
}
}
$companies = Invoke-RestMethod @InvokeRestMethodParams
To view the response headers, you can call the variable, which, in my example, would be $responseHeaders
.
Unfortunately, you would need to do some regex magic to get just the link
URL out of the key-value pair. However, you may have noticed that the URL increments the page by 1. Knowing this, we can recursively use Invoke-RestMethod
and increment the page by 1. We will know we are on the last page of results if the page has fewer results than the previous page.
Below, I created a function that will return all ConnectWise Companies using this method. The highlighted lines will recursively do an API call to get the next page of 1,000 companies, increment the page by 1, and then do it again until it gets to a page with fewer results than prior.
function Get-CWCompanies {
[cmdletbinding()]
param(
[Parameter(Position = 0, mandatory)]
[system.string]$clientID,
[Parameter(Position = 1, mandatory)]
[system.string]$base64Auth,
[Parameter(Position = 2, mandatory)]
[system.string]$server
)
begin {
# Initialize pagination variables
[system.int32]$page = 1
[system.int32]$pageSize = 1000
$allResults = @()
Write-Verbose "Getting all companies"
$Url = "https://$server/v4_6_release/apis/3.0/company/companies?page=$page&pageSize=$pageSize"
$InvokeRestMethodParams = @{
Method = "Get"
URI = $Url
ErrorAction = "SilentlyContinue"
Verbose = $false
Headers = @{
"Authorization" = "Basic $base64Auth"
'clientId' = $clientID
}
}
}
process {
$response = Invoke-RestMethod @InvokeRestMethodParams
# Loop to handle pagination
do {
Write-Verbose "Working on page: $page"
# Make the API request
try {
$response = Invoke-RestMethod @InvokeRestMethodParams
}
catch {
Write-Error -Message "Failed to get ConnectWise Companies. Error: $_"
}
# Append results to the collection
$allResults += $response
# Increment the page number
$page++
$Url = "https://$server/v4_6_release/apis/3.0/company/companies?page=$page&pageSize=$pageSize"
$InvokeRestMethodParams['URI'] = $Url
# Check if the response contains fewer items than the page size (indicating the last page)
$isLastPage = ($response.Count -lt $pageSize)
} while (-not $isLastPage)
}
end {
# Check if the response is null (indicating an error occurred)
if ($null -eq $allResults) {
Write-Warning -Message "API call returned null. Check previous error logs for details."
}
else {
$allResults | Sort-Object Name
}
}
}
Forward-Only
Forward-Only was released in 2018.5 and does not make use of the query parameter, “Page
” because, technically, all results are on page 1. To use this method, you must include pagination-type: forward-only
in your request header. The forward-only method will return a single link in the Link property, and that will be the URL for the next page of data. Below is an example of the call; notice the header now includes pagination-type
.
$Url = "https://SERVERNAME/v4_6_release/apis/3.0/company/companies?pageSize=1000"
$InvokeRestMethodParams = @{
Method = "Get"
URI = $Url
ErrorAction = "SilentlyContinue"
Verbose = $false
ResponseHeadersVariable = "responseHeaders"
Headers = @{
"Authorization" = "Basic [BASE64 AUTHSTRING]"
'clientId' = '[CLIENTID]'
'pagination-type' = 'forward-only'
}
}
$companies = Invoke-RestMethod @InvokeRestMethodParams
The new response header is as follows:
Key Value
--- -----
Cache-Control {no-cache}
Pragma {no-cache}
Strict-Transport-Security {max-age=31536000; includeSubDomains}
x-server-name {SERVCWAPI01}
x-cw-request-id {03252345f3-80e5-4234-b679-9a2342359}
X-Frame-Options {SAMEORIGIN}
Link {<https://SERVERNAME/v4_6_release/apis/3.0/company/companies?pageSize=1000&page=1&pageId=21723>; rel="next"}
api-current-version {2024.1}
X-Content-Type-Options {nosniff}
X-XSS-Protection {1; mode=block}
Content-Security-Policy {frame-ancestors 'self' blob: *.myconnectwise.net *.connectwisedev.com; default-src 'self' 'unsafe-inline' 'u…
Referrer-Policy {strict-origin-when-cross-origin}
Date {Fri, 07 Jun 2024 01:23:14 GMT}
Content-Length {443531}
Content-Type {application/json; charset=utf-8}
Expires {-1}
Since we don’t care about this response header for either method, we can still use the function from the previous method. But what if we could remove the logic of incrementing the page and looping Invoke-Restmethod
?
FollowRelLink
Invoke-RestMethod
contains a parameter called FollowRelLink
. This is not included in Windows PowerShell but is available in PowerShell Core. The –FollowRelLink
parameter for Invoke-RestMethod
in PowerShell automatically follows relational links (like “next”) in the response headers to retrieve subsequent pages of data in a paginated API response. This simplifies handling pagination by allowing you to continuously fetch all pages of results without manually parsing links.
One thing to note when using the parameter is that it will create sets of results as an array for each page. In the example below, I will use -FollowRelLink
to automatically get all of the data, but then use the count
property, you will see that I only have 7 results. But each 7 contains 1,000 companies within.
$Url = "https://SERVERNAME/v4_6_release/apis/3.0/company/companies?pageSize=1000"
$InvokeRestMethodParams = @{
Method = "Get"
URI = $Url
ErrorAction = "SilentlyContinue"
Verbose = $false
FollowRelLink = $true
Headers = @{
"Authorization" = "Basic [BASE64 AUTHSTRING]"
'clientId' = '[CLIENTID]'
'pagination-type' = 'forward-only'
}
}
$companies = Invoke-RestMethod @InvokeRestMethodParams
I can see that it returns 7 results using the count
property on the companies
array.
But if I call the ID
property and then the Length
(or the Count
) property, I will see that I have all of my results.
To update my function, which gets all companies, I will remove the loop and, in the process block, ‘unpack’ my paged results into a single array for simplicity.
function Get-CWCompanies {
[cmdletbinding()]
param(
[Parameter(Position = 0, mandatory)]
[system.string]$clientID,
[Parameter(Position = 1, mandatory)]
[system.string]$base64Auth,
[Parameter(Position = 2, mandatory)]
[system.string]$server
)
begin {
# Initialize pagination variables
[system.int32]$pageSize = 1000
$fullResponse = @()
Write-Verbose "Getting all companies"
$Url = "https://$server/v4_6_release/apis/3.0/company/companies?pageSize=$pageSize"
$InvokeRestMethodParams = @{
Method = "Get"
URI = $Url
ErrorAction = "SilentlyContinue"
Verbose = $false
FollowRelLink = $true
Headers = @{
"Authorization" = "Basic $base64Auth"
'clientId' = $clientID
'pagination-type' = 'forward-only'
}
}
}
process {
$response = Invoke-RestMethod @InvokeRestMethodParams
foreach ($arrayMember in $response) {
$fullResponse += $arrayMember
}
}
end {
$fullResponse
}
}
Note: This will work for the Forward-Only method and Navigable
My name is Bradley Wyatt; I am a 5x Microsoft Most Valuable Professional (MVP) in Microsoft Azure and Microsoft 365. I have given talks at many different conferences, user groups, and companies throughout the United States, ranging from PowerShell to DevOps Security best practices, and I am the 2022 North American Outstanding Contribution to the Microsoft Community winner.
One thought on “How The ConnectWise Manage API Handles Pagination with PowerShell”
Great example!
This is the way I handled pagination, not as elegant but it works
Simple search function:
function Search-Tickets {
param ($SearchQuery, $CWBaseURL)
$i = 1
do {
$Query = Invoke-RestMethod -uri “https://$CWBaseURL/v4_6_release/apis/3.0/service/tickets/search?pagesize=1000&page=$i” -Method POST -Headers $CWAuthHeader -Body $SearchQuery -ContentType ‘application/json’
$i++
$Query | foreach {
[pscustomobject]@{
TicketID = $_.id
Board = $_.board.name
Company = $_.company.name
Type = $_.type.name
SubType = $_.subType.name
Summary = $_.Summary
“Total Hours” = $_.”Total Hours”
Contact = $_.contact.Name
ContactEmail = $_.contact.email
Status = $_.Status.name
DateEntered = $_.’_info’.dateEntered
LastUpdated = $_.’_info’.lastUpdated
“Entered By” = $_.’_info’.enteredBy
Owner = $_.owner.name
Resources = $_.Resources
Notes = $null
}
}
}
until ($Query.count -lt 1)
}