Integrating With REST APIs From PowerShell — Invoke-RestMethod in Practice

· · PowerShell, REST API, Windows, Automation, Business Systems, Integration, JSON, Operational Improvement

“Fetch order data from the core system’s Web API and drop it into an in-house Excel report.” “Hit a SaaS attendance API every morning and output the day’s shift plan.” Among the situations where PowerShell gets used in business, REST API integration is the one that has clearly grown over the past few years. Not worth buying a dedicated tool for, but impossible to keep doing by hand. As the tool that fills that gap, Invoke-RestMethod is extremely powerful.

At the same time, it is characteristic of API integration that getting it working is easy but putting it into production suddenly gets hard. Japanese text turns into mojibake, the contents of error responses can’t be read, it occasionally fails with a 429, it won’t get through in a proxy environment, and it gets rejected at the TLS layer on Windows PowerShell 5.1 only. Every one of these is a problem peculiar to “a system with another party on the other end”.

This article is aimed at IT staff and developers calling APIs from PowerShell in-house, and organises authentication, sending and receiving JSON, error handling, retries, paging, and the 5.1-specific pitfalls, in the order you need them in practice.

1. The Bottom Line First

  • For JSON/XML APIs, use Invoke-RestMethod. It turns the response into objects automatically. If you need the status code or headers, use Invoke-WebRequest, or -StatusCodeVariable / -ResponseHeadersVariable.12
  • Passing authentication in a header is the most universally applicable approach. From PowerShell 6 onwards you can also use -Authentication Bearer -Token (a SecureString).1
  • Sending non-ASCII JSON as a UTF-8 byte array is the reliable option. State charset=utf-8 explicitly in -ContentType.
  • ConvertTo-Json defaults to a depth of 2. Deeply nested objects get truncated unless you specify -Depth.3
  • 4xx/5xx become terminating errors. From PowerShell 7 onwards you can read the body with $_.ErrorDetails.Message. -SkipHttpErrorCheck is an option for stopping them being raised as exceptions.1
  • For 429, wait as instructed by Retry-After. Specify -MaximumRetryCount and the built-in behaviour follows it automatically. Note, however, that the built-in retry covers everything in the 400–599 range (plus 304), so 401 and 404 get resent too. Implement your own only when you need per-code handling or exponential backoff.1
  • Do not automatically retry non-idempotent requests such as POST. Even on a communication error or a 5xx, the server may already have processed it, and resending creates a duplicate record.
  • Link-header paging can be automated with -FollowRelLink. Cursor-based paging needs your own loop.1
  • Windows PowerShell 5.1 has its own pitfalls. Three of them: whether -UseBasicParsing is needed, explicitly enabling TLS 1.2, and how encoding is handled.4
  • Don’t use -SkipCertificateCheck in permanent operation. Making your internal CA trusted is the correct fix.

2. Invoke-RestMethod and Invoke-WebRequest

First, the differences.12

  Invoke-RestMethod Invoke-WebRequest
Handling of the response JSON/XML is converted to objects automatically WebResponseObject (raw body, headers, code)
Main use REST APIs Fetching HTML, inspecting status and headers
Status code Obtained via -StatusCodeVariable (PS7+) .StatusCode
Headers Obtained via -ResponseHeadersVariable (PS6+) .Headers

For calling APIs, Invoke-RestMethod is the default choice. Even when you need headers or codes, you can get them via the dedicated variable parameters.

$data = Invoke-RestMethod -Uri 'https://api.example.co.jp/v1/orders' `
        -Headers @{ Authorization = "Bearer $token" } `
        -StatusCodeVariable status -ResponseHeadersVariable headers -TimeoutSec 30

"HTTP $status / requests remaining: $($headers['X-RateLimit-Remaining'])"
$data.items | Select-Object orderId, customerName, amount

3. Passing Authentication

The most universally applicable method is writing it directly into the header, which works the same way on both 5.1 and 7.

# (1) Bearer token (the most common)
$headers = @{ Authorization = "Bearer $accessToken"; Accept = 'application/json' }
Invoke-RestMethod -Uri $uri -Headers $headers

# (2) API key (the header name follows the provider's specification)
$headers = @{ 'X-Api-Key' = $apiKey }

# (3) Basic authentication (-Authentication is available from PowerShell 6 onwards)
Invoke-RestMethod -Uri $uri -Authentication Basic -Credential $cred

# (4) Passing a Bearer token via -Token (PowerShell 6 onwards; can be a SecureString)
Invoke-RestMethod -Uri $uri -Authentication Bearer -Token $secureToken

