Skip to main content

Testing APIs with curl: The curl Commands and Flags You Actually Use

A practical curl cheat sheet for testing APIs: sending JSON, headers, auth, following redirects, downloading files, and debugging requests with -v and -i.

Published By Li Lei
#curl #api-testing #http #command-line #developer-tools

Testing APIs with curl: The curl Commands and Flags You Actually Use

I have probably typed curl more than any other command this year. Not because I love it, but because when an API misbehaves, curl is the shortest path between "the dashboard says it's broken" and "here is the exact request that fails." No Postman tab to set up, no SDK to install, no GUI between me and the wire. Just a one-liner I can paste into a ticket and hand to anyone.

The catch is that curl has roughly two hundred flags and you only ever need about eight of them. This guide is the eight. Everything below is something I reach for in a normal week of poking at HTTP endpoints.

The Six Flags That Cover 90% of API Work

Start here. These are the flags I type without thinking:

  • -X sets the HTTP method (-X POST, -X PUT, -X DELETE).
  • -H adds a request header (-H "Content-Type: application/json").
  • -d sends a request body (and quietly implies POST).
  • -i includes the response headers in the output.
  • -L follows redirects instead of printing the stub.
  • -o writes the response to a file (-o out.json).

A bare GET is just the URL:

curl https://api.example.com/health

To see what the server actually sent back — status line, headers, body — add -i:

curl -i https://api.example.com/health

That single -i answers half the questions you'd otherwise open a debugger for: the real status code, the Content-Type, the rate-limit headers, the Set-Cookie. If you only want the headers and not the body, use -I, which fires a HEAD request.

Sending JSON to an API

This is the request I get wrong most often when I'm tired, so it's worth slowing down on. Three things have to line up: the method, the content type, and the body.

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"name":"Li Lei","role":"editor"}' \
  https://api.example.com/users

The single most common failure is writing -X POST and forgetting -d. curl sends an empty body, the server validates it, and you get a 400 that tells you nothing useful. The fix is almost philosophical: drop -X entirely. The moment you add -d, curl already switches to POST. So -X is really only needed for the methods curl can't infer — PUT, PATCH, DELETE.

The second trap is sending -d with no Content-Type. Without the header, many APIs assume application/x-www-form-urlencoded, try to parse your JSON as a form, and return a 415. The header is not optional.

A third trap bites people coming from other languages: -d does not URL-encode for you. If your value has a space or an &, it goes onto the wire raw and corrupts the request. For form fields with special characters, use --data-urlencode "q=hello world" instead.

When the JSON gets long or has awkward quoting, stop fighting the shell and put it in a file:

curl -X POST -H "Content-Type: application/json" \
  --data @payload.json \
  https://api.example.com/orders

And if you're piping JSON out of jq, read it from stdin without losing newlines:

jq '.items' input.json | curl -H "Content-Type: application/json" --data-binary @- https://api.example.com/bulk

A Worked Scenario: POST and Read the Response Headers

Here's a real sequence from last month. A POST /orders call kept failing in CI with a 400, and the logs only said "invalid request." I needed to see the whole conversation, not just the body, so I ran it with -i to include the response headers:

curl -i -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"sku":"A-19","qty":2}' \
  https://staging.example.com/orders

The output started like this:

HTTP/2 401
www-authenticate: Bearer error="invalid_token"
x-request-id: 7c2f...
content-type: application/json

That 401 (not the 400 the app log claimed) plus the www-authenticate header told the whole story in two lines: the token had expired, and a downstream proxy was rewriting the status before the app saw it. Without -i, I'd have stared at a generic error body for an hour. The response headers are where APIs keep their honesty — the real status, the x-request-id you quote in the ticket, the rate-limit counters. If you want to map that status code to what it actually means, the HTTP status code explorer breaks down every code with the common causes. For the curl flag to pair with each, the curl cheat sheet has the copy-ready one-liner.

Auth, Redirects, and the One Header curl Drops on Purpose

For a Bearer token or JWT, the pattern is one header:

curl -H "Authorization: Bearer $TOKEN" https://api.example.com/me

Pull the token from an environment variable, never paste it inline — your shell history is the easiest secret leak there is. For old-school Basic auth, -u user:pass does the encoding for you.

Now the subtle one. When you add -L to follow redirects, curl deliberately drops the Authorization header if the redirect lands on a different hostname:

curl -L -H "Authorization: Bearer $TOKEN" https://api.example.com/download

That is the correct, safe default — you don't want your token forwarded to whatever host a redirect points at. If, and only if, you fully trust the entire redirect chain, --location-trusted keeps the header attached. I have watched people lose an hour to a "mysterious 401 after redirect" that was curl protecting them from themselves.

Downloading Files and Following Redirects

The classic file download combines -L (follow the redirect to the real CDN URL) with -o (pick the output name):

curl -L -o release.tar.gz https://example.com/downloads/latest

Use -O (capital O) if you want curl to keep the server's filename, and -OJ to honor a Content-Disposition filename. For a flaky connection, -C - resumes a half-finished download instead of starting over, and --limit-rate 2M keeps a big pull from saturating your link. Adding timeouts is good hygiene on anything unattended:

curl -L --connect-timeout 5 --max-time 60 -o big.iso https://example.com/big.iso

The first caps the handshake so an unreachable host fails fast; the second caps the whole operation so a slow server can't hang you for an hour.

Debugging When Nothing Makes Sense

When a request behaves in a way the headers don't explain, escalate to -v (verbose). It prints the full handshake: DNS resolution, the TLS negotiation, every request header curl sent, and every response header it got back.

curl -v https://api.example.com/me

Lines starting with > are what curl sent, < are what came back, and * lines are connection-level notes (which IP, which TLS version, certificate details). This is where you catch the header you thought you sent but didn't, or the SNI mismatch behind a "certificate doesn't match" error. For pure latency questions — "where do these three seconds go?" — the -w timing template is better than -v:

curl -w 'dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n' \
  -o /dev/null -s https://example.com

One line tells you whether DNS, the TCP connect, the TLS handshake, or the server itself is the bottleneck. I caught a 1.8-second DNS resolution this way that no application log would ever have surfaced.

A small but vital pairing: -s silences the progress meter, which is what you want in scripts, but it also silences real errors. Always write -sS together so failures still print. And resist the urge to -k past a certificate error — that disables TLS verification entirely. Trust the right CA with --cacert ./ca.pem instead.

Keep the Reference Open

Nobody memorizes all of this, and you shouldn't try. The flags above are the spine; the long tail — --aws-sigv4, multipart -F, --resolve to pin a hostname to an IP, conditional GET with -z — comes up just often enough to forget between uses. Keep the searchable curl cheat sheet in a tab; it filters across the command, the description, the pitfall, and the example at once, so typing "ttfb" or "json" surfaces the exact one-liner even when the keyword only lives in the example. It's the reference I actually have open while writing this.


Made by Toolora · Updated 2026-06-13