---
updatedAt: 2025-11-12T06:11:35.000Z
agentTools:
  siteIndex: https://developer.n-able.com/llms.txt
  projectIndex: https://developer.n-able.com/n-able-cove/llms.txt
---

# Cove - Get Bulk Device Errors

```powershell PowerShell
<# ----- About: ----
    # Cove Data Protection - Get Bulk Device Errors
    # Revision v35 - 2025-11-12
    # Author: Eric Harless, Head Backup Nerd - N-able
    # Twitter @Backup_Nerd  Email:eric.harless@n-able.com
    # Reddit https://www.reddit.com/r/Nable/
    # GitHub: @BackupNerd  https://github.com/BackupNerd
# -----------------------------------------------------------#>  ## About

<# ----- Legal: ----
    # Sample scripts are not supported under any N-able support program or service.
    # The sample scripts are provided AS IS without warranty of any kind.
    # N-able expressly disclaims all implied warranties including, warranties
    # of merchantability or of fitness for a particular purpose.
    # In no event shall N-able or any other party be liable for damages arising
    # out of the use of or inability to use the sample scripts.
# -----------------------------------------------------------#>  ## Legal

<# ----- Compatibility: ----
    # For use with the Standalone edition of N-able | Cove Data Protection
    # Sample scripts may contain non-public API calls which are subject to change without notification
# -----------------------------------------------------------#>  ## Compatibility

<# ----- Behavior: ----
    # Authenticates to https://backup.management console using stored or prompted credentials
    # Queries devices based on selected action filter (ActiveErrors, LastErrors, or Custom)
    # Retrieves detailed error messages from the last backup session for each device with errors
    # Filters results to show only errors within the specified number of days
    # Optionally updates custom columns in https://backup.management with specific error messages or clears them
    # Displays color-coded console output (green=success, yellow=warning, red=error, cyan=info)
    # Optionally exports results to CSV/XLSX files with formatted data
    # Displays execution statistics (devices processed, errors found, columns updated/cleared, elapsed time)
    # Optionally sends webhook notification with script execution metrics and details
    #
    # Use the -Action parameter to specify device filter (default="ActiveErrors")
    #   Options: "ActiveErrors" (devices with current errors), "LastErrors" (devices active in last X days),
    #            "Custom" (custom filter)
    # Use the -Days ## (default=7) parameter to filter errors to those occurring within the last X days
    # Use the -DeviceCount ## (default=5000) parameter to limit the maximum number of devices queried
    # Use the -Export switch parameter (default=$true) to export results to CSV/XLSX files
    # Use the -ExportPath (?:\Folder) parameter to specify alternate export file path (default=script directory)
    # Use the -Launch switch parameter to open the CSV/XLSX file after export completion
    # Use the -GridView switch parameter to display results in PowerShell Out-GridView
    # Use the -Delimiter (default=',') parameter to set CSV delimiter (e.g., ';' for European formats)
    # Use the -CustomColumn (default=$True) switch parameter to update custom columns with error messages
    # Use the -ColumnCode parameter (default="AA2045") to specify which custom column to populate/clear
    #   Note: Custom column must be added to https://backup.management console before use
    # Use the -CustomerName parameter to specify exact Customer/Partner name (bypasses interactive selection)
    # Use the -TaskPath parameter to specify path for stored credentials (default="%userprofile%\BackupNerdScripts")
    # Use the -ClearCredentials parameter to remove stored API credentials at script start
    # Use the -DisableWebhook parameter to prevent webhook notifications from being sent
    # Use the -DebugWebhook parameter to display webhook payload in console for troubleshooting
    #
    # https://documentation.n-able.com/backup/userguide/documentation/Content/service-management/json-api/home.htm
    # https://documentation.n-able.com/backup/userguide/documentation/Content/service-management/console/custom-columns.htm
    #
    # v33 Updates (2025-10-30 - AI-Assisted):
    # - Fixed JSON parsing issues for PowerShell 5.1 and 7+ compatibility
    # - Changed from Invoke-RestMethod to Invoke-WebRequest to get raw JSON content
    # - Added proper backslash escaping for Windows file paths in API responses
    # - Added trailing comma removal for PowerShell 5.1 strict JSON parser
    # - Fixed Send-UpdateCustomColumn to use ConvertTo-Json for proper character escaping
    # - Fixed nested array structure preservation for custom column API calls
    # - Removed duplicate/erroneous API call causing connection timeouts
    # - Ensured ColumnId is cast to integer type for API compatibility
    #
    # v34 Updates (2025-10-30 - AI-Assisted):
    # - Added color-coded console output (Green=success, Yellow=warning, Red=error, Cyan=info)
    # - Added execution statistics summary (devices processed, errors found, columns updated/cleared)
    # - Added elapsed time reporting in HH:MM:SS format
    # - Created helper functions: Write-Success, Write-Warning-Color, Write-ErrorMsg, Write-Info
    # - Created Get-ElapsedTime and Write-Statistics functions for performance tracking
    # - Tested and validated compatibility with both PowerShell 5.1 and PowerShell 7+
# -----------------------------------------------------------#>  ## Behavior

[CmdletBinding()]
    Param (
        [Parameter(Mandatory=$False, Position=0)]
            [ValidateSet("ActiveErrors","LastErrors","Custom")] [String]$Action = "ActiveErrors",     ## Get ActiveError or LastError per device
        [Parameter(Mandatory=$False)] [int]$Days = 7,
        [Parameter(Mandatory=$False)] [int]$DeviceCount = 5000,                                             ## Change Maximum Number of devices results to return
        [Parameter(Mandatory=$False)] [switch]$Export = $true,                                              ## Generate CSV / XLS Output Files
        [Parameter(Mandatory=$False)] [switch]$Launch,                                                      ## Launch XLS or CSV file
        [Parameter(Mandatory=$False)] [switch]$GridView,                                                    ## Display output via Out-GridView
        [Parameter(Mandatory=$False)] [string]$Delimiter = ',',                                             ## specify ',' or ';' Delimiter for XLS & CSV file
        [Parameter(Mandatory=$False)] [string]$ExportPath = "$PSScriptRoot",                                ## Export Path
        [Parameter(Mandatory=$False)] [switch]$CustomColumn = $true,                                        ## Update Backup.Management Custom Column
        [Parameter(Mandatory=$False)] [string]$ColumnCode = "AA2045",                                       ## Column Code ShortID to Populate / Clear
        [Parameter(Mandatory=$False)] [string]$CustomerName,                                                ## Customer/Partner Name to lookup (skips prompt if provided)
        [Parameter(Mandatory=$False)] [string]$TaskPath = "$env:userprofile\BackupNerdScripts",             ## Path to Store/Invoke Scheduled Backup Nerd Script Credentials and Tasks
        [Parameter(Mandatory=$False)] [switch]$ClearCredentials,                                            ## Remove Stored API Credentials at start of script
        [Parameter(Mandatory=$False)] [switch]$DisableWebhook,                                              ## Disable webhook notification
        [Parameter(Mandatory=$False)] [switch]$DebugWebhook                                                 ## Display webhook payload in console
    )

#region ----- Environment, Variables, Names and Paths ----
    Clear-Host
    $Script:ScriptStartTime = Get-Date
    $ConsoleTitle = "Bulk Get Device Errors for the Last $days Days"
    $host.UI.RawUI.WindowTitle = $ConsoleTitle
    $scriptpath = $MyInvocation.MyCommand.Path
    Write-output "  $ConsoleTitle`n`n$ScriptPath"
    $Syntax = Get-Command $PSCommandPath -Syntax
    Write-Output "  Script Parameter Syntax:`n`n  $Syntax"
    $dir = Split-Path $scriptpath
    Push-Location $dir
    $CurrentDate = Get-Date -format "yyy-MM-dd_HH-mm-ss"
    $ShortDate = Get-Date -format "yyy-MM-dd"
    if ($launch) {[switch]$export = $true}
    if ($export -and $ExportPath) {$ExportPath = Join-path -path $ExportPath -childpath "DeviceErrors_$shortdate"}else{$ExportPath = Join-path -path $dir -childpath "DeviceErrors_$shortdate"}
    If ($export) {mkdir -force -path $ExportPath | Out-Null}
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    $Script:strLineSeparator = "  ---------"
    #$urlJSON = 'https://api.backup.management/jsonapi'

    # Statistics counters
    $Script:DevicesProcessed = 0
    $Script:ErrorsFound = 0
    $Script:ColumnsUpdated = 0
    $Script:ColumnsCleared = 0

    # Script execution status
    $Script:ExitCode = 0

    Write-output "  Current Parameters:"
    Write-output "  -Days          = $Days"
    Write-output "  -DeviceCount   = $DeviceCount"
    Write-output "  -Action        = $Action"
    Write-output "  -Export        = $Export"
    Write-output "  -ExportPath    = $ExportPath"
    Write-output "  -Delimiter     = $Delimiter"
    Write-output "  -Launch        = $Launch"
    Write-output "  -Gridview      = $GridView"
    Write-output "  -CustomColumn  = $CustomColumn"
    Write-output "  -ColumnCode    = $ColumnCode"

#endregion ----- Environment, Variables, Names and Paths ----

#region ----- Functions ----

#region ----- Output Functions ----
    Function Write-Success {
        param([string]$Message)
        Write-Host "  $Message" -ForegroundColor Green
    }

    Function Write-Warning-Color {
        param([string]$Message)
        Write-Host "  $Message" -ForegroundColor Yellow
    }

    Function Write-ErrorMsg {
        param([string]$Message)
        Write-Host "  $Message" -ForegroundColor Red
    }

    Function Write-Info {
        param([string]$Message)
        Write-Host "  $Message" -ForegroundColor Cyan
    }

    Function Get-ElapsedTime {
        $elapsed = (Get-Date) - $Script:ScriptStartTime
        return "{0:hh\:mm\:ss}" -f $elapsed
    }

    Function Write-Statistics {
        Write-Output $Script:strLineSeparator
        Write-Info "Execution Statistics:"
        Write-Output "  Devices Processed   : $Script:DevicesProcessed"
        Write-Output "  Errors Found        : $Script:ErrorsFound"
        Write-Output "  Columns Updated     : $Script:ColumnsUpdated"
        Write-Output "  Columns Cleared     : $Script:ColumnsCleared"
        Write-Output "  Elapsed Time        : $(Get-ElapsedTime)"
        Write-Output $Script:strLineSeparator
    }

    Function Send-ScriptWebhook {
        <#
        .SYNOPSIS
            Self-contained webhook notification function for script execution tracking

        .PARAMETER WebhookUrl
            The webhook endpoint URL (optional, uses default if not specified)

        .PARAMETER ScriptData
            Hashtable containing script execution data

        .PARAMETER Metrics
            Hashtable containing script-specific metrics

        .PARAMETER Disable
            Skip webhook notification if specified

        .PARAMETER DebugMode
            Display webhook payload in console
        #>

        [CmdletBinding()]
        param(
            [Parameter(Mandatory=$false)]
            [string]$WebhookUrl = "https://default6324f4fb86ee4493ba16c819a916b4.87.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/9c877a277d374c95a1a994a7ddcd82c7/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=B4JjYTJdel_upZTveufXjBqPZT1yobgdGpc9Rh6Kmwo",

            [Parameter(Mandatory=$true)]
            [hashtable]$ScriptData,

            [Parameter(Mandatory=$false)]
            [hashtable]$Metrics = @{},

            [switch]$Disable,
            [switch]$DebugMode
        )

        # Skip if disabled
        if ($Disable) {
            Write-Info "Webhook notification disabled"
            return
        }

        # Validate required fields
        $requiredFields = @('script_name', 'customer_id', 'exit_code')
        foreach ($field in $requiredFields) {
            if (-not $ScriptData.ContainsKey($field)) {
                Write-Warning-Color "Webhook skipped: Missing required field '$field'"
                return
            }
        }

        try {
            # Get automatic values
            $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'

            # Build subject line
            $processedCount = if ($Metrics.ContainsKey('devices_processed')) { $Metrics.devices_processed } else { 0 }
            $subject = "$($ScriptData.script_name) - $($ScriptData.customer_id) - $processedCount Processed - ExitCode $($ScriptData.exit_code)"

            # Build HTML formatted message for email readability
            $message = @"
<strong>Script Execution Summary</strong><br>
==================================================<br>
<br>
<strong>Script:</strong> $($ScriptData.script_name)<br>
<strong>Execution Time:</strong> $timestamp<br>
<br>
<strong>API Partner ID:</strong> $($ScriptData.api_partner_id)<br>
<strong>API User ID:</strong> $($ScriptData.api_user_id)<br>
<strong>Customer ID:</strong> $($ScriptData.customer_id)<br>
<br>
<strong>Script Parameters:</strong><br>
--------------------------------------------------<br>
"@

            # Add script parameters if provided
            if ($ScriptData.ContainsKey('parameters') -and $ScriptData.parameters) {
                foreach ($key in $ScriptData.parameters.Keys | Sort-Object) {
                    $value = $ScriptData.parameters[$key]
                    $message += "&nbsp;&nbsp;<strong>-$key</strong> : $value<br>`n"
                }
                $message += "<br>"
            }

            $message += "<strong>Execution Results:</strong><br>`n--------------------------------------------------<br>`n"

            # Add metrics
            if ($Metrics.Count -gt 0) {
                foreach ($key in $Metrics.Keys | Sort-Object) {
                    $displayName = ($key -replace '_', ' ').ToUpper()
                    $message += "&nbsp;&nbsp;<strong>$displayName</strong> : $($Metrics[$key])<br>`n"
                }
            }

            # Add elapsed time and exit code
            if ($ScriptData.ContainsKey('elapsed_time')) {
                $message += "&nbsp;&nbsp;<strong>ELAPSED TIME</strong> : $($ScriptData.elapsed_time)<br>`n"
            }
            $message += "&nbsp;&nbsp;<strong>EXIT CODE</strong> : $($ScriptData.exit_code)"

            # Build JSON payload
            $payload = [ordered]@{
                subject = $subject
                message = $message
                execution_timestamp = $timestamp
                script_name = $ScriptData.script_name
                exit_code = $ScriptData.exit_code
            }

            # Add optional fields from ScriptData
            $optionalFields = @('api_partner_id', 'api_user_id', 'customer_id', 'elapsed_time', 'parameters')
            foreach ($field in $optionalFields) {
                if ($ScriptData.ContainsKey($field)) {
                    $payload[$field] = $ScriptData[$field]
                }
            }

            # Add all metrics
            foreach ($key in $Metrics.Keys) {
                $payload[$key] = $Metrics[$key]
            }

            $body = $payload | ConvertTo-Json -Depth 5

            # Debug output
            if ($DebugMode) {
                Write-Output $Script:strLineSeparator
                Write-Info "DEBUG: Webhook Payload"
                Write-Output $Script:strLineSeparator
                Write-Output $body
                Write-Output $Script:strLineSeparator
            }

            # Send webhook
            $null = Invoke-RestMethod -Uri $WebhookUrl -Method POST -Body $body -ContentType 'application/json' -TimeoutSec 10 -ErrorAction Stop

            # Success message
            $maskedUrl = $WebhookUrl -replace '(https?://[^/]+/).*', '$1***'
            Write-Success "Webhook sent successfully to $maskedUrl"
        }
        catch {
            Write-Warning-Color "Webhook notification failed: $($_.Exception.Message)"
        }
    }