# (5) Client certificate
Invoke-RestMethod -Uri $uri -Certificate $cert

When you use -Authentication, PowerShell refuses by default to use it over anything other than HTTPS (you can get around this with -AllowUnencryptedAuthentication, but you shouldn’t, since credentials would travel in the clear).1

Never writing tokens or API keys directly into a script goes without saying. Storage using SecretManagement is covered in “Handling Credentials Safely in PowerShell”.

4. Sending JSON — The Mojibake and -Depth Traps

The two things you will definitely hit when sending are mojibake and truncated nesting.

ConvertTo-Json’s -Depth defaults to 2, and levels deeper than that are not expanded — they get replaced with things like the type name string.3 Always specify it for request bodies with nesting.

$body = @{
    order = @{
        customer = @{ code = 'C001'; name = '株式会社サンプル' }   # third level
        lines    = @( @{ item = 'A-100'; qty = 3 } )
    }
}

# [BAD] with the default -Depth 2, the contents of customer and lines are lost
$json = $body | ConvertTo-Json

# [GOOD] specify a sufficient depth
$json = $body | ConvertTo-Json -Depth 10

For mojibake, converting to a UTF-8 byte array before sending is the most reliable approach.

$json  = $body | ConvertTo-Json -Depth 10
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)

$res = Invoke-RestMethod -Uri $uri -Method Post `
       -Headers @{ Authorization = "Bearer $token" } `
       -ContentType 'application/json; charset=utf-8' `
       -Body $bytes -TimeoutSec 60

On PowerShell 7 a string passed as-is is sent as UTF-8, but in scripts shared with 5.1, converting to a byte array absorbs the environmental difference. For the broader picture of character encodings on Windows, see “Windows Text Encodings and Line Endings”.

5. Error Handling — You Can’t Investigate What You Can’t Read

Invoke-RestMethod treats 4xx/5xx responses as terminating errors. That means you can catch them with try/catch, but the question becomes how to read the error message body the API returned.

try {
    $res = Invoke-RestMethod -Uri $uri -Method Post -Body $bytes `
           -ContentType 'application/json; charset=utf-8' -TimeoutSec 30
}
catch {
    $status = $_.Exception.Response.StatusCode      # e.g. BadRequest / 400
    # From PowerShell 7 onwards the response body lands here (the API's error message)
    $detail = $_.ErrorDetails.Message
    Write-Warning "API call failed ($status): $detail"
    throw
}

So that you don’t burn time on investigations of the form “we got an HTTP 400 back, but we have no idea why it was rejected”, design your scripts to always log the error body.

When you want to branch on the status code, using -SkipHttpErrorCheck to stop exceptions being raised makes for more straightforward code (PowerShell 7 onwards).1

$res = Invoke-RestMethod -Uri $uri -SkipHttpErrorCheck -StatusCodeVariable code -TimeoutSec 30
switch ($code) {
    200     { $res.items }
    404     { Write-Warning 'The target does not exist'; @() }
    { $_ -ge 500 } { throw "Server-side error: $code" }
    default { throw "Unexpected response: $code" }
}

6. Retries — 429 and Transient Failures

From PowerShell 6 onwards, Invoke-RestMethod has -MaximumRetryCount and -RetryIntervalSec, which retry on failure. What’s more, when a 429 response includes Retry-After, that header’s value is used instead of the interval you specified.1 In other words, if all you want is “when rate limited, wait as instructed and retry”, the built-in feature is enough.

# For rate limiting alone, this is often all you need
Invoke-RestMethod -Uri $uri -Headers $headers -MaximumRetryCount 4 -RetryIntervalSec 5

However, 429 is not the only thing that gets retried. The documentation states that it retries “when it receives a failure code between 400 and 599 (and 304)”.1 That means requests that will never succeed no matter how many times you send them — 401 (authentication error), 403 (insufficient permissions), 404 (wrong URL) — get retried too. Run unattended with a misconfigured token and you waste -MaximumRetryCount × -RetryIntervalSec seconds before you even learn it failed, while the API receives the same invalid request over and over. If you want to stop immediately on a permanent error, you need the custom implementation that follows.

You need your own retry function when you have requirements like these.

  • Treating status codes differently (fail immediately on 4xx, retry only 5xx, and so on)
  • Using exponential backoff (the built-in retries at the interval you specify)
  • Logging the API’s error body on failure

Here is what that looks like.

