HTTP Status Code Checker: Test URL Response Codes
The 200 OK That Broke Production Monitoring
A team at a fintech startup built a payment processing API. For six months, their monitoring dashboard showed zero errors — 100% success rate. Then a customer reported they'd been charged without receiving a confirmation email. The team dug in and found the root cause: when a payment failed, the API returned 200 OK with a JSON body containing {"success": false, "error": "card_declined"}. The monitoring tool checked HTTP status codes. Everything showed green.
HTTP status codes are a contract. Per RFC 9110 (HTTP Semantics, published June 2022 by the IETF, superseding RFC 7231), every HTTP response carries a three-digit status code that precisely describes the outcome of the request. When that contract is violated — wrong codes, or 200 for errors — the entire ecosystem of clients, monitoring tools, caches, search engines, and load balancers breaks down.
This guide covers how to check HTTP status codes using curl, wget, Python, and online tools; the most consequential status code mistakes in API design; redirect chain analysis; and a practical decision framework for choosing the right code. For the full status code reference, see BytePane's HTTP status codes page.
Key Takeaways
- ▸HTTP status codes are defined in RFC 9110 (June 2022, supersedes RFC 7231). Use
curl -o /dev/null -s -w "%{http_code}" URLto get just the numeric code from any URL. - ▸401 means unauthenticated (not “unauthorized”) — the server doesn't know who you are. 403 means forbidden — the server knows exactly who you are but you lack permission. The names are counterintuitive by design of the original HTTP spec.
- ▸For API redirects, always use 307/308 instead of 301/302. RFC 9110 guarantees 307/308 preserve the HTTP method. 301 and 302 are historically allowed to silently change POST to GET.
- ▸Use 410 Gone (not 404) when a resource has been permanently deleted — 410 tells search engines to immediately deindex, while 404 implies the resource may return.
- ▸Returning 200 OK with an error in the response body breaks HTTP semantics — it defeats caching, monitoring, load balancer health checks, and client error handling. Always use the correct 4xx or 5xx code.
How to Check HTTP Status Codes
There are four practical ways to check the HTTP status code returned by any URL:
curl — The Universal Method
# Get just the numeric status code (discard body and headers)
curl -o /dev/null -s -w "%{http_code}
" https://example.com
# Output: 200
# Get all response headers (HEAD request — faster, no body)
curl -I https://example.com
# HTTP/2 200
# content-type: text/html; charset=UTF-8
# ...
# Follow redirects and show the final URL + status code
curl -s -o /dev/null -w "%{url_effective} %{http_code}
" -L https://example.com/old-path
# https://example.com/new-path 200
# Full redirect chain (shows headers at each hop)
curl -I -L --max-redirs 10 https://example.com/redirect-me
# HTTP/1.1 301 Moved Permanently
# Location: https://example.com/step-two
# ...
# HTTP/1.1 302 Found
# Location: https://example.com/final
# ...
# HTTP/2 200
# Extract multiple metrics in one request
curl -o /dev/null -s -w "status=%{http_code} time=%{time_total}s size=%{size_download}b
" https://example.com
# status=200 time=0.125s size=1256b
# Test with a specific HTTP method (useful for API health checks)
curl -o /dev/null -s -w "%{http_code}
" -X POST -H 'Content-Type: application/json' --data-raw '{"ping":true}' https://api.example.com/healthPython: Programmatic Checking
import requests
from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple
class UrlStatus(NamedTuple):
url: str
status_code: int
final_url: str # After redirects
elapsed_ms: float
def check_url(url: str, timeout: int = 10) -> UrlStatus:
"""Check a URL's HTTP status code without downloading the full body."""
try:
# Use stream=True to avoid downloading large bodies
response = requests.get(url, timeout=timeout, stream=True,
allow_redirects=True)
return UrlStatus(
url=url,
status_code=response.status_code,
final_url=response.url,
elapsed_ms=response.elapsed.total_seconds() * 1000,
)
except requests.exceptions.Timeout:
return UrlStatus(url=url, status_code=408, final_url=url, elapsed_ms=timeout*1000)
except requests.exceptions.ConnectionError:
return UrlStatus(url=url, status_code=0, final_url=url, elapsed_ms=0)
finally:
if 'response' in dir():
response.close()
# Bulk URL checker with thread pool
def check_urls(urls: list[str], max_workers: int = 10) -> list[UrlStatus]:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
return list(executor.map(check_url, urls))
# Usage
urls = [
"https://example.com",
"https://example.com/page-that-might-be-gone",
"https://api.example.com/health",
]
results = check_urls(urls)
for r in results:
symbol = "✓" if 200 <= r.status_code < 300 else "✗"
print(f"{symbol} [{r.status_code}] {r.url} → {r.final_url} ({r.elapsed_ms:.0f}ms)")Node.js: TypeScript Bulk Checker
interface UrlResult {
url: string
statusCode: number | null
finalUrl: string
redirectCount: number
error?: string
}
async function checkUrl(url: string, timeoutMs = 10_000): Promise<UrlResult> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: 'follow', // Follow redirects (default)
})
// Count redirects via response.redirected (bool) — for full chain, use manual redirect
return {
url,
statusCode: response.status,
finalUrl: response.url,
redirectCount: response.redirected ? 1 : 0, // fetch API doesn't count hops
}
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
const isTimeout = error.includes('abort') || error.includes('timeout')
return {
url,
statusCode: isTimeout ? 408 : null,
finalUrl: url,
redirectCount: 0,
error,
}
} finally {
clearTimeout(timer)
}
}
// Check multiple URLs concurrently (batch to avoid overwhelming servers)
async function checkUrlsBatch(urls: string[], batchSize = 10): Promise<UrlResult[]> {
const results: UrlResult[] = []
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize)
const batchResults = await Promise.all(batch.map(checkUrl))
results.push(...batchResults)
}
return results
}RFC 9110: The Five Status Code Classes
RFC 9110 (HTTP Semantics) was published by the IETF in June 2022, superseding RFC 7231. It defines the semantics of all registered HTTP status codes and organizes them into five classes based on the first digit:
| Class | Range | Meaning | Key Codes | Cacheable? |
|---|---|---|---|---|
| 1xx Informational | 100–199 | Request received, processing continues | 100 Continue, 101 Switching Protocols | No |
| 2xx Success | 200–299 | Request was received, understood, and accepted | 200, 201, 204, 206 | 200, 206 by default |
| 3xx Redirection | 300–399 | Client must take additional action | 301, 302, 304, 307, 308 | 301, 308 by default |
| 4xx Client Error | 400–499 | The request contains bad syntax or cannot be fulfilled | 400, 401, 403, 404, 409, 422, 429 | 404, 410 by default |
| 5xx Server Error | 500–599 | The server failed to fulfill a valid request | 500, 502, 503, 504 | No (unsafe) |
The Six Most Consequential Status Code Mistakes
1. Returning 200 OK for Errors
Returning 200 OK with a JSON body like {"error": "card_declined"} is the most disruptive API design mistake. It breaks:
- Monitoring tools — which check status codes, not body content
- CDN caching — 200 responses get cached; error responses should not be
- Client error handling — try/catch on HTTP status won't fire
- Load balancer health checks — passive health checks use status codes
- APM tools (Datadog, New Relic, Sentry) — track error rates by status code
The correct pattern: use the appropriate 4xx or 5xx code, and include the error detail in the response body as structured JSON:
// Wrong: HTTP 200 with error body
HTTP/1.1 200 OK
{"success": false, "error": "card_declined", "code": "PAYMENT_FAILED"}
// Correct: HTTP 402 Payment Required (or 422 Unprocessable Entity)
HTTP/1.1 402 Payment Required
{
"error": {
"code": "card_declined",
"message": "Your card was declined. Please try a different payment method.",
"type": "payment_error",
"decline_code": "insufficient_funds"
}
}2. Confusing 401 and 403
RFC 9110 Section 15.5.2 (401 Unauthorized) and Section 15.5.4 (403 Forbidden) are routinely confused. The naming is admittedly counterintuitive:
// 401 Unauthorized — semantically means "UNAUTHENTICATED"
// Use when: no credentials provided, or credentials are invalid
// Requires: WWW-Authenticate header in the response
// Client action: present credentials (log in, include auth token)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.example.com"
// 403 Forbidden — semantically means "UNAUTHORIZED" (lacks permission)
// Use when: credentials are valid, but the user lacks the required role/scope
// No WWW-Authenticate header needed
// Client action: request elevated permissions, contact administrator
HTTP/1.1 403 Forbidden
{"error": "You need the 'admin' role to access this resource"}
// Decision tree:
// Is the user logged in / authenticated?
// No → 401 (with WWW-Authenticate)
// Yes → Is the resource they're accessing within their permission scope?
// No → 403
// Yes → allow access3. Using 400 When You Mean 422
// 400 Bad Request: the request is syntactically malformed
// Use for: invalid JSON, missing required headers, malformed URL parameters
// The server can't parse the request at all
HTTP/1.1 400 Bad Request
{"error": "Invalid JSON: unexpected token at position 42"}
// 422 Unprocessable Entity: syntactically valid but semantically wrong
// Use for: valid JSON that fails business logic validation
// The server understood the request but can't process it
HTTP/1.1 422 Unprocessable Entity
{
"error": "Validation failed",
"fields": {
"email": "Already registered",
"age": "Must be 18 or older",
"start_date": "Cannot be in the past"
}
}
// Practical rule:
// 400 = parser error (couldn't even deserialize the request)
// 422 = validation error (deserialized successfully, but values are invalid)4. Using 301/302 for API Redirects
// 301 Moved Permanently: historically allowed clients to change POST to GET // Some HTTP/1.0 clients DID silently change POST→GET on 301 redirects // RFC 9110 acknowledges this historical behavior remains common // For API redirects, always use 307 or 308 — they GUARANTEE method preservation // 307 Temporary Redirect: same method, but check original URL on next request // 308 Permanent Redirect: same method, update bookmarks/links // Example: renaming an API endpoint without breaking clients // Old: POST /api/v1/order → New: POST /api/v2/orders # Wrong: might silently change POST to GET HTTP/1.1 301 Moved Permanently Location: https://api.example.com/v2/orders # Correct: guarantees POST is preserved HTTP/1.1 308 Permanent Redirect Location: https://api.example.com/v2/orders # Browser note: 301/302 are fine for HTML page redirects # where changing GET→GET doesn't matter and search engines need to update indexes
5. Using 404 When You Should Use 410
Per RFC 9110, 410 Gone means the resource existed and has been permanently removed — it will never return. 404 Not Found means the resource wasn't found and might exist in the future. The practical difference: Google and other search engines treat 410 as a signal to immediately remove the URL from their index. A 404 stays in the index longer while crawlers retry. For intentionally removed content (discontinued products, deleted articles), 410 deindexes faster — reducing crawl budget waste and preventing searchers from finding removed pages.
6. Returning 500 for All Server Errors
Using 500 Internal Server Error as a catch-all loses information that helps clients and retry logic. The precise codes matter:
- 502 Bad Gateway — upstream dependency returned an invalid response (database, external API)
- 503 Service Unavailable — server is temporarily overloaded or in maintenance (should include
Retry-Afterheader) - 504 Gateway Timeout — upstream dependency timed out (use exponential backoff with this one)
- 500 Internal Server Error — unexpected server-side exception (the true catch-all)
Analyzing Redirect Chains
Redirect chains — multiple sequential redirects before reaching the final destination — are common on production sites and have real performance and SEO implications. Each additional redirect adds a round-trip latency (typically 50–200ms). Search engines follow chains but may lose PageRank across multiple hops.
# Show full redirect chain with timing at each hop
curl -s -o /dev/null -w "Code: %{http_code} | URL: %{url_effective}
" -L --max-redirs 10 https://example.com/old
# Verbose headers at each hop (shows Location headers)
curl -vL --max-redirs 10 https://example.com/old 2>&1 | grep -E '^< (HTTP|Location)'
# Python: map the full redirect chain
import requests
def trace_redirects(url: str) -> list[dict]:
"""Return every hop in the redirect chain, including the final destination."""
session = requests.Session()
# Disable auto-redirect so we can see each step
response = session.get(url, allow_redirects=False, timeout=10)
chain = []
while response.is_redirect:
chain.append({
"url": url,
"status": response.status_code,
"location": response.headers.get("Location", ""),
})
url = response.headers["Location"]
response = session.get(url, allow_redirects=False, timeout=10)
chain.append({"url": url, "status": response.status_code, "location": None})
return chain
hops = trace_redirects("https://bit.ly/example")
for i, hop in enumerate(hops):
print(f" {i+1}. [{hop['status']}] {hop['url']}")Redirect chain red flags to check: HTTP → HTTPS redirect missing (should 301 to https:// version); www → non-www (or vice versa) adding an extra hop; trailing-slash redirects creating needless chains (/page → /page/); chains longer than 2 hops (should be consolidatable to direct 301 to final URL); 302 where 301 is intended (302 prevents PageRank consolidation).
Status Code Decision Tree for REST API Design
| Scenario | Correct Code | Body | Common Mistake |
|---|---|---|---|
| GET resource found | 200 OK | The resource | — |
| POST resource created | 201 Created | New resource + Location header | Returning 200 instead of 201 |
| DELETE / action with no response body | 204 No Content | Empty body | Returning 200 with empty body |
| Invalid request syntax / malformed JSON | 400 Bad Request | Error detail | Using 422 for parse errors |
| Missing / invalid auth token | 401 Unauthorized | WWW-Authenticate header | Returning 403 for unauthenticated |
| Authenticated but insufficient permissions | 403 Forbidden | Error detail | Returning 401 for permission denied |
| Resource not found | 404 Not Found | Error detail | Returning 200 with null body |
| Conflict (duplicate key, optimistic lock fail) | 409 Conflict | Error detail + current state | Using 400 for all client errors |
| Valid JSON but fails business validation | 422 Unprocessable | Field-level errors | Using 400 for all validation errors |
| Rate limit exceeded | 429 Too Many Requests | Retry-After header | Using 503 for rate limits |
| Unexpected server error | 500 Internal Error | Generic error (no stack traces) | Leaking stack traces to clients |
For a complete reference of all registered status codes including their RFC citations, cacheability, and retry semantics, see BytePane's HTTP status codes complete reference. For the background on designing consistent API error responses, the REST API best practices guide covers error response formatting in detail.
Using Status Code Checks for Uptime Monitoring
HTTP status code checking is the foundation of uptime monitoring. A simple monitoring script that pings endpoints every minute and alerts on non-2xx responses catches most outages. Here is a production-grade implementation pattern:
#!/bin/bash
# Simple uptime monitor — run via cron: */1 * * * * /path/to/monitor.sh
ENDPOINTS=(
"https://api.example.com/health"
"https://app.example.com/"
"https://api.example.com/v1/status"
)
ALERT_EMAIL="[email protected]"
TIMEOUT=10
for url in "${ENDPOINTS[@]}"; do
# -o /dev/null: discard body
# -s: silent mode (no progress bar)
# -w "%{http_code}": write just the status code
# --max-time: total timeout
status=$(curl -o /dev/null -s -w "%{http_code}" --max-time $TIMEOUT "$url")
if [[ "$status" != 2* ]]; then
# Non-2xx response — send alert
echo "ALERT: $url returned HTTP $status at $(date -u)" | mail -s "Uptime Alert: $url" "$ALERT_EMAIL"
logger -t uptime-monitor "FAILED: $url returned HTTP $status"
else
logger -t uptime-monitor "OK: $url returned HTTP $status"
fi
doneFor production monitoring at scale, purpose-built tools (Datadog Synthetics, New Relic Synthetics, Uptime Robot, Better Uptime) run health checks from multiple geographic locations, track response time percentiles, and provide alerting workflows. The core mechanism in all of them is identical to the curl command above — check status code, alert on non-2xx. Understanding the curl approach helps you debug why a monitoring tool is or is not firing when expected.
One critical detail for health check endpoints: they should return 200 only when the service is fully healthy. If your database is down, the health endpoint should return 503 Service Unavailable — not 200. A health check that always returns 200 defeats the entire purpose of uptime monitoring. For the same reason, never use the 200-with-error-body anti-pattern on health endpoints.
Frequently Asked Questions
How do I check the HTTP status code of a URL?
The fastest way: curl -o /dev/null -s -w "%{http_code}\n" https://example.com — outputs just the numeric code. For full headers: curl -I https://example.com. In Python: import requests; print(requests.get(url).status_code). Online tools like httpstatus.io or BytePane's HTTP status codes reference work without installing anything.
What is the difference between 401 and 403?
Per RFC 9110: 401 Unauthorized means the request lacks valid authentication credentials — the server doesn't know who you are. 403 Forbidden means the server knows who you are but you lack permission for this resource. Despite the name, 401 means "unauthenticated" and 403 means "unauthorized." Return 401 when no token is provided or it's expired; return 403 when the token is valid but the user lacks the required role.
When should I use 404 vs 410?
404 Not Found means the resource wasn't found and may exist in the future. 410 Gone means the resource existed and has been permanently deleted — it will never return. Per RFC 9110, 410 tells search engines to immediately deindex the URL, while 404 causes them to retry on future crawls. Use 410 for intentionally removed content (discontinued products, deleted articles) to accelerate Google deindexing.
What is the difference between 301 and 302 redirects?
301 Moved Permanently tells browsers and search engines to permanently update their records. 302 Found is a temporary redirect — browsers re-check the original URL on future requests. For API redirects, use 307 (temporary) or 308 (permanent) instead — they guarantee the HTTP method is preserved. 301 and 302 historically allowed POST to silently become GET on redirect.
Why does my API return 200 but the request is actually failing?
This is the "200 OK with error body" anti-pattern — returning HTTP 200 but including an error in the JSON body. It breaks monitoring tools (which check status codes), HTTP caching (200 responses get cached, errors shouldn't), and client error handling (try/catch on HTTP status won't trigger). Fix: return the appropriate 4xx status code (400 for syntax errors, 422 for validation errors, 402 for payment failures) and include error details in the response body.
How do I check a redirect chain?
Use curl -I -L --max-redirs 10 https://example.com to follow and display headers at each redirect hop. For just the final URL and code: curl -s -o /dev/null -w "%{url_effective} %{http_code}\n" -L https://example.com. Redirect chains longer than 2 hops are worth investigating — each extra hop adds latency and can dilute SEO signals.
What HTTP status code should a health check endpoint return?
Health check endpoints should return 200 only when the service is fully operational. If the database is down or a critical dependency is failing, return 503 Service Unavailable — optionally with a Retry-After header. Never return 200 unconditionally from a health endpoint — monitoring tools check the status code, not the body. A health endpoint that always returns 200 defeats its entire purpose.
HTTP Status Code Reference
Browse the complete list of all HTTP status codes with RFC citations, cacheability, retry semantics, and real-world usage examples — organized by class and sorted by frequency of use.
View HTTP Status Codes →