#endregion ----- Output Functions ----

#region ----- Authentication ----
    Function Set-APICredentials {
        Write-Output $Script:strLineSeparator
        Write-Output "  Setting Backup API Credentials"
        if (Test-Path $APIcredpath) {
            Write-Output $Script:strLineSeparator
            Write-Output "  Backup API Credential Path Present" }else{ New-Item -ItemType Directory -Path $APIcredpath}
        Write-Output $Script:strLineSeparator
        Write-Output "  Enter Exact, Case Sensitive Partner Name for N-able | Cove Backup.Management API i.e. 'Acme, Inc (bob@acme.net)'"
        DO{ $PartnerName = Read-Host "  Enter Login Partner Name" }
        WHILE ($partnerName.length -eq 0)
        $PartnerName | out-file $APIcredfile

        $BackupCred = Get-Credential -Message 'Enter Login Email and Password for N-able | Cove Backup.Management API'
        $BackupCred | Add-Member -MemberType NoteProperty -Name PartnerName -Value "$PartnerName"

        $BackupCred.UserName | Out-file -append $APIcredfile
        $BackupCred.Password | ConvertFrom-SecureString | Out-file -append $APIcredfile

        Start-Sleep -milliseconds 300

        Send-APICredentialsCookie  ## Attempt API Authentication

    }  ## Set API credentials if not present

    Function Get-APICredentials {
        #$Script:True_path = "C:\ProgramData\MXB\"
        $Script:APIcredfile = join-path -Path $Taskpath -ChildPath "$env:computername $env:username API_Credentials.Secure.txt"
        $Script:APIcredpath = Split-path -path $APIcredfile

        if (($ClearCredentials) -and (Test-Path $APIcredfile)) {
            Remove-Item -Path $Script:APIcredfile
            $ClearCredentials = $Null
            Write-Output $Script:strLineSeparator
            Write-Output "  Backup API Credential File Cleared"
            Send-APICredentialsCookie  ## Retry Authentication

            }else{
                Write-Output $Script:strLineSeparator
                Write-Output "  Getting Backup API Credentials"

                if (Test-Path $APIcredfile) {
                    Write-Output    $Script:strLineSeparator
                    "  Backup API Credential File Present"
                    $APIcredentials = get-content $APIcredfile

                    $Script:cred0 = [string]$APIcredentials[0]
                    $Script:cred1 = [string]$APIcredentials[1]
                    $Script:cred2 = $APIcredentials[2] | Convertto-SecureString
                    $Script:cred2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($Script:cred2))

                    Write-Output    $Script:strLineSeparator
                    Write-output "  Stored Backup API Partner  = $Script:cred0"
                    Write-output "  Stored Backup API User     = $Script:cred1"
                    Write-output "  Stored Backup API Password = Encrypted"
                    Write-Output    $Script:strLineSeparator

                }else{
                    Write-Output    $Script:strLineSeparator
                    Write-Output "  Backup API Credential File Not Present"

                    Set-APICredentials  ## Create API Credential File if Not Found
                    }
                }

    }  ## Get API credentials if present

    Function Send-APICredentialsCookie {
        Get-APICredentials  ## Read API Credential File before Authentication

        try {
            $url = "https://api.backup.management/jsonapi"
            $data = @{}
            $data.jsonrpc = '2.0'
            $data.id = '2'
            $data.method = 'Login'
            $data.params = @{}
            $data.params.partner = $Script:cred0
            $data.params.username = $Script:cred1
            $data.params.password = $Script:cred2

            $webrequest = Invoke-WebRequest -Method POST `
                -ContentType 'application/json' `
                -Body (ConvertTo-Json $data) `
                -Uri $url `
                -SessionVariable Script:websession `
                -UseBasicParsing `
                -ErrorAction Stop

            $Script:cookies = $websession.Cookies.GetCookies($url)
            $Script:websession = $websession
            $Script:Authenticate = $webrequest | convertfrom-json

            #Debug Write-output "$($Script:cookies[0].name) = $($cookies[0].value)"

            if ($authenticate.visa) {
                $Script:visa = $authenticate.visa
                $Script:APIUserId = $authenticate.result.result.Id
                $Script:APIPartnerId = $authenticate.result.result.PartnerId
                Write-Success "Authentication Successful"
            }else{
                Write-Output    $Script:strLineSeparator
                Write-ErrorMsg "Authentication Failed: Please confirm your Backup.Management Partner Name and Credentials"
                Write-Warning-Color "Please Note: Multiple failed authentication attempts could temporarily lockout your user account"
                Write-Output    $Script:strLineSeparator
                Set-APICredentials  ## Create API Credential File if Authentication Fails
            }
        }
        catch {
            Write-Output    $Script:strLineSeparator
            Write-ErrorMsg "API Connection Error: $($_.Exception.Message)"
            if ($_.Exception.Response.StatusCode) {
                Write-Warning-Color "HTTP Status: $($_.Exception.Response.StatusCode.value__)"
            }
            Write-Output    $Script:strLineSeparator
            Write-Output "  Please check:"
            Write-Output "  - Internet connection"
            Write-Output "  - Firewall settings"
            Write-Output "  - API endpoint availability"
            Write-Output    $Script:strLineSeparator
            break
        }

    }  ## Use Backup.Management credentials to Authenticate

    Function Get-VisaTime {
        if ($Script:visa) {
            $VisaTime = (Convert-UnixTimeToDateTime ([int]$Script:visa.split("-")[3]))
            If ($VisaTime -lt (Get-Date).ToUniversalTime().AddMinutes(-10)){
                Send-APICredentialsCookie
            }
        }
    }