function Invoke-KsApi {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Uri,
        [string] $Method = 'Get',
        [object] $Body,
        [hashtable] $Headers = @{},
        [ValidateRange(1, 10)] [int] $MaxAttempts = 4,
        # If Retry-After instructs a longer wait than this, abort instead of waiting
        [ValidateRange(1, 86400)] [int] $MaxWaitSeconds = 300,
        # Allow retrying POST and similar only when the API supports idempotency keys
        [string] $IdempotencyKey
    )

    # Retrying is only acceptable when receiving the same request twice doesn't change
    # the outcome. POST/PATCH can end up in the "the server succeeded but the response
    # never arrived" case, where a naive resend creates a duplicate record
    $idempotentMethods = 'Get', 'Head', 'Options', 'Put', 'Delete'
    $canRetry = ($Method -in $idempotentMethods) -or $IdempotencyKey
    if ($IdempotencyKey) { $Headers = $Headers + @{ 'Idempotency-Key' = $IdempotencyKey } }

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        $params = @{
            Uri                     = $Uri
            Method                  = $Method
            Headers                 = $Headers
            TimeoutSec              = 60
            SkipHttpErrorCheck      = $true          # we want to branch on the code, so no exception
            StatusCodeVariable      = 'code'
            ResponseHeadersVariable = 'resHeaders'
        }
        # So that a $Body value of $false, 0 or an empty string can still be sent as a body,
        # test whether the argument was passed rather than testing for truthiness
        if ($PSBoundParameters.ContainsKey('Body')) {
            # Passing it through the pipe makes an empty array @() count as "zero input"
            # and return $null, so convert it directly with -InputObject (@() becomes [])
            $json = ConvertTo-Json -InputObject $Body -Depth 10
            $params.Body        = [System.Text.Encoding]::UTF8.GetBytes($json)
            $params.ContentType = 'application/json; charset=utf-8'
        }

        # -SkipHttpErrorCheck only suppresses HTTP error responses. Communication errors
        # where no response comes back at all - timeouts, name resolution failures,
        # connection resets, TLS errors - are still thrown, so catch them here and retry
        try {
            $code = $null
            $res  = Invoke-RestMethod @params
        }
        catch {
            # For a non-idempotent request, the server may have succeeded and only the
            # response failed to arrive. Don't resend automatically; leave the decision to the caller
            if (-not $canRetry) {
                throw "Communication error ($Method is not retried; please check whether it was processed): $($_.Exception.Message)"
            }
            if ($attempt -eq $MaxAttempts) { throw }
            $wait = [math]::Min([math]::Pow(2, $attempt), 60)
            Write-Warning "Communication error: $($_.Exception.Message) - retrying in $wait seconds ($attempt/$MaxAttempts)"
            Start-Sleep -Seconds $wait
            continue
        }

        if ($code -lt 400) { return $res }                    # success

        # Only transient errors are worth retrying. Make it explicit with an allow list
        # (retrying permanent errors such as 405 or 415 wastes time and leaves you with
        #  nothing but an irrelevant "retry limit" message at the end)
        $retryable = @(408, 429, 500, 502, 503, 504)
        if ($code -notin $retryable) {
            throw "API error ($code): $($res | ConvertTo-Json -Compress -Depth 3)"
        }
        # A 5xx may mean "the server processed it and then failed", so don't resend
        # non-idempotent requests. The same applies to 429 (no guarantee it was rejected before processing)
        if (-not $canRetry) {
            throw "API error ($code). $Method is not retried automatically: $($res | ConvertTo-Json -Compress -Depth 3)"
        }

        if ($attempt -eq $MaxAttempts) {
            throw "Retry limit reached ($code): $($res | ConvertTo-Json -Compress -Depth 3)"
        }

        # Retry-After can come back not only as a number of seconds but in HTTP-date form.
        # Casting that straight to [int] throws, taking the whole retry down with it
        $wait = $null
        $retryAfter = if ($resHeaders) { $resHeaders['Retry-After'] | Select-Object -First 1 }
        if ($retryAfter) {
            $seconds = 0
            $date    = [datetime]::MinValue
            if ([int]::TryParse($retryAfter, [ref] $seconds)) {
                $wait = $seconds
            }
            elseif ([datetime]::TryParse($retryAfter,
                        [cultureinfo]::InvariantCulture,
                        [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref] $date)) {
                $wait = [math]::Max(0, [int]($date - [datetime]::UtcNow).TotalSeconds)
            }
        }
        if ($null -eq $wait) {
            $wait = [math]::Min([math]::Pow(2, $attempt), 60)             # exponential backoff (capped at 60s)
        }
        elseif ($wait -gt $MaxWaitSeconds) {
            # Truncating the server's instruction and resending early just repeats the 429
            # until you hit the limit. If the wait is too long, return it to the caller with the wait time
            throw "Rate limited. The server's instructed wait of $wait seconds exceeds the limit of $MaxWaitSeconds seconds, so the call was aborted. Please try again later ($code)"
        }
        Write-Warning "HTTP $code - retrying in $wait seconds ($attempt/$MaxAttempts)"
        Start-Sleep -Seconds $wait
    }
}

