~/2019/01/09/powershell-connectwise-documents-api-uploading-a-document-or-attachment-to-a-ticket.md

PowerShell: ConnectWise Documents API, Uploading a Document or Attachment to a Ticket

---
author: 
date: 
updated: 
read: 2 min
in:   [ps, scripts]
tags: [connectwise, powershell]
---
PowerShell: ConnectWise Documents API, Uploading a Document or Attachment to a Ticket

$ grep -n '^#' post.md

Phew, this one took a minute to figure out. ConnectWise has a form based documents API (technically not really API, but it’s the way you get yourself a document into a CW ticket). First is the really amazing documentation that CW provides around the documents API

ConnectWise Document API documentation showing the uploadsample GET endpoint and its Upload a document form

Second is then working with PowerShell to handle streamed encoding correctly, build a multipart form data payload, and then getting it to actually send the correct thing. Ultimately there were some good learning steps here. Mainly on how to construct a proper content type of “multipart/form-data”. I’m writing this in hopes that many of you that are out there that are facing a similar challenge on getting documents to upload into CW via PowerShell aren’t faced with the same 2 day challenge I just had.

Requirements

  • PowerShell 5.1 or later, with Invoke-RestMethod.
  • A ConnectWise API member with its public key, private key and your company identifier. Creating one is covered in Query Contacts by Email Address. The member's security role must be allowed to add documents to the record you upload to.
  • Outbound HTTPS to the ConnectWise documents endpoint on na.myconnectwise.net. Use the regular host here, not the api- one.
  • The file to upload, readable from the machine running the script, and the ID of the record (for example a ticket) it should be attached to.

Parameters

The script has no parameters. Edit these values in it before running.

NameTypeRequiredDescription
$global:CWcompanystringYesYour CW company identifier, <company-id>.
$global:CWprivatestringYesThe API member's private key, <private-key>.
$global:CWpublicstringYesThe API member's public key, <public-key>.
$global:CWserverstringYesThe documents endpoint URL, https://na.myconnectwise.net/v4_6_release/apis/3.0/system/documents. Despite the name it is the full endpoint, and it must not use the api- host.
$FilePathstringYesFull path of the file to upload, for example C:\path\to\file.jpg.
recordTypestringYesForm field in $bodyLines: the kind of record to attach to. Ticket in this script.
recordIdstringYesForm field in $bodyLines: the ID of that record, <record-id>.
TitlestringYesForm field in $bodyLines: the title of the document in CW, <document-title>.

Walkthrough

Encoding Issues

Mainly the Encoding Issues were around reading a file into PowerShell and then using Invoke-RestMethod to send it off. Typically you’d work in UTF-8, while that’s great in PS when working, sending that encoding via the Invoke-RestMethod seems to break things a little and none of the characters are correct, thus resulting in a data stream sent to your destination being garbled.

Two Notepad windows of multipart form data: readable JPEG headers on the left, garbled characters on the rightLeft – Proper Data | Right – Garbled Data

I happened to stumble, and by stumble, I’ve been searching the Google masters for quite a while trying to understand why the encoding wasn’t working correctly, upon this article:

https://social.technet.microsoft.com/Forums/en-US/26f6a32e-e0e0-48f8-b777-06c331883555/invokewebrequest-encoding?forum=winserverpowershell

which nicely pointed me here:

https://windowsserver.uservoice.com/forums/301869-powershell/suggestions/13685217-invoke-restmethod-and-invoke-webrequest-encoding-b

Taking from this, I modified the following from:

powershell
$fileEnc = [System.Text.Encoding]::GetEncoding('UTF-8').GetString($fileBytes)

To using the ISO 8859-1 encoding type of 28591. Converting this line to:

powershell
$fileEnc = [System.Text.Encoding]::GetEncoding(28591).GetString($fileBytes)

Multipart boundaries

The rest of the time was learning to deal with boundaries in a multipart/form-data payload. Essentially finding this article:

https://gist.github.com/weipah/19bfdb14aab253e3f109

This taught me a bit about the boundaries that need to be set and more-so having to use `r`n in different places, you’ll see this referenced the same way as in the link in my script below using the $LF variable.

Usage

Edit the values listed above, save the script as Add-CWDocument.ps1 and run it from a PowerShell prompt.

powershell
.\Add-CWDocument.ps1

Invoke-RestMethod prints whatever the CW documents API returns for the upload. To attach to a different record, change recordType and recordId in $bodyLines.

Script

Enjoy, here’s the full code layout:

powershell
<#
.SYNOPSIS
    Uploads a file to a ConnectWise record (for example a ticket) through the Documents API.
.DESCRIPTION
    Reads the file at $FilePath, re-encodes its bytes as ISO 8859-1 (code page 28591) so
    Invoke-RestMethod does not garble them, builds a multipart/form-data body by hand
    (recordType, recordId, Title and the file) and posts it to the ConnectWise
    system/documents endpoint using an API member's keys.
.EXAMPLE
    .\Add-CWDocument.ps1
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.1 (2026-09-20)
    History : 1.0 2019-01-09 Initial version.
              1.1 2026-09-20 The uploaded file name now comes from $FilePath.
    Requires: PowerShell 5.1 or later; ConnectWise API member with permission to add documents
.LINK
    https://gist.github.com/weipah/19bfdb14aab253e3f109
#>

# Initializations
$global:CWcompany    = "<company-id>"
$global:CWprivate    = "<private-key>"
$global:CWpublic     = "<public-key>"
# Don't use the api- URL here for the server.
$global:CWserver     = "https://na.myconnectwise.net/v4_6_release/apis/3.0/system/documents"

# CW auth string
[string]$Authstring  = $CWcompany + '+' + $CWpublic + ':' + $CWprivate
$encodedAuth         = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(($Authstring)))

# CW headers
$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
$headers.Add("Authorization", "Basic $encodedAuth")

# Read the file and build the multipart body
$FilePath = 'C:\path\to\file.jpg'
$fileName = [System.IO.Path]::GetFileName($FilePath)
$fileBytes = [System.IO.File]::ReadAllBytes($FilePath)
$fileEnc = [System.Text.Encoding]::GetEncoding(28591).GetString($fileBytes)
$boundary = [System.Guid]::NewGuid().ToString()
$LF = "`r`n"

$bodyLines = (
    "--$boundary",
    "Content-Disposition: form-data; name=`"recordType`"$LF",
    "Ticket",
    "--$boundary",
    "Content-Disposition: form-data; name=`"recordId`"$LF",
    "<record-id>",
    "--$boundary",
    "Content-Disposition: form-data; name=`"Title`"$LF",
    "<document-title>",
    "--$boundary",
    "Content-Disposition: form-data; name=`"file`"; filename=`"$fileName`"",
    "Content-Type: application/octet-stream$LF",
    $fileEnc,
    "--$boundary--$LF"
) -join $LF

# Upload
Invoke-RestMethod -Uri $CWserver -Method Post -ContentType "multipart/form-data; boundary=`"$boundary`"" -Body $bodyLines -Headers $headers

Notes

  • Update 2026-09-20 (v1.1): the file name in the multipart body was hard-coded as image001.jpg, so every upload was named that way whatever $FilePath pointed at. It is now taken from $FilePath.