BytePane

HTTP Status Code Checker: Test URL Response Codes

HTTP15 min read

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}" URL to 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

curl status code extraction patterns
# 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/health

Python: Programmatic Checking

Python status code checking — single URL and bulk
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

Concurrent URL status checker with timeout handling
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:

ClassRangeMeaningKey CodesCacheable?
1xx Informational100–199Request received, processing continues100 Continue, 101 Switching ProtocolsNo
2xx Success200–299Request was received, understood, and accepted200, 201, 204, 206200, 206 by default
3xx Redirection300–399Client must take additional action301, 302, 304, 307, 308301, 308 by default
4xx Client Error400–499The request contains bad syntax or cannot be fulfilled400, 401, 403, 404, 409, 422, 429404, 410 by default
5xx Server Error500–599The server failed to fulfill a valid request500, 502, 503, 504No (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 access

3. 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-After header)
  • 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.

Diagnosing redirect chains with curl and Python
# 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

ScenarioCorrect CodeBodyCommon Mistake
GET resource found200 OKThe resource
POST resource created201 CreatedNew resource + Location headerReturning 200 instead of 201
DELETE / action with no response body204 No ContentEmpty bodyReturning 200 with empty body
Invalid request syntax / malformed JSON400 Bad RequestError detailUsing 422 for parse errors
Missing / invalid auth token401 UnauthorizedWWW-Authenticate headerReturning 403 for unauthenticated
Authenticated but insufficient permissions403 ForbiddenError detailReturning 401 for permission denied
Resource not found404 Not FoundError detailReturning 200 with null body
Conflict (duplicate key, optimistic lock fail)409 ConflictError detail + current stateUsing 400 for all client errors
Valid JSON but fails business validation422 UnprocessableField-level errorsUsing 400 for all validation errors
Rate limit exceeded429 Too Many RequestsRetry-After headerUsing 503 for rate limits
Unexpected server error500 Internal ErrorGeneric 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:

Minimal uptime monitor with alerting logic
#!/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
done

For 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 →