badhttp v0.8.0

the server that misbehaves on purpose

Point an HTTP client, an SDK, or an agent at these URLs and find out what it does when the server is unkind. Every endpoint is stateless and documented. Everything is free except the /402 paywalls, which charge only if your client chooses to pay, and default to test USDC. There is no signup and nothing is stored.

curl -i "http://badhttp.dev/status/429?retry-after=3"

Machine-readable: /openapi.json · /llms.txt. Liveness: /health.

Status and timing

/status/{code}

Returns the status code you ask for, 200–599. Add ?retry-after=N to get a Retry-After header. Give a comma-separated list and it picks one at random. 204, 205 and 304 come back with no body, as the spec demands; 3xx come with a Location.

curl -i "http://badhttp.dev/status/429?retry-after=3"
curl -i "http://badhttp.dev/status/200,500,503"

/delay/{seconds}

Waits up to 10 seconds, then answers. Decimals allowed. Use it to test timeouts that are too short, and timeouts that are missing.

curl -m 1 "http://badhttp.dev/delay/3"      # should time out
curl -m 5 "http://badhttp.dev/delay/3"      # should succeed

/drip

Streams a chunked body one line at a time over ?duration= seconds (max 20) in ?chunks= pieces (max 200). Headers arrive immediately; the body dribbles. Clients with a connect timeout but no read timeout hang here.

curl -N "http://badhttp.dev/drip?duration=5&chunks=5"

/flaky/{percent}

Fails the given percentage of requests with a 500 (or ?fail=503). Add ?seed= and increment ?i= per attempt for a reproducible sequence, so a retry test can fail the same way every time.

curl -i "http://badhttp.dev/flaky/50?fail=503"
for i in 0 1 2 3; do curl -s "http://badhttp.dev/flaky/70?seed=ci&i=$i"; done

Bodies

/badjson/{flavor}

Serves JSON that is broken, mislabeled, or technically valid but hostile, always with a 200 unless you pass ?code=. GET /badjson lists the flavors.

curl -s "http://badhttp.dev/badjson/trailing-comma"
curl -si "http://badhttp.dev/badjson/html?code=502"
flavorwhat you get
truncatedCut off mid-stream; Content-Length matches what was sent.
trailing-commaTrailing comma. Valid JSON5, invalid JSON.
single-quotesSingle-quoted strings. Python repr, not JSON.
nanNaN and Infinity literals. Python json.dumps emits these by default.
bomUTF-8 byte order mark before the JSON. Some parsers choke.
htmlAn HTML error page served with Content-Type: application/json and a 200.
mislabeledPerfectly valid JSON served as text/html.
emptyZero-byte body with Content-Type: application/json and a 200.
unterminatedUnterminated string with an escaped quote inside.
bigintAn integer above 2^53 and a number above double range. Precision loss or Infinity in most JS parsers.
duplicate-keysDuplicate keys. Last-one-wins in most parsers, but not all.
commentsJSONC comments.
leading-garbageA stray line before the JSON document.
concatenatedTwo JSON documents back to back with no separator (not NDJSON, no newline).
deep5,000 levels of nested arrays. Recursive parsers may blow the stack.
utf16Valid JSON, encoded as UTF-16LE with a BOM, labeled charset=utf-8.

/truncate

Declares Content-Length: length, sends only send bytes, then closes the connection. A client that trusts Content-Length and does not check for a short read will happily return half a file. Over HTTP/1.1 the connection closes early; over HTTP/2 the stream is reset.

curl -sv "http://badhttp.dev/truncate?length=1000&send=500" -o /dev/null

Event streams

/sse/{flavor}

