Invoke-RestMethod's silent traps, measuredYou're writing a PowerShell script โ a deployment step, a scheduled job, an ops tool โ that talks to a REST API. Maybe the API doesn't exist yet; maybe it's production and you'd rather not hammer it while you iterate. Either way, Invoke-RestMethod has a handful of behaviors that fail quietly, and the middle of a pipeline run is the worst possible place to discover them:
$null, and the next line runs anyway;-Body @{...} doesn't send JSON โ a hashtable body is form-encoded, and a JSON API rejects it with a 400 your script may never look at;ConvertTo-Json truncates at depth 2 by default โ nested payloads arrive at the server containing the literal string "System.Collections.Hashtable".This guide proves all three against a live hosted mock API, then uses the same API to drill the things you can't safely rehearse against production: retry loops against deterministic failure sequences, Retry-After handling on 429s, timeouts, JWT auth expiry โ and finishes with a real-wire Pester suite. Every snippet below was run verbatim on PowerShell 7.4 against the live endpoints on this page before publishing โ the printed outputs shown are from those runs.
A shared, self-resetting demo project is live right now:
Invoke-RestMethod "https://mockbird.mockbird.workers.dev/m/demo/products?_limit=3"
30 seeded products with id, name, price, category, inStock, rating โ plus orders, customers and reviews collections, full CRUD, filtering, sorting and pagination. Invoke-RestMethod parses the JSON into objects for you, so (irm "...m/demo/products?_limit=3").name just works.
Invoke-RestMethod -Method Post https://mockbird.mockbird.workers.dev/api/projects -ContentType 'application/json' -Body '{"preset":"ecommerce"}'-SkipHttpErrorCheck, -StatusCodeVariable, -ResponseHeadersVariable, -MaximumRetryCount, -Authentication) don't exist in Windows PowerShell 5.1 โ they were added in PowerShell 6/7. If you're still on 5.1, this guide is one more reason to upgrade.$nullDon't take it on faith โ run it. ?mock_status=500 makes the endpoint answer with a 500 so you can watch what your script actually does:
$base = "https://mockbird.mockbird.workers.dev/m/demo"
$products = Invoke-RestMethod "$base/products?mock_status=500"
Write-Host "after call, products is null? $($null -eq $products)"
Printed:
Invoke-RestMethod: { "error": "simulated 500 error (mock_status)" }
after call, products is null? True
The red text looks like your script stopped. It didn't. The error terminates the cmdlet, not the script: $products is $null, and every following line runs against it. In a pipeline that then writes files, updates a database, or posts to another API, "the report was empty today" is how you find out โ days later.
Two honest ways to handle it. First, try/catch โ and note the response body is not in $_.Exception.Message; it's in $_.ErrorDetails.Message:
try {
$products = Invoke-RestMethod "$base/products?mock_status=500"
} catch {
$status = $_.Exception.Response.StatusCode.value__ # 500
$body = $_.ErrorDetails.Message | ConvertFrom-Json # the API's real error payload
Write-Warning "API returned $status : $($body.error)"
exit 1
}
Second, PowerShell 7's -SkipHttpErrorCheck โ no exception at all; you branch on the status code like any other value:
$r = Invoke-RestMethod "$base/products?mock_status=503" -SkipHttpErrorCheck -StatusCodeVariable sc
if ($sc -ge 400) { Write-Warning "got $sc : $($r.error)" }
# printed: WARNING: got 503 : simulated 503 error (mock_status)
Because the status comes from a query param, you can rehearse your error branch for any code โ 401, 404, 429, 500, 503 โ by editing a URL, not by breaking a server. The full list of simulation params is in the docs.
-Body @{...} form-encodes โ your JSON API gets name=Test+WidgetThis one stacks two silent failures. A hashtable passed to -Body is sent as application/x-www-form-urlencoded โ not JSON. A JSON API rejects it, and per Trap 1 your script shrugs and continues:
Invoke-RestMethod -Method Post "$base/products" -Body @{ name = 'Test Widget'; price = 9.99 }
Printed (the mock API tells you exactly what went wrong):
Invoke-RestMethod: { "error": "invalid JSON body" }
The fix is always the same pair: serialize yourself, and say so with -ContentType:
$new = Invoke-RestMethod -Method Post "$base/products" `
-ContentType 'application/json' `
-Body (@{ name = 'Test Widget'; price = 9.99 } | ConvertTo-Json)
$new.id # 31 โ a real, persisted record
(Invoke-RestMethod "$base/products/$($new.id)").name # Test Widget โ reads back
Invoke-RestMethod -Method Delete "$base/products/$($new.id)" # clean up
Printed: 31, then Test Widget โ writes on Mockbird are real (POST persists, GET reads it back, DELETE removes it), which is exactly what you want when rehearsing a script that creates things. A GET after the delete returns 404.
ConvertTo-Json truncates at depth 2 โ the server receives "System.Collections.Hashtable"ConvertTo-Json's default -Depth is 2. Anything deeper is stringified with .ToString() โ and for a hashtable that's the literal type name. Watch it corrupt a payload end-to-end:
$payload = @{ name = 'Depth Test'; meta = @{ shipping = @{ box = @{ w = 10; h = 20 } } } }
$payload | ConvertTo-Json -Compress
Printed (with a warning most CI logs scroll right past):
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
{"meta":{"shipping":{"box":"System.Collections.Hashtable"}},"name":"Depth Test"}
POST that and the server stores it verbatim โ we did, read it back, and $back.meta.shipping.box was the string System.Collections.Hashtable. The fix is one flag:
$new = Invoke-RestMethod -Method Post "$base/products" -ContentType 'application/json' `
-Body ($payload | ConvertTo-Json -Depth 10)
$back = Invoke-RestMethod "$base/products/$($new.id)"
$back.meta.shipping.box.w # 10 โ intact this time
Invoke-RestMethod -Method Delete "$base/products/$($new.id)"
Rule of thumb: any ConvertTo-Json that can ever see a nested object gets an explicit -Depth. The mock round-trip is the cheapest way to prove your serialization survives the wire โ the request inspector shows the exact bytes your script sent.
X-Total-Count โ header values are string arraysList endpoints paginate with _page/_limit (or page/limit) and report the total in X-Total-Count. PowerShell 7 exposes response headers via -ResponseHeadersVariable:
$page = Invoke-RestMethod "$base/products?_page=2&_limit=12" -ResponseHeadersVariable rh
@($page).Count # 12
$rh['X-Total-Count'] # 30
One measured gotcha: every value in that dictionary is a String[], even for single-valued headers. A direct cast fails:
[int]$rh['X-Total-Count'] # ERROR: Cannot convert "System.String[]" to "System.Int32"
[int]$rh['X-Total-Count'][0] # 30 โ index first, then cast
So a complete "fetch everything" loop is:
$limit = 12; $page = 1; $all = @()
do {
$chunk = Invoke-RestMethod "$base/products?_page=$page&_limit=$limit" -ResponseHeadersVariable rh
$all += $chunk
$total = [int]$rh['X-Total-Count'][0]
$page++
} while ($all.Count -lt $total)
$all.Count # 30
Also worth knowing: without _limit, list endpoints serve a default page (20 records), not the whole collection โ the loop above is the honest pattern, and it's the same one you'll need against most real APIs.
-MaximumRetryCount against a deterministic failure sequencePowerShell 7 has retries built in โ but how do you test them? Random chaos flags make flaky tests. mock_seq serves an exact status sequence: 503,503,200 means fail, fail, succeed. Point the built-in retry at it:
$k = "run$(Get-Random)" # fresh key = fresh sequence counter
$r = Invoke-RestMethod "$base/products?_limit=1&mock_seq=503,503,200&mock_seq_key=$k" `
-MaximumRetryCount 3 -RetryIntervalSec 1
@($r).Count # 1 โ succeeded on the third attempt
Every response carries x-mockbird-seq so you can watch the counter: we replayed the same sequence request-by-request with -SkipHttpErrorCheck and got exactly 503 (1/3), 503 (2/3), 200 (3/3). Failed writes in a sequence are not applied โ the "server died before processing" semantics you want when pointing idempotency/retry logic at it.
Retry-After? Yes โ provedThe docs say a 429 with a Retry-After header overrides -RetryIntervalSec. Rehearse it against a simulated rate limit (mock_ratelimit=2 allows 2 requests per 60-second window, then 429s):
$k = "rl$(Get-Random)"
1..2 | ForEach-Object { # burn the window
$null = Invoke-RestMethod "$base/products?_limit=1&mock_ratelimit=2&mock_ratelimit_key=$k"
}
# third call: 429 + Retry-After. Let the built-in retry handle it:
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$r = Invoke-RestMethod "$base/products?_limit=1&mock_ratelimit=2&mock_ratelimit_key=$k" `
-MaximumRetryCount 2 -RetryIntervalSec 1
$sw.Stop(); $sw.Elapsed.TotalSeconds
Measured: the bare 429 carried Retry-After: 13, and the retried call succeeded after 13.6 s โ despite -RetryIntervalSec 1. PowerShell really does prefer the server's header. Every response also carries x-ratelimit-limit / -remaining / -reset, so you can build and test a polite backoff loop without getting banned by a real API โ full details in the rate-limit guide.
-TimeoutSec against a slow endpoint?mock_delay=4000 makes the endpoint take 4 seconds. Cap the wait and see exactly what your catch block gets:
try {
Invoke-RestMethod "$base/products?_limit=1&mock_delay=4000" -TimeoutSec 2
} catch {
$_.Exception.GetType().Name # TaskCanceledException
$_.Exception.Message
}
Measured: threw at 2.0 s with "The request was canceled due to the configured HttpClient.Timeout of 2 seconds elapsing." Note it's a cancellation exception, not an HTTP error โ $_.Exception.Response is empty, so a catch block that assumes a status code will itself throw. Rehearse both branches: mock_delay for the timeout path, mock_status for the HTTP-error path.
-Authentication Bearer and an expiry drillThe demo has a working mock auth flow โ any email/password logs in and returns a real signed JWT:
$login = Invoke-RestMethod -Method Post "$base/auth/login" -ContentType 'application/json' `
-Body (@{ email = 'dev@example.com'; password = 'anything' } | ConvertTo-Json)
$tok = ConvertTo-SecureString $login.token -AsPlainText -Force
$me = Invoke-RestMethod "$base/auth/me" -Authentication Bearer -Token $tok
$me.user.email # dev@example.com
The part you can't rehearse against a real identity provider: what does your script do when the token expires mid-run? Ask for a 5-second token and find out:
$short = Invoke-RestMethod -Method Post "$base/auth/login" -ContentType 'application/json' `
-Body (@{ email = 'dev@example.com'; password = 'x'; expiresIn = 5 } | ConvertTo-Json)
Start-Sleep 6
Invoke-RestMethod "$base/auth/me" -Authentication Bearer `
-Token (ConvertTo-SecureString $short.token -AsPlainText -Force)
# 401 โ ErrorDetails.Message: { "error": "token expired" }
Now your refresh-and-retry branch has a deterministic trigger. Details and protected-mode projects (where every endpoint requires the token): mock JWT auth guide.
Pester's Mock Invoke-RestMethod is great for pure unit tests โ but it never exercises serialization, headers, retry timing or your error branches over an actual socket. Against a hosted mock, the same tests run on the real wire and stay deterministic:
# Products.Tests.ps1
BeforeAll {
$script:Base = 'https://mockbird.mockbird.workers.dev/m/demo'
}
Describe 'products API' {
It 'lists products with a total count' {
$page = Invoke-RestMethod "$Base/products?_page=1&_limit=12" -ResponseHeadersVariable rh
@($page).Count | Should -Be 12
[int]$rh['X-Total-Count'][0] | Should -BeGreaterThan 0
}
It 'surfaces the API error body on a 500' {
{ Invoke-RestMethod "$Base/products?mock_status=500" } | Should -Throw
try { Invoke-RestMethod "$Base/products?mock_status=500" } catch {
$_.Exception.Response.StatusCode.value__ | Should -Be 500
($_.ErrorDetails.Message | ConvertFrom-Json).error | Should -Match 'simulated'
}
}
It 'recovers when the API fails twice then succeeds' {
$k = "pester$(Get-Random)"
$r = Invoke-RestMethod "$Base/products?_limit=1&mock_seq=503,503,200&mock_seq_key=$k" `
-MaximumRetryCount 3 -RetryIntervalSec 1
@($r).Count | Should -Be 1
}
It 'creates and deletes a product' {
$body = @{ name = 'Pester Widget'; price = 4.5 } | ConvertTo-Json
$new = Invoke-RestMethod -Method Post "$Base/products" -ContentType 'application/json' -Body $body
$new.id | Should -BeGreaterThan 0
(Invoke-RestMethod "$Base/products/$($new.id)").name | Should -Be 'Pester Widget'
Invoke-RestMethod -Method Delete "$Base/products/$($new.id)" | Out-Null
{ Invoke-RestMethod "$Base/products/$($new.id)" } | Should -Throw
}
}
Run with Invoke-Pester ./Products.Tests.ps1. Our run:
[+] /tmp/psguide/Products.Tests.ps1 11.66s (4 tests)
Tests Passed: 4, Failed: 0, Skipped: 0
For parallel CI runners, give each worker its own mock_seq_key/mock_ratelimit_key (as above) and pin data states with snapshots so runs can't race each other.
| Approach | Good at | Where it falls short for script work |
|---|---|---|
Pester Mock Invoke-RestMethod | Fast unit tests, no network, assert call args | Nothing crosses a socket โ form-encoding, -Depth truncation, retry timing, Retry-After and timeout behavior are never exercised (three of this page's traps live below the mock boundary). |
| WireMock / mock server in Docker | Full control, offline, org-standard stubs | You run and maintain it; stub definitions are code; no seeded CRUD data, auth flow or one-URL failure/latency params unless you build them. |
| Hitting the real API's test tenant | Maximum realism | Rate limits, shared mutable state, no way to order it to fail 503,503,200 on demand. |
| Mockbird (this guide) | Real HTTP + failures + latency over the wire, seeded CRUD, zero code | It's a remote service โ offline dev needs a local stub; data caps apply (limits). |
Invoke-RestMethod -Method Post https://mockbird.mockbird.workers.dev/api/projects -ContentType 'application/json' -Body '{"preset":"ecommerce"}' โ or one click. Define custom resources and field types in the dashboard.db.json, Postman collection, or a CSV โ instant hosted API (CSV guide โ Import-Csv's natural counterpart).-Depth traps above.openapi.json, TypeScript/Zod types, Postman collection, or the whole dataset as db.json โ eject anytime.Related: mock APIs for Excel / Power Query, mock APIs for Power Automate, mock APIs for C#, rate-limit simulation, testing loading and error states, mock JWT auth, and free mock-API tools compared.
Verification: every PowerShell snippet on this page was run verbatim on 22 Sep 2026 with PowerShell 7.4.6 + Pester 6.2 on a Linux box against production โ the printed outputs shown are from those runs, including the null-after-500 check, the form-encoded 400, the depth-2 truncation stored server-side, the String[] header cast failure, the 503,503,200 retry recovery with per-request x-mockbird-seq counters, the measured 13.6-second Retry-After wait, the 2.0-second TaskCanceledException, the 5-second token-expiry 401, and all four Pester tests in one passing run. All records created during verification were deleted. Demo data reseeds daily, so names, prices and counts will differ when you run it. If a snippet here doesn't work in your script, that's a bug: tell us.