NEWScrapingAnt MCP for Claude Code, Cursor & Windsurf — try it free →
Skip to main content

How to Send POST Requests With Wget: Form Data, JSON, Files, Redirects

· 15 min read
Oleg Kulyk
Co-Founder @ ScrapingAnt

How to Send POST Requests With Wget: Form Data, JSON, Files, Redirects

Updated 2026-09-16

Rewritten as a tested reference. Every command below was run by the example package against a local echo server that reports the method, headers and body it received, and the output is copied from that run (wget 1.25.0). The 2024 version's redirect table, copied from the manual, was wrong for this wget: 301 and 302 turn the POST into a GET, only 307 and 308 keep it. New: --method with --body-data/--body-file, the missing-error-body trap and --content-on-error, --keep-session-cookies, --retry-on-http-error, percent-encoding, exit codes.

The three commands most people are looking for:

wget -qO- --post-data 'user=foo&lang=en' http://127.0.0.1:8000/echo # a form
wget -qO- --header='Content-Type: application/json' --post-data='{"key":"value"}' http://127.0.0.1:8000/echo # JSON
wget -qO- --header='Content-Type: application/json' --post-file=fixtures/data.json http://127.0.0.1:8000/echo # a body from a file

--post-data makes the request a POST, sets Content-Type: application/x-www-form-urlencoded unless you set your own with --header, and sends the string as the body. -qO- writes the response to stdout and silences the progress output. Everything else on this page is what happens around that: the response you did not get on an error, the redirect that silently changed your method, the login cookie that was not saved, the retry that did not retry.

The echo server, and how to read the outputs​

