The disclosure, kept short since it barely applies here: we are Geonode and we sell proxies. A POST request needs nothing from us. Everything below works from your own connection against your own endpoints. Proxies only enter the picture much later, and there is a brief note near the end about the one thing that genuinely changes when you POST through one — which is not what most people expect.
The Basics
curl -d "name=Ada&role=engineer" https://api.example.com/users
The curl manual describes -d, --data: "Send the specified data to a server. For HTTP(S), this is done with the POST method in the same way that a browser does when a user has filled in an HTML form and presses the submit button. This option makes curl pass the data to the server using the content-type application/x-www-form-urlencoded."
Two things happen automatically and both matter.
-d implies POST. You do not need -X POST, and adding it changes nothing except in redirect handling, where -X is applied to every hop.
-d sets Content-Type: application/x-www-form-urlencoded. This is correct for form submissions and wrong for almost everything else.
You can repeat -d and curl joins the pieces: the manual notes that "using -d name=daniel -d skill=lousy would generate a post chunk that looks like name=daniel&skill=lousy."
Sending JSON
The most common real use, and where the errors are.
The explicit way:
curl -X POST https://api.example.com/users \
-H "Content-Type: application/json" \
-d '{"name":"Ada","role":"engineer"}'
The shortcut most people have not seen. curl has a dedicated --json option, documented as: "Send the specified JSON data in a POST request to the HTTP server. --json works as a shortcut for passing on these three options: --data-binary [arg], --header "Content-Type: application/json", --header "Accept: application/json"."
curl --json '{"name":"Ada","role":"engineer"}' https://api.example.com/users
Three options in one, and it sets Accept as well as Content-Type, which is usually what you wanted. It also reads from a file or stdin with @:
curl --json @payload.json https://api.example.com/users
cat payload.json | curl --json @- https://api.example.com/users
One honest caveat from the manual: "There is no verification that the passed in data is actual JSON or that the syntax is correct." It sets headers; it does not validate. A malformed body still gets sent with a JSON content type, and the server's complaint will be about your JSON rather than about curl.
The headers it sets "can be overridden with --header as usual", so you can keep the shortcut and adjust one part of it.
Single quotes matter. Wrap JSON bodies in single quotes so the shell does not expand $ or interpret the double quotes inside. If your JSON also contains single quotes, put it in a file.
The Five Data Options and When to Use Each
This is the part that resolves most confusion, because curl has several --data variants that differ in specific ways.
| Option | Content type set | @ special? | Newlines | Use for |
|---|---|---|---|---|
-d / --data | form-urlencoded | Yes, reads a file | Stripped | Form submissions |
--data-raw | form-urlencoded | No | Stripped | Data that starts with @ |
--data-binary | form-urlencoded | Yes | Preserved | Files, exact bytes |
--data-urlencode | form-urlencoded | Yes | Encoded | Values with special characters |
--json | application/json | Yes | Preserved | JSON bodies |
--data-raw exists for one reason: the manual says it posts data "similarly to --data but without the special interpretation of the @ character". If your literal data begins with @ — an email address, a handle, a mention — -d will try to read it as a filename and fail confusingly. Their own example is curl --data-raw "@at@at@".
--data-binary is the one to use for files. The manual: "Post data exactly as specified with no extra processing whatsoever... newlines and carriage returns are preserved and conversions are never done." Note that it still sends application/x-www-form-urlencoded by default, so if you are posting arbitrary binary the manual tells you to override it: -H "Content-Type: application/octet-stream".
This is why -d @file.json can subtly break: newlines are stripped. For JSON that usually does not matter; for anything where whitespace is significant, it does. --data-binary @file.json or --json @file.json is the safer form.
--data-urlencode handles values containing &, =, spaces or anything else that would break form encoding. The manual documents several syntaxes, and the one you want is almost always name=content, which URL-encodes the content and leaves the name alone:
curl --data-urlencode "comment=hello & goodbye = fine" https://example.com/post
Without it, that & would be read as a field separator and your comment would be silently truncated. There is also name@filename, which loads the content from a file, URL-encodes it, and appends = to the name.
File Uploads and Multipart Forms
For actual file uploads, -F is the option, and it works differently from -d.
The manual: "-F, --form <name=content> ... emulate a filled-in form in which a user has pressed the submit button. This makes curl POST data using the Content-Type multipart/form-data according to RFC 2388."
curl -F "file=@report.pdf" -F "title=Q3 Report" https://api.example.com/upload
The @ and < distinction is worth learning because it is not intuitive. The manual: "To force the 'content' part to be a file, prefix the filename with an @ sign. To get the content part from a file, prefix the filename with the symbol <. The difference between @ and < is then that @ makes a file get attached in the post as a file upload, while the < makes a text field and gets the contents for that text field from a file."
So @ uploads a file as a file; < sends a file's contents as a text field value.
To set a content type on a part:
curl -F "file=@data.csv;type=text/csv" https://api.example.com/upload
And if you need a literal value that begins with @ or <, use --form-string, which does not interpret either character.
Do not set Content-Type: multipart/form-data yourself. curl generates it including a boundary parameter, and overriding it produces a request the server cannot parse — a very common cause of unexplained 400s and 415s.
For a straightforward PUT of a file, -T is simpler: "Upload the specified local file to the remote URL... If this option is used with an HTTP(S) URL, the PUT method is used."
Authentication and Headers
curl --json '{"a":1}' \
-H "Authorization: Bearer eyJhbG..." \
https://api.example.com/items
-H is repeatable and overrides curl's defaults, including the ones --json sets.
For basic auth, -u user:password — or -u user alone, which makes curl prompt so the password stays out of your shell history. The manual notes that "on systems where it works, curl hides the given option argument from process listings", while adding that "this is not enough to protect credentials".
For a session-based API, capture and reuse cookies:
curl -c jar.txt -d "user=ada&pass=secret" https://example.com/login
curl -b jar.txt --json '{"a":1}' https://example.com/api/items
Debugging a POST That Does Not Work
A short sequence that resolves nearly everything.
See exactly what you sent:
curl -v --json '{"a":1}' https://api.example.com/items 2>&1 | grep -E '^[<>]'
Lines beginning > are your request, < is the response. Confirm the method, the Content-Type and the body are what you intended. A surprising share of "the API is broken" reports end here.
Read the status code and the error body:
curl -sS -o body.txt -D headers.txt --json '{"a":1}' https://api.example.com/items
head -1 headers.txt; head -c 300 body.txt
Interpret the common failures:
| Status | Usually means |
|---|---|
| 400 | Malformed body or missing required field |
| 401 | Missing or invalid credentials |
| 403 | Authenticated but not permitted |
| 405 | The endpoint does not accept POST — check the URL and method |
| 413 | Body too large |
| 415 | Wrong Content-Type — the classic -d with JSON mistake |
| 422 | Content type fine, data failed validation |
The 415-versus-422 distinction is the one worth internalising: 415 means the wrapper is wrong, 422 means the contents are. We covered it in detail in what is a 415 status code.
Make failures loud in scripts:
curl --fail-with-body --silent --show-error \
--connect-timeout 5 --max-time 30 \
--json @payload.json https://api.example.com/items
--fail-with-body gives a non-zero exit on HTTP errors while still printing the response body, which is what you want when the API returns useful JSON error messages. Plain --fail discards the body, which throws away the explanation.
Converting a Browser Request Into curl
The fastest way to reproduce a POST that works in a browser and not in your code — and a technique most people discover far later than they should.
Copy it from developer tools. In Chrome, Firefox and Safari, open the Network tab, find the request, right-click and choose "Copy as cURL". You get a complete command with every header, cookie and body the browser sent. Paste it into a terminal and it should behave identically.
That immediately answers the question underneath most debugging sessions: is the problem the request, or is it my code? If the copied command works and yours does not, the difference is in what you are sending, and you now have both versions side by side to compare.
Then strip it down. A copied command typically carries thirty headers, most of which are irrelevant. Remove them a few at a time and re-run until it breaks. What remains is the minimum set the server actually requires, and that is what belongs in your application:
curl 'https://api.example.com/items' \
-H 'content-type: application/json' \
-H 'authorization: Bearer eyJhbG...' \
--data-raw '{"name":"Ada"}'
Note that browsers export --data-raw rather than -d, precisely because a body beginning with @ would otherwise be misread as a filename.
Watch for two things that will not survive the copy. Cookies are included as a literal header and will expire. And anything the page computed in JavaScript — a CSRF token, a signature, a timestamp-derived value — is baked into the copied command as a fixed string, so it works once and then stops. If a reproduced request succeeds and then fails on the second run, that is almost always why, and the fix is to fetch the token rather than hard-code it.
Going the other way, several tools convert a curl command into code for most languages, which is a reasonable way to move from a working command to a working client without retyping headers by hand.
POSTing Through a Proxy
Brief, and the important point is a caution rather than a technique.
curl -x http://user:pass@proxy.example.com:9000 \
--json '{"a":1}' https://api.example.com/items
The mechanics are unchanged. What changes is the retry calculus, and this is the part worth thinking about before you add --retry.
POST is generally not idempotent. Sending it twice may create two records. curl's --retry only fires on transient conditions by default, but --retry-all-errors broadens that considerably — and through a proxy, a 5xx often means the target refused you rather than had a bad moment. Retrying that is at best pointless and at worst duplicates a write.
A timeout is not proof of failure. If a request times out after the server received it, the operation may have completed while you saw an error. Through a proxy there is an extra hop where this can happen. If the operation matters, use an idempotency key — most serious APIs support one — rather than relying on retry logic to be safe.
And check which hop refused you. %{http_connect} reports the proxy's response to the CONNECT separately from the target's status:
curl -sS -o /dev/null -x "$PROXY" \
-w 'connect=%{http_connect} status=%{response_code}\n' \
--json '{"a":1}' https://api.example.com/items
connect=407 means the proxy wanted credentials. connect=200 status=403 means the proxy worked and the target refused. Different problems, different fixes.
People Also Ask
How do I send a POST request with curl?
curl -d "key=value" URL. The -d option implies POST, so -X POST is unnecessary. It also sets Content-Type: application/x-www-form-urlencoded, which is correct for form submissions and wrong for JSON.
How do I POST JSON with curl?
curl --json '{"key":"value"}' URL is the shortcut — it sets --data-binary plus both the Content-Type and Accept headers to application/json. The longer form is -X POST -H "Content-Type: application/json" -d '...'. Note that --json does not validate your JSON.
Why do I get 415 Unsupported Media Type from curl?
Almost always because you used -d with a JSON body without setting the content type. -d sends form-encoded, and an API expecting JSON rejects it. Use --json, or add -H "Content-Type: application/json".
What is the difference between -d and --data-raw?
-d treats a leading @ as "read from this file". --data-raw does not, so it is what you need when your literal data begins with @ — an email address or a handle, for instance. Otherwise they behave identically.
How do I upload a file with curl POST?
curl -F "file=@document.pdf" URL, which sends multipart/form-data. Use @ to attach the file as a file and < to send its contents as a text field value. Do not set the Content-Type header yourself — curl generates it with the required boundary.
How do I send POST data from a file?
curl --json @payload.json URL for JSON, or --data-binary @file for exact bytes including newlines. Avoid -d @file when whitespace matters, because -d strips newlines and carriage returns.
How do I POST a value containing an ampersand?
Use --data-urlencode "field=value with & inside". With plain -d, the ampersand is read as a field separator and your value is silently truncated at that point.
Should I retry a failed POST?
Carefully. POST is generally not idempotent, so a retry may create a duplicate — and a timeout does not prove the server did not process the request. Use an idempotency key where the API supports one, and be cautious with --retry-all-errors, particularly through a proxy where a 5xx often means refusal rather than a transient fault.
Wrapping Up
The whole topic reduces to one question: what content type does the endpoint want, and does your command send it?
-d sends form-encoded, which is right for form submissions and wrong for JSON — and that single mismatch is behind most 415 errors people meet. --json is the option to reach for instead, and it is under-known: one flag that sets the body handling and both headers, with @ support for files and stdin.
Beyond that, the variants exist for specific reasons worth remembering. --data-raw when your data starts with @. --data-binary when newlines matter. --data-urlencode when a value contains characters that would break form encoding. -F for actual file uploads, with @ to attach a file and < to read a text field from one.
And when something fails, run it with -v and read the > lines before changing anything. The request you sent is frequently not the request you thought you sent, and that gap is where most of the confusion in this area lives.
