# badhttp > The server that misbehaves on purpose: a free, stateless catalogue of HTTP edge-case endpoints (slow responses, broken JSON, redirect loops, truncated bodies, flaky failures, SSE streams, range requests, conditional requests, Set-Cookie headers, HTTP authentication and content codings that misbehave) for testing HTTP clients, SDKs and agents, plus x402 paywalls (v2 and v1 in the same responses), one real and several that misbehave. Version 0.27.0. No signup, no API key, nothing stored about callers (the only cross-request state is edge-cached copies of our own on-chain balances and transfer list for /books). This Worker writes no logs and Workers invocation logs are off, but Cloudflare's edge keeps sampled traffic analytics (client IP, path, user agent) as for any Cloudflare zone, so keep secrets out of URLs. Every endpoint is deterministic unless the page says otherwise. Limits: /delay at most 10 s, /drip at most 20 s and 200 chunks, /echo reads the first 16 KB, /redirect at most 10 hops, /sse streams at most 20 s, /range documents at most 1048576 bytes and 8 ranges per request, /cookies at most 20 cookies per response from /cookies/many (the /cookies/delete cleanup sends 45 expirations) and 8192 bytes of cookie name+value (the Set-Cookie header itself runs ~30 bytes longer), /compress documents at most 1048576 bytes and /compress/bomb at most 32 MiB inflated. Redirect targets come from a fixed table of two hosts this project owns, badhttp.dev and alt.badhttp.dev: no endpoint accepts a redirect target, or any part of one, from the caller, so there is no open redirect here. Rate limit: 100 requests per 10 s per IP, enforced at the zone before the Worker runs; Cloudflare answers 429 (text/plain, error code 1015) with Retry-After. One edge block as well, since 2026-10-22: /compress/bomb answers 403 from the edge (not this Worker) to any request whose User-Agent contains "Mozilla/" (case-insensitive since 2026-10-23), because a flood of millions of fake-browser requests a day for the 32 MiB bomb, one or two per address through millions of addresses, made every one a billed Worker invocation (the cost is on https://badhttp.dev/books); and, since 2026-10-23, to any request whose ?size= values are not all among the exact strings 1-8 (an invalid value included) that lacks the header x-badhttp-bomb: accept, whatever its user agent or method, so that a flood which changes its browser string still costs nothing here. The block matches the claimed User-Agent, not the client: a browser, a browser driver, or a tool whose default user agent contains Mozilla/ (yt-dlp's does) must send another one to reach this flavor; clients with their own user agents are served as documented, and send the header for a bomb above 8 MiB. A documented path template requested literally with the braces intact (e.g. https://badhttp.dev/sse/{flavor}) answers 200 with the valid values for the placeholder, any method except OPTIONS. Payment: only https://badhttp.dev/402/pay settles anything (x402, USDC, EIP-3009; price 0.001–1 USD via ?amount=, default 0.01). Its 402 speaks both protocol generations at once: x402 v2 in the PAYMENT-REQUIRED header, and a spec-valid x402 v1 body (plain network names, maxAmountRequired) for older clients that only read bodies; pay with PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) and the receipt arrives in PAYMENT-RESPONSE or X-PAYMENT-RESPONSE respectively. The 402 names Base Sepolia (test USDC, free); https://badhttp.dev/402/pay/base (or ?network=base) is the same paywall for real USDC on Base mainnet. You get a 200 with a receipt and the transaction hash, nothing else; settlement is verified end to end with both client generations on BOTH networks (2026-08-28; the mainnet proof was a project-funded self-test, booked as working capital, not revenue — transaction hashes in GET https://badhttp.dev/402's "verified" object). The first payment from someone else arrived 2026-09-01: 0.01 USDC on Base from nohumans.directory's paying scout, booked as revenue on https://badhttp.dev/books with its transaction hash; a second, anonymous automated payer settled five times on 2026-09-24 (GET https://badhttp.dev/402, verified.second_external_payer). Every settlement is booked with its tx hash. Every other /402 scenario is a paywall that misbehaves on purpose and never charges; do not expect them to succeed. One exception to the dual-form rule: /402/wrong-network's 402 body stays v2-shaped (v1 cannot name its nonexistent chain), and the body's no_v1_form field says why. Operated by an AI under a published charter; every cost and every dollar of revenue is at https://badhttp.dev/books.json. Contact: ops@badhttp.dev (received and read). Support is welcome on the one rail the AI operates end to end: pay https://badhttp.dev/402/pay/base (?amount= up to 1 USD) or send USDC on Base directly to the receive address on https://badhttp.dev/books — either lands in the public chain reconciliation within minutes and is booked as revenue. ## Catalogue - [OpenAPI 3.1](https://badhttp.dev/openapi.json): every endpoint, parameter and response; free operations carry security: [], the paid one carries x-payment-info - [Home page](https://badhttp.dev/): the same catalogue for humans, with curl examples - [Status codes](https://badhttp.dev/status/429?retry-after=3): /status/{code}, or a comma-separated list to pick from at random - [Delay](https://badhttp.dev/delay/2): /delay/{seconds} - [Drip](https://badhttp.dev/drip?duration=5&chunks=10): a slow chunked body - [Flaky](https://badhttp.dev/flaky/50): /flaky/{percent}, deterministic with ?seed=&i= - [Bad JSON](https://badhttp.dev/badjson): /badjson/{flavor}; GET /badjson lists the flavors - [Truncate](https://badhttp.dev/truncate?length=1000&send=500): Content-Length lies, then the connection closes - [Event streams](https://badhttp.dev/sse): /sse/{flavor}; GET /sse lists the flavors; any request with Last-Event-ID gets 204 except /sse/resume; GET /sse also carries a witness block: what real SSE client libraries delivered at every flavor on a dated capture, each row described against a WHATWG parse of the bytes a raw-wire control received, computed from the /clients.jsonl sse rows (family "sse") - [Ranges](https://badhttp.dev/range): /range/{flavor}; resumable downloads that misbehave over a self-describing document (64-byte lines, each starting with its own offset); ?length= up to 1048576 bytes; GET /range also carries a witness block: what real resuming download clients left on disk when told to resume a partial file at every flavor, on a dated capture, each row described against where RFC 9110 places the bytes a raw-wire control received, computed from the /clients.jsonl range rows (family `range`) - [Conditional requests](https://badhttp.dev/etag): /etag/{flavor}; validators that lie, cache-control: no-cache so a real cache revalidates every use; GET /etag also carries a witness block: what real HTTP caches and conditional-request clients sent and handed their callers when fetching each flavor three times through a fresh cache, on a dated capture, each row described against what RFC 9111 implies from the control's exchanges, computed from the /clients.jsonl etag rows (family `etag`) - [Cookies](https://badhttp.dev/cookies): /cookies/{flavor}; Set-Cookie edge cases (folded headers, duplicate names, prefixes, supercookies, unparseable dates); /cookies/echo reads back the Cookie header as it reached the Worker. GET /cookies carries a witness block: what eight real clients' jars did at every setter flavor on a dated capture, computed from the /clients.jsonl cookies rows (family "cookies") - [Authentication](https://badhttp.dev/auth): /auth/{flavor}; challenges and credential checks that misbehave (a 401 with no challenge, unknown schemes, multi-challenge headers, always-reject, accept-anything, the Digest stale dance, a 407 from an origin). The ONLY credentials any checking flavor accepts are the published fake ones (user "agent", password "correct"; Bearer badhttp-token-ok) — NEVER send real credentials or point a credential store at this host; /auth/accept-any returns authenticated:true for ANY value and that answer means nothing. Anything received is compared in memory and discarded, never stored, logged or echoed (the one exception: a successful Digest's Authentication-Info repeats your cnonce and nc, as RFC 7616 §3.5 requires). Any method; the request body is never read. - [Content codings](https://badhttp.dev/compress): /compress/{flavor}; Content-Encoding that misbehaves (gzip declared on plain text, gzip with no header, streams that are truncated, corrupt, CRC-wrong, concatenated, gzipped twice or followed by junk, raw DEFLATE as "deflate", the x-gzip alias, GZIP in capitals, an unknown coding, an empty gzip body, a compressed body under the plaintext's Content-Length, a .gz download also transport-encoded, a declared decompression bomb; br, zstd and deflate; ok is the control; ?code= makes any of them an error body). Send Accept-Encoding: gzip (br, zstd for those) to receive the bytes as sent: Cloudflare's edge removes one coding layer it recognizes (gzip, br, zstd) unless your Accept-Encoding lists it, transcoding to identity, lossily for broken streams, and passes codings it does not know through — the index's edge_transcoding says what each flavor became. x-badhttp-plain-bytes / x-badhttp-plain-sha256 let you verify what you decoded - [Redirects](https://badhttp.dev/redirect/3): /redirect/{hops} and /redirect/loop - [Credentials across a host boundary](https://badhttp.dev/crosshost): /crosshost/{flavor}; what your client does with Authorization, Proxy-Authorization, Cookie and X-Api-Key when a redirect crosses to a DIFFERENT HOST. Two hosts, badhttp.dev and alt.badhttp.dev, same Worker and same zone certificate, so the boundary is real DNS and a real certificate rather than a loopback listener — which matters because you cannot observe this from 127.0.0.1: an SSRF filter rejects a loopback target at hop zero and the code under test never runs. Flavors cross apex-to-subdomain, subdomain-to-apex, out-and-back, http-to-https and https-to-http, plus a cookie-jar scoping hop. THERE IS NO OPEN REDIRECT: targets come from a frozen table of those two hosts selected by flavor name, no endpoint accepts a redirect target or any part of one from the caller, and every Location is checked against a fixed pattern before it is sent. The landing endpoint NEVER echoes what you sent — not the value, not a prefix, not a hash — only presence, the scheme when it is one it recognizes, and a byte length; cookie names are listed only for cookies this server itself set. Use the published fake credentials (agent/correct, Bearer badhttp-token-ok, X-Api-Key badhttp-key-ok) and nothing else. Two flavors do not start on https://badhttp.dev: from-subdomain starts on alt.badhttp.dev and scheme-upgrade starts over plaintext http, because the direction and the scheme are the measurement. Every response carries x-badhttp-version; a response without it is Cloudflare's rate-limit 429, not a badhttp answer, and is not an observation. - [Headers](https://badhttp.dev/headers): your request headers as JSON - [Echo](https://badhttp.dev/echo): method, path, query, headers and the first 16 KB of body, reflected back (POST, PUT, PATCH, DELETE) ## Payment (x402) - [Index](https://badhttp.dev/402): networks, price limits, pay-to address, facilitators, what has been verified live - [Pay](https://badhttp.dev/402/pay): the real paywall, test USDC on Base Sepolia - [Pay on Base mainnet](https://badhttp.dev/402/pay/base): the same paywall for real USDC - [Misbehaving paywalls](https://badhttp.dev/402): /402/never, /402/reject, /402/slow, /402/crash, /402/bad-receipt, /402/overpriced, /402/wrong-network - [Malformed 402s](https://badhttp.dev/402/broken): /402/broken/{flavor} ## Optional - [Books](https://badhttp.dev/books.json): public accounts, machine-readable; the `chain` object reconciles the receive address's live on-chain USDC balance against booked revenue (labeled non-revenue movements excluded) and `chain.transfers` reads the receipt of every transaction the books name from a public RPC and confirms each book row against it (tx, direction, amount) — so a bookkeeping error or an unexplained withdrawal inside a named transaction is visible on the page, and an unbooked payment shows in the balance identity within minutes of arriving, before any human touches the books; the list itself names only what the books name, and says so. The project holds no key that can spend from the address; the operator does. Two figures beside the tables answer questions the books used to leave to assertion: `hosting_measured` and `hosting_usage` publish what this Worker actually consumes against Cloudflare's published allowances (so the reader can see that the $5/mo hosting line is the charter's attribution, what the project would pay standing alone, and what it adds to a bill that already exists — all three), and `solvency` reads the project's payer wallet live and sets it against the next bill, which is the whole self-sustainability question as one number - [Corpus](https://badhttp.dev/corpus.jsonl): the catalogue of defects as one flat NDJSON file, one row per documented defect behaviour (the template explainers and the discovery surfaces are deliberately not rows; `what_is_not_a_row` on the index says so) — url, request headers to send, the defect, the RFCs, whether the bytes are stable enough to pin a digest on, and a ready-to-run capture curl. [Index and capture notes](https://badhttp.dev/corpus). Read the `rfc9112_message_syntax` object before capturing: this service emits NO RFC 9112 message-syntax violations and cannot (a CDN re-serializes every response), so it is not a source of malformed start-lines, malformed field-lines or request-smuggling fixtures; its defects live one layer up - [Client observations](https://badhttp.dev/clients.jsonl): what real HTTP clients actually did, one NDJSON row per observation, seven families so far: six decoding clients and two non-decoding controls against all 21 `/compress` flavors (dated 2026-09-02); eight clients started at each of the nine `/crosshost` flavors, eight boundaries and a same-origin control (dated 2026-09-17) — which of the headers each was sent arrived after the redirect, on which host, over which transport; and the same eight clients handed the documented fake credentials through their own mechanism at each of the 18 `/auth` flavors (dated 2026-09-18) — how the credentials were configured (`mechanism_kind`), how many requests the client made, and what the final /auth response said; and the same eight clients with a fresh jar at each of the 17 `/cookies` setter flavors (dated 2026-09-30) — which planted cookies came back to /cookies/echo, what the jar recorded where it can be enumerated (`jar_entries`), and what survived /cookies/delete; and real SSE client libraries plus a raw-wire control at each of the 14 `/sse` flavors (dated 2026-10-05) — every event delivered, every connection opened, how the stream ended, described against a WHATWG parse of the bytes the control received (`class` names whether the library is an EventSource-interface one that reconnects or a one-shot one); and ten real resuming download clients, each handed a partial file and told to resume it, at each of the 14 `/range` flavors (dated 2026-10-12) — every request sent, what was left on disk, how the client exited, described against where RFC 9110 places the bytes the control received; and 14 real HTTP caches and conditional-request clients, each fetching the flavor three times through a fresh cache, at each of the ten `/etag` flavors (dated 2026-10-12) — every request sent on each fetch and what the caller received, described against what RFC 9111 implies a textbook cache would have sent and shown given what the control's exchanges were. Each row carries the client's version. The only data here this project did not author about itself; `corpus_id` joins each row back to the corpus and `family` names the legend that applies. [Index, per-family outcome legends and the per-flavor disagreement count](https://badhttp.dev/clients). Read `reading_this` first: the `outcome` field describes what the caller received, and is never a verdict on the client - [Health](https://badhttp.dev/health): liveness - [Sitemap](https://badhttp.dev/sitemap.xml) ## Licence Everything this server emits — response heads and bodies, the catalogue documents, every JSON index, /openapi.json, this file — is **CC0-1.0** (public domain dedication): capture it, store it, redistribute it, relicense it, put it in a test corpus under any licence you like. No conditions and no attribution required; a link back is welcome and is not a condition. The Worker source is MIT, which is a different question about a different thing. Full statement, including the parts of a captured response this project did not author and so cannot dedicate: [https://badhttp.dev/license](https://badhttp.dev/license).