ScreenshotNeo

BlogHow-to

How to Build a Website Monitoring Script in PowerShell

Build a reliable PowerShell website monitor with status and content checks, timeouts, logging, alerts, troubleshooting, and scheduling.

By the ScreenshotNeo team1 October 20265 min read

Direct answer: use PowerShell’s Invoke-WebRequest with finite connection and operation timeouts, expected status and content checks, structured logging, and per-target try/catch handling. Alert only after consecutive failures.

Microsoft documents Invoke-WebRequest as the PowerShell cmdlet for HTTP and HTTPS requests (documentation).

1. Define health

For each target define its URL, expected status, optional expected text, timeout values, redirect limit, and alert policy. A 200 response alone may still be an outage if the body is an error page.

2. Complete PowerShell 7.4 monitor

$targets = @(
    [pscustomobject]@{ Url='https://example.com/'; ExpectedStatus=200; ExpectedText=$null },
    [pscustomobject]@{ Url='https://status.example.com/'; ExpectedStatus=200; ExpectedText='All systems operational' }
)

$results = foreach ($target in $targets) {
    $watch = [Diagnostics.Stopwatch]::StartNew()
    $status = $null; $contentOk = $false; $errorClass = $null; $errorMessage = $null
    try {
        $response = Invoke-WebRequest -Uri $target.Url `
            -ConnectionTimeoutSeconds 15 `
            -OperationTimeoutSeconds 30 `
            -MaximumRedirection 5 `
            -UserAgent 'SiteMonitor/1.0' `
            -ErrorAction Stop
        $status = [int]$response.StatusCode
        $contentOk = if ($target.ExpectedText) {
            $response.Content -like "*$($target.ExpectedText)*"
        } else { $true }
        if ($status -ne $target.ExpectedStatus) {
            $errorClass = 'UnexpectedStatus'
            $errorMessage = "Expected $($target.ExpectedStatus), received $status"
        } elseif (-not $contentOk) {
            $errorClass = 'ContentMismatch'
            $errorMessage = 'Expected text was not found'
        }
    } catch {
        $errorMessage = $_.Exception.Message
        if ($_.Exception.Response) {
            try { $status = [int]$_.Exception.Response.StatusCode } catch {}
        }
        $errorClass = switch -Regex ($errorMessage) {
            'DNS|NameResolution' { 'DnsFailure'; break }
            'SSL|TLS|certificate' { 'TlsFailure'; break }
            'timed out|timeout' { 'Timeout'; break }
            'redirect' { 'RedirectFailure'; break }
            'refused|No connection' { 'ConnectionRefused'; break }
            default { 'RequestFailure' }
        }
    } finally { $watch.Stop() }

    [pscustomobject]@{
        TimestampUtc = [DateTime]::UtcNow.ToString('o')
        Url=$target.Url; StatusCode=$status
        ElapsedMs=[math]::Round($watch.Elapsed.TotalMilliseconds,0)
        ContentOk=$contentOk
        Healthy=($status -eq $target.ExpectedStatus -and $contentOk -and $null -eq $errorClass)
        ErrorClass=$errorClass; Error=$errorMessage
    }
}

$results | Format-Table -AutoSize
$results | Export-Csv './website-monitor.csv' -NoTypeInformation -Append
if ($results.Healthy -contains $false) { exit 1 } else { exit 0 }

-ErrorAction Stop makes HTTP errors catchable. Non-success responses such as 404 and 500 may throw; the exception can still contain a response whose status code should be recorded.

3. Consecutive-failure alerts

Alerting on one failed request creates noise. Persist a counter and notify when a target fails twice (or another policy you choose).

$statePath = './monitor-state.json'
$state = if (Test-Path $statePath) { Get-Content $statePath -Raw | ConvertFrom-Json } else { [pscustomobject]@{} }
foreach ($result in $results) {
    $key = $result.Url -replace '[^a-zA-Z0-9]', '_'
    $old = $state.$key
    $count = if ($result.Healthy) { 0 } elseif ($null -eq $old) { 1 } else { [int]$old + 1 }
    $state | Add-Member -NotePropertyName $key -NotePropertyValue $count -Force
    if ($count -eq 2) { Write-Warning "ALERT: $($result.Url) $($result.ErrorClass): $($result.Error)" }
}
$state | ConvertTo-Json | Set-Content $statePath

4. Important options

Option Purpose
-ConnectionTimeoutSeconds Bounds connection setup in PowerShell 7.4.
-OperationTimeoutSeconds Bounds stalls while reading a response.
-MaximumRedirection Detects redirect loops and unexpected chains.
-UserAgent Identifies monitoring traffic.
-Headers, -WebSession Supports authentication and cookies; keep secrets out of logs.
-Method, -Body, -ContentType Checks POST or other API health endpoints.
-SslProtocol Restricts TLS only when compatibility or compliance requires it.

5. Content and JSON assertions

For APIs, parse JSON and check a field instead of searching raw text.

$r = Invoke-WebRequest -Uri 'https://api.example.com/health' -ConnectionTimeoutSeconds 15 -OperationTimeoutSeconds 30 -ErrorAction Stop
$data = $r.Content | ConvertFrom-Json
if ($data.status -ne 'ok') { throw "Health field was $($data.status)" }

HTML text checks can fail after redirects, authentication changes, A/B tests, or client-side rendering. Log the final URL and response length, and prefer a stable server-rendered health endpoint.

6. PowerShell 5.1

Windows PowerShell 5.1 uses -TimeoutSec. Its default is zero (no timeout), and DNS resolution can take up to 15 seconds, so a very small timeout may take longer to surface. Microsoft’s December 9, 2025 security change may require -UseBasicParsing when only fetching content.

Invoke-WebRequest -Uri 'https://example.com' -TimeoutSec 30 -UseBasicParsing -MaximumRedirection 5 -ErrorAction Stop

7. Scheduling and logs

  1. Store the script and state file in a protected directory.
  2. Run it interactively under the account used by the scheduler.
  3. Schedule pwsh -NoProfile -File C:\path\monitor.ps1 with Task Scheduler, cron, or an automation runner.
  4. Retain CSV or JSON records containing UTC time, URL, status, elapsed milliseconds, error class, and content result.
  5. Rotate logs and protect credentials.

8. Troubleshooting

Problem Cause and fix
Hanging checks Missing timeouts or slow DNS. Set connection and operation timeouts.
404/500 terminates the run Use -ErrorAction Stop and catch each target separately.
TLS failures Inspect certificate trust, proxy inspection, and protocol compatibility. Restrict -SslProtocol only when required.
Missing expected text Check redirects, login pages, variants, and client-rendered content.
Redirect errors Inspect the chain and set a deliberate redirect limit.
Works manually but not scheduled Use absolute paths, -NoProfile, the correct account, and explicit proxy or secret settings.
False alerts Require consecutive failures and consecutive successes for recovery.

9. Performance, reliability, and cost

  • Sequential requests are simplest. Bounded parallelism reduces wall-clock time for many targets but increases load and rate-limit complexity.
  • A dedicated health endpoint is faster and more stable than a full homepage.
  • Use retries sparingly: they can distinguish transient packet loss but delay detection and add traffic.
  • A local script is transparent and customizable but normally observes one network location and requires you to operate scheduling, alerts, history, and upgrades.
  • A hosted service can provide independent vantage points and managed history; review retention, credentials, alerting, and total cost.

10. Equivalent checks

curl --fail --silent --show-error --max-time 30 -o /dev/null -w '%{http_code}\n' https://example.com/
import requests
r = requests.get('https://example.com/', timeout=(15, 30))
r.raise_for_status()
assert r.status_code == 200
const res = await fetch('https://example.com/');
if (!res.ok) throw new Error(`HTTP ${res.status}`);

Or skip the browser setup

If monitoring needs visual evidence, ScreenshotNeo provides a screenshot API. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; X-Page-Verdict and X-Billed headers report the result.

It supports full-page or CSS-element capture, device presets, custom waits, dark mode, headers, cookies, JavaScript, PDFs, caching, bulk calls, and an MCP server for Claude, Cursor, and other MCP clients. See the API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

1,000 screenshots per month are free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

What status should be healthy?

Use the status defined by the endpoint contract. 200 is common, but 204 or a documented redirect may be correct.

Should checks run from multiple locations?

For regional outage detection, yes. A local script reports only its own network view.

How often should it run?

Choose an interval matching your recovery objective and the endpoint’s rate limits.

Can authenticated pages be monitored?

Yes. Supply headers or a web session, protect secrets, and never log tokens or cookies.