httptest

An httpbin-compatible HTTP request & response service, plus a browser explorer for firing at it. Point a client, test suite, or CI job at it when you need an endpoint that reflects your request back, forces a status code, challenges for auth, stalls on purpose, or hands you junk bytes.

It exists because httpbin.org has a habit of being down.

Base URL

https://hypothesis.sh/api/httptest

Every endpoint hangs off that prefix, so migrating from httpbin is a find-and-replace:

- curl https://httpbin.org/get
+ curl https://hypothesis.sh/api/httptest/get

Conventions

These hold across every endpoint unless noted otherwise.

CORSWide open — Access-Control-Allow-Origin: *, all methods, all headers. Browser clients work with no proxy. OPTIONS preflight returns 204.
CachingCache-Control: no-store. A cached answer would defeat the point. (One exception: /response-headers lets you overwrite it — that's the endpoint's job.)
Wrong method405 with an Allow header listing what's accepted.
Unknown path404 with a JSON error.
Body limit1 MB. Larger returns 413.

Error bodies are always JSON:

curl https://hypothesis.sh/api/httptest/post          # GET on a POST-only endpoint
{ "error": "Method not allowed. Try POST." }
curl https://hypothesis.sh/api/httptest/nope
{ "error": "No such endpoint: /api/httptest/nope" }

The explorer

The `/httptest` page lists every endpoint and fires real requests from your browser — status, timing, response headers, and body. Pick from the catalog or type any path. POST/PUT/PATCH/DELETE reveal a body field sent as application/json. Binary responses are summarized by length rather than rendered as mojibake.

The current method and path live in the URL, so a specific request is shareable:

/httptest?path=/status/418
/httptest?path=/anything/test&method=POST

Request methods

GET /get

Reflects the query args, request headers, caller IP, and full URL.

curl 'https://hypothesis.sh/api/httptest/get?foo=bar&foo=baz'
{
  "args": { "foo": ["bar", "baz"] },
  "headers": {
    "Host": "hypothesis.sh",
    "User-Agent": "curl/8.7.1",
    "Accept": "*/*"
  },
  "origin": "203.0.113.7",
  "url": "https://hypothesis.sh/api/httptest/get?foo=bar&foo=baz"
}

A param given once is a string; repeated params become an array — the quickest way to check how your client serializes lists.

POST /post · PUT /put · PATCH /patch · DELETE /delete

Everything /get returns, plus four views of the request body: data, form, json, files.

A JSON body populates data (raw) and json (parsed):

curl -X POST -H 'Content-Type: application/json' \
     -d '{"name":"ada","id":42}' \
     https://hypothesis.sh/api/httptest/post
{
  "args": {},
  "headers": { "Content-Type": "application/json", "Content-Length": "22" },
  "origin": "203.0.113.7",
  "url": "https://hypothesis.sh/api/httptest/post",
  "data": "{\"name\":\"ada\",\"id\":42}",
  "form": {},
  "json": { "name": "ada", "id": 42 },
  "files": {}
}

A form body populates form and leaves data empty — repeats become arrays here too:

curl -X POST -d 'x=1&x=2&y=3' https://hypothesis.sh/api/httptest/post
{
  "data": "",
  "form": { "x": ["1", "2"], "y": "3" },
  "json": null,
  "files": {}
}

Unparseable JSON is not an error — you get the raw data and a null json, so you can see exactly what you sent:

curl -X POST -H 'Content-Type: application/json' -d '{oops' \
     https://hypothesis.sh/api/httptest/post
{ "data": "{oops", "json": null }

/put, /patch, and /delete behave identically to /post — only the method changes.

multipart/form-data is not decoded — it lands in data as raw text and files stays empty. This is the one deliberate parity gap; see Differences.

Request inspection

GET /headers

Just the request headers, title-cased the way httpbin reports them.

curl -H 'X-Request-Id: abc-123' https://hypothesis.sh/api/httptest/headers
{
  "headers": {
    "Host": "hypothesis.sh",
    "User-Agent": "curl/8.7.1",
    "Accept": "*/*",
    "X-Request-Id": "abc-123"
  }
}

GET /ip

