Testing and Debugging APIs
Finding out what an API really does: curl, Postman and Bruno, the browser's network panel, reading requests and responses, mock servers and automated API tests.
A debugging routine
"The API doesn't work" can mean a hundred things. The general debugging method applies, adapted to HTTP:
-
Reproduce it. Get the exact request: method, full
URL, headers and body, and the time it failed. "It returns an
error" is not enough; "
POST /v1/outagesreturns 415 since 09:12" is. - Isolate where it fails. Send the same request with a neutral tool such as curl from the same machine. If curl works and the application does not, the problem is in the client. If nothing gets a response at all, it is the network (DNS, firewall, proxy, TLS). If you get an HTTP status code back, the request reached a server, though perhaps a proxy or gateway rather than the API itself.
-
Read the full response. The status code says
which kind of problem; the body usually says which one exactly (a
good API returns
Problem Details), and headers such as
WWW-Authenticate,Retry-AfterorAllowsay what to do about it. An HTML error page in reply to an API call tells you a proxy, load balancer or web server answered, not the API. - Change one thing at a time and compare with a request that works: a colleague's, the documentation's example, yesterday's.
curl: the universal tool
curl is installed on practically every Linux and macOS system and on Windows 10 and later. As of 2026 the current release is 8.22.0 (September 2026). Because a curl command is plain text with the complete request in it, it is the easiest way to share a problem: API documentation gives examples in curl, browsers can copy any request as curl, and a support team can run exactly what you ran. The options you will use most:
| Option | What it does |
|---|---|
-i |
Show the response status line and headers before the body |
-v |
Verbose: connection, TLS, request headers
(>) and response headers (<)
|
-X METHOD |
Use another method, such as -X DELETE |
-H "Name: value" |
Add or replace a request header |
-d DATA |
Send a body; this makes it a POST with
application/x-www-form-urlencoded
|
--json DATA |
Send JSON: sets Content-Type and
Accept to application/json (curl
7.82.0 and later)
|
-s, -o FILE |
Silent: no progress meter (add -S to still show
errors); write the body to a file
|
A verbose call to an API that wants a token shows the whole conversation. The token is read from an environment variable, so it does not end up in your shell history:
curl -v -H "Authorization: Bearer $OUTAGE_API_TOKEN" https://api.example.com/v1/outages
* Host api.example.com:443 was resolved.
* Trying 203.0.113.10:443...
* Connected to api.example.com (203.0.113.10) port 443
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN: server accepted h2
* Server certificate:
* subject: CN=api.example.com
* issuer: C=NL; O=Example CA; CN=Example Issuing CA
* SSL certificate verify ok.
> GET /v1/outages HTTP/2
> Host: api.example.com
> User-Agent: curl/8.22.0
> Accept: */*
> Authorization: Bearer eyJhbGciOiJSUzI1NiIs…
>
< HTTP/2 401
< content-type: application/problem+json
< www-authenticate: Bearer realm="outages", error="invalid_token", error_description="The access token expired"
<
{"title": "Unauthorized", "status": 401, "detail": "The access token expired"}
Read it from the top: DNS resolved, TCP connected, TLS succeeded
with a valid certificate, the request went out with the token, and
the API refused it because the token expired. Network, TLS and
routing are all fine; the fix is a new token. Lines starting with
* are curl's own notes, > is what was
sent and < what came back.
curl -v prints the whole Authorization
header (shortened here). Remove tokens before you paste verbose
output into a ticket or chat.
Sending JSON, and showing the response headers:
curl -i --json '{"area": "Deventer"}' -H "Authorization: Bearer $OUTAGE_API_TOKEN" https://api.example.com/v1/outages
HTTP/2 201
content-type: application/json
location: /v1/outages/OUT-1043
{"id": "OUT-1043", "area": "Deventer"}
Postman, Bruno, Insomnia and HTTPie
For exploring an API, saving requests and sharing them with a team, most people use a dedicated API client. They all do the basics: build requests in a form, keep them in collections, switch between environments (test, acceptance, production) with variables, and run scripted checks. They differ in where your data lives. As of 2026:
- Postman is one of the most widely used API platforms: a desktop and web client with collections, environments, test scripts, mock servers, documentation and team workspaces. It is commercial, with a free tier; collections are normally synchronized to Postman's cloud through your account.
- Bruno is an open-source client that stores each collection as plain-text files in a folder on your disk. That folder can live in the same Git repository as the API, so requests are reviewed and versioned like code.
- Insomnia, owned by Kong, is a desktop client with REST, GraphQL and gRPC support, and options for local or cloud storage.
-
HTTPie is a command-line client with readable
syntax and colored output (
https POST api.example.com/v1/outages area=Deventer), plus a desktop and web app.
The browser's network panel and HAR files
When the problem is "the web page shows an error", the best evidence is in the browser itself. Open the developer tools (F12, or Ctrl+Shift+I; Cmd+Option+I on a Mac), choose the Network panel and reload. Every request the page makes is listed with its status, type, size and timing. Click one to see request and response headers, the payload sent and the response body. Useful habits:
- Tick Preserve log, so requests stay visible across redirects and page loads.
- Filter on Fetch/XHR to see only the API calls, not images and scripts.
- Right-click a request and choose Copy as cURL to repeat it outside the browser, with exactly the same headers and cookies, so the copied command contains your session cookie or token; treat it as a secret.
- Check the Console panel too: CORS and mixed-content errors are reported there, not as a status code.
A HAR file (HTTP Archive) is a JSON export of everything in the network panel: every request and response with headers, bodies and timings. Vendors' support teams often ask for one because it shows exactly what happened.
Common symptoms and what they usually mean
| Symptom | Usual cause | What to check |
|---|---|---|
| 401 Unauthorized | No credentials, an expired or wrong token, or the wrong authentication scheme |
The WWW-Authenticate header; whether the
Authorization header was really sent (some
redirects drop it); token expiry. See
API Authentication
|
| 403 Forbidden | Authenticated but not allowed: missing scope or role, IP allowlist, a web application firewall rule | Permissions of the account or key; whether the body comes from the API or from a gateway or firewall |
| 404 on a URL that should exist |
A trailing slash (/outages/ versus
/outages), wrong base path or version
(/v1), wrong host or environment, case in the
path
|
Compare character by character with the documentation; a 404 in HTML usually comes from a proxy, not the API |
| 405 Method Not Allowed | Right URL, wrong method (POST instead of PUT); or a redirect turned a POST into a GET |
The Allow header lists the methods the resource
supports
|
| 415 Unsupported Media Type |
Missing or wrong Content-Type, for example a
JSON body sent as a form with curl -d
|
Send Content-Type: application/json (or use
--json)
|
| 422 Unprocessable Content (or 400) | The body is valid JSON, but a field is missing, has the wrong type or an invalid value | The Problem Details body usually names the field |
| 429 Too Many Requests | Rate limit reached |
Retry-After; slow down and back off instead of
retrying in a loop
|
| 502, 503, 504 | 502 and 504: a proxy or gateway got a bad answer, or no answer in time, from the server behind it. 503: the service, or the proxy in front of it, is overloaded or in maintenance |
Server health and logs; for 503, often a
Retry-After header
|
| "Blocked by CORS policy" in the browser, but curl works | The API does not allow the page's origin; the browser enforces this, curl does not |
The Access-Control-Allow-Origin response header
and the preflight request. See
Browsers and the Same-Origin Policy
|
| Timeout, no status code | Firewall dropping packets, wrong port, DNS pointing to an old address, a very slow backend |
curl -v: where does it stop? Resolving,
connecting, or waiting for the response?
|
| Certificate or TLS error | Expired certificate, missing intermediate certificate, wrong host name, a company proxy inspecting TLS, an old client |
The certificate details in curl -v. See
HTTPS and TLS in Practice
|
Testing APIs
Debugging finds out what went wrong once; testing makes sure it does not happen again. API testing sends real requests to an API and checks the responses automatically: status codes, headers, body structure and values. Because it works at the HTTP level, without a browser, it is fast and fits the integration layer of the test pyramid. The main kinds:
- Functional tests: each operation does what the documentation says for valid input.
- Negative tests: invalid input, missing fields, wrong content type, no token, someone else's object. These find more bugs and more security holes than the happy path; the API must answer with the right 4xx code, never a 500.
- Contract tests: the API still matches what its consumers rely on (see below).
- Load tests: many requests in parallel, to find the point where response times or error rates go up. Run them against a test environment, never against someone else's API without permission (see Performance and Load Testing).
Contract testing
Contract testing checks the agreement between an API and its consumers rather than its full behavior. In schema-based contract testing, real responses are validated against the OpenAPI description; any field that is missing or has the wrong type fails the test. In consumer-driven contract testing, each consumer records the requests it makes and the parts of the responses it depends on, and the provider runs those expectations in its own pipeline. Either way, a breaking change is caught before release instead of in production. The consumer-driven flow between services is shown on API Design Between Services, and Pact itself on Integration and Contract Testing.
An automated API test in Python
This example uses unittest from the standard library.
setUpClass starts a small outage API on a free port of
your own machine, once for all tests; each test sends a request with
urllib.request and checks status, headers and body.
Note that urlopen raises HTTPError for 4xx
and 5xx responses, so the helper catches it and returns the error
response like any other. To keep the output identical on every run,
a small result class prints one line per test instead of unittest's
usual summary with its timing. (Python's http.server is
fine for tests like this, but not for production.)
import json
import threading
import unittest
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
OUTAGES = {"OUT-1042": {"id": "OUT-1042", "area": "Zwolle", "customers": 1250}}
class OutageAPI(BaseHTTPRequestHandler):
"""A tiny outage API: the system under test."""
def log_message(self, format, *args):
pass
def reply(self, status, body, content_type="application/json"):
data = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def problem(self, status, title, detail):
self.reply(status, {"title": title, "status": status, "detail": detail},
"application/problem+json")
def do_GET(self):
if self.path == "/outages":
self.reply(200, list(OUTAGES.values()))
elif self.path.startswith("/outages/") and self.path[9:] in OUTAGES:
self.reply(200, OUTAGES[self.path[9:]])
else:
self.problem(404, "Not found", f"No resource at {self.path}")
def do_POST(self):
if self.headers.get("Content-Type") != "application/json":
self.problem(415, "Unsupported media type", "Send application/json")
return
length = int(self.headers.get("Content-Length", 0))
report = json.loads(self.rfile.read(length))
if not report.get("area"):
self.problem(422, "Invalid report", "Field 'area' is required")
return
self.reply(201, {"id": "OUT-1043", "area": report["area"]})
class OutageAPITests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), OutageAPI)
host, port = cls.server.server_address
cls.base = f"http://{host}:{port}"
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
def call(self, method, path, body=None, content_type="application/json"):
"""Send a request; return (status, headers, parsed JSON body)."""
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(self.base + path, data=data, method=method)
if data is not None:
request.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(request) as response:
return response.status, response.headers, json.loads(response.read())
except urllib.error.HTTPError as error: # 4xx and 5xx arrive here
return error.code, error.headers, json.loads(error.read())
def test_list_outages(self):
status, headers, body = self.call("GET", "/outages")
self.assertEqual(status, 200)
self.assertEqual(headers["Content-Type"], "application/json")
self.assertIsInstance(body, list)
def test_get_one_outage(self):
status, _, body = self.call("GET", "/outages/OUT-1042")
self.assertEqual(status, 200)
self.assertEqual(body["area"], "Zwolle")
self.assertIsInstance(body["customers"], int)
def test_unknown_outage_is_problem_details(self):
status, headers, body = self.call("GET", "/outages/OUT-9999")
self.assertEqual(status, 404)
self.assertEqual(headers["Content-Type"], "application/problem+json")
self.assertEqual(body["status"], 404)
def test_create_report(self):
status, _, body = self.call("POST", "/outages", {"area": "Deventer"})
self.assertEqual(status, 201)
self.assertEqual(body["area"], "Deventer")
def test_wrong_content_type_is_415(self):
status, _, _ = self.call("POST", "/outages", {"area": "Deventer"}, "text/plain")
self.assertEqual(status, 415)
def test_missing_field_is_422(self):
status, _, body = self.call("POST", "/outages", {"area": ""})
self.assertEqual(status, 422)
self.assertIn("area", body["detail"])
class PrintingResult(unittest.TestResult):
"""Print one stable line per test (no timings)."""
def addSuccess(self, test):
super().addSuccess(test)
print("ok ", test._testMethodName)
def addFailure(self, test, err):
super().addFailure(test, err)
print("FAIL ", test._testMethodName)
def addError(self, test, err):
super().addError(test, err)
print("ERROR", test._testMethodName)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(OutageAPITests)
result = PrintingResult()
suite.run(result)
failed = len(result.failures) + len(result.errors)
print(f"{result.testsRun} tests, {result.testsRun - failed} passed, {failed} failed")
ok test_create_report ok test_get_one_outage ok test_list_outages ok test_missing_field_is_422 ok test_unknown_outage_is_problem_details ok test_wrong_content_type_is_415 6 tests, 6 passed, 0 failed
Three of the six tests are negative or error cases; in a real suite they usually outnumber the happy-path tests. In a real project the server would be the actual application started in test mode, and the same tests would run in the CI pipeline on every change, usually with unittest or pytest and their normal output.
Mock servers
A mock server is a stand-in that answers like the real API, with fixed or generated example responses, but without the real logic or data behind it. Teams use one to:
- build a front end or client while the API is still being written, working from the agreed OpenAPI description;
- test a client against situations that are hard to produce on the real API: a 500, a 429, a slow response, an empty list;
- run tests without depending on a third-party API that is slow, rate-limited, costs money per call or has no test environment.
Postman and several open-source tools can generate a mock server from an OpenAPI document's examples; the server in the test above is a hand-written stand-in of a few lines. The limit of every mock is that it only knows what you told it: when the real API changes, the mock does not. Contract tests close that gap. (A mock server is a network-level stand-in; for the in-code kind, see Test Doubles.)