When an API call fails, it is tempting to start changing code. It is usually faster to read what the server actually sent back, in a fixed order: status code → headers → body → credentials → data. Each step either finds the problem or rules out a whole category of causes. This guide walks through that workflow with real requests and links to the tool that helps at each step.
Examples were run against a local test API with curl 8.7.1, jq 1.7.1 and Node.js 24.21. Hostnames such as api.example.com are placeholders.
Step 1: See the whole response, not just the error
Client libraries often turn a failed request into a one-line exception and throw away the details. Reproduce the request with curl -i, which prints the status line and headers before the body:
$ curl -i https://api.example.com/v1/orders/9001 -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"
{"error":"invalid_token","message":"Token expired"}Use curl -v to also see the request headers curl sent, which is how you catch a missing Authorization header or the wrong Content-Type. To print just the summary:
$ curl -s -o /dev/null -w "%{http_code} %{content_type} %{time_total}s\n" https://api.example.com/v1/report
502 text/html 0.000822sIn a browser, the Network tab in developer tools shows the same information. Right-click a request and choose Copy as cURL to replay it in a terminal.
Step 2: Let the status code narrow the search
| Status | Usually means | Check first |
|---|---|---|
400 Bad Request | The server could not parse or accept the request. | Request body is valid JSON; required fields present; query string encoded correctly. |
401 Unauthorized | No credentials, or credentials the server rejects. | The token is sent, not expired, and issued for this API (Step 5). |
403 Forbidden | Authenticated, but not allowed. | Roles, scopes or claims in the token; resource ownership. |
404 Not Found | Wrong path or ID, or the API hides resources you may not see. | Base URL, API version prefix, trailing slash, ID encoding. |
415 Unsupported Media Type | The body format is not accepted. | Content-Type: application/json on the request. |
422 Unprocessable Content | Valid syntax, invalid values. | The error body usually lists the failing fields. |
429 Too Many Requests | Rate limited. | The Retry-After header; retries without back-off. |
500 | Unhandled error on the server. | Server logs; a request ID header to quote to the API owner. |
502 / 503 / 504 | A proxy, load balancer or gateway could not get a good answer from the app. | Whether the app is running and how long it took; these responses are often HTML. |
Read the response headers too. WWW-Authenticate on a 401 often states the exact reason, as in the example above (The access token expired). Many APIs also return a request ID header, which is the fastest way to find the matching server log entry.
Step 3: Confirm the body is the JSON you expect
The most common confusing error in API clients is this one:
SyntaxError: Unexpected token '<', "<html><hea"... is not valid JSONThe code called response.json(), but the server returned HTML — here a gateway's 502 page. The JSON parser is not the problem; the status and Content-Type are. Read the body as text first when a request fails:
const res = await fetch(url);
const text = await res.text();
if (!res.ok) {
throw new Error(`${res.status} ${res.headers.get("content-type")}: ${text.slice(0, 200)}`);
}
const data = JSON.parse(text);For the 502 above, that error message reads 502 text/html: <html><head><title>502 Bad Gateway</title>..., which points straight at the gateway instead of at your parsing code.
If the body is JSON but hard to read, paste it into the JSON formatter. It pretty-prints the response, reports the exact line and column of any syntax error, and warns when an ID is too large for JavaScript to represent exactly — a classic cause of "the ID in the response doesn't match the database". On the command line, jq does the same and lets you extract a field:
$ curl -s https://api.example.com/v1/cart | jq '.items[0].sku'
"A1"
$ echo '{"a":1,}' | jq .
jq: parse error: Expected another key-value pair at line 1, column 8Common JSON syntax errors explains each parse error and its fix.
Step 4: Compare with a response that works
"It works in staging but not in production" or "it broke after the last release" are comparison problems. Save a good response and a bad one and compare them structurally with the JSON diff tool. It ignores formatting and key order and lists each change with its path. Its copyable report looks like this (comments added):
~ $.id: 42 -> "42" # type changed: number became string
- $.email: "ada@example.com" # field removed
~ $.settings.theme: "dark" -> "light"A type change such as a number turning into a string is easy to miss in a text diff and often breaks typed clients. A missing key and a key set to null are reported differently, which matters for APIs where absent means "unchanged" and null means "clear the value".
Step 5: Inspect the credentials
For 401 and 403, look inside the token you are sending. Most APIs use JWT bearer tokens, which you can decode with the JWT decoder without sending the token anywhere. Check, in order:
- Is a token sent at all?
curl -vshows the request headers. A missingBearerprefix or an empty variable is common. exp— has it expired? The decoder shows the time in UTC, in your local time and relative to now. A token that expires within seconds of being issued usually means a seconds/milliseconds bug when it was created.aud— is it issued for this API? A token forapi.example.comis rejected bybilling.example.comeven though it is otherwise valid.iss— is it from the expected environment? Staging tokens sent to production are a frequent cause of sudden 401s.- Scopes or roles — for a
403, compare the claims with what the endpoint needs.
Decoding shows what a token claims; only the server's signature verification decides whether it is genuine. See JWT decoding vs. signature verification.
Step 6: Check timestamps
Dates cause bugs that look like logic errors: records that appear in the wrong day, caches that never expire, tokens that are "expired" the moment they are issued. Paste suspicious numbers into the timestamp converter:
- A date in January 1970 means a value in seconds was read as milliseconds.
- A date thousands of years in the future means milliseconds were read as seconds.
- A date off by a whole number of hours means a time-zone conversion happened twice, or a date string was sent without an offset (
2026-03-15T09:30:00instead of2026-03-15T09:30:00Z).
Unix timestamps explained covers the units and conversions in more depth.
Step 7: Check how the URL was encoded
When a search returns nothing or a filter is ignored, the server may have received a different value from the one you meant to send. Our test API echoes the query parameters it received:
$ curl -s "https://api.example.com/v1/search?q=red+shoes&tag=a+b"
{"q":"red shoes","tag":["a b"]}
$ curl -s "https://api.example.com/v1/search?q=caf%25C3%25A9"
{"q":"caf%C3%A9","tag":[]}Two classic bugs are visible here:
- Plus signs become spaces. In a query string,
+means a space, soa+barrived asa b. A literal plus must be sent as%2B. - Double encoding.
%25is an encoded%, so the server received the literal textcaf%C3%A9instead ofcafé. Something encoded a value that was already encoded.
Let the tool encode values for you instead of building query strings by hand:
$ curl -s -G https://api.example.com/v1/search \
--data-urlencode "q=red shoes & socks" --data-urlencode "tag=a+b"
{"q":"red shoes & socks","tag":["a+b"]}In JavaScript, build query strings with URLSearchParams or encode each value with encodeURIComponent. The URL encoder shows what each encoding mode produces and breaks any URL into its decoded parameters, so you can see exactly what the server will receive.
Checklist
- Reproduce with
curl -i(or Copy as cURL) and read the full response. - Use the status code to pick where to look; read
WWW-Authenticate,Retry-Afterand request ID headers. - Confirm the body is JSON before parsing; log the status, content type and first part of the body on failure.
- Format and validate the body; watch for IDs too large for JavaScript.
- Diff a failing response against a working one.
- Decode the token: present, not expired, right audience and issuer, right scopes.
- Check timestamps for unit and time-zone mistakes.
- Check the query string for
+,&and double-encoding problems.
Every tool linked above runs in your browser, so you can paste response bodies and tokens without sending them to another server — though it is still good practice to use test data or expired tokens where you can.