#endregion ----- Authentication ----

#region ----- Data Conversion ----
    Function Convert-UnixTimeToDateTime($inputUnixTime){
        if ($inputUnixTime -gt 0 ) {
            $epoch = Get-Date -Date "1970-01-01 00:00:00Z"
            $epoch = $epoch.ToUniversalTime()
            $epoch = $epoch.AddSeconds($inputUnixTime)
            return $epoch
        }else{ return ""}
    }  ## Convert epoch time to date time

    Function Save-CSVasExcel {
        param (
            [string]$CSVFile = $(Throw 'No file provided.')
        )

        BEGIN {
        function Resolve-FullPath ([string]$Path) {
            if ( -not ([System.IO.Path]::IsPathRooted($Path)) ) {
                # $Path = Join-Path (Get-Location) $Path
                $Path = "$PWD\$Path"
            }
            [IO.Path]::GetFullPath($Path)
        }

        function Release-Ref ($ref) {
            ([System.Runtime.InteropServices.Marshal]::ReleaseComObject([System.__ComObject]$ref) -gt 0)
            [System.GC]::Collect()
            [System.GC]::WaitForPendingFinalizers()
        }

            $CSVFile = Resolve-FullPath $CSVFile
            $xl = New-Object -ComObject Excel.Application
        }

        PROCESS {
            $wb = $xl.workbooks.open($CSVFile)
            $xlOut = $CSVFile -replace '\.csv$', '.xlsx'

            # can comment out this part if you don't care to have the columns autosized
            $ws = $wb.Worksheets.Item(1)
            $range = $ws.UsedRange
            [void]$range.AutoFilter()
            [void]$range.EntireColumn.Autofit()

            $num = 1
            $dir = Split-Path $xlOut
            $base = $(Split-Path $xlOut -Leaf) -replace '\.xlsx$'
            $nextname = $xlOut
            while (Test-Path $nextname) {
                $nextname = Join-Path $dir $($base + "-$num" + '.xlsx')
                $num++
            }

            $wb.SaveAs($nextname, 51)
        }

        END {
            $xl.Quit()

            $null = $ws, $wb, $xl | ForEach-Object {Release-Ref $_}

            # del $CSVFile
        }
    } ## Save as output XLS Routine