curl https://hypothesis.sh/api/httptest/ip
{ "origin": "203.0.113.7" }

GET /user-agent

curl -A 'my-client/2.1' https://hypothesis.sh/api/httptest/user-agent
{ "user-agent": "my-client/2.1" }

/anything/* — any method, any path

The catch-all. Accepts GET, POST, PUT, PATCH, DELETE on any trailing path, and adds method to the full reflection. Reach for it when you want one endpoint that never 405s.

curl -X PATCH -H 'Content-Type: application/json' -d '{"k":"v"}' \
     https://hypothesis.sh/api/httptest/anything/foo/bar
{
  "args": {},
  "headers": { "Content-Type": "application/json" },
  "origin": "203.0.113.7",
  "url": "https://hypothesis.sh/api/httptest/anything/foo/bar",
  "data": "{\"k\":\"v\"}",
  "form": {},
  "json": { "k": "v" },
  "files": {},
  "method": "PATCH"
}

Status codes

/status/:codes — all methods

Responds with the status you name. The body is empty; the status line is the payload.

curl -i https://hypothesis.sh/api/httptest/status/418
HTTP/1.1 418 I'm a Teapot

Give a comma-separated list to have one picked at random — useful for exercising retry logic against a flaky-looking endpoint:

curl -o /dev/null -w '%{http_code}\n' \
     https://hypothesis.sh/api/httptest/status/200,200,500

Accepts 100599. Anything else — /status/999, /status/abc — is a 400, not a clamp: answering with a status you never asked for is worse than an error.

Response formats

GET /json

A fixed sample document, identical to httpbin's.

curl https://hypothesis.sh/api/httptest/json
{
  "slideshow": {
    "author": "Yours Truly",
    "date": "date of publication",
    "slides": [
      { "title": "Wake up to WonderWidgets!", "type": "all" },
      {
        "items": [
          "Why <em>WonderWidgets</em> are great",
          "Who <em>buys</em> WonderWidgets"
        ],
        "title": "Overview",
        "type": "all"
      }
    ],
    "title": "Sample Slide Show"
  }
}

GET /html

A small HTML document (text/html; charset=utf-8) — a Moby-Dick excerpt.

curl https://hypothesis.sh/api/httptest/html
<!DOCTYPE html>
<html>
  <head>
    <title>Sample HTML</title>
  </head>
  ...
</html>

GET /xml

A sample slideshow document (application/xml).

curl https://hypothesis.sh/api/httptest/xml
<?xml version='1.0' encoding='us-ascii'?>
<slideshow title="Sample Slide Show" date="date of publication" author="Yours Truly">
  <slide type="all">
    <title>Wake up to WonderWidgets!</title>
  </slide>
  ...
</slideshow>

GET /uuid

A fresh UUID v4 on every call.

curl https://hypothesis.sh/api/httptest/uuid
{ "uuid": "98a87d14-2c69-44d3-b66f-61df3a1ad239" }

Dynamic data

/delay/:n — all methods

Responds after n seconds with the /get shape. Max 10 seconds; higher values clamp rather than error.

curl -o /dev/null -w '%{http_code} in %{time_total}s\n' \
     https://hypothesis.sh/api/httptest/delay/2
# 200 in 2.003790s

The obvious use — prove your client's timeout actually fires:

curl --max-time 1 https://hypothesis.sh/api/httptest/delay/5
# curl: (28) Operation timed out after 1000 milliseconds

GET /bytes/:n

n random bytes as application/octet-stream. Max 100 KB, clamped.

curl -s https://hypothesis.sh/api/httptest/bytes/16 | xxd
# 00000000: c33e 4697 d89b ba4f bd72 d829 b0ba f22b  .>F....O.r.)...+

GET /stream/:n

n newline-delimited JSON objects, each the /get reflection plus an incrementing id. Max 100 lines, clamped. Good for exercising a streaming/NDJSON parser.

curl -s https://hypothesis.sh/api/httptest/stream/3
{"args":{},"headers":{...},"origin":"203.0.113.7","url":"...","id":0}
{"args":{},"headers":{...},"origin":"203.0.113.7","url":"...","id":1}
{"args":{},"headers":{...},"origin":"203.0.113.7","url":"...","id":2}
The response is application/json but is not a single JSON document — parse it line by line.

Redirects

GET /redirect/:n

Redirects n times with 302, landing on /get. Max 10 hops, clamped.

curl -i https://hypothesis.sh/api/httptest/redirect/2
HTTP/1.1 302 Found
Location: /api/httptest/redirect/1
curl -sL -o /dev/null -w '%{num_redirects} hops -> %{url_effective}\n' \
     https://hypothesis.sh/api/httptest/redirect/3
# 3 hops -> https://hypothesis.sh/api/httptest/get

Use it to check your client actually caps redirect chains:

curl -sL --max-redirs 2 -o /dev/null https://hypothesis.sh/api/httptest/redirect/5
# curl: (47) Maximum (2) redirects followed

Auth

Nothing here is a real secret — the expected credentials arrive in the request URL. These exist to exercise a client's auth handling, not to protect anything.

GET /basic-auth/:user/:passwd

Challenges with Basic auth. Wrong or missing credentials get a 401:

curl -i https://hypothesis.sh/api/httptest/basic-auth/user/passwd
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Fake Realm"

Correct credentials get a 200:

curl -u user:passwd https://hypothesis.sh/api/httptest/basic-auth/user/passwd
{ "authenticated": true, "user": "user" }

Any user/password pair works — they're taken from the path, so /basic-auth/ada/hunter2 expects ada:hunter2.

GET /bearer

Any non-empty Bearer token is accepted and echoed back — the point is the header handling, not the token.

curl -H 'Authorization: Bearer tok_abc123' https://hypothesis.sh/api/httptest/bearer
{ "authenticated": true, "token": "tok_abc123" }

No token, or a non-Bearer scheme, gets 401 with WWW-Authenticate: Bearer.

Response inspection

GET/POST /response-headers

Echoes every query param back as a real response header, and lists the response's headers in the body.

curl -i 'https://hypothesis.sh/api/httptest/response-headers?X-Custom=hello&Cache-Control=no-cache'
HTTP/1.1 200 OK
Cache-Control: no-cache
Content-Type: application/json
X-Custom: hello
Content-Security-Policy: sandbox
Content-Length: 104

{"Cache-Control":"no-cache","Content-Length":"104","Content-Type":"application/json","X-Custom":"hello"}

Two behaviors look like bugs and aren't:

  • Repeated params repeat the header. ?a=1&a=2 emits two a: lines and reports "a": ["1","2"] — matching httpbin exactly.
  • Params are _added_ to `Content-Type`, not substituted. ?Content-Type=text/html reports an array in the body — {"Content-Type": ["application/json", "text/html"]} — just as httpbin does. On the wire, though, only text/html arrives: the CDN collapses duplicate Content-Type to the last value. See Differences.

The body reports its own Content-Length — so writing that number changes the body's length. The value is resolved by re-serializing to a fixpoint, exactly as httpbin does, and always matches the bytes actually sent.

Unusable header names or values are rejected with 400.

This endpoint is sandboxed. It lets a caller put arbitrary bytes in the body _and_ set Content-Type, so ?Content-Type=text/html&x=<script>… is a live XSS on real httpbin. httpbin.org gets away with that as a sacrificial domain; this one shares an origin with every other tool here, so responses carry Content-Security-Policy: sandbox. Scripts can't execute, every header you asked for is still set, and programmatic clients see no difference. The header is added after the body is built, so it isn't reflected — the same way httpbin's own CORS headers aren't.

Cookies

GET /cookies

Returns the cookies you sent.

curl -b 'flavor=chocolate' https://hypothesis.sh/api/httptest/cookies
{ "cookies": { "flavor": "chocolate" } }

GET /cookies/set?name=value

Sets each query param as a cookie on Path=/, then 302s to /cookies.

curl -i 'https://hypothesis.sh/api/httptest/cookies/set?flavor=chocolate&count=2'
HTTP/1.1 302 Found
Set-Cookie: flavor=chocolate; Path=/
Set-Cookie: count=2; Path=/
Location: /api/httptest/cookies

Follow the redirect with a cookie jar to see the round trip:

curl -sLc jar.txt -o /dev/null 'https://hypothesis.sh/api/httptest/cookies/set?flavor=chocolate'
curl -sb jar.txt https://hypothesis.sh/api/httptest/cookies
# {"cookies":{"flavor":"chocolate"}}

GET /cookies/delete?name

Expires each named cookie (the value is ignored), then 302s to /cookies.

curl -sLb jar.txt -c jar.txt 'https://hypothesis.sh/api/httptest/cookies/delete?flavor'
# {"cookies":{}}

Index

GET /api/httptest returns a machine-readable catalog of every endpoint — this stands in for httpbin's Swagger landing page.

curl https://hypothesis.sh/api/httptest
{
  "service": "httptest — httpbin-compatible test endpoints",
  "base": "/api/httptest",
  "endpoints": [
    {
      "path": "/get",
      "methods": ["GET"],
      "description": "Returns the GET request's args, headers, origin, and URL."
    }
  ]
}

Limits

EndpointCapOver the cap
/delay/:n10 secondsClamped
/bytes/:n100 KBClamped
/stream/:n100 linesClamped
/redirect/:n10 hopsClamped
/status/:codes100599`400`
Request body1 MB`413`

Sizes and durations clamp, matching httpbin. Status codes don't — see Status codes.

Differences from httpbin.org

This is a TypeScript port of httpbin's core surface, not the Flask app. Known deviations, all verified against psf/httpbin 0.10.4 running locally:

  • Multipart bodies are not decoded. multipart/form-data lands in data as raw text with files empty; real httpbin populates files. Form-encoded and JSON bodies behave identically.
  • `/json`, `/html`, and `/xml` can return `304 Not Modified` to a conditional If-None-Match request, because the framework attaches an ETag to static bodies. Real httpbin sets no ETag and always returns 200. Rarely hit in practice — every response is no-store, so a well-behaved cache never revalidates — and the reflection endpoints are immune, since echoing If-None-Match into the body changes the body and the tag never matches.
  • `/response-headers` is sandboxed (see the note above) and won't emit a duplicate `Content-Length`: real httpbin honors ?Content-Length=999 with a _second, conflicting_ header, which is response-smuggling material rather than a testable behavior. The body still reports what you asked for; only the true length is sent. Real httpbin also returns 500 on a CRLF-bearing value; this returns 400.
  • `/response-headers` can't emit a duplicate `Content-Type` or `ETag`. Ask for ?Content-Type=text/html and the body reports the array httpbin reports, but only the last value reaches the wire. The handler does set both — a bare Node server given the same setHeader call writes two Content-Type: lines — so this is the CDN in front of the function collapsing singleton headers, and it can't be fixed in the handler. It applies only to Content-Type and ETag; ?a=1&a=2, and duplicate Set-Cookie, Vary, and Content-Language, all reach the client intact.
  • `/delay` caps at 10 seconds, matching httpbin's own limit. Each delayed request holds a serverless function open, so the ceiling is enforced, not advisory.
  • JSON key order differs. Real httpbin sorts keys (Flask's jsonify); this returns a fixed but unsorted order. JSON objects are unordered — every key and value matches.
  • A `__proto__` query param or request header won't round-trip. Next.js strips __proto__ from the query string, and Node's HTTP parser drops a __proto__ request header — both before this handler runs. A __proto__ _cookie_ round-trips correctly. Real httpbin, backed by a Python dict, reflects all three.
  • Not implemented: /gzip, /brotli, /deflate, /image/*, /robots.txt, /deny, /links/:n, /range/:n, /drip, /digest-auth, /forms/post, /cache, /etag/:tag, /base64/:value, /relative-redirect/:n, /absolute-redirect/:n, /redirect-to?url=.
Why a port and not the real thing? postmanlabs/httpbin has not had a commit to master since November 2018 and no longer imports on a modern install — core.py still does from werkzeug.wrappers import BaseResponse, which Werkzeug removed in 2.1.0. The maintained descendant is psf/httpbin (what pip install httpbin actually gives you), but running Flask here would mean adding a Python toolchain and restructuring the Vercel deploy config around Services. A focused TypeScript port keeps the whole thing in one handler with no new runtime.