Testing APIs with curl: Methods, Headers, --json, Authentication, Uploads, Error Codes and Using It in Scripts
The rule for using curl safely in scripts is not to trust its exit code, which is 0 for 401 and 500 too: --fail-with-body exits with 22 and still prints the ProblemDetails. Against Bookstore.Api, with real output: -v and --trace-ascii, -d versus --json (415), keeping the token out of history with --variable and -H @file, cover uploads with -F and the antiforgery error, the limits of --retry, a smoke script, curl.exe quoting on Windows, Invoke-RestMethod and xh.
Version note: The code in this post was built and run with .NET 10; the commands were run with curl 8.x, jq 1.7, PowerShell 7.7 and xh 0.26. Behaviour specific to Windows PowerShell 5.1 and Chrome DevTools is taken from the official documentation.
Guess First
Bookstore.Api is running and you run these three commands in order:
curl -X POST http://localhost:5080/api/auth/login -d '{"email":"ada@example.com","password":"Bookstore-Demo-1"}'
curl -s --fail http://localhost:5080/api/orders/1; echo "exit=$?"
curl -s http://localhost:5080/api/orders/1; echo "exit=$?"
Does the first command return a token? Guess what the second and third commands print and what their exit codes are. Which of these commands would make a CI script miss an error? The answers are in the relevant sections, with real output.
Seeing the Request and the Response: -i, -v and --trace-ascii
By default curl prints only the response body. What you need in your first conversation with an API is the status code and the headers; -i puts them in front of the body (-s turns off the progress meter):
$ curl -si http://localhost:5080/api/books/9780261102217
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 09:00:47 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"isbn":"9780261102217","title":"The Hobbit","author":"J.R.R. Tolkien","category":"Fantasy","price":329.90,"publishedYear":1937,"description":"Bilbo Baggins leaves his comfortable home for an adventure with thirteen dwarves.","available":25}
When you also want to see the request you sent, use -v. Lines starting with > are headers curl sent, lines starting with < are headers the server returned, and lines starting with * are curl's own notes. All of them go to stderr; the body stays on stdout:
$ curl -sv http://localhost:5080/api/auth/login --json '{"email":"ada@example.com","password":"Bookstore-Demo-1"}' -o /dev/null
* Host localhost:5080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:5080...
* Connected to localhost (::1) port 5080
> POST /api/auth/login HTTP/1.1
> Host: localhost:5080
> User-Agent: curl/8.7.1
> Content-Type: application/json
> Accept: application/json
> Content-Length: 57
>
} [57 bytes data]
* upload completely sent off: 57 bytes
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Sat, 14 Nov 2026 09:05:38 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
<
{ [402 bytes data]
* Connection #0 to host localhost left intact
-v does not show the body. When you need to see the bytes sent as well, --trace-ascii file writes everything to a file. We will see why that is useful in the file upload section. The line-by-line format of an HTTP message is covered in the Networking and HTTP series post "Reading HTTP/1.1 Off the Wire".
Sending a Body: -d, --json and --data-urlencode
The first command from the guessing question does not return a token:
$ curl -si -X POST http://localhost:5080/api/auth/login -d '{"email":"ada@example.com","password":"Bookstore-Demo-1"}'
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 09:05:38 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-22ee6168ff7f497b53bef9019819f590-74aabb0aeb047615-00"}
-d sends the body but sets Content-Type to application/x-www-form-urlencoded; the minimal API expects JSON and returns 415. The old fix was adding -H "Content-Type: application/json". Since curl 7.82.0 there is --json: it sends the body as is, adds the Content-Type: application/json and Accept: application/json headers and makes the method POST. Both headers are visible in the -v output above. -X POST becomes unnecessary too; if you need PUT, adding -X PUT is enough. --json @order.json reads the body from a file, --json @- from stdin. One warning: curl does not check whether the text you give it is valid JSON.
When a query string value contains spaces or non-ASCII characters, use -G with --data-urlencode instead of encoding by hand. -G appends the parts given with -d and --data-urlencode to the URL instead of the body:
$ curl -s -G http://localhost:5080/api/books --data-urlencode "search=war and peace" -d pageSize=2 -o /dev/null -w '%{url_effective}\n'
http://localhost:5080/api/books?search=war+and+peace&pageSize=2
Authentication: Keeping the Bearer Token off the Command Line
Bookstore.Api expects a Bearer token. The best-known form is -H "Authorization: Bearer $TOKEN"; curl also has its own option, --oauth2-bearer "$TOKEN". Both produce the same header. The problem: once the token is an argument of the command, it lands in shell history and shows up in ps output while curl runs. A variable saves you from history but not from the process list. There are two clean ways.
The first is reading the header from a file. -H @file adds each line as a header:
$ umask 077
$ printf 'Authorization: Bearer %s\n' "$BOOKSTORE_TOKEN" > token.header
$ curl -s -H @token.header http://localhost:5080/api/auth/me
{"email":"ada@example.com","name":"Ada Lovelace","role":"Customer"}
The second is the variables that came with curl 8.3.0. --variable %BOOKSTORE_TOKEN imports an environment variable into curl, and --expand-header replaces {{BOOKSTORE_TOKEN}} inside the header. The token appears neither on the command line nor in a file:
$ curl -s --variable %BOOKSTORE_TOKEN --expand-header "Authorization: Bearer {{BOOKSTORE_TOKEN}}" http://localhost:5080/api/auth/me
{"email":"ada@example.com","name":"Ada Lovelace","role":"Customer"}
-u user:password is for Basic authentication. Bookstore.Api does not accept Basic, but looking at what gets sent is instructive:
$ curl -sv -u ada@example.com:Bookstore-Demo-1 http://localhost:5080/api/auth/me -o /dev/null 2>&1 | grep -E '^> Authorization|^< HTTP|^< WWW'
> Authorization: Basic YWRhQGV4YW1wbGUuY29tOkJvb2tzdG9yZS1EZW1vLTE=
< HTTP/1.1 401 Unauthorized
< WWW-Authenticate: Bearer
$ echo 'YWRhQGV4YW1wbGUuY29tOkJvb2tzdG9yZS1EZW1vLTE=' | base64 -d
ada@example.com:Bookstore-Demo-1
Basic only encodes the password with base64; without HTTPS it is the same as plain text. The server's WWW-Authenticate: Bearer response also tells you which scheme it expects.
For applications that keep a session in cookies (Identity's cookie login, server-side MVC), -c jar.txt writes incoming cookies to a file and -b jar.txt sends them back on the next request. Bookstore.Api does not use cookies, so I show no output here; the logic is the same as with the token file: the jar file is a secret too and does not go into the repo.
Uploading Files: -F and multipart/form-data
For this post I added a book cover upload endpoint to Bookstore.Api: PUT /api/books/{isbn}/cover, open only to the StoreStaff role, accepting JPEG or PNG and limited to 2 MB. It does not trust the Content-Type and also looks at the first bytes of the file:
public static RouteGroupBuilder MapBookCoverEndpoints(this RouteGroupBuilder catalog)
{
// Bearer token, no cookies: a cross-site form cannot send the token, so antiforgery is not needed.
catalog.MapPut("/books/{isbn}/cover", UploadCover)
.RequireAuthorization(policy => policy.RequireRole("StoreStaff"))
.DisableAntiforgery();
catalog.MapGet("/books/{isbn}/cover", GetCover);
return catalog;
}
The .DisableAntiforgery() line came later. On the first try curl got a 500 and the log said:
System.InvalidOperationException: Endpoint HTTP: PUT /api/books/{isbn}/cover => UploadCover contains anti-forgery metadata, but a middleware was not found that supports anti-forgery.
Since .NET 8, minimal API endpoints that bind IFormFile require antiforgery validation. That is the right default in an application that works with cookies; it is unnecessary in an API that carries the token in a header. Limits and streaming for file uploads are in the Web API series post "File Upload and Download in ASP.NET Core Web API: IFormFile, Limits and Streaming".
-F "field=@file" builds a multipart/form-data body and makes the method POST; since the endpoint is PUT, we add -X PUT. First as Ada (Customer), then as Grace (StoreStaff):
$ curl -si -X PUT $B/api/books/9780261102217/cover -H "Authorization: Bearer $ADA" -F "file=@the-hobbit.png" | head -1
HTTP/1.1 403 Forbidden
$ curl -s -X PUT $B/api/books/9780261102217/cover -H "Authorization: Bearer $GRACE" -F "file=@the-hobbit.png"
{"isbn":"9780261102217","fileName":"9780261102217.png","contentType":"image/png","size":838}
$ curl -s -o cover-back.png -w '%{http_code} %{content_type} %{size_download}\n' $B/api/books/9780261102217/cover
200 image/png 838
Now I upload notes.png, which contains plain text. curl guesses the part's Content-Type from the file extension; --trace-ascii shows what went out:
$ curl -s --trace-ascii trace.txt -X PUT $B/api/books/9780261102217/cover -H "Authorization: Bearer $GRACE" -F "file=@notes.png"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"detail":"Only JPEG and PNG covers are accepted.","traceId":"00-81ecab38ecee730fc716dd8a63658cb2-6fdc7cd59a1bce66-00"}
$ sed -n '/=> Send data/,/<= Recv header/p' trace.txt
=> Send data, 218 bytes (0xda)
0000: --------------------------HGOQzqvW2AXXBFGcxZE64i
0032: Content-Disposition: form-data; name="file"; filename="notes.png
0072: "
0075: Content-Type: image/png
008e:
0090: not really an image.
00a6: --------------------------HGOQzqvW2AXXBFGcxZE64i--
The part went out as image/png; what saved the API was the signature check. An endpoint that only looked at file.ContentType would have accepted this file. To set the part's type yourself, write -F "file=@cover.jpg;type=image/jpeg".
curl in Scripts: Exit Codes, --fail-with-body and --retry
The second and third commands from the guessing question:
$ curl -s http://localhost:5080/api/orders/1; echo " exit=$?"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"traceId":"00-313d5ccd5dc00c76ea989fa22e5ebac1-db9f584881551b79-00"} exit=0
$ curl -s --fail http://localhost:5080/api/orders/1; echo " exit=$?"
exit=22
$ curl -sS --fail http://localhost:5080/api/orders/1; echo " exit=$?"
curl: (22) The requested URL returned error: 401
exit=22
$ curl -s --fail-with-body http://localhost:5080/api/auth/login --json '{"email":"ada@example.com","password":"x"}'; echo " exit=$?"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid email or password.","traceId":"00-e903d1afc743adaad316c0df654539cf-2bb5012aff1c0e1e-00"} exit=22
Here is the real trap: for curl a 401 is not an error but a successfully completed HTTP conversation; the exit code is 0. A CI script that only uses curl -s stays green on the day the API returns 401 for every request. --fail exits with 22 on status 400 and above but swallows the body; together with -s it prints nothing at all, and adding -S at least shows curl's error line. --fail-with-body, added in curl 7.76.0, combines the two: it exits with 22 and also prints the ProblemDetails body. This should be your default in scripts.
To get the status code into a variable, send the body to a file and use -w:
$ code=$(curl -s -o body.json -w '%{http_code}' http://localhost:5080/api/orders/1); echo "$code"
401
--retry N only retries transient errors: timeouts and statuses such as 408, 429, 500, 502, 503 and 504; it honours the Retry-After header and doubles the wait on each attempt. A refused connection (the API has not started yet) is not transient by default; that needs --retry-connrefused:
$ curl -sS --retry 3 http://localhost:5099/api/books; echo " exit=$?"
curl: (7) Failed to connect to localhost port 5099 after 0 ms: Couldn't connect to server
exit=7
$ curl -sS --retry 3 --retry-connrefused --retry-delay 1 http://localhost:5099/api/books; echo " exit=$?"
curl: (7) Failed to connect to localhost port 5099 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to localhost port 5099 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to localhost port 5099 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to localhost port 5099 after 0 ms: Couldn't connect to server
exit=7
There is also --retry-all-errors; curl's own documentation describes it as a blunt tool and warns that data may be sent twice. Retrying a POST order request may mean two orders. My rule: retries only for requests that are safe to repeat. --max-time caps each attempt, and exceeding it gives exit code 28. Measuring which phase of a request is slow with -w's timing variables is the topic of the Networking and HTTP series post "Where Does a Slow Request Come From".
Putting these together gives a smoke script that runs the shared scenario and stops at the first error with a meaningful message. In the repo, scripts/smoke.sh:
#!/usr/bin/env bash
# Bookstore smoke test: login -> books -> cart -> order -> read the order.
# Usage: BOOKSTORE_PASSWORD=... ./scripts/smoke.sh (BOOKSTORE_URL and BOOKSTORE_EMAIL are optional)
# Needs curl 8.3+ (--variable) and jq.
set -euo pipefail
base="${BOOKSTORE_URL:-http://localhost:5080}"
email="${BOOKSTORE_EMAIL:-ada@example.com}"
password="${BOOKSTORE_PASSWORD:?Set BOOKSTORE_PASSWORD}"
common=(--silent --show-error --fail-with-body --max-time 10)
# only for requests that are safe to repeat
retry=(--retry 3 --retry-connrefused --retry-delay 1)
auth=(--variable %BOOKSTORE_TOKEN --expand-header "Authorization: Bearer {{BOOKSTORE_TOKEN}}")
# call <step> <curl arguments...>: prints the body, or the error body and exits on HTTP >= 400
call() {
local step=$1 body
shift
if ! body=$(curl "${common[@]}" "$@"); then
echo "FAIL $step: $body" >&2
exit 1
fi
printf '%s' "$body"
}
login_body=$(jq -n --arg email "$email" --arg password "$password" '{email: $email, password: $password}')
BOOKSTORE_TOKEN=$(call login "${retry[@]}" "$base/api/auth/login" --json "$login_body" | jq -r .accessToken)
export BOOKSTORE_TOKEN
echo "ok login as $email"
isbn=$(call books "${retry[@]}" "$base/api/books?page=1&search=Hobbit" | jq -r '.items[0].isbn')
echo "ok book $isbn"
cart=$(call cart "${auth[@]}" -X POST "$base/api/carts" | jq -r .id)
total=$(call add-item "${auth[@]}" -X PUT "$base/api/carts/$cart/items/$isbn" --json '{"quantity":2}' | jq -r .total)
echo "ok cart $cart total $total"
order_body=$(jq -n --arg cart "$cart" --arg email "$email" '{cartId: $cart, customerName: "Smoke Test", customerEmail: $email}')
location=$(call order "${auth[@]}" "$base/api/orders" --json "$order_body" -o /dev/null -w '%header{location}')
echo "ok order $location"
status=$(call read-order "${auth[@]}" "${retry[@]}" "$location" | jq -r .status)
if [[ "$status" != "Placed" ]]; then
echo "FAIL read-order: expected Placed, got $status" >&2
exit 1
fi
echo "ok status $status"
A few decisions: JSON bodies are built with jq -n --arg, so the JSON does not break even if the password contains quotes or backslashes. The order address comes straight from the Location header via -w '%header{location}' (curl 7.84.0). POST requests get no retries. jq itself will be covered in detail in the Terminal and Command Line series. Three runs:
$ BOOKSTORE_PASSWORD=Bookstore-Demo-1 ./scripts/smoke.sh; echo "exit=$?"
ok login as ada@example.com
ok book 9780261102217
ok cart 01a1d94a-b8bd-7321-8836-8f8308bcfd7d total 659.80
ok order http://localhost:5080/api/orders/1
ok status Placed
exit=0
$ BOOKSTORE_PASSWORD=wrong ./scripts/smoke.sh; echo "exit=$?"
curl: (22) The requested URL returned error: 401
FAIL login: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid email or password.","traceId":"00-e79496c710ff3a2555dce00168ddaaf1-8e93d51d077f3a3d-00"}
exit=1
$ ./scripts/smoke.sh; echo "exit=$?"
./scripts/smoke.sh: line 9: BOOKSTORE_PASSWORD: Set BOOKSTORE_PASSWORD
exit=1
curl.exe and PowerShell on Windows
Since Windows 10 1803, C:\Windows\System32\curl.exe ships with the operating system and is updated through Windows Update; Windows 11 releases in 2026 carry the 8.x series. Two things need attention.
The first is the name. In Windows PowerShell 5.1, curl is an alias for Invoke-WebRequest; typing curl -s ... runs the cmdlet, not curl, and it does not know the parameters. PowerShell 7 does not define this alias. In both versions the safe spelling is curl.exe.
The second is quotes. PowerShell 7.3 changed how arguments are passed to native programs and made it controllable with $PSNativeCommandArgumentPassing. The default is Windows on Windows and Standard elsewhere; for curl.exe both behave the same and the double quotes in the JSON are preserved. Windows PowerShell 5.1's behaviour is the old (Legacy) one: double quotes get lost on the way. I tried both modes with the same command in PowerShell 7.7:
PS> curl -s "$base/api/auth/login" --json '{"email":"ada@example.com","password":"Bookstore-Demo-1"}'
{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5...
PS> $PSNativeCommandArgumentPassing = 'Legacy'
PS> curl -s "$base/api/auth/login" --json '{"email":"ada@example.com","password":"Bookstore-Demo-1"}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-15fb2253e7da905120068ef7a83b6407-76ba2e638e1cf814-00"}
The API log shows why: the body arrived as {email:ada@example.com,...}, 'e' is an invalid start of a property name. Returning 500 instead of 400 is specific to Development: in Development, minimal APIs throw BadHttpRequestException for a malformed body and the exception handler turns it into 500. If you have to work in 5.1, writing the body to a file and passing it with --json "@login.json" removes the quoting problem entirely.
In PowerShell, Invoke-RestMethod is often more comfortable than curl: it turns the JSON response into an object, you build the body from a hashtable, and the quoting problem disappears:
$base = 'http://localhost:5080'
$body = @{ email = 'ada@example.com'; password = $env:BOOKSTORE_PASSWORD } | ConvertTo-Json
$login = Invoke-RestMethod -Method Post -Uri "$base/api/auth/login" -ContentType 'application/json' -Body $body
$token = ConvertTo-SecureString $login.accessToken -AsPlainText -Force
(Invoke-RestMethod "$base/api/books?page=1&pageSize=3").items | Format-Table isbn, title, price
Invoke-RestMethod "$base/api/orders/1" -Authentication Bearer -Token $token -AllowUnencryptedAuthentication |
Format-List id, status, total
isbn title price
---- ----- -----
9780451524935 1984 279.000
9780143035008 Anna Karenina 429.000
9780060850524 Brave New World 299.000
id : 1
status : Placed
total : 659.8
The -AllowUnencryptedAuthentication on the last line was missing on the first try, and the cmdlet did not send the request at all: it failed with "The cmdlet cannot protect plain text secrets sent over unencrypted connections". -Authentication Bearer refuses to send a token over HTTP; in local development you allow it explicitly with this parameter, and over HTTPS it is not needed. On errors Invoke-RestMethod throws an exception; to get the status code in a script, use -SkipHttpErrorCheck -StatusCodeVariable status:
PS> $problem = Invoke-RestMethod "$base/api/orders/1" -SkipHttpErrorCheck -StatusCodeVariable status
PS> "$status $($problem.title)"
401 Unauthorized
Copy as cURL and More Readable Alternatives
The shortest way to reproduce a bug starts in the browser: right-click the request in the Network panel and choose Copy as cURL (see the Browser DevTools series post "The Network Panel"). On Windows, Chrome splits this menu into "cURL (cmd)" and "cURL (bash)"; the cmd version escapes special characters with ^, the bash version uses single quotes. If you are pasting into PowerShell, "Copy as PowerShell" causes fewer problems. The copied command carries all of the browser's headers and cookies; delete the Authorization and Cookie lines before attaching it to an issue.
For everyday exploration curl's syntax can feel long. httpie and xh, its compatible single-binary rewrite in Rust, build a JSON body from field=value pairs and colour and format the response:
$ xh --pretty=format -b :5080/api/books page==1 pageSize==2 search=="war and peace"
{
"items": [
{
"isbn": "9781400079988",
"title": "War and Peace",
"author": "Leo Tolstoy",
"category": "Classics",
"price": 689.0,
"publishedYear": 1869
}
],
"total": 1,
"page": 1,
"pageSize": 2
}
$ xh --offline --pretty=format POST :5080/api/carts/x/items/y quantity:=2
POST /api/carts/x/items/y HTTP/1.1
...
Content-Type: application/json
User-Agent: xh/0.26.3
{
"quantity": 2
}
== means a query string parameter, = a string field and := a raw JSON value; --offline prints the request without sending it. Note one detail: the API returned 689.00, xh's formatter showed 689.0. Formatters can rewrite numbers; if you are checking decimal precision, look at the raw body (--pretty=none). These tools are comfortable for exploring, but not as portable as curl, which is already on every CI runner and every Windows machine.
In Production
If you have to run curl against production (a health check during an incident, triggering a webhook by hand), show the command to a colleague first and read -X DELETE and PUT requests twice. Pass the token with -H @file or --variable; the output of --trace-ascii and -v prints the Authorization header in clear, so clean it before pasting it into a ticket or a chat.
Do not forget --max-time when you run the smoke script in CI. By default curl has no overall time limit; an API that does not respond keeps the job waiting until the runner's own timeout. Keep retries limited too: three attempts with a short delay are enough while the API starts; more than that delays a real failure by minutes.
And -k (--insecure): it turns off certificate verification. It is tempting when testing locally with a self-signed certificate, but the day it gets into a script it stays off in production too. For local development use a trusted certificate via dotnet dev-certs https --trust, or pass that certificate with --cacert.
Common Pitfalls
Sending JSON with -d and getting 415: use --json or set Content-Type explicitly.
Trusting the exit code of curl -s: 401 and 500 return 0 too. Use --fail-with-body in scripts.
Writing the token on the command line: it lands in shell history and the process list. Use -H @file or --variable %ENV. If you typed it by mistake, delete that line with history -d in bash and rotate the token.
Adding --retry-all-errors to a POST request: a retry after a timeout can create a second copy of an order the server actually processed. Keep retries off for non-idempotent requests, or use an idempotency key on the API side (covered in the API Security series post on API6).
Typing curl in Windows PowerShell 5.1 and running Invoke-WebRequest, and single-quoted JSON getting mangled: type curl.exe, pass the body from a file, or use Invoke-RestMethod.
A Small Experiment
★☆☆ Log in as Grace with curl -sv, put the token into BOOKSTORE_TOKEN and call GET /api/auth/me with --variable. Then send the same request without a token and find the WWW-Authenticate header.
★★☆ Add a step to smoke.sh: log in as Grace, upload the book's cover with -F, and check with -w '%{content_type}' that GET /api/books/{isbn}/cover returns image/png. See that the same upload with Ada's token stops the script with 403.
★★★ Write the PowerShell 7 version of the script with Invoke-RestMethod: on errors it should print the detail field of the ProblemDetails and exit with 1. Try writing the same script with curl.exe while $PSNativeCommandArgumentPassing = 'Legacy' and note which lines break.
The flow in this post works, but it depends on bash and jq and does not run with one click in the IDE. In the next post we move the same flow into an .http file that lives in the repo: variables, environments and request chaining in Visual Studio, Rider and Visual Studio Code, plus a real run with JetBrains' ijhttp CLI.