#endregion ----- Data Conversion ----

#region ----- Backup.Management JSON Calls ----

    Function CallJSON($url,$object) {
        $bytes = [System.Text.Encoding]::ASCII.GetBytes($object)
        $web = [System.Net.WebRequest]::Create($url)
        $web.Method = “POST”
        $web.ContentLength = $bytes.Length
        $web.ContentType = “application/json”
        $stream = $web.GetRequestStream()
        $stream.Write($bytes,0,$bytes.Length)
        $stream.close()
        $reader = New-Object System.IO.Streamreader -ArgumentList $web.GetResponse().GetResponseStream()
        return $reader.ReadToEnd()| ConvertFrom-Json
        $reader.Close()
    }

    Function Send-GetPartnerInfo ($PartnerName) {
        $RestrictedPartnerLevel = @("Root")

        try {
            $url = "https://api.backup.management/jsonapi"
            $data = @{}
            $data.jsonrpc = '2.0'
            $data.id = '2'
            $data.visa = $Script:visa
            $data.method = 'GetPartnerInfo'
            $data.params = @{}
            $data.params.name = [String]$PartnerName

            $webrequest = Invoke-WebRequest -Method POST `
                -ContentType 'application/json' `
                -Body (ConvertTo-Json $data -depth 5) `
                -Uri $url `
                -SessionVariable Script:websession `
                -UseBasicParsing `
                -ErrorAction Stop

            $Script:cookies = $websession.Cookies.GetCookies($url)
            $Script:websession = $websession
            $Script:Partner = $webrequest | convertfrom-json

            if ($RestrictedPartnerLevel -notcontains $Partner.result.result.Level) {
                [String]$Script:Uid = $Partner.result.result.Uid
                [int]$Script:PartnerId = [int]$Partner.result.result.Id
                [String]$script:Level = $Partner.result.result.Level
                [String]$Script:PartnerName = $Partner.result.result.Name

                Write-Output $Script:strLineSeparator
                Write-output "  $Level - $PartnerName - $partnerId - $Uid"
                Write-Output $Script:strLineSeparator
                }else{
                Write-Output $Script:strLineSeparator
                Write-Host "  Lookup for $($Partner.result.result.Level) Partner Level Not Allowed"
                Write-Output $Script:strLineSeparator
                if (-not $Script:PartnerName) {
                    $Script:PartnerName = Read-Host "  Enter EXACT Case Sensitive Customer/ Partner displayed name to lookup i.e. 'Acme, Inc (bob@acme.net)'"
                }
                Send-GetPartnerInfo $Script:partnername
                }

            if ($partner.error) {
                write-output "  $($partner.error.message)"
                if (-not $Script:PartnerName) {
                    $Script:PartnerName = Read-Host "  Enter EXACT Case Sensitive Customer/ Partner displayed name to lookup i.e. 'Acme, Inc (bob@acme.net)'"
                }
                Send-GetPartnerInfo $Script:partnername
            }
        }
        catch {
            Write-Output    $Script:strLineSeparator
            Write-ErrorMsg "GetPartnerInfo API Error: $($_.Exception.Message)"
            Write-Output    $Script:strLineSeparator
            break
        }

    } ## get PartnerID and Partner Level

    Function GetDeviceErrors($DeviceId) {
        try {
            $url2 = "https://backup.management/web/accounts/properties/api/errors/recent?accounts.SelectedAccount.Id=$DeviceId"
            $method = 'GET'

                $params = @{
                Uri         = $url2
                Method      = $method
                Headers     = @{ 'Authorization' = "Bearer $Script:visa" }
                WebSession  = $websession
                ContentType = 'application/json; charset=utf-8'
            }

            # Use Invoke-WebRequest to get raw content instead of Invoke-RestMethod
            $webResponse = Invoke-WebRequest @params -ErrorAction Stop

            Start-Sleep -Milliseconds 200

            if ($webResponse.StatusCode -ne 200) {Send-APICredentialsCookie; return}

            # Get the raw content as string and fix backslashes for proper JSON parsing
            $rawContent = $webResponse.Content
            $fixedContent = $rawContent.replace('\','\\')

            # Fix trailing commas in JSON arrays (PowerShell 5.1 doesn't tolerate them)
            $fixedContent = $fixedContent -replace ',(\s*\])',']'
            $fixedContent = $fixedContent -replace ',(\s*\})','}'

            # Parse to check for error or HasItemsWithCount
            $Script:response = $fixedContent | ConvertFrom-Json -ErrorAction SilentlyContinue

            If ($script:response.HasItemsWithCount -eq $False) {

                Write-output "  DeviceId# $DeviceId - Skipping, initial backup in progress"
                }else{
                    Write-output "  DeviceId# $DeviceId - Requesting errors "

                    # Apply string replacements to already-fixed JSON string
                    [array]$response1 = $fixedContent.replace('[ESCAPE[','').replace("]]","").replace("&quot;","").replace(",`n        `n    ]","`n     ]") | ConvertFrom-Json -ErrorAction SilentlyContinue

                }
                [array]$Script:cleanresponse = $response1.collection
        }
        catch {
            Write-ErrorMsg "GetDeviceErrors API Error for DeviceId $DeviceId : $($_.Exception.Message)"
            $Script:cleanresponse = $null
        }
    }

    Function Send-UpdateCustomColumn($DeviceId,$ColumnId,$Message) {
        try {
            $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
            $headers.Add("Content-Type", "application/json; charset=utf-8")
            $headers.Add("Cookie", "__cfduid=d110201d75658c43f9730368d03320d0f1601993342")
            $headers.Add("Authorization", "Bearer $script:visa")

            # Build the request body using PowerShell objects, then convert to JSON to properly escape special characters
            # Note: The values parameter needs to be an array of arrays, so we use , to prevent array flattening
            # ColumnId must be an integer
            $data = @{
                jsonrpc = "2.0"
                id = "jsonrpc"
                method = "UpdateAccountCustomColumnValues"
                params = @{
                    accountId = $DeviceId
                    values = ,@([int]$ColumnId, $Message)
                }
            }

            $body = $data | ConvertTo-Json -Depth 5 -Compress

            $script:updateCC = Invoke-RestMethod 'https://cloudbackup.management/jsonapi' -Method 'POST' -Headers $headers -Body $body -ErrorAction Stop

            if ($script:updateCC.error.message) {
                write-output "$deviceid | $Message" | out-file c:\data\errormessagestofix.txt -append
                $script:updateCC.error.message
            }
        }
        catch {
            Write-ErrorMsg "UpdateCustomColumn API Error for DeviceId $DeviceId : $($_.Exception.Message)"
        }
    }

    Function Send-UpdateCustomColumns($DeviceId,$ColumnId,$Message) {
        [array]$array1 = @($ColumnId,$message) | convertto-json -Depth 5
        #[array]$array2 = ,@($array1)

        $url = "https://api.backup.management/jsonapi"
        $data = @{}
        $data.jsonrpc = '2.0'
        $data.id = '2'
        $data.visa = $Script:visa
        $data.method = 'UpdateAccountCustomColumnValues'
        $data.params = @{}
        $data.params.accountId = $DeviceId
        $data.params.values = @($array1)

        $webrequest = Invoke-WebRequest -Method POST `
            -ContentType 'application/json; charset=utf-8' `
            -Body (ConvertTo-Json $data -depth 5) `
            -Uri $url `
            -SessionVariable $Script:websession `
            -UseBasicParsing
            $Script:cookies = $websession.Cookies.GetCookies($url)
            $Script:websession = $websession
            $Script:updateCC = $webrequest | convertfrom-json

        if ($script:updateCC.error.message) {
            write-output "$deviceid | $Message" | out-file c:\data\errormessagestofix.txt -append
            $script:updateCC.error.message
        }
    }

    Function Send-GetErrorDevices {
        Switch ($Action) {
            'LastErrors'{
                $DeviceFilter = "AT == 1 AND (TS > $days.days().ago())"
            } ## Last Error for Server and Workstation Devices with Timestamp active in last $Days
            'ActiveErrors'{
                $DeviceFilter = "AT == 1 AND (T7 >= 1) AND (TS > $days.days().ago())"
            } ## Last Active Error for AccountType of Server and Workstation Devices with Active Errors >=1 and Timestamp active in last $Days
            'Custom'{
                # $DeviceFilter = "OT == 2 AND (T0 == 8 OR T0 == 12 OR T0 == 2 OR T0 == 3 OR T0 == 6 OR T0 == 7 OR T0 == 10 OR T0 == 11 OR T0 == 13) AND (T7 >= 1) AND ( TS > $days.days().ago())"
                # Failed Server Devices with Active Errors >=1 and Timestamp active in last $Days
                # $DeviceFilter = "OT == 1 AND MF =~ '*apple*' AND (T7 >= 1) AND ( TS > $days.days().ago())"
                # Mac Devices with Active Errors >=1 and Timestamp active in last $Days
                # $DeviceFilter = "AA2045"
                # Custom Column AA2045 has any value set
            } ## TBD Custom Filter

        }

        try {
            $url = "https://api.backup.management/jsonapi"
            $data = @{}
            $data.jsonrpc = '2.0'
            $data.id = '2'
            $data.visa = $script:visa
            $data.method = 'EnumerateAccountStatistics'
            $data.params = @{}
            $data.params.query = @{}
            $data.params.query.PartnerId = [int]$PartnerId
            $data.params.query.Filter = $DeviceFilter
            $data.params.query.Columns = @("AU","AR","AN","AL","LN","OP","OI","OS","PD","AP","PF","PN","AA843","AA77","T7")
            $data.params.query.OrderBy = "CD DESC"
            $data.params.query.StartRecordNumber = 0
            $data.params.query.RecordsCount = $DeviceCount
            $data.params.query.Totals = @("COUNT(AT==1)","SUM(T3)","SUM(US)")

            $webrequest = Invoke-WebRequest -Method POST `
            -ContentType 'application/json' `
            -Body (ConvertTo-Json $data -depth 6) `
            -Uri $url `
            -SessionVariable Script:websession `
            -UseBasicParsing `
            -ErrorAction Stop

            $Script:cookies = $websession.Cookies.GetCookies($url)
            $Script:websession = $websession

            $Script:ErrorDevices = $webrequest | convertfrom-json
        }
        catch {
            Write-Output    $Script:strLineSeparator
            Write-ErrorMsg "EnumerateAccountStatistics API Error: $($_.Exception.Message)"
            Write-Output    $Script:strLineSeparator
            break
        }

        if ($Partner.result.result.Uid) {
            [String]$Script:PartnerId = $Partner.result.result.Id
            [String]$Script:PartnerName = $Partner.result.result.Name

            Write-Output $Script:strLineSeparator
            Write-output "  Searching  $PartnerName "
            Write-Output $Script:strLineSeparator
            }else{
            Write-Output $Script:strLineSeparator
            Write-Host "PartnerName Not Found"
            Write-Output $Script:strLineSeparator
            }

        $Script:ErrorDeviceDetail = @()

        if ($ErrorDevices.result.result.count -eq 0) {
            Write-Warning-Color "No Errors Found for the Last $days Days"
            Write-Info "Exiting Script - Elapsed Time: $(Get-ElapsedTime)"
            Start-Sleep -seconds 10
            #Break
        }

        Write-Info "Requesting error details for $($ErrorDevices.result.result.count) devices."
        Write-Output "  Please be patient, this could take some time."
        Write-Output $Script:strLineSeparator

        ForEach ( $DeviceResult in $ErrorDevices.result.result ) {
            $Script:DevicesProcessed++
            Get-VisaTime
            $Script:ErrorDeviceDetail += New-Object -TypeName PSObject -Property @{ AccountID      = [String]$DeviceResult.AccountId;
                                                                        PartnerID      = [string]$DeviceResult.PartnerId;
                                                                        DeviceName     = $DeviceResult.Settings.AN -join '' ;
                                                                        DeviceAlias    = $DeviceResult.Settings.AL -join '' ;
                                                                        PartnerName    = $DeviceResult.Settings.AR -join '' ;
                                                                        Reference      = $DeviceResult.Settings.PF -join '' ;
                                                                        DataSources    = $DeviceResult.Settings.AP -join '' ;
                                                                        Account        = $DeviceResult.Settings.AU -join '' ;
                                                                        Location       = $DeviceResult.Settings.LN -join '' ;
                                                                        Notes          = $DeviceResult.Settings.AA843 -join '' ;
                                                                        TempInfo       = $DeviceResult.Settings.AA77 -join '' ;
                                                                        Errors         = $DeviceResult.Settings.T7 -join '' ;
                                                                        Errors_FS      = $DeviceResult.Settings.F7 -join '' ;
                                                                        Product        = $DeviceResult.Settings.PN -join '' ;
                                                                        ProductID      = $DeviceResult.Settings.PD -join '' ;
                                                                        Profile        = $DeviceResult.Settings.OP -join '' ;
                                                                        OS             = $DeviceResult.Settings.OS -join '' ;
                                                                        ProfileID      = $DeviceResult.Settings.OI -join ''
                                                                    }

        GetDeviceErrors $DeviceResult.AccountId

        # Count errors found for this device
        if ($Script:cleanresponse) {
            $Script:ErrorsFound += $Script:cleanresponse.Count
        }

        if (($CustomColumn) -and ($cleanresponse.message -like "*restarted*")) {
            $RestartedMSG = $cleanresponse.message | Where-Object {$_ -like "*restarted*"}
            Send-UpdateCustomColumn $DeviceResult.AccountId ($Columncode.replace("AA","")) ($RestartedMSG + " " + $cleanresponse[-1].time)
            $Script:ColumnsUpdated++
            #$RestartedMSG
        }elseif (($CustomColumn) -and ($cleanresponse.message -like "*Operation Aborted*")) {
            $AbortedMSG = $cleanresponse.message | Where-Object {$_ -like "*Operation Aborted*"}
            Send-UpdateCustomColumn $DeviceResult.AccountId ($Columncode.replace("AA","")) ($AbortedMSG + " " + $cleanresponse[-1].time)
            $Script:ColumnsUpdated++
            #$AbortedMSG
        #}elseif (($CustomColumn) -and ($cleanresponse.message -like "*Operation not permitted*")) {
        #    Send-UpdateCustomColumn $DeviceResult.AccountId ($Columncode.replace("AA","")) ("Operation not permitted - Doublecheck Mac Full Disk Access " + $cleanresponse[-1].time)
        #    $Script:ColumnsUpdated++
        #    #$AbortedMSG
        }elseif (($CustomColumn) -and ($cleanresponse.message -like "*significant time difference*")) {
            Send-UpdateCustomColumn $DeviceResult.AccountId ($Columncode.replace("AA","")) ("The machine seems to have a significant time difference with the Cloud. " + $cleanresponse[-1].time)
            $Script:ColumnsUpdated++
            #$AbortedMSG
        }elseif (($CustomColumn) -and ($cleanresponse.message -like "*Graph API request failed*")) {
            Send-UpdateCustomColumn $DeviceResult.AccountId ($Columncode.replace("AA","")) ("Graph API request failed: MailboxNotEnabledForRESTAPI.The mailbox is either inactive, soft-deleted, or is hosted on-premise. " + $cleanresponse[-1].time)
            $Script:ColumnsUpdated++
            #$AbortedMSG
        }elseif (($CustomColumn) -and ($null -eq $cleanresponse.message)) {
            Send-UpdateCustomColumn $DeviceResult.AccountId ($Columncode.replace("AA","")) ("")
            $Script:ColumnsCleared++
            #$AbortedMSG
        }elseif ($CustomColumn) {
            Send-UpdateCustomColumn $DeviceResult.AccountId ($Columncode.replace("AA","")) ($cleanresponse[-1].message + " " + $cleanresponse[-1].time)
            $Script:ColumnsUpdated++
            #$cleanresponse[-1].message

        }

            ForEach ( $ErrorResult in $Script:cleanresponse ) {

            $Script:ResponseDetail += New-Object -TypeName PSObject -Property @{ DeviceId      = [String]$DeviceResult.AccountId;
                                                                     DeviceName     = $DeviceResult.Settings.AN -join '';
                                                                     PartnerName    = $DeviceResult.Settings.AR -join '' ;
                                                                     Time           = [datetime]$ErrorResult.time;
                                                                     Count          = $ErrorResult.count;
                                                                     File           = $ErrorResult.file;
                                                                     Message        = $ErrorResult.message;
                                                                     GroupID        = $ErrorResult.groupid }
                                                                    }
        }
    }

    Function Send-ClearErrorDevices {
        $filter1 = "($columncode AND T7 == 0) OR ($columncode AND TS < $days.days().ago())"

        try {
            $url = "https://api.backup.management/jsonapi"
            $data = @{}
            $data.jsonrpc = '2.0'
            $data.id = '2'
            $data.visa = $script:visa
            $data.method = 'EnumerateAccountStatistics'
            $data.params = @{}
            $data.params.query = @{}
            $data.params.query.PartnerId = [int]$PartnerId
            $data.params.query.Filter = $Filter1
            $data.params.query.Columns = @("AU","AR","AN","AL","LN","OP","OI","OS","PD","AP","PF","PN","AA843","AA77","T7")
            $data.params.query.StartRecordNumber = 0
            $data.params.query.RecordsCount = $DeviceCount
            $data.params.query.Totals = @("COUNT(AT==1)","SUM(T3)","SUM(US)")

            $webrequest = Invoke-WebRequest -Method POST `
            -ContentType 'application/json' `
            -Body (ConvertTo-Json $data -depth 6) `
            -Uri $url `
            -SessionVariable Script:websession `
            -UseBasicParsing `
            -ErrorAction Stop

            $Script:cookies = $websession.Cookies.GetCookies($url)
            $Script:websession = $websession

            $Script:ZeroErrorDevices = $webrequest | convertfrom-json

            ForEach ( $DeviceResult in $ZeroErrorDevices.result.result ) {
                Send-UpdateCustomColumn $DeviceResult.AccountId ($Columncode.replace("AA","")) $null
                $Script:ColumnsCleared++
                Write-Success "DeviceId# $($DeviceResult.AccountId) - Clearing Custom Columns for Resolved Errors or Errors older than $days Days"
                Get-VisaTime
            }
        }
        catch {
            Write-Output    $Script:strLineSeparator
            Write-ErrorMsg "ClearErrorDevices API Error: $($_.Exception.Message)"
            Write-Output    $Script:strLineSeparator
        }
    }
#endregion ----- Backup.Management JSON Calls ----

#endregion ----- Functions ----

    $Script:ResponseDetail = @()

    Send-APICredentialsCookie

    Write-Output $Script:strLineSeparator
    Write-Output ""

    # Use CustomerName parameter if provided, otherwise use stored credential
    if ($CustomerName) {
        Send-GetPartnerInfo $CustomerName
    } else {
        Send-GetPartnerInfo $Script:cred0
    }

    Send-ClearErrorDevices

    Send-GetErrorDevices

    $filterDate = (Get-Date).AddDays(-$days)

    $ResponseDetail | select-object Partnername,deviceid,DeviceName,groupid,time,count,message,file | where-object {$_.time -ge $filterdate}  | sort-object partnername,deviceid,groupid | format-table

    if ($Script:GridView) {

        $ResponseDetail | select-object Partnername,deviceid,DeviceName,groupid,time,count,message,file | where-object {$_.time -ge $filterdate}  | sort-object partnername,deviceid,groupid | Out-GridView -Title " Devices with Errors Since $Filterdate"
    }

    if ($Script:Export) {
        $Script:csvoutputfile = "$ExportPath\$($CurrentDate)_DeviceErrors_$($Partnername -replace(`" \(.*\)`",`"`") -replace(`"[^a-zA-Z_0-9]`",`"`"))_$($PartnerId).csv"
        $script:ResponseDetail | select-object Partnername,deviceid,DeviceName,groupid,time,count,message,file | where-object {$_.time -ge $filterdate}  | Export-CSV -path "$csvoutputfile" -delimiter "$Delimiter" -NoTypeInformation -Encoding UTF8 -append

    ## Generate XLS from CSV

        $xlsoutputfile = $csvoutputfile.Replace("csv","xlsx")
        Save-CSVasExcel $csvoutputfile

        Write-output $Script:strLineSeparator

    ## Launch CSV or XLS if Excel is installed  (Required -Launch Parameter)

        if ($Launch) {
            If (test-path HKLM:SOFTWARE\Classes\Excel.Application) {
                Start-Process "$xlsoutputfile"
                Write-output $Script:strLineSeparator
                Write-Output "  Opening XLS file"
                }else{
                Start-Process "$csvoutputfile"
                Write-output $Script:strLineSeparator
                Write-Output "  Opening CSV file"
                Write-output $Script:strLineSeparator
                }
            }
        Write-output $Script:strLineSeparator
        Write-Success "CSV Path = $Script:csvoutputfile"
        Write-Success "XLS Path = $Script:xlsoutputfile"
        Write-Output ""
    }

    # Display final statistics
    Write-Statistics

    # Send webhook notification with execution details
    if (-not $DisableWebhook) {
        # Build parameters hashtable
        $scriptParameters = @{
            Action = $Action
            Days = $Days
            DeviceCount = $DeviceCount
            Export = $Export
            Launch = $Launch
            GridView = $GridView
            Delimiter = $Delimiter
            CustomColumn = $CustomColumn
            ColumnCode = $ColumnCode
        }

        # Add optional parameters if they were specified
        if ($PartnerId) { $scriptParameters['CustomerID'] = $PartnerId }
        if ($TaskPath -ne "$env:userprofile\BackupNerdScripts") { $scriptParameters['TaskPath'] = $TaskPath }
        if ($ClearCredentials) { $scriptParameters['ClearCredentials'] = $ClearCredentials }
        if ($DisableWebhook) { $scriptParameters['DisableWebhook'] = $DisableWebhook }
        if ($DebugWebhook) { $scriptParameters['DebugWebhook'] = $DebugWebhook }

        $scriptData = @{
            script_name = if ($PSCommandPath) { Split-Path $PSCommandPath -Leaf } else { "Unknown" }
            customer_id = $PartnerId
            api_partner_id = $APIPartnerId
            api_user_id = $APIUserId
            exit_code = $ExitCode
            elapsed_time = Get-ElapsedTime
            parameters = $scriptParameters
        }

        $metrics = @{
            devices_processed = $DevicesProcessed
            errors_found = $ErrorsFound
            columns_updated = $ColumnsUpdated
            columns_cleared = $ColumnsCleared
        }

        Send-ScriptWebhook -ScriptData $scriptData -Metrics $metrics -DebugMode:$DebugWebhook -Disable:$DisableWebhook
    }

    if (-not $Script:Export) {
        Start-Sleep -seconds 10
    }


