โ† All guides

Mock a REST API for PowerShell โ€” Invoke-RestMethod's silent traps, measured

You'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:

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.

1. Try it in 10 seconds (no signup)

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.

โšก Want your own instead of the shared demo? This link creates a live, seeded e-commerce backend in the dashboard โ€” one click, no signup. Or one line of PowerShell: Invoke-RestMethod -Method Post https://mockbird.mockbird.workers.dev/api/projects -ContentType 'application/json' -Body '{"preset":"ecommerce"}'
๐ŸชŸ Everything here was verified on PowerShell 7.4. Several parameters used below (-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.

2. Trap 1: a 500 doesn't stop your script โ€” it keeps running with $null

Don'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.

3. Trap 2: -Body @{...} form-encodes โ€” your JSON API gets name=Test+Widget

This 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.

4. Trap 3: 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.

5. Pagination and X-Total-Count โ€” header values are string arrays

List 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.

6. Retry drills: -MaximumRetryCount against a deterministic failure sequence

PowerShell 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.

Does PowerShell honor Retry-After? Yes โ€” proved

The 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.

7. Timeouts: -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.

8. JWT auth: -Authentication Bearer and an expiry drill

The 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.

9. A real-wire Pester suite

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.

10. Honest comparison: the PowerShell-native options

ApproachGood atWhere it falls short for script work
Pester Mock Invoke-RestMethodFast unit tests, no network, assert call argsNothing 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 DockerFull control, offline, org-standard stubsYou 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 tenantMaximum realismRate 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 codeIt's a remote service โ€” offline dev needs a local stub; data caps apply (limits).

11. Useful extras

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.