Handling idempotency is the second key point. GET and PUT give the same result when the same request is received twice, but POST does not. In particular, in the case where “the server registered the record successfully, but the connection dropped before the response came back”, a naive resend produces a duplicate record. The implementation above allows automatic retries only for idempotent methods, or when the API supports an idempotency key (Idempotency-Key), and otherwise stops with an explicit “please check whether it was processed”.

Not truncating Retry-After is another important point. If the server is telling you to come back in 30 minutes and you resend after 5 minutes because of your own limit, all you get back is the same 429. You burn your attempts on pointless requests and are left, at the end, with nothing but a “retry limit” message. In the implementation above, when the instructed wait exceeds -MaxWaitSeconds, it fails immediately with the wait time attached, rather than waiting for a shorter period and trying again. In batch processing, the caller can catch this exception and choose either to carry the work over to the next run or to wait exactly as instructed.

Another key point is that the retryable set is spelled out as an allow list. Write it as “enumerate the permanent errors and exclude them” and any code you forget to list (405 Method Not Allowed, 415 Unsupported Media Type and so on) gets treated as transient, so you repeat a request that was never going to succeed and end up with nothing but an unexplained “retry limit” message. Retry only the codes you know to be transient, and fail immediately on everything else, error body and all. For retry design in general, see “PowerShell Error Handling and Retry Design”.

7. Paging

An API will almost never return everything at once. There are two main schemes.

(1) The Link header scheme (used by GitHub among others) lets you follow the next page automatically with -FollowRelLink.1

# Follow the next page automatically (you can also cap the number of pages fetched)
$all = Invoke-RestMethod -Uri $uri -Headers $headers -FollowRelLink -MaximumFollowRelLink 20

(2) The cursor/offset scheme requires your own loop.

$items    = [System.Collections.Generic.List[object]]::new()
$cursor   = $null
$page     = 0
$maxPages = 100

do {
    $page++

    # The separator depends on whether the original URI already has a query string.
    # Always appending ? gives .../items?status=active?cursor=..., and to the server
    # the cursor looks like part of the value of status.
    # The cursor itself is an opaque string (it may contain + & = # etc.), so always encode it
    $u = if ($cursor) {
        $sep = if ($uri.Contains('?')) { '&' } else { '?' }
        "$uri$sep" + "cursor=$([uri]::EscapeDataString($cursor))"
    }
    else { $uri }

    $res = Invoke-KsApi -Uri $u -Headers $headers
    $items.AddRange([object[]]$res.items)
    $cursor = $res.nextCursor

    # Always announce a truncation. Bailing out silently makes people think they got everything
    if ($page -ge $maxPages -and $cursor) {
        Write-Warning "Reached the page limit ($maxPages) and stopped. Some records may be missing"
        break
    }
} while ($cursor)

"Records fetched: $($items.Count)"

List[T] is used for $items to avoid the array being rebuilt by += (see “Where to Look When a PowerShell Script Is Slow”).

Don’t forget to always set a page limit either. Bugs where the server keeps returning the same cursor, or forgets to blank out nextCursor, really do happen. Without a limit, the script never stops and just keeps firing requests. And the fact that it was truncated needs to be surfaced as a warning. Break out silently and the caller will process an incomplete result believing it to be the full set.

8. Pitfalls Specific to Windows PowerShell 5.1

In environments where 5.1 remains, these are the first three things to suspect.4

(1) TLS 1.2 is not enabled. Left on the old default settings, you cannot connect to APIs that accept only TLS 1.2 or higher, and you get “The underlying connection was closed”.

# The incantation for Windows PowerShell 5.1 (at the top of the script)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

(2) -UseBasicParsing may be required. 5.1’s Invoke-WebRequest parses HTML using the Internet Explorer engine by default, so it fails under accounts where IE has never been initialised (service accounts, for example). From PowerShell 6 onwards this dependency is gone and -UseBasicParsing is ignored even if specified.4