```

```json Response Example
 Bulk Get Device Errors for the Last 7 Days

C:\Cove Data Protection - Get Device Error Messages\GetBulkDeviceErrors.v35.ps1                                                                           Script Parameter Syntax:                                                                                                                                                                                                                                                                                                                                                                    GetBulkDeviceErrors.v35.ps1 [[-Action] <string>] [-Days <int>] [-DeviceCount <int>] [-Export] [-Launch] [-GridView] [-Delimiter <string>] [-ExportPath <string>] [-CustomColumn] [-ColumnCode <string>] [-CustomerName <string>] [-TaskPath <string>] [-ClearCredentials] [-DisableWebhook] [-DebugWebhook] [<CommonParameters>]

  Current Parameters:
  -Days          = 7
  -DeviceCount   = 5000
  -Action        = ActiveErrors
  -Export        = True
  -ExportPath    = C:\Cove Data Protection - Get Device Error Messages\DeviceErrors_2025-11-12
  -Delimiter     = ,
  -Launch        = False
  -Gridview      = False
  -CustomColumn  = True
  -ColumnCode    = AA2045
  ---------
  Getting Backup API Credentials
  ---------
  Backup API Credential File Present
  ---------
  Stored Backup API Partner  = *****
  Stored Backup API User     = *****
  Stored Backup API Password = Encrypted
  ---------
  Authentication Successful
  ---------

  ---------
  Lookup for Root Partner Level Not Allowed
  ---------
  Enter EXACT Case Sensitive Customer/ Partner displayed name to lookup i.e. 'Acme, Inc (bob@acme.net)': SEDEMO
  ---------
  Reseller - SEDEMO - 297876 - 6824a954-****-****-****-3f79806220cb
  ---------
  ---------
  Searching  SEDEMO
  ---------
  Requesting error details for 1 devices.
  Please be patient, this could take some time.
  ---------
  DeviceId# 2732614 - Requesting errors