The package starts a small server on 127.0.0.1:8000. /echo answers with a JSON description of the request it received (method, Content-Type, Content-Length, the raw body, and the parsed form when the body is URL-encoded); other paths return redirects, a 400 with a JSON body, a Basic-auth challenge, a login that sets a cookie, a 503 that clears after two attempts, and a slow response. Output blocks are that JSON, with the path, authorization, cookie and user_agent lines left out where they add nothing; dates and the server banner are masked. exit= is the exit status of the last command in the block: wget's where wget is the only command, otherwise the last command's (wc in section 6, where wget's own status is printed on the line above; grep -c in section 9). Where the recorded command pipes wget through grep to trim a transcript (sections 7 and 10) or runs in a temporary directory (sections 6 and 9), the article shows the wget command and the plumbing is in run.sh. The user-agent version and multipart boundaries are masked as <version> and <boundary>. To reproduce: clone the examples repository, cd examples/wget-post-requests, ./run.sh.

1. Form data with --post-data​

wget -qO- --post-data 'user=foo&lang=en' http://127.0.0.1:8000/echo
{
"method": "POST",
"content_type": "application/x-www-form-urlencoded",
"content_length": "16",
"body": "user=foo&lang=en",
"form": {
"user": ["foo"],
"lang": ["en"]
}
}
exit=0

2. JSON with --header​

wget -qO- --header='Content-Type: application/json' --post-data='{"key":"value"}' http://127.0.0.1:8000/echo
{
"method": "POST",
"content_type": "application/json",
"content_length": "15",
"body": "{\"key\":\"value\"}"
}
exit=0

Your --header replaces the default form content type. Quote the JSON in single quotes so the shell leaves the double quotes alone; for anything longer than a line, put it in a file.

3. A body from a file with --post-file​

wget -qO- --header='Content-Type: application/json' --post-file=fixtures/data.json http://127.0.0.1:8000/echo
{
"method": "POST",
"content_type": "application/json",
"content_length": "36",
"body": "{\"name\": \"ant\", \"tags\": [\"a\", \"b\"]}\n"
}
exit=0

--post-file sends the file's contents as the body, trailing newline included (36 bytes here). It is not a file upload: wget has no multipart/form-data support at all, per the manual and per section 12 below. The file must be a regular file, and you cannot combine the two options:

echo 'user=foo' | wget -nv -O- --post-file=/dev/stdin http://127.0.0.1:8000/echo; echo 'user=foo' | wget -nv -O- --post-file=- http://127.0.0.1:8000/echo
BODY data file '/dev/stdin' missing: Illegal seek
BODY data file '-' missing: No such file or directory
exit=3
wget -qO- --post-data 'a=1' --post-file=fixtures/form.txt http://127.0.0.1:8000/echo
You cannot specify both --post-data and --post-file.
exit=1

The manual explains the stdin limitation: wget needs the body's size before it sends the request, so the argument must be a regular file, and a FIFO or /dev/stdin will not work. Write the data to a temporary file first.

4. PUT, PATCH, DELETE: --method with --body-data or --body-file​

--post-data is POST-only. The general form is --method, which the manual describes as sending whatever method string you give it, with the body in --body-data or --body-file ("must be set when additional data needs to be sent to the server along with the Method"):

wget -qO- --method=PUT --header='Content-Type: application/json' --body-file=fixtures/data.json http://127.0.0.1:8000/echo
wget -qO- --method=POST --body-data='a=1&b=2' http://127.0.0.1:8000/echo
{
"method": "PUT",
"content_type": "application/json",
"content_length": "36",
"body": "{\"name\": \"ant\", \"tags\": [\"a\", \"b\"]}\n"
}
exit=0
{
"method": "POST",
"content_type": "application/x-www-form-urlencoded",
"content_length": "7",
"body": "a=1&b=2",
"form": {
"a": ["1"],
"b": ["2"]
}
}
exit=0

As the second echo shows, --method=POST --body-data=… produces the same request as --post-data=…, including the default form content type; use whichever reads better in your script, and --method for everything that is not a POST.

5. Seeing the response headers: -S​

wget -S -qO /dev/null --post-data 'user=foo' http://127.0.0.1:8000/echo
HTTP/1.1 200 OK
Server: <server>
Date: <date>
Content-Type: application/json
Content-Length: 250
exit=0

-S (--server-response) prints the response headers to stderr even with -q, which is the quickest way to check a status code or a Set-Cookie without saving anything.

6. The trap: no response body on a 4xx or 5xx​

An API that rejects your POST usually tells you why in the body. By default wget throws that body away:

wget -nv -O- --post-data 'x=1' http://127.0.0.1:8000/error400
http://127.0.0.1:8000/error400:
<time> ERROR 400: Bad Request.
exit=8

Nothing on stdout, exit 8. With -O, the file is created and left empty:

wget -nv -O response.json --post-data 'x=1' http://127.0.0.1:8000/error400; echo "wget exit=$?"; wc -c < response.json
http://127.0.0.1:8000/error400:
<time> ERROR 400: Bad Request.
wget exit=8
0
exit=0

--content-on-error keeps the body, and the exit status still reports the error:

wget -nv -O- --content-on-error --post-data 'x=1' http://127.0.0.1:8000/error400
{"error": "field user is required"}
http://127.0.0.1:8000/error400:
<time> ERROR 400: Bad Request.
exit=8

Exit 8 is wget's "server issued an error response"; put --content-on-error in every API script and read the exit code rather than the output.

Explore the most reliable residential proxies

Try out ScrapingAnt's residential proxies with millions of IP addresses across 190 countries!

7. Redirects: what happens to your POST​

The manual says that on a 301, 302 or 307 wget "will, in accordance with RFC2616, continue to send a POST request", and that a server wanting a method change should send 303. The tested wget does not do that. Each endpoint below redirects to /echo:

wget -qO- --post-data 'user=foo' http://127.0.0.1:8000/redirect301
wget -qO- --post-data 'user=foo' http://127.0.0.1:8000/redirect302
wget -qO- --post-data 'user=foo' http://127.0.0.1:8000/redirect303
wget -qO- --post-data 'user=foo' http://127.0.0.1:8000/redirect307
wget -qO- --post-data 'user=foo' http://127.0.0.1:8000/redirect308
"method": "GET",
"content_type": null,
"body": ""
exit=0
"method": "GET",
"content_type": null,
"body": ""
exit=0
"method": "GET",
"content_type": null,
"body": ""
exit=0
"method": "POST",
"content_type": "application/x-www-form-urlencoded",
"body": "user=foo",
exit=0
"method": "POST",
"content_type": "application/x-www-form-urlencoded",
"body": "user=foo",
exit=0

On wget 1.25.0: 301, 302 and 303 become a GET with no body; 307 and 308 re-send the POST with the body. RFC 9110 permits exactly that for 301 and 302 ("for historical reasons, a user agent MAY change the request method from POST to GET"), which is why 307 and 308 exist; the manual's paragraph does not describe what this version does. Other wget versions may differ; the package records what yours does. The consequence for scripts: if an endpoint answers your POST with a 301 or 302 (an http:// to https:// upgrade is the classic case), the data never reaches the final URL. Post to the final URL directly, or check with -S first.

8. Percent-encoding is your job​

--post-data sends the string exactly as given. Spaces and a stray & or = inside a value change what the server parses:

wget -qO- --post-data 'q=hello world&note=a&b=c' http://127.0.0.1:8000/echo
{
"method": "POST",
"content_type": "application/x-www-form-urlencoded",
"content_length": "24",
"body": "q=hello world&note=a&b=c",
"form": {
"q": ["hello world"],
"note": ["a"],
"b": ["c"]
}
}
exit=0

note was meant to be a&b=c and arrived as a, with a new field b. Encode values before you build the string; with Python 3 installed, urllib.parse.urlencode does it:

wget -qO- --post-data "$(python3 -c 'import urllib.parse; print(urllib.parse.urlencode({"q": "hello world", "note": "a&b=c"}))')" http://127.0.0.1:8000/echo
{
"method": "POST",
"content_type": "application/x-www-form-urlencoded",
"content_length": "28",
"body": "q=hello+world&note=a%26b%3Dc",
"form": {
"q": ["hello world"],
"note": ["a&b=c"]
}
}
exit=0

9. Log in with a POST, then reuse the session cookie​

The manual's login example, run for real. The server sets a session cookie on a successful login and requires it on /me:

wget -qO- --save-cookies cookies.txt --keep-session-cookies --post-data 'user=foo&password=bar' http://127.0.0.1:8000/login && wget -qO- --load-cookies cookies.txt http://127.0.0.1:8000/me && grep session cookies.txt
logged in
hello foo
127.0.0.1:8000 FALSE / FALSE 0 session abc123
exit=0

Two things the manual's example leaves out. Without --keep-session-cookies, a session cookie (one without an expiry) is not written to the file at all, so the second command fails in a way that looks like a wrong password:

wget -qO- --save-cookies cookies.txt --post-data 'user=foo&password=bar' http://127.0.0.1:8000/login; grep -c session cookies.txt
logged in
0
exit=1

The 0 is grep -c's count of session lines in the file (and its exit 1 means no match): the cookie was never written. And the protected page without the cookie file reports the 401 as an authentication failure with exit 6:

wget -nv -O- http://127.0.0.1:8000/me
Username/Password Authentication Failed.
exit=6

The saved-cookie format is Netscape's, with expiry 0 marking the session cookie; cookies in general are covered in How to use Wget with cookies.

10. Basic authentication on a POST​

wget -S -qO- --http-user=user --http-password=pass --post-data 'x=1' http://127.0.0.1:8000/auth
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="test"
HTTP/1.1 200 OK
"method": "POST",
"authorization": "Basic dXNlcjpwYXNz",
"body": "x=1",
exit=0

Two round trips: wget posts once without credentials, gets the 401 challenge, and posts again with the Authorization header. That second POST is automatic, so for an idempotent request it does not matter; for one that is not (an order, a payment), be aware the server sees the unauthenticated attempt first. --auth-no-challenge sends the header on the first request:

wget -S -qO- --auth-no-challenge --http-user=user --http-password=pass --post-data 'x=1' http://127.0.0.1:8000/auth
HTTP/1.1 200 OK
"method": "POST",
"authorization": "Basic dXNlcjpwYXNz",
"body": "x=1",
exit=0

The manual calls --auth-no-challenge "not recommended" and intended for servers that never send a challenge, because it sends the plaintext credentials to every URL you give it, so use it only for a known host over HTTPS. The transcripts here are trimmed to the status, challenge and echo lines.

11. Retries and timeouts​

wget retries by default (--tries, 20 by default), with the exceptions the manual names: "connection refused" and 404 are not retried, and in practice an HTTP error response such as 503 is final too. The /flaky endpoint answers 503 twice, then 200 (the counter is per query string, so each case below starts fresh):

wget -nv -O- --tries=3 --post-data 'x=1' 'http://127.0.0.1:8000/flaky?case=16'
http://127.0.0.1:8000/flaky?case=16:
<time> ERROR 503: Service Unavailable.
exit=8

--retry-on-http-error makes the listed codes retryable, with --waitretry capping the linear backoff between attempts. The POST body is re-sent on each attempt: -nv prints one URL line per failed attempt, the server log shows three POSTs, and wget's summary line ends in [3], the attempt that succeeded:

wget -nv -O- --tries=3 --retry-on-http-error=503 --waitretry=1 --post-data 'x=1' 'http://127.0.0.1:8000/flaky?case=17'
http://127.0.0.1:8000/flaky?case=17:
http://127.0.0.1:8000/flaky?case=17:
{
"method": "POST",
"content_type": "application/x-www-form-urlencoded",
"content_length": "3",
"body": "x=1",
"form": {
"x": ["1"]
}
}
<time> URL:http://127.0.0.1:8000/flaky?case=17 [249/249] -> "-" [3]
exit=0

The manual's own caveat applies: retrying a 503 or 429 on a server that is telling you to slow down is your responsibility. On a slow server, --read-timeout limits how long wget waits for data (the default is 900 seconds); --tries=1 stops it from retrying the timeout:

wget -nv -O- --read-timeout=1 --tries=1 --post-data 'x=1' http://127.0.0.1:8000/slow
Read error (Operation timed out) in headers.
exit=4

Exit 4 is a network failure. --timeout sets the DNS, connect and read timeouts at once; --connect-timeout and --dns-timeout set them separately.

12. What wget cannot do: multipart uploads​

A form with a file field (<input type="file">) needs multipart/form-data, which wget does not produce. --post-file sends bytes, not a named file part. This is what a multipart request looks like when curl builds it:

curl -s -F file=@fixtures/data.json -F note=hello http://127.0.0.1:8000/echo
{
"method": "POST",
"content_type": "multipart/form-data; boundary=<boundary>",
"content_length": "353",
"body": "--<boundary>\r\nContent-Disposition: form-data; name=\"file\"; filename=\"data.json\"\r\nContent-Type: application/octet-stream\r\n\r\n{\"name\": \"ant\", \"tags\": [\"a\", \"b\"]}\n\r\n--<boundary>\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhello\r\n--<boundary>--\r\n"
}
exit=0

For uploads, switch to curl; the rest of curl's POST options are in How to send POST requests with cURL.

Exit codes you will meet​

ExitMeaning (from the manual)Seen above
0successmost cases
1generic error--post-data with --post-file
3file I/O error--post-file=/dev/stdin
4network failureread timeout
6username/password authentication failureprotected page without the cookie
8server issued an error response400 with --content-on-error, 503 without retry

When ScrapingAnt is not needed, and when it is​

Every case on this page is your own server, an API you hold credentials for, or a form on a site that accepts your request; wget and curl are the right tools and no service is involved. Where the target refuses requests from your address, the ScrapingAnt API accepts the same verbs: to send a POST / PUT / DELETE request, send the POST / PUT / DELETE request to the main endpoint with your x-api-key and url parameters, and the data will be forwarded transparently to the target web page; set the Ant-Content-Type header to the Content-Type you are sending. With wget that is the same --post-data plus one --header, pointed at the API's general endpoint with the target in the url query parameter and your key in x-api-key; see POST / PUT / DELETE requests. A request without a browser through a datacenter proxy costs 1 API credit. Not run by the package (it needs a key).

Limitations​

  • Everything was measured on wget 1.25.0 (macOS, Homebrew) against a local HTTP server; the redirect behaviour in section 7 is version-dependent, and the package's monthly CI run on Ubuntu's wget records what that version does.
  • No HTTPS target; TLS options are unchanged from How to ignore SSL certificate errors with Wget.
  • The API call in the last section is documented, not run.

Examples tested on 2026-09-16 with GNU Wget 1.25.0, curl 8.7.1 and Python 3.10 for the local server. Code: scrapingant-examples/examples/wget-post-requests.

Related: How to ignore SSL certificate errors with Wget, How to download images with Wget, How to use proxies with Wget, How to use Wget with cookies, Wget cheatsheet.

This article was drafted with AI assistance from a tested evidence packet and reviewed by the named author, who is responsible for the code, measurements and corrections.

Forget about getting blocked while scraping the Web

Try out ScrapingAnt Web Scraping API with thousands of proxy servers and an entire headless Chrome cluster