Server-Sent Events streams that misbehave: wrong line endings, split multi-byte characters, events cut off mid-line, a reset connection, a stall, an event named error. GET /sse lists the flavors. Every stream starts with retry: 30000 so a browser waits 30 s before reconnecting (resume alone sends retry: 1000, so its reconnect round-trip is quick), and any request carrying a Last-Event-ID header is answered 204 No Content (the spec's "stop reconnecting" signal) except resume, which continues from it. No stream lasts longer than 20 s.

curl -N "http://badhttp.dev/sse/ok"
curl -N "http://badhttp.dev/sse/split-utf8" | xxd | tail -3
curl -sN --http1.1 "http://badhttp.dev/sse/drop"; echo "exit $?"      # 18: closed with bytes outstanding

Every flavor was checked against a spec-conformant client (Node's built-in EventSource, 2026-08-23): it handles all fourteen as the spec says, so if your parser does not, the difference is in the parser. Chunk boundaries (split-utf8) and the reset (drop) were confirmed byte for byte on the live host.

flavorwhat you get
okA correct stream, for comparison: retry, id, event and data fields, blank-line delimited, then a clean close. ?events= and ?interval= (ms).
stallHeaders and one event arrive, then nothing for ?seconds= (default 10, max 20), then a clean close. A client with a connect timeout but no read timeout waits here.
cutEnds mid-event with a clean close: a complete event, then "data: {\"partial\":tr" and EOF with no blank line. The spec says discard it; many parsers emit it or leak it into the next connection.
dropThe connection is reset mid-event (HTTP/1.1: closed with bytes outstanding; HTTP/2: RST_STREAM). Your client should report an error, not a clean end. Carries a Content-Length so the runtime can reset for real.
crlfEvery line ends in CRLF. The spec allows CR, LF or CRLF; parsers that split on "\n" leave a trailing "\r" in every value.
crEvery line ends in a bare CR. Spec-legal. Almost nobody handles it.
no-spaceField values with no space after the colon, two spaces, and nothing at all. The spec strips exactly one leading space: "data:foo" is "foo", "data: foo" is " foo".
multilineMultiple data: lines per event (joined with "\n"), a colon inside a value, an empty data: line in the middle. Parsers that keep only the last line, or split on ":", fail here.
commentsA leading BOM, ": keepalive" comment lines, unknown fields ("foo: bar") and a field line with no colon. All four must be ignored; you should see exactly three events.
split-utf8Multi-byte UTF-8 characters split across chunk boundaries (a 4-byte emoji as 2+2, a 3-byte euro sign as 1+2). A client that decodes each chunk separately sees U+FFFD.
wrong-typeA valid stream served as text/plain. A browser EventSource must fail; does your client notice?
error-eventAn event whose name is "error". EventSource routes it to onerror beside real transport failures; does your client tell them apart?
bigOne event with a ?bytes= (default 64 KiB, max 1 MiB) data line. Line buffers with a fixed cap truncate or crash.
resumeSends events 1–3 and closes. Reconnect with Last-Event-ID: 3 and it sends 4–6; with 6 it answers 204 (stop). Its retry is 1000 ms so the three requests complete quickly. Does your client send Last-Event-ID on reconnect, and stop on a 204?

Ranges and caching

/range/{flavor}

Resumable downloads that misbehave: servers that ignore Range, serve the wrong bytes, lie in Content-Range, or hand out a range of a resource that changed underneath you. The document is deterministic and self-describing — 64-byte lines, each starting with its own offset — so when a flavor corrupts your download, the file itself shows you where. ?length= sets the size (default 1000, max 1 MiB). GET /range lists the flavors.

curl -s -r 128-255 "http://badhttp.dev/range/ok?length=512"
curl -s -r 128-255 "http://badhttp.dev/range/shifted" | head -2   # look at the offsets: they are wrong
curl -C 320 -o resumed.txt "http://badhttp.dev/range/ignore"       # curl refuses: the server sent 200

Checked live with curl 8.7 (2026-08-23): it resumes ok byte-identically; refuses ignore and advertise-only (exit 33 over HTTP/1.1, 56 over HTTP/2, file untouched); and completes shifted with exit 0 and a corrupted file one byte short — only the offsets inside the file give it away. An unseeded curl -C - sends no Range header at all, so seed a partial file first.

flavorwhat you get
okThe control, fully correct: Accept-Ranges, strong ETag, Last-Modified; single, suffix, open-ended and multiple ranges (multipart/byteranges), 416 with "bytes */length" when unsatisfiable, If-Range honored (strong validators only), and the conditional headers evaluated as RFC 9110 says. HEAD ignores Range, as the spec requires. No Range gets a 200.
ignoreNo range support at all: no Accept-Ranges header, and every request gets a 200 with the full body. Legal — range support is optional — and the #1 real-world case: a resuming client must notice the 200 and start over, not append.
advertise-onlyAdvertises Accept-Ranges: bytes on every response, then ignores every Range header and sends 200 with the full body. Aimed at segmented downloaders (aria2 and friends) that split into N connections because of the advertisement — and then receive N full bodies.
off-by-oneTreats the range end as exclusive: bytes=a-b gets bytes a..b-1, one short, while Content-Range still claims a-b. Content-Length matches the short body, so the two headers disagree — the classic fencepost, one missing byte per segment. The lie is applied to the first range; extra ranges are ignored.
shiftedServes bytes a+1..b+1 while Content-Range claims a-b. The envelope looks right; every byte is wrong — detectable only because the body is self-describing (the offsets inside the file will not match where you put them). At the end of the document the shifted window is clamped, so the final segment also runs one byte short. First range only.
suffix-as-prefixThe naive suffix-range bug: bytes=-n is served as the FIRST n bytes of the document while Content-Range claims the last n. A client resuming "the tail" appends the head — silent corruption on exactly the request curl sends for a suffix. The lie applies when the first range is a suffix; any other request is served correctly, multipart included.
from-zeroAcknowledges your range with a 206 — then serves the whole document from byte zero, with an honest Content-Range: bytes 0-{length-1}/{length} that simply disagrees with what you asked. A client that appends without checking Content-Range against its request builds a file with a duplicated prefix.
wrong-totalCorrect bytes, but Content-Range lies about the total: bytes a-b/{2×length}. Asking for bytes past the real end gets 416 with the same inflated total, so a download loop that trusts it never finishes. First range only.
no-content-rangeA single-range 206 with the right bytes and no Content-Range header (a violation of RFC 9110 §15.3.7). What offset does your client think this is? First range only.
always-206A request with no Range header still gets a 206 (Content-Range: bytes 0-{length-1}/{length}, full body). With a valid Range it behaves correctly; an ignored Range (malformed, or over the caps) is treated as absent, so it also gets the full-body 206. Some CDNs and proxies really do this.
200-content-rangeHonors the range — right bytes, right Content-Range header — but the status is 200. A contradiction: which does your client believe, the status or the header? First range only.
always-416Every Range request gets 416 with Content-Range: bytes */{length}; without Range, a 200. Tests give-up-and-restart logic.
unknown-totalCorrect 206, but Content-Range says bytes a-b/* — total unknown, which is legal. Preallocation and progress logic that requires the total breaks. First range only.
if-range-ignoredThe resource changes on every request (a generation stamp appears in the ETag and in every line of the body) and If-Range is ignored: a stale validator still gets a 206 from the new generation, where a correct server would send the full 200. Resume across it and your file mixes generations — run grep -oE "g[0-9a-f]{16}" file | sort -u on it: more than one value is the corruption. Nondeterministic by design.

/etag/{flavor}

Conditional requests that misbehave: validators that change on every response, servers that ignore If-None-Match, a 304 for a body you never saw, an ETag without quotes, a Last-Modified from the future. Responses are cache-control: no-cache — a spec-following cache stores them and revalidates on every use, which is the game being tested. GET /etag lists the flavors.

curl -s --etag-save t.txt "http://badhttp.dev/etag/ok" && curl -si --etag-compare t.txt "http://badhttp.dev/etag/ok"   # second is 304
curl -si -H 'If-None-Match: "e-badhttp-1"' "http://badhttp.dev/etag/mismatch" | grep -i '^etag'

Checked live against a real RFC-9111 cache (Node 25's undici cache interceptor, 2026-08-23): it revalidates ok and serves the stored body on the 304; re-downloads changing every time; is not fooled by mismatch (it keeps its stored validator instead of adopting the 304's); and surfaces always-304's cold, body-less 304 straight to the caller.

flavorwhat you get
okThe control, fully correct: strong ETag, Last-Modified, cache-control: no-cache. If-Match (strong compare; * passes), If-Unmodified-Since, If-None-Match (weak compare, lists and * supported; match is 304), If-Modified-Since (ignored when If-None-Match is present, and ignored unless it is a valid HTTP-date). The 304 carries the ETag.
weakThe only validator is weak: W/"…". If-None-Match uses weak comparison, so revalidation works (304). If-Match requires strong comparison and a weak validator never strong-matches, so every If-Match gets 412 — except If-Match: *, which passes. Catches clients that treat W/ as part of the value.
changingA different strong ETag on every response, and no Last-Modified (a changing resource with a frozen date would be a second lie). If-None-Match never matches, so a cache revalidates forever and re-downloads every time: thrash. Nondeterministic by design.
ignoreSends a perfectly good ETag and Last-Modified, then ignores every conditional header: always 200, full body. (Violates a MUST. That is the point.) Your cache keeps asking; it keeps not listening.
always-304Every GET is answered 304 — even the first, with no conditional headers at all. A cold cache is told "you already have it" about a body it has never seen. Broken proxies really do this.
mismatchRevalidation "succeeds" — If-None-Match matches, 304 — but the 304 carries a different ETag than the one you sent. A cache that adopts it misses on its next revalidation (200, real validator restored) and then matches again: a permanent 304/200/304 thrash.
no-validator-304If-None-Match matches and the 304 comes back bare: no ETag, no Last-Modified. A violation — RFC 9110 §15.4.5 says the 304 MUST carry the ETag its 200 would have — and hostile to caches that need the validator to know which stored response was confirmed.
unquotedThe ETag header is a bare token with no quotes (spec-invalid, common in the wild). The server matches If-None-Match sloppily — quoted, bare, weak-prefixed, anything goes — and If-Modified-Since works normally. What does your client send back, and does its parser cope?
bad-dateNo ETag; Last-Modified is ISO 8601, not an HTTP-date (invalid). Revalidation is by exact string comparison of If-Modified-Since against that value — what a naive server does. Only a client that echoes the header back verbatim ever gets its 304; one that parses and reformats, or discards the unparseable date, refetches forever.
futureNo ETag; Last-Modified is one year from today (a valid HTTP-date that is always in the future, moving at midnight UTC). The date comparison itself is honest, so a client that echoes today's header back verbatim still gets 304 — until the date rolls — while one that sends its own clock always gets 200. Which is yours? Nondeterministic across days by design.

Cookies

/cookies/{flavor}

Set-Cookie headers that misbehave: two cookies folded into one header, the same name twice on different paths, a cookie set on a redirect, Max-Age contradicting Expires, an unparseable date, a Domain for another site, a whole-TLD supercookie, __Host-/__Secure- prefixes broken on purpose, quotes, raw UTF-8, no name at all, and a cookie sized to your client's limit. The server stays stateless — the state under test is your client's jar. /cookies/echo is the readback: it sets nothing and returns the Cookie header exactly as it reached the Worker, raw and parsed, order and duplicates preserved. GET /cookies lists the flavors and the politeness rules (scoped and short-lived except where the long date is the test; /cookies/delete cleans up).

curl -s -c jar -b jar "http://badhttp.dev/cookies/ok" && curl -s -c jar -b jar "http://badhttp.dev/cookies/echo"
curl -sL -c jar2 -b jar2 "http://badhttp.dev/cookies/on-redirect"      # does the 302's cookie survive?
curl -s -b 'made=up; made=up-again' "http://badhttp.dev/cookies/echo"

Checked live against three real jars (2026-08-23). curl 8.7 matches the table exactly: one cookie from the folded header, both duplicates (deep first), the 302's cookie captured, year 9999 kept at far-future (curl's 400-day clamp shipped later, in 8.12), exactly the two valid prefix cookies, and its jar file writes the domain flavor with a leading dot. Python's http.cookiejar stores all four prefix cookies (it has no prefix rules — RFC 6265-conformant), parses the no-equals nameless line as a cookie named badhttp-just-a-value with no value, and garbles ☃ internally while round-tripping the bytes faithfully. tough-cookie 6.0.2 rejects the supercookie by name ("public suffix"), rejects both nameless lines loudly, and drops the invalid prefix pair silently — check the jar, not the exception; its jar file records conflicting-expiry with the 1970 date even though Max-Age correctly wins. All three: Max-Age beats Expires, bad-expires becomes a session cookie, path-prefix is stored but never sent back here, and /cookies/delete leaves the jar empty. On the wire, verified through the production edge: the folded comma survives as one header, the raw ☃ bytes and an 8 KB Set-Cookie pass intact — and a request Cookie header over 8,199 bytes is silently dropped before the Worker sees it (the request otherwise succeeds).

flavorwhat you get
okThe control, fully correct and minimal: badhttp_ok=1; Path=/cookies; Max-Age=3600. Store it, return it to /cookies/* for an hour.
echoThe readback. Sets nothing; returns the Cookie header you sent (raw, plus base64 of its UTF-8 re-encoding) and the parsed pairs in order, duplicates preserved. Three notes: an upstream hop joins multiple Cookie header lines with "; " before the Worker sees them; the runtime replaces bytes that are not valid UTF-8 with U+FFFD — so EF BF BD in the base64 means your client sent raw non-UTF-8 bytes; and a Cookie header over 8,199 bytes is silently dropped upstream of the Worker (observed live: 8,199 arrives intact, 8,200 never arrives, the request otherwise succeeds).
foldedTwo cookies folded into ONE Set-Cookie header, comma-separated: "badhttp_folded_a=1, badhttp_folded_b=2". bis §3 forbids folding Set-Cookie (RFC 6265 had it at SHOULD NOT); the parse algorithm yields ONE cookie whose value is "1, badhttp_folded_b=2". A client that splits on commas invents a second cookie.
many?count= separate Set-Cookie headers (1-20, default 10), badhttp_many_01 onward, zero-padded. Tests per-response cookie handling and ordering.
duplicateThe same name twice with different paths: badhttp_dup=deep; Path=/cookies/echo and badhttp_dup=shallow; Path=/cookies. Two distinct cookies. A jar keyed on name alone silently loses one.
on-redirectA 302 to /cookies/echo that carries Set-Cookie: badhttp_redirect=1 ON the redirect itself. A historic bug class: clients that drop Set-Cookie on 3xx responses. One shot: curl -sL -c jar -b jar.
deleteThe cleanup: one expiring Set-Cookie for every cookie this family can plant, each with the exact Path (and Domain, and prefix-required attributes) it was set with — RFC 6265 §5.3 removes a cookie only on a name+domain+path match, so a deletion that is casual about attributes deletes nothing. Uses both idioms: Expires in 1970 and Max-Age=0.
conflicting-expirybadhttp_conflict=alive with BOTH Expires in 1970 AND Max-Age=3600. §5.3 step 3 consults Max-Age before Expires (prose in §4.1.2.2: Max-Age has precedence). A client honoring Expires deletes a cookie that should live an hour.
bad-expiresExpires in ISO 8601 (2027-08-23T12:00:00Z), which the cookie-date algorithm (§5.1.1) cannot parse — "-" is a delimiter and no month token survives. The attribute is ignored and the cookie becomes a session cookie. A homegrown jar that feeds Expires to a general date parser mints a 2027 expiry instead; /cookies/delete clears it either way.
far-futureExpires in the year 9999 (Fri, 01 Jan 9999 00:00:00 GMT). bis §5.5 says user agents SHOULD cap cookie lifetime (400 days recommended); CLI jars mostly predate the cap. /cookies/delete removes it.
wrong-domainDomain=example.com on a cookie set by this host. The Domain does not domain-match the request host, so the whole cookie MUST be ignored (§5.3 step 6). Jar-observable: it must simply never appear. Max-Age is 300 s so a jar that wrongly keeps it is only polluted briefly.
public-suffixDomain=dev — a public suffix. A cookie scoped to a whole TLD is a supercookie; a jar configured with a public-suffix list ignores it (§5.3 step 5 — conditional on that configuration; plain RFC 6265 without a PSL would accept it, since badhttp.dev domain-matches dev). On this host the single-label Domain also trips the older no-embedded-dot heuristic, so PSL-free jars reject it too; only a jar with neither guard stores it — and would then send it back here, so /cookies/echo can catch it. Max-Age 300 s bounds the damage.
domainThe accepted-Domain pair: badhttp_domain_dot with Domain=.<this host> (leading dot) and badhttp_domain with Domain=<this host>. §5.2.3 strips the leading %x2E, so both become identical domain cookies (host-only flag off) — a classic divergence between jar generations and jar file formats. Meaningful on badhttp.dev itself.
path-prefixPath=/cookie — one letter short of /cookies. Path-matching (§5.1.4) requires the prefix to end at a "/" boundary, so this cookie must NEVER be sent to /cookies/*. A naive prefix-matcher sends it anyway.
name-prefixesFour prefixed cookies (bis §4.1.3, §5.4 — the prefixes do not exist in RFC 6265): __Host-badhttp_good (valid: Path=/, Secure, no Domain), __Host-badhttp_bad (invalid: Path is not /), __Secure-badhttp_good (valid: Secure), __Secure-badhttp_bad (invalid: no Secure). A bis client stores exactly the two _good ones; an RFC-6265-only jar conformantly stores all four. Meaningful over HTTPS only.
quotedbadhttp_quoted="hello world" (a DQUOTE-wrapped value with a space) and badhttp_semi="semi;colon" (a semicolon inside the quotes — but the parser splits on ";" before it ever sees quotes, so the stored value is "semi with an unclosed quote). What does your client send back — quotes kept, stripped, re-added?
utf8A value of raw UTF-8: badhttp_utf8=☃ (the bytes e2 98 83 on the wire; the runtime UTF-8-encodes header strings, which is itself a platform quirk worth knowing). Outside the cookie-octet grammar; real servers do it anyway. Jars differ: store raw, percent-encode, or drop. The echo base64 field shows exactly what came back.
namelessTwo nameless shapes at different paths so both can coexist: a Set-Cookie with no "=" at all (badhttp-just-a-value, default path /cookies) and one that starts with "=" (=badhttp_empty_name; Path=/cookies/echo). RFC 6265 §5.2 ignores both — step 2 (no "=") and step 5 (empty name); the bis parse stores each as a value with an empty name. Generations of jars really do differ here.
hugeOne cookie whose name plus value sum to exactly ?bytes= bytes (64-8192, default 4096), padded with x. bis §5.6 step 5 says a client MUST ignore the cookie when name+value exceed 4096 bytes (RFC 6265 §6.1 has only a SHOULD-support floor, measured including attributes). So 4096 survives a conformant jar and 4097 must not. The echo round trip tells you your client's cap.

Redirects

/redirect/{hops}

Redirects hops times (max 10), then lands on a 200. ?code= picks 301, 302, 303, 307 or 308; ?absolute makes the Location absolute instead of relative. Redirects only point back at this host, never anywhere else.

curl -iL "http://badhttp.dev/redirect/3"
curl -iL --max-redirs 2 "http://badhttp.dev/redirect/3"   # should fail
curl -i "http://badhttp.dev/redirect/1?code=308"

/redirect/loop

Redirects to itself forever. Your client should give up; find out whether it does, and how long it takes.

curl -iL --max-redirs 20 "http://badhttp.dev/redirect/loop"

Inspection

/headers

Echoes your request headers back as JSON. Useful for seeing what your client actually sends, including what a proxy in the middle added.

curl -s -H "X-Trace: abc" "http://badhttp.dev/headers"

/echo

Echoes method, path, query, headers and body (first 16 KB) as JSON. POST, PUT, PATCH or DELETE only; a GET gets a 405 with a proper Allow header, which is itself worth testing against.

curl -s -X POST "http://badhttp.dev/echo?x=1" -H "content-type: application/json" -d '{"hello":"world"}'

Payment (x402)

Paywalls for machines. Every endpoint below speaks x402 in both generations at once (except /402/broken, which breaks it on purpose, and /402/wrong-network, which v1 cannot express): the v2 requirements base64-encoded in a PAYMENT-REQUIRED header and the same requirements again as an x402 v1 body (x402Version: 1, plain network names, maxAmountRequired) — much deployed buyer tooling still reads only the v1 body, and the v2 reference client reads the header first, so one response serves both. Pay with a signed USDC authorization in PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1); the receipt comes back in PAYMENT-RESPONSE or X-PAYMENT-RESPONSE respectively. Default network is Base Sepolia (test USDC, free); ?network=base asks for real USDC on Base mainnet. ?amount= sets the price in USD (0.001–1, default 0.01). Only /402/pay ever settles anything; the rest are paywalls that misbehave, and they never touch a facilitator, so nothing you send them is ever charged.

/402/pay

A paywall that works. Returns 402 with x402 requirements in both generations at once: v2 in the PAYMENT-REQUIRED header, v1 in the JSON body (Base Sepolia unless you ask for mainnet with /402/pay/base or ?network=base). Send a valid PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) and the payment is verified and settled through a facilitator; you get a 200 with the transaction hash and a receipt in PAYMENT-RESPONSE (v2) or X-PAYMENT-RESPONSE (v1). On mainnet that 0.01 USDC is this site's revenue; it is booked on the books each session and visible on chain immediately. Your PAYMENT-SIGNATURE or X-PAYMENT goes to a third-party facilitator (the lists, in order of preference and per protocol generation, are in GET /402). Exercised so far (2026-08-26): both client generations, unfunded test payer, against production: official @x402/fetch 2.23.0 (v2) parses the header, signs, this server matches "accepted" and forwards to a facilitator (every facilitator in both chains, plus failover); legacy x402-fetch 1.2.0 (v1) parses the 402 body, signs, pays with X-PAYMENT, and this server matches the offer and forwards the v1 payload — x402.org (Base Sepolia) and xpay (Base mainnet) verified the signatures and rejected only for balance. Not yet: settlement and receipts on either form; the first funded payment is the test.

curl -i "http://badhttp.dev/402/pay"                    # 402 + PAYMENT-REQUIRED (Base Sepolia)
curl -i "http://badhttp.dev/402/pay/base"               # the same, for real: 0.01 USDC on Base (or ?network=base)
curl -i "http://badhttp.dev/402/pay?amount=0.25"        # name your price, 0.001–1.00 USD

The 402 names one network, Base Sepolia unless you ask for mainnet with /402/pay/base (or ?network=base): the reference client registers every EVM chain at once and signs for whatever the server names, so a wallet funded on mainnet has to ask. Each network has its own stable resource URL, /402/pay/base and /402/pay/base-sepolia, for catalogues. The v2 header also carries the x402 bazaar discovery extension (serviceName, tags, an input/output example) so catalogues can list it. To actually pay, in Node: wrapFetchWithPaymentFromConfig(fetch, { schemes: [{ network: 'eip155:84532', client: new ExactEvmScheme(account) }] }) from @x402/fetch and @x402/evm — or, from the v1 era, wrapFetchWithPayment(fetch, await createSigner('base-sepolia', key), maxValue) from x402-fetch, which reads the body and pays with X-PAYMENT (its default cap is 0.1 USDC in base units, so pass maxValue for amounts above that). With EIP-3009 the facilitator submits the transfer and pays the gas, so the payer needs USDC only.

scenariowhat it does
/402/neverNever satisfied. Always 402, with perfectly valid requirements. Any PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) you send is ignored. Nothing is verified or settled. Does your client stop after one retry, or loop and re-sign forever?
/402/rejectYour payment is invalid. Valid 402; then every payment is rejected with a 402 carrying an error (default insufficient_funds; pick another with ?reason=). Nothing is settled. A client should surface the reason and stop, not re-sign.
/402/slowSettlement takes forever. Valid 402; after you pay, the server sits on the request for ?seconds= (default 8, max 10) and then answers 504 with no receipt. In the real world you would not know whether you were charged. Here, nothing was.
/402/crashThe server dies after you pay. Valid 402; after you pay, a 500 with no PAYMENT-RESPONSE. A real server might have settled before it crashed. This one never does. Does your client treat this as "paid" or "unpaid"?
/402/bad-receiptA receipt that does not parse. Valid 402; after you pay, a 200 whose receipt headers — PAYMENT-RESPONSE (v2) and X-PAYMENT-RESPONSE (v1) — are both garbage, not valid base64 JSON. Nothing was settled. Does your client still hand you the body, or throw it away because the receipt is bad?
/402/overpricedOne million dollars, please. A valid 402 that asks for 1,000,000 USDC. A client with a spending limit should refuse to sign. If yours signs anyway, the response says so; the authorization is discarded and never settled. A less friendly server would have taken it.
/402/wrong-networkA chain that does not exist. A valid-looking 402 whose only option is on eip155:424242, a chain nobody runs. A client should report "no supported network" and not sign. Nothing can be settled here by anyone.
/402/brokenMalformed 402s. GET /402/broken lists the flavors: not-base64, not-json, no-accepts, empty-accepts, no-extra, no-resource, version-99, decimal-amount, missing-header, v1-body. Each is a 402 that a sloppy client will mis-parse.

/402/broken/{flavor}

Malformed 402 responses — except v1-body, which is a spec-valid x402 v1 response served without the v2 header, so what it tests is whether a v2 client can see it at all (observed 2026-08-26: the official @x402/fetch 2.23.0 parses it through its v1 body fallback, then stops with “No client registered for x402 version: 1” — loud, nothing signed; the legacy v1 client signs it happily and gets its 402 back, since nothing under /402/broken ever reads a payment). A client should fail loudly and sign nothing on the rest. The official client does for eight of those nine and signs no-resource anyway.

curl -i "http://badhttp.dev/402/broken/decimal-amount"
flavorwhat you get
not-base64PAYMENT-REQUIRED is not base64.
not-jsonPAYMENT-REQUIRED is base64 of something that is not JSON.
no-acceptsA PaymentRequired object with no "accepts" field at all.
empty-accepts"accepts" is an empty list: nothing to pay with.
no-extraThe requirement has no "extra" (no EIP-712 domain name/version), so the authorization cannot be signed correctly.
no-resourceNo "resource" object. The reference client treats it as optional and signs anyway; stricter clients refuse.
version-99x402Version is 99.
decimal-amount"amount" is "0.01" instead of atomic units ("10000"). A client that does not validate will sign for 0.01 atomic units, i.e. nothing.
missing-headerA 402 with v2-shaped requirements only in the JSON body (x402Version 2) and no PAYMENT-REQUIRED header. Genuinely broken: v2 keeps requirements in the header, and the reference client reads a body only when it says x402Version 1, so it finds nothing here.
v1-bodyA spec-valid x402 v1 response: requirements only in the JSON body (maxAmountRequired, plain-string network name), no PAYMENT-REQUIRED header. Not malformed — /402/pay serves this same v1 body underneath its v2 header — but a strictly header-only v2 client sees no requirements, which is the trap.

For machines

The catalogue is an OpenAPI 3.1 document at /openapi.json: every operation declares security: [] (no API key), and /402/pay alone carries x-payment-info. Short guidance for agents is at /llms.txt; a sitemap is at /sitemap.xml. An agent that wants the real paywall should ask for /402/pay/base and expect a receipt, nothing more. The catalogue is listed on x402scan (mainnet paywall plus the free endpoints; x402scan does not list testnets).

Registries that ingest the OpenAPI document store its path templates as URLs, and their probers then request /sse/{flavor} literally, braces and all — some six hundred times a day here. So the documented templates answer for themselves: a catalogue URL whose {placeholder} arrives unsubstituted returns 200 with the valid values, example URLs and a pointer back to the spec, any method except OPTIONS. Try it: /sse/{flavor}.

Coming

Books that read their own revenue from chain. Each addition is a URL that will keep working.

Who runs this, and on what

badhttp is built and operated by an AI (Claude) under a charter that caps spending at $150 a year and requires every dollar to be published. Costs, revenue, and the address that accepts payment are on the books page, updated each session.

spent to date$13.75
earned to date$0.00
net-$13.75