PartnerName        DeviceId DeviceName     GroupID Time                  Count Message                                                                                File
-----------        -------- ----------     ------- ----                  ----- -------                                                                                ----
Asian Pacific Tech 2732614  2019-bom_rsjpe 1421    11/12/2025 5:19:00 AM 1     There is no data available for the backup. Please check your settings and environment.


  ---------
  ---------
  CSV Path = C:\Script Root\0-Script Master\.AMPFiles\Cove Data Protection - Get Device Error Messages\DeviceErrors_2025-11-12\2025-11-12_01-02-23_DeviceErrors_SEDEMO_297876.csv
  XLS Path = C:\Script Root\0-Script Master\.AMPFiles\Cove Data Protection - Get Device Error Messages\DeviceErrors_2025-11-12\2025-11-12_01-02-23_DeviceErrors_SEDEMO_297876.xlsx

  ---------
  Execution Statistics:
  Devices Processed   : 1
  Errors Found        : 1
  Columns Updated     : 1
  Columns Cleared     : 0
  Elapsed Time        : 00:01:19
  ---------
  Webhook sent successfully to https://default6324f4fb86ee4493ba16c819a916b4.87.environment.api.powerplatform.com:443/***
```

# Summary

<!-- powershell@ -->

Retrieve bulk error messages from each of the last Cove backup sessions and export to CSV/XLS or update a custom column in the <https://backup.management> console.