Evomi

Blog / Tool Guides

cURL POST Request: JSON, Form Data, Files and Proxies

Sarah WhitmoreSarah Whitmore9 min read
A small golden robot stands at a wooden workbench and inserts a sealed envelope into a slot on a brass machine labeled "CURL", which is topped with four circular dial gauges inside a rustic workshop filled with tools, wooden crates, and shelves.

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

-d 'a=1&b=2'

application/x-www-form-urlencoded

Form fields with spaces or &

--data-urlencode 'q=a & b'

application/x-www-form-urlencoded

JSON (curl 7.82.0 or newer)

--json '{"a":1}'

application/json, plus Accept: application/json

JSON (any version)

-H 'Content-Type: application/json' -d '{"a":1}'

whatever you set with -H

A JSON file, byte for byte

--json @body.json or --data-binary @body.json

as above

A file upload

-F 'file=@report.csv'

multipart/form-data

An empty POST

-X POST

none

Your first cURL POST request

With -d, cURL sends the data as an HTML form would:

Shell
curl -d 'name=Ada&lang=en' https://httpbin.io/post

httpbin.io reports what arrived. The relevant part of the response:

JSON
{
  "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:

Rust
curl -H 'Content-Type: application/json' \
  -d '{"name":"Ada","plan":"trial"}' \
  https://httpbin.io/post

Since 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:

Shell
curl --json '{"name":"Ada","plan":"trial"}' https://httpbin.io/post
JSON
{
  "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:

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:

SQL
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 @-:

Shell
echo '{"from":"stdin"}' | curl --json @- https://httpbin.io/post

URL-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:

Go
curl -d 'q=proxies & more' https://httpbin.io/post
# "form": { " more": [""], "q": ["proxies "] }

--data-urlencode encodes each value for you:

Go
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:

Shell
curl -F 'file=@report.csv' -F 'note=October export' https://httpbin.io/post
JSON
{
  "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:

Shell
curl -H "Authorization: Bearer $API_TOKEN" --json '{"ok":true}' https://httpbin.io/post

For HTTP Basic authentication, -u builds the header for you. httpbin.io received Basic YWRhOnMzY3JldA== for this one:

Shell
curl -u 'ada:s3cret' -d 'x=1' https://httpbin.io/post

Keep 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

-i

Prints the status line and response headers before the body

-w '%{http_code}\n'

Prints the status code, handy with -o /dev/null to discard the body

--fail

Exits with code 22 on HTTP 400 or above, instead of 0

--fail-with-body

Same exit code, but still prints the error body (curl 7.76.0 or newer)

-s / -S

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:

Shell
curl -s --fail-with-body -d 'x=1' https://httpbin.io/status/422; echo "exit=$?"
# exit=22

Redirects 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:

Rust
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):

Rust
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 >:

Shell
curl -v --json '{"a":1}' https://httpbin.io/post -o /dev/null
Plain Text
> POST /post HTTP/1.1
> Host: httpbin.io
> User-Agent: curl/8.7.1
> Content-Type: application/json
> Accept: application/json
> Content-Length: 7

For 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:

Shell
curl -x http://127.0.0.1:8899 -U 'demo:demo-pass_country-US' \
  --json '{"a":1}' https://httpbin.io/post

That returned "json": {"a": 1} through our local test proxy. Three other forms gave the same result:

Shell
# 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:

Plain Text
> CONNECT httpbin.io:443 HTTP/1.1
< HTTP/1.1 200 Connection established
> POST /post HTTP/1.1
< HTTP/1.1 200 OK

That'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}:

Shell
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 000

Without -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:

Shell
curl -x http://rp.evomi.com:1000 -U 'your_username:your_password_country-DE' \
  --json @payload.json https://httpbin.io/post

Before 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.

Shell
#!/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"
Shell
./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.json

One 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).