API Tester
Send GET, POST, or other HTTP requests with custom headers and body and inspect the response.
Compose a request — method, URL, query parameters, headers, body — send it, and read back the status, response headers, and payload. The request is executed server-side rather than from your browser tab, which is what makes endpoints without CORS headers reachable and which changes what the timing number represents. This page covers both, plus the HTTP semantics behind the most misdiagnosed failures.
Which requests trigger an OPTIONS preflight
The Fetch standard divides cross-origin requests into two classes. A simple request goes straight out with no negotiation, and it qualifies only if all three conditions hold: the method is GET, HEAD, or POST; every header you set is on the CORS-safelisted list (Accept, Accept-Language, Content-Language, Content-Type, and Range with a simple range value); and if Content-Type is present it is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain.
Notice what is missing from that list. application/json is not a safelisted media type, so essentially every JSON POST is preflighted, as is any request carrying Authorization, X-API-Key, or a custom tracing header. The browser sends an OPTIONS request first, advertising Access-Control-Request-Method and Access-Control-Request-Headers, and the server must reply with matching Access-Control-Allow-Methods and Access-Control-Allow-Headers values before the real request is allowed out.
Two details save a lot of time later. Access-Control-Max-Age caches that permission, but browsers clamp it well below whatever you send — Chrome caps it at two hours regardless of a larger value — so a preflight on every request is normal, not a bug. And a wildcard Access-Control-Allow-Origin: * is rejected outright when the request carries credentials; the server has to echo the specific origin and add Access-Control-Allow-Credentials: true. Response headers are also hidden from JavaScript unless the server names them in Access-Control-Expose-Headers, which is why a rate-limit header you can plainly see in devtools reads as null from code.
A CORS failure is a browser verdict, not a server error
When CORS blocks a call, the server has usually already received it, processed it, and replied. The browser simply refuses to hand the response to your JavaScript. The error you get is deliberately uninformative — a bare TypeError with no status code, no headers, and no body — because leaking any of that would defeat the point of the policy. This is why the same endpoint responds perfectly to curl: curl has no origin and enforces no same-origin policy, so there is nothing to violate.
Rather than fight that, this tester does not call the target from your tab at all. It posts your composed request to a same-origin route at /api/proxy, and that route performs the fetch from the server, where CORS is not a concept. Any HTTP or HTTPS endpoint becomes reachable, including ones that send no CORS headers whatsoever and ones that would never allow a browser origin.
Three things the proxy changes, so you can account for them when a result surprises you. Connection-specific headers are stripped before forwarding — Host, Connection, Content-Length, Transfer-Encoding, Keep-Alive, TE, Trailer, Upgrade, and the Proxy- pair — because RFC 9110 defines these as hop-by-hop and forwarding them across a hop is incorrect. Accept-Encoding is dropped so the runtime negotiates compression itself. And if you set no User-Agent, one identifying this tool is supplied, which a WAF or bot filter may score differently from your browser. Redirects are followed automatically, so a 301 or 302 is invisible and you see the final status — if the chain itself is what you are debugging, the redirect checker shows every hop.
Safe methods, idempotency, and what you can retry
RFC 9110 section 9.2.1 designates GET, HEAD, OPTIONS, and TRACE as safe: they are read-only from the client's perspective. Section 9.2.2 designates GET, HEAD, PUT, DELETE, OPTIONS, and TRACE as idempotent: sending the identical request twice leaves the server in the same state as sending it once. POST is neither, and PATCH (RFC 5789) is neither — a PATCH that increments a counter produces a different result each time it runs.
This is the rule that decides what a client may retry. A PUT that times out can be replayed without thought. A POST that times out cannot, because a timeout tells you nothing about whether the server committed before the connection dropped — you may be about to create a second order. Idempotency keys exist precisely for this gap: the client generates a unique key per logical operation and sends it as a header, and the server records the outcome against that key and replays the stored response instead of acting a second time. Stripe's Idempotency-Key is the pattern most APIs have copied.
Redirects interact with all of this. 301 and 302 were so widely implemented as "convert the POST to a GET and drop the body" that RFC 9110 documents the behaviour as established practice; 307 and 308 exist specifically to preserve the method and body across the hop. If a POST that goes through a redirect arrives at the origin as a bodyless GET, that is the cause. Worth knowing too that idempotent does not mean identical response: a second DELETE returning 404 is correct, because the resulting state is the same even though the status differs.
Media types, negotiation, and the status codes people misread
Content-Type describes the body you are sending; Accept declares what you are willing to receive. They are independent, and a GET has no body, so setting Content-Type on one is meaningless. The distinction shows up in the errors: a 415 Unsupported Media Type is the server rejecting your Content-Type, while a 406 Not Acceptable is the server unable to produce anything matching your Accept. Omitting Content-Type on a JSON POST typically yields a 415 or a silently empty parsed body; this proxy defaults to application/json when you supply a structured body and no type, which is convenient but means a form-encoded endpoint needs the header set explicitly.
The authentication pair is misused constantly. 401 Unauthorized means unauthenticated — credentials absent, malformed, or expired — and RFC 9110 section 15.5.2 requires the response to include a WWW-Authenticate header naming the scheme. 403 Forbidden means the server knows exactly who you are and you are not permitted. An API that returns 403 for an expired token sends you off to audit permissions when you should be refreshing a credential.
A few more that reward precision. 400 Bad Request is for malformed syntax the parser cannot handle; 422 Unprocessable Content (RFC 9110 section 15.5.21) is for a body that parsed cleanly but failed validation — valid JSON, invalid values. 204 No Content must carry no body at all, and an API that sends one anyway will break clients that trust the status. 429 Too Many Requests should include Retry-After, given either as seconds or an HTTP-date, and honouring it is cheaper than guessing a backoff. And a 200 wrapping { "error": ... } defeats every retry policy, monitoring dashboard, and cache layer that reads status codes rather than parsing bodies.
The request runs on our server, not your machine
That has two consequences worth holding onto. The first is what the duration means. The number reported is measured around the server-side fetch, from a datacentre, across its network path, to your target — it bundles DNS resolution, TCP connect, TLS handshake, and the server's own processing into a single figure, and it is not a measurement of your users' latency. The first call to a given host pays for resolution and a full handshake; an immediate repeat usually drops tens of milliseconds because the connection is pooled, so compare second runs rather than first ones. To separate the phases properly, curl -w "%{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer}" reports DNS, TCP, TLS, and time-to-first-byte individually, which tells you whether a slow endpoint is slow to resolve, slow to handshake, or slow to think.
Related: when the target cannot be reached at all — hostname does not resolve, connection refused, certificate expired, connect timeout — there is no HTTP response to show, so the result comes back as a proxy-level error rather than a status code. An error mentioning DNS or TLS describes the link between our server and the target, not something the API said.
The second consequence is about credentials. Whatever you put in an Authorization header travels through our infrastructure on its way to the target, exactly as it would with any hosted HTTP client. Apply the same judgement you would to any third-party tool: prefer a staging environment, a read-only scope, or a short-lived token, and rotate anything you paste into a tool you do not run yourself. If the question is only whether a bearer token has expired, you do not need to send it anywhere — decode it locally with the JWT decoder and read the exp claim.
Related tools
Try these tools that work well with this one
URL Redirect Checker
Trace HTTP redirects, status codes, and headers
Base64 Decoder
Convert Base64 back to original text and files
Base64 Encoder
Convert text and files to Base64 format
CSV to JSON Converter
Convert CSV data to JSON
CSV to XML Converter
Convert CSV text to XML
Hash Generator
Generate MD5, SHA1, SHA256, SHA512 hashes
Related guides
Tutorials and guides to get more from this tool
API Debugging: Techniques Beyond console.log
Debug APIs effectively by inspecting headers, status codes, and payloads. Practical techniques beyond console.log.
CORS Explained: Cross-Origin Resource Sharing Guide 2025
Understand CORS errors and how to fix them. Learn about preflight requests, credentials, and CORS security best practices for modern web applications.
HTTP Status Codes: Complete Developer Guide 2025
Master HTTP status codes from 200 to 599. Learn what each code means, when to use them, and how to debug API errors. Essential for every developer.
ThenCatch is a free developer toolkit. Learn more about us.