(3) -SkipHttpErrorCheck and -Authentication don’t exist. On 5.1 you have to read the response stream yourself in order to read an error body. If you are going to do API integration seriously, introducing PowerShell 7 is the cheapest fix (see “Differences Between Windows PowerShell 5.1 and PowerShell 7”).

9. Proxies and Certificates

When calling an API on the internet from inside a corporate network, getting through the proxy is the first hurdle.

# Specify the proxy explicitly and authenticate with the logged-on user's credentials
Invoke-RestMethod -Uri $uri -Proxy 'http://proxy.example.co.jp:8080' -ProxyUseDefaultCredentials

When you run under a Task Scheduler service account, the proxy settings can differ from those during an interactive logon. This is the classic “it works on my machine but only the overnight batch fails” pattern. Match the execution account when you test (see “When Task Scheduler Tasks Don’t Run”).

Using -SkipCertificateCheck for certificate errors should be limited to a temporary workaround in a test environment. The permanent fix is to place your internal CA’s certificate in the Trusted Root Certification Authorities store and issue server certificates properly.

10. Standard Practice in the Field (Decision Table)

Issue Options Rule of thumb
Cmdlet Invoke-RestMethod / Invoke-WebRequest The former for JSON APIs. Get headers and codes via the dedicated variables12
Authentication Header written directly / -Authentication Use the header approach if sharing with 5.1. Store the values in SecretManagement
Sending JSON String / UTF-8 byte array + explicit charset Avoids mojibake caused by environmental differences
ConvertTo-Json Default / specify -Depth The default is 2. Always specify it for nesting3
Errors try/catch only / log the body as well $_.ErrorDetails.Message (PS7). It decides whether you can find the cause1
Lots of branching catch / -SkipHttpErrorCheck + code branching The latter is more natural when the status decides the processing1
Retries -MaximumRetryCount / your own The built-in follows 429’s Retry-After automatically. But it retries everything from 400 to 599, so roll your own when you want permanent errors to fail immediately1
Retrying POST Only when there is an idempotency key Registration may have succeeded with only the response lost, so a naive resend creates a duplicate
Paging -FollowRelLink / your own loop The former for the Link header scheme1
5.1 environments As-is / explicit TLS 1.2 + consider PS7 Most connection errors come down to TLS settings4
Certificate errors -SkipCertificateCheck / trust the internal CA Never disable validation in permanent operation

11. Summary

  • Invoke-RestMethod is the default for JSON APIs. Headers and status codes are available via -ResponseHeadersVariable / -StatusCodeVariable.
  • Send non-ASCII JSON as a UTF-8 byte array and state charset=utf-8 explicitly. Forgetting ConvertTo-Json -Depth leads to missing nested content.
  • 4xx/5xx are terminating errors. Read the body with $_.ErrorDetails.Message and log it. If you have a lot of branching, -SkipHttpErrorCheck is easier to work with.
  • Do not automatically retry non-idempotent requests such as POST. A communication error or a 5xx may mean “the server already processed it”, and resending creates a duplicate record. Retrying requires an idempotency key mechanism.
  • For 429, the basic approach is to wait as instructed by Retry-After. Use -MaximumRetryCount and this is handled by the built-in behaviour. But since the built-in retries everything from 400 to 599, you need your own retry logic if you want 401 or 404 to fail immediately. The same goes for per-code handling and exponential backoff — permanent errors should fail immediately rather than being retried.
  • For paging, use -FollowRelLink for the Link header scheme and your own loop for the cursor scheme. Don’t use += to accumulate the results.
  • In 5.1 environments the three obstacles are explicit TLS 1.2, the IE engine dependency, and missing features. If you’re going to keep doing API integration, introducing PowerShell 7 is the quickest resolution.

Download the Sample Code

The code covered in this article is packaged in ready-to-run form. It includes an API caller implementing retries, idempotency and paging, plus an HTTP server for testing.

Download the sample code (zip)

The samples in this article have been actually executed and verified on PowerShell 7.6 (21 Pester tests). Run the Invoke-SampleTests.ps1 included in the zip and you can reproduce the same verification on your own machine.

# Syntax parsing + static analysis + Pester tests
./Invoke-SampleTests.ps1

The configuration values (paths, server names, tenant IDs and so on) are examples. Do not run them against a production environment as they are — adapt them to your own environment.

KomuraSoft LLC handles the design and implementation of in-house integrations using core-system and SaaS APIs, automation that replaces existing manual work with API integration, and investigation of communication-related defects.

