A cURL POST request sends data to a server in the request body. The shortest one is curl -d 'name=Ada' https://httpbin.io/post: the -d flag adds the body and switches cURL from GET to POST on its own.
What usually goes wrong is everything around that one flag: the wrong Content-Type, a JSON file that loses its line breaks, a form field split in two by an &, or a redirect that quietly turns the POST into a GET. This guide covers each of those with the exact command and the output it produced.
Every command below was run with curl 8.7.1 against httpbin.io, which echoes back whatever it receives, and through a local authenticating proxy for the proxy section. If you are new to cURL, start with this overview of cURL as a data-transfer tool or with performing cURL GET requests.
The quick answer
You want to send | Use | Content-Type cURL sets |
|---|---|---|
Form fields |
|
|
Form fields with spaces or |
|
|
JSON (curl 7.82.0 or newer) |
|
|
JSON (any version) |
| whatever you set with |
A JSON file, byte for byte |
| as above |
A file upload |
|
|
An empty POST |
| none |
Your first cURL POST request
With -d, cURL sends the data as an HTML form would:
curl -d 'name=Ada&lang=en' https://httpbin.io/posthttpbin.io reports what arrived. The relevant part of the response:
{
"form": { "lang": ["en"], "name": ["Ada"] },
"headers": {
"Content-Length": ["16"],
"Content-Type": ["application/x-www-form-urlencoded"],
"User-Agent": ["curl/8.7.1"]
}
}cURL set the method, the Content-Type and the Content-Length for you. You can repeat -d: -d 'name=Ada' -d 'lang=en' produces the same body, joined with &.
Do you need -X POST?
No, not with -d, --json or -F, because each of them already makes the request a POST. -X POST on its own sends a POST with an empty body (httpbin.io reported 0 bytes uploaded), which is occasionally what an API wants for an action endpoint.
Use -X when you need a different method with a body, for example curl -X PUT --json '{"a":1}' https://httpbin.io/anything, which arrived as a PUT with the JSON intact.
Send JSON with cURL
An API that expects JSON will usually reject a body labelled as form data, so tell it what you are sending:
curl -H 'Content-Type: application/json' \
-d '{"name":"Ada","plan":"trial"}' \
https://httpbin.io/postSince curl 7.82.0 there is a shorter form. --json sets the body, the Content-Type and an Accept: application/json header in one go:
curl --json '{"name":"Ada","plan":"trial"}' https://httpbin.io/post{
"json": { "name": "Ada", "plan": "trial" },
"headers": {
"Accept": ["application/json"],
"Content-Type": ["application/json"]
}
}Check your version with curl --version. On an older cURL, --json is an unknown option and the request fails, so keep the -H form for scripts that run on old servers.
Send a JSON file (and why -d @file can change it)
Long payloads belong in a file. The @ prefix reads the body from one, but -d and --data-binary read it differently. With this payload.json:
{
"name": "Ada",
"plan": "trial"
}-d @payload.json strips the line breaks, while --data-binary sends the file exactly as it is on disk. This is what httpbin.io received for each:
curl -H 'Content-Type: application/json' -d @payload.json https://httpbin.io/post
# "data": "{ \"name\": \"Ada\", \"plan\": \"trial\"}"
curl -H 'Content-Type: application/json' --data-binary @payload.json https://httpbin.io/post
# "data": "{\n \"name\": \"Ada\",\n \"plan\": \"trial\"\n}\n"For JSON the two parse the same, because line breaks between tokens don't matter. They matter when the body is signed (a webhook signature or HMAC over the raw bytes), when it holds a multi-line string, or when it isn't JSON at all, such as NDJSON with one record per line. When in doubt, use --data-binary. --json @payload.json also sends the file unchanged.
To send the output of another command, read the body from standard input with @-:
echo '{"from":"stdin"}' | curl --json @- https://httpbin.io/postURL-encode form fields
-d sends your text as it is, so special characters change the meaning of the body. An & inside a value starts a new field:
curl -d 'q=proxies & more' https://httpbin.io/post
# "form": { " more": [""], "q": ["proxies "] }--data-urlencode encodes each value for you:
curl --data-urlencode 'q=proxies & more' --data-urlencode 'page=1' https://httpbin.io/post
# "data": "q=proxies+%26+more&page=1"
# "form": { "page": ["1"], "q": ["proxies & more"] }Upload a file with multipart form data
-F builds a multipart/form-data body, the format a browser uses for a file input. Each -F is one field, and @ attaches a file:
curl -F 'file=@report.csv' -F 'note=October export' https://httpbin.io/post{
"files": { "file": ["id,price\n1,9.99\n"] },
"form": { "note": ["October export"] }
}cURL picks the file's content type itself. Set it explicitly with ;type=, as in -F 'file=@report.csv;type=text/csv'. Don't combine -F with -d in one command: they build two incompatible kinds of body.
Add headers and authentication
Add any header with -H. Most APIs take a bearer token:
curl -H "Authorization: Bearer $API_TOKEN" --json '{"ok":true}' https://httpbin.io/postFor HTTP Basic authentication, -u builds the header for you. httpbin.io received Basic YWRhOnMzY3JldA== for this one:
curl -u 'ada:s3cret' -d 'x=1' https://httpbin.io/postKeep secrets out of your shell history by reading them from environment variables, as with $API_TOKEN above. Our cURL Basic Auth guide covers the details, including the difference between authenticating to a site and to a proxy.
Read the response and catch errors
By default cURL prints only the response body. These flags tell you what actually happened:
Flag | What it does |
|---|---|
| Prints the status line and response headers before the body |
| Prints the status code, handy with |
| Exits with code 22 on HTTP 400 or above, instead of 0 |
| Same exit code, but still prints the error body (curl 7.76.0 or newer) |
| Hides the progress meter / still shows errors |
The exit code matters in scripts. Without --fail, a 422 Unprocessable Entity from the API still exits 0, and your script carries on as if the POST had worked:
curl -s --fail-with-body -d 'x=1' https://httpbin.io/status/422; echo "exit=$?"
# exit=22Redirects can turn your POST into a GET
Add -L to follow redirects, and look at what the second request becomes. On a 301, 302 or 303, cURL switches to GET and drops the body, as browsers do:
curl -L -d 'x=1' 'https://httpbin.io/redirect-to?url=/anything&status_code=301'
# "method": "GET", "form": {}If the API really means "send the same POST over there", keep the method with --post301 (or --post302, --post303):
curl -L --post301 -d 'x=1' 'https://httpbin.io/redirect-to?url=/anything&status_code=301'
# "method": "POST", "form": { "x": ["1"] }A 307 or 308 redirect keeps the method and body without any extra flag. That conversion is the usual answer when a POST works in Postman and silently "loses its data" in a script.
See exactly what cURL sends
-v prints the request headers, marked with >:
curl -v --json '{"a":1}' https://httpbin.io/post -o /dev/null> POST /post HTTP/1.1
> Host: httpbin.io
> User-Agent: curl/8.7.1
> Content-Type: application/json
> Accept: application/json
> Content-Length: 7For the body as well, use --trace-ascii trace.txt, which logs every byte sent and received to a file.
Send a POST request through a proxy
-x (or --proxy) routes the request through a proxy, and -U (or --proxy-user) supplies the proxy's username and password. The POST itself doesn't change:
curl -x http://127.0.0.1:8899 -U 'demo:demo-pass_country-US' \
--json '{"a":1}' https://httpbin.io/postThat returned "json": {"a": 1} through our local test proxy. Three other forms gave the same result:
# credentials in the proxy URL (URL-encode @ and : in the password)
curl -x 'http://demo:demo-pass_country-US@127.0.0.1:8899' -d 'x=1' https://httpbin.io/post
# long flag names, with a file upload
curl --proxy http://127.0.0.1:8899 --proxy-user 'demo:demo-pass_country-US' \
-F 'file=@report.csv' https://httpbin.io/post
# an environment variable, picked up by every curl in this shell
export https_proxy='http://demo:demo-pass_country-US@127.0.0.1:8899'
curl --json '{"via":"env"}' https://httpbin.io/post-U is the easiest of these when the password contains @, : or other reserved characters, because nothing has to be URL-encoded.
What the proxy sees
For an https:// URL, cURL doesn't hand the POST to the proxy. It asks the proxy to open a tunnel with CONNECT, then sends the request inside TLS, so the proxy carries the body without being able to read it. -v shows the two steps:
> CONNECT httpbin.io:443 HTTP/1.1
< HTTP/1.1 200 Connection established
> POST /post HTTP/1.1
< HTTP/1.1 200 OKThat's also why a plain http:// proxy endpoint is enough for HTTPS APIs: the encryption runs end to end between cURL and the site.
When the proxy rejects your credentials
A wrong proxy password fails before your POST is sent, so %{http_code} reports 000 and cURL exits with code 56. Ask for the proxy's own answer with %{http_connect}:
curl -s -x http://127.0.0.1:8899 -U 'demo:wrong' --json '{"a":1}' \
https://httpbin.io/post -o /dev/null -w '%{http_connect} %{http_code}\n'
# 407 000Without -s, cURL prints CONNECT tunnel failed, response 407. A 407 is the proxy refusing you, not the API. Check the username and password first. For other codes, see our list of common proxy error codes.
Test an API from another country
A common reason to put a proxy in front of a POST is to see how an API responds to a request from a particular location: localised pricing, regional content, or a geo-restricted rule you are testing on your own service. With Evomi's residential proxies, the country goes in the password:
curl -x http://rp.evomi.com:1000 -U 'your_username:your_password_country-DE' \
--json @payload.json https://httpbin.io/postBefore testing the API, check which exit you got with curl -x http://rp.evomi.com:1000 -U 'your_username:your_password_country-DE' https://ip.evomi.com/s, or look the address up in the IP geolocation checker. By default each request can leave from a different IP. Add _session-abc123 to the password when a sequence of calls, such as a login followed by a POST, has to come from one address.
If you use the HTTPS proxy port (1001), use the hostname rp.evomi-proxy.com, which is the name the proxy's TLS certificate covers. rp.evomi.com on that port fails certificate verification. The full list of endpoints is in the Evomi cURL integration guide.
A reusable POST script
This script wraps the habits above: strict failure on HTTP errors, timeouts, and an optional proxy taken from the environment.
#!/usr/bin/env bash
# usage: ./post.sh <url> <json-file>
set -euo pipefail
url="$1"; body="$2"
proxy_args=()
if [[ -n "${PROXY_URL:-}" ]]; then
proxy_args=(--proxy "$PROXY_URL" --proxy-user "$PROXY_USER:$PROXY_PASS")
fi
curl --silent --show-error \
--fail-with-body \
--connect-timeout 10 --max-time 60 \
--retry 3 --retry-delay 2 \
"${proxy_args[@]+"${proxy_args[@]}"}" \
--json @"$body" \
--write-out '\nHTTP %{http_code} in %{time_total}s\n' \
"$url"./post.sh https://httpbin.io/post payload.json
# ...response body...
# HTTP 200 in 0.46s
PROXY_URL=http://rp.evomi.com:1000 PROXY_USER=your_username PROXY_PASS=your_password \
./post.sh https://httpbin.io/post payload.jsonOne warning about --retry: cURL retries a POST on timeouts and on HTTP 408, 429, 500, 502, 503 and 504. Against a 500 it sent the same request four times. That is safe for an idempotent call, but an endpoint that creates an order or sends a message could act on it four times. Remove --retry there, or send an idempotency key if the API supports one.
On Windows
Windows 10 and 11 include curl.exe. In Windows PowerShell 5.1, plain curl is an alias for Invoke-WebRequest, so type curl.exe. Quoting inline JSON differs between PowerShell, cmd.exe and bash. The portable fix is to put the body in a file and send it with --json @payload.json or --data-binary @payload.json.
cURL or Postman?
They do the same job at the HTTP level, and every request in this guide can be built in either. cURL is better for scripts, CI jobs, servers and sharing a reproducible command in a bug report. Postman is better for exploring an unfamiliar API, saving collections and reading large responses. Most developers use both: Postman can export any request as a cURL command and import one back. To send Postman requests through a proxy, see setting up a proxy in Postman.
FAQ
How do I make a cURL POST request from a file?
Put @ before the file name: --json @body.json for JSON on curl 7.82.0 or newer, or -H 'Content-Type: application/json' --data-binary @body.json on any version. Avoid -d @file when the exact bytes matter, because it strips line breaks. To upload the file as an attachment instead, use -F 'file=@body.json'.
How do I send JSON in a cURL POST request?
Use --json '{"key":"value"}', which sets the Content-Type and Accept headers for you. On older versions, use -H 'Content-Type: application/json' -d '{"key":"value"}'. Wrap the JSON in single quotes in bash so the shell leaves the double quotes alone.
What does a cURL request mean?
It is an HTTP request sent from the command line with cURL instead of from a browser or an app. API documentation often gives examples as cURL commands because they spell out the method, URL, headers and body in one line that anyone can run.
How do I write a cURL request?
Start with the URL, then add what the request needs: -d, --json or -F for a body, -H for each header, -u for Basic authentication, and -x with -U for a proxy. Add -i or -v while you work on it, so you can see the status code and headers.
Why is my cURL POST turning into a GET?
Usually because of -L and a 301, 302 or 303 redirect: cURL follows it with a GET and drops the body. Add --post301, --post302 or --post303 to keep the POST, or call the final URL directly.
Is cURL better than Postman?
Neither is better overall. cURL wins for automation and reproducibility, Postman for exploring and documenting an API. They send the same requests, and either can import the other's format.
For the full option reference, see the official cURL documentation and the method definitions in the HTTP specification (RFC 9110).