References

  1. Microsoft Learn, Invoke-RestMethod. On sending requests to REST endpoints and converting the response’s JSON/XML into PowerShell objects; on specifying -Headers / -Body / -ContentType / -Method; on -Authentication (Basic / Bearer / OAuth) and -Token, and authentication over anything but HTTPS being refused by default; on -SkipHttpErrorCheck making 4xx/5xx non-exceptional; on retrieval via -StatusCodeVariable and -ResponseHeadersVariable; on retries via -MaximumRetryCount / -RetryIntervalSec; on Link-header paging via -FollowRelLink / -MaximumFollowRelLink; and on -Proxy / -ProxyUseDefaultCredentials, -SkipCertificateCheck and -TimeoutSec.  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16

  2. Microsoft Learn, Invoke-WebRequest. On returning the response as a WebResponseObject giving access to StatusCode, Headers and Content; on Windows PowerShell 5.1 parsing HTML with the Internet Explorer engine by default and -UseBasicParsing being the way around it; and on PowerShell 6 onwards dropping the IE dependency so that -UseBasicParsing is ignored.  2 3

  3. Microsoft Learn, ConvertTo-Json. On -Depth defaulting to 2 and levels deeper than that not being converted, and on -Compress removing whitespace.  2 3

  4. Microsoft Learn, Differences between Windows PowerShell 5.1 and PowerShell 7.x. On behavioural differences in the web-related cmdlets, constraints specific to Windows PowerShell 5.1, and features added in PowerShell 7. Also the ServicePointManager.SecurityProtocol property on specifying the TLS version.  2 3 4

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

How do I choose between Invoke-RestMethod and Invoke-WebRequest?
For handling an API's JSON or XML, use Invoke-RestMethod. It parses the response body automatically and converts it into PowerShell objects, so you don't have to call ConvertFrom-Json yourself. Invoke-WebRequest returns the response as an HtmlWebResponseObject, giving you access to the status code, headers and raw body. Choose Invoke-WebRequest when you want to inspect the status code or headers, or when you want to work with HTML directly. Note that from PowerShell 6 onwards Invoke-RestMethod has -ResponseHeadersVariable and -StatusCodeVariable, so if all you need are headers or the code, you can stay with Invoke-RestMethod.
When I send JSON containing Japanese, it arrives as mojibake at the other end.
This happens because the byte sequence of the body and the declaration in Content-Type disagree. The reliable approach is to convert the string produced by ConvertTo-Json into a UTF-8 byte array, pass that to -Body, and state charset=utf-8 explicitly in -ContentType. On Windows PowerShell 5.1 a string passed as-is can be sent using the default encoding, so this fix is particularly effective there. Mojibake on the receiving side has the same root cause — check whether the response's charset is declared correctly.
When the API returns 404 or 500, I want to read the error message in the response body.
On PowerShell 7 and later, look at $_.ErrorDetails.Message in the catch block — the response body is in there. The status code is available from $_.Exception.Response.StatusCode. In addition, adding -SkipHttpErrorCheck means 4xx/5xx responses are received as normal responses rather than raised as exceptions, which makes life easier when you want to branch on the status code. On Windows PowerShell 5.1 you have to read the response stream yourself, which is another reason we recommend using 7.
The API is returning 429 (rate limited). How should I handle it?
The basic approach is to wait for the number of seconds indicated by the response's Retry-After header and then retry. If the header is absent, widen the interval with exponential backoff (2 seconds, 4 seconds, 8 seconds and so on). From PowerShell 6 onwards there are -MaximumRetryCount and -RetryIntervalSec, and when a 429 response includes Retry-After the header's value is used instead of the interval you specified — so for rate limiting alone, the built-in behaviour is enough. Use your own retry function only when you need to treat different status codes differently or need exponential backoff. Also, retrying a non-idempotent request such as a POST risks creating duplicate records, so avoid it unless there is an idempotency key mechanism. Fundamentally, reducing the number of calls in the first place (fetching only the fields you need, using bulk-retrieval APIs) is the surer fix.
I get an error with an internal API's self-signed certificate. Is it OK to use -SkipCertificateCheck?
Avoid it in permanent operation. Turning off certificate validation means you are not confirming that the party you are talking to is genuine, which leaves room for a man-in-the-middle attack even on an internal network. The correct fix is to place your internal CA's certificate in the execution environment's Trusted Root Certification Authorities store and issue server certificates properly. Even when using it temporarily in a test environment, make it an explicit switch via a configuration file or a parameter so that it never leaks into a production script.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog