Linux xh Command
last modified October 23, 2026
xh is a command-line HTTP client designed for working with web
APIs. It is a reimplementation of HTTPie in Rust: it has the same friendly
syntax for building requests, formats and colours JSON responses, and ships
as a single fast binary with no runtime dependencies. Where
curl is a universal transfer tool with
hundreds of options, xh focuses on HTTP and makes the common
tasks short: sending JSON, setting headers and query parameters,
authenticating, and reading the response.
The syntax is xh [options] [METHOD] URL [REQUEST_ITEM...].
Request items such as name=Jan or
X-Api-Key:abc123 describe the data, the headers, and the query
parameters; the separator decides which is which. The examples use xh 0.26.3
and a local httpbin test server. Outputs were captured in a terminal, where
xh formats the output; the colours are not shown.
Table of contents
- Getting started: installation, test server, first request
- Building requests: query parameters, headers, JSON data, nested JSON, forms, file uploads, raw bodies, methods
- Output: choosing parts, verbose mode, curl translation, formatting and pipes
- Behaviour: exit status, redirects, authentication, sessions, downloads, resuming, streaming, timeouts
- Connections: HTTPS and HTTP/2, certificates, custom DNS, Unix sockets
- Scripting: standard input, xh and curl, comparison table
Installation
xh is packaged for most systems: sudo apt install
xh on Debian 13 and Ubuntu 25.04 or later, pacman -S xh
on Arch Linux, brew install xh on macOS, and cargo
install xh --locked anywhere Rust is available. The project's
release page also has prebuilt static binaries.
xh --version
The second line shows the TLS backend: the prebuilt binaries use rustls,
while distribution packages are usually built with the system's native TLS
library. The install script and some packages also create
xhs, a link to xh that defaults to HTTPS; with a
release binary, create it yourself with ln -s xh xhs in the
same directory. Shell completions are generated with
xh --generate=complete-bash (or -zsh,
-fish).
$ xh --version xh 0.26.3 -native-tls +rustls
A local test server
httpbin is a service that echoes requests back as JSON, which makes it ideal
for learning an HTTP client. It runs at httpbin.org, but a
local copy in Docker is faster and shows the same results for everyone.
docker run -d --name httpbin -p 8080:80 kennethreitz/httpbin
docker ps --filter name=httpbin --format '{{.Names}} {{.Image}} {{.Ports}}'
The server listens on port 8080 of the local machine. Its endpoints include
/get, /post, /headers,
/status/CODE, /redirect/N, authentication
endpoints, and /delay/SECONDS. The examples also work against
https://httpbin.org; note that its responses include your
public IP address in the origin field.
$ docker run -d --name httpbin -p 8080:80 kennethreitz/httpbin
dd999f1610d2c0338b3fbf0ab577ac445ca5187d9000ee41feb37cf0f8c025f9
$ docker ps --filter name=httpbin --format '{{.Names}} {{.Image}} {{.Ports}}'
httpbin kennethreitz/httpbin 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
The first request
With a URL only, xh sends a GET request and prints the
response headers and body.
xh :8080/get
A leading colon is shorthand for localhost, so
:8080/get means http://localhost:8080/get.
Without a scheme, xh uses http://. The headers are
sorted and the JSON body is indented. httpbin shows the request it
received: the default User-Agent is xh/0.26.3, and
the origin is the address of the Docker bridge.
$ xh :8080/get
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 271
Content-Type: application/json
Date: Fri, 23 Oct 2026 13:38:23 GMT
Server: gunicorn/19.9.0
{
"args": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Connection": "keep-alive",
"Host": "localhost:8080",
"User-Agent": "xh/0.26.3"
},
"origin": "172.17.0.1",
"url": "http://localhost:8080/get"
}
Query parameters
Items with a double equals sign, key==value, are added to the
URL as query parameters.
xh -b :8080/get q==linux page==2 'tag==shell scripts'
xh builds and encodes the query string, so the space in
shell scripts becomes + in the URL. This is easier
than encoding the parameters by hand. The -b option prints only
the response body.
$ xh -b :8080/get q==linux page==2 'tag==shell scripts'
{
"args": {
"page": "2",
"q": "linux",
"tag": "shell scripts"
},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Connection": "keep-alive",
"Host": "localhost:8080",
"User-Agent": "xh/0.26.3"
},
"origin": "172.17.0.1",
"url": "http://localhost:8080/get?q=linux&page=2&tag=shell+scripts"
}
Headers
Items with a colon, Name:value, set request headers. A header
name followed by a colon and nothing else removes a default header.
xh -b :8080/headers X-Api-Key:abc123 User-Agent:report-bot/1.0 Accept-Encoding:
The request carries an API key and a custom user agent, and the default
Accept-Encoding header is removed. To send a header with an
empty value, use a semicolon, as in X-Empty;. A header value
can be read from a file with @, for example
X-Api-Key:@key.txt, which keeps secrets out of the shell
history.
$ xh -b :8080/headers X-Api-Key:abc123 User-Agent:report-bot/1.0 Accept-Encoding:
{
"headers": {
"Accept": "*/*",
"Connection": "keep-alive",
"Host": "localhost:8080",
"User-Agent": "report-bot/1.0",
"X-Api-Key": "abc123"
}
}
JSON data
Items with a single equals sign, key=value, become fields of a
JSON object in the request body. With :=, the value is raw JSON,
such as a number, a boolean, an array, or an object.
xh --offline :8080/post name=Jan age:=42 admin:=false 'langs:=["go","rust"]'
The --offline option builds the request and prints it without
sending it, which is a good way to check what xh will send.
Because the request has a body, the method defaults to POST. JSON is the
default format: xh sets Content-Type to
application/json and the Accept header to prefer
JSON. age=42 would send the string "42";
age:=42 sends a number.
$ xh --offline :8080/post name=Jan age:=42 admin:=false 'langs:=["go","rust"]'
POST /post HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Connection: keep-alive
Content-Length: 59
Content-Type: application/json
Host: localhost:8080
User-Agent: xh/0.26.3
{
"name": "Jan",
"age": 42,
"admin": false,
"langs": [
"go",
"rust"
]
}
Sending JSON to a server
The same items without --offline send the request. httpbin
returns the parsed JSON in its json field.
xh -b :8080/post name=Jan age:=42 | jq .json
When the output goes to a pipe, xh prints the body unchanged,
without colours or reformatting, so tools such as jq receive
exactly what the server sent.
$ xh -b :8080/post name=Jan age:=42 | jq .json
{
"age": 42,
"name": "Jan"
}
Nested JSON
Keys can be paths that build nested objects and arrays.
xh --offline -p B :8080/post 'user[name]=Jan' 'user[langs][]=go' 'user[langs][]=rust' 'user[address][city]=Bratislava'
user[name] creates the user object with a
name field, and [] appends to an array. The
items must be quoted, because the shell treats brackets as glob
characters. -p B prints only the request body. For large
documents, a JSON file is more practical, as shown below.
$ xh --offline -p B :8080/post 'user[name]=Jan' 'user[langs][]=go' 'user[langs][]=rust' 'user[address][city]=Bratislava'
{
"user": {
"name": "Jan",
"langs": [
"go",
"rust"
],
"address": {
"city": "Bratislava"
}
}
}
Form data
The -f (--form) option sends the
key=value items as an HTML form instead of JSON.
xh --offline -f :8080/post name=Jan 'city=Nové Zámky'
The body is URL-encoded, with the content type
application/x-www-form-urlencoded, as a browser sends a simple
form. Non-ASCII characters and spaces are encoded automatically.
$ xh --offline -f :8080/post name=Jan 'city=Nové Zámky' POST /post HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Content-Length: 34 Content-Type: application/x-www-form-urlencoded Host: localhost:8080 User-Agent: xh/0.26.3 name=Jan&city=Nov%C3%A9+Z%C3%A1mky
Uploading files
In form mode, field@file attaches a file, and the request
becomes multipart/form-data.
xh -b -f :8080/post title='Weekly meeting' file@notes.txt | jq '{form, files}'
httpbin returns the ordinary form fields and the uploaded files separately.
The file name and content type can be set explicitly, as in
'photo@img.jpg;type=image/jpeg;filename=profile.jpg'. The
--multipart option forces a multipart request even without
files.
$ xh -b -f :8080/post title='Weekly meeting' file@notes.txt | jq '{form, files}'
{
"form": {
"title": "Weekly meeting"
},
"files": {
"file": "Meeting notes\n- budget approved\n"
}
}
Request bodies from files and standard input
A body can also come from a file, from standard input, or from the
--raw option.
xh --offline -p B :8080/post @book.json cat book.json | xh -b :8080/post | jq .json.pages xh --offline -p HB :8080/post --raw='Hello, server' Content-Type:text/plain
@book.json, without a field name, sends the file as the whole
body. When standard input is redirected, xh reads the body from
it. --raw sends a string as it is; since the default content
type is still JSON, the example sets Content-Type explicitly.
-p HB prints the request headers and body.
$ xh --offline -p B :8080/post @book.json
{
"title": "Linux basics",
"pages": 320,
"tags": [
"linux",
"shell"
]
}
$ cat book.json | xh -b :8080/post | jq .json.pages
320
$ xh --offline -p HB :8080/post --raw='Hello, server' Content-Type:text/plain
POST /post HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Connection: keep-alive
Content-Length: 13
Content-Type: text/plain
Host: localhost:8080
User-Agent: xh/0.26.3
Hello, server
HTTP methods
An optional method before the URL selects it explicitly: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, or any other name.
xh -b PUT :8080/put status=done | jq -c .json xh -h DELETE :8080/delete xh -b :8080/post name=Jan | jq -r .url
Without a method, xh uses GET, or POST when the request has
data. This rule makes the last command a POST request. The method name is
case-insensitive.
$ xh -b PUT :8080/put status=done | jq -c .json
{"status":"done"}
$ xh -h DELETE :8080/delete
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 337
Content-Type: application/json
Date: Fri, 23 Oct 2026 13:38:24 GMT
Server: gunicorn/19.9.0
$ xh -b :8080/post name=Jan | jq -r .url
http://localhost:8080/post
Choosing what to print
By default, xh prints the response headers and body. The
-h, -b, and -m options print only the
headers, only the body, or only the metadata; -p combines the
parts freely.
xh -h :8080/uuid xh -b :8080/uuid xh -p Hh :8080/uuid xh -m :8080/uuid
The letters of -p are H (request headers),
B (request body), h (response headers),
b (response body), and m (metadata). The metadata
shows the elapsed time and the address of the server.
$ xh -h :8080/uuid
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 53
Content-Type: application/json
Date: Fri, 23 Oct 2026 13:38:24 GMT
Server: gunicorn/19.9.0
$ xh -b :8080/uuid
{
"uuid": "5b7ddce4-48f8-4cd7-8685-383f1d2d2251"
}
$ xh -p Hh :8080/uuid
GET /uuid HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br, zstd
Connection: keep-alive
Host: localhost:8080
User-Agent: xh/0.26.3
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 53
Content-Type: application/json
Date: Fri, 23 Oct 2026 13:38:24 GMT
Server: gunicorn/19.9.0
$ xh -m :8080/uuid
Elapsed time: 0.00214s
Remote address: 127.0.0.1:8080
Verbose mode
The -v option prints the whole exchange: the request headers
and body, followed by the response.
xh -v :8080/post name=Jan
This is the first thing to try when an API does not behave as expected:
the output shows exactly which headers and data were sent. -vv
adds the metadata, and --debug prints internal log messages
for connection problems.
$ xh -v :8080/post name=Jan
POST /post HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Connection: keep-alive
Content-Length: 14
Content-Type: application/json
Host: localhost:8080
User-Agent: xh/0.26.3
{
"name": "Jan"
}
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 466
Content-Type: application/json
Date: Fri, 23 Oct 2026 13:38:24 GMT
Server: gunicorn/19.9.0
{
"args": {},
"data": "{\"name\":\"Jan\"}",
"files": {},
"form": {},
"headers": {
"Accept": "application/json, */*;q=0.5",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Connection": "keep-alive",
"Content-Length": "14",
"Content-Type": "application/json",
"Host": "localhost:8080",
"User-Agent": "xh/0.26.3"
},
"json": {
"name": "Jan"
},
"origin": "172.17.0.1",
"url": "http://localhost:8080/post"
}
Translating to curl
The --curl option prints the equivalent curl command instead
of sending the request.
xh --curl :8080/post name=Jan age:=42 X-Api-Key:abc123 xh --curl -f :8080/post name=Jan
This is useful when a request worked in xh and must go into a
script or documentation where only curl is available. It also shows what
the short xh syntax means in curl's terms. With
--curl-long, the long option names are used.
$ xh --curl :8080/post name=Jan age:=42 X-Api-Key:abc123
curl http://localhost:8080/post -H 'x-api-key: abc123' -H 'content-type: application/json' -H 'accept: application/json, */*;q=0.5' -d '{"name":"Jan","age":42}'
$ xh --curl -f :8080/post name=Jan
curl http://localhost:8080/post --data-urlencode 'name=Jan'
Formatting and pipes
The --pretty option controls formatting and colours, and
--format-options fine-tunes them.
xh -b --pretty=none :8080/uuid xh -b --format-options=json.indent:2 :8080/uuid xh -b :8080/uuid > uuid.json cat uuid.json xh -b :8080/uuid | jq -r .uuid
--pretty=none prints the body as the server sent it; httpbin
itself indents with two spaces. In a terminal, the default is
all (format and colours); when the output is redirected to a
file or a pipe, the default is none, so saved files and data
for jq stay byte-exact. The values of --pretty are
all, colors, format, and
none.
$ xh -b --pretty=none :8080/uuid
{
"uuid": "39f99089-837a-490d-a751-dca6153e82d7"
}
$ xh -b --format-options=json.indent:2 :8080/uuid
{
"uuid": "f830dbea-8dab-4400-9ba3-60931fe25440"
}
$ xh -b :8080/uuid > uuid.json
$ cat uuid.json
{
"uuid": "c82bc209-5e96-43fc-9c8e-b714b889b2c0"
}
$ xh -b :8080/uuid | jq -r .uuid
f8fcc421-5e7f-4f33-acca-8afd37fda02e
Exit status
xh sets its exit status from the HTTP status code, which makes
it easy to use in scripts.
xh :8080/status/404; echo "exit: $?" xh -h :8080/status/503; echo "exit: $?" xh -b :8080/redirect/1; echo "exit: $?" xh -q :8080/status/200 && echo "service is up" xh -b :8080/status/404 > /dev/null; echo "exit: $?"
A 4xx response gives exit status 4, a 5xx status 5, and an unfollowed
redirect status 3; other errors give 1, a timeout 2, and too many redirects
6. -q suppresses the output, which leaves a clean health check.
When the output is redirected, xh writes a warning with the
status to standard error, so errors are not silently saved to a file. This
behaviour is on by default, unlike in curl, which needs -f
for it.
$ xh :8080/status/404; echo "exit: $?" HTTP/1.1 404 NOT FOUND Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * Connection: keep-alive Content-Length: 0 Content-Type: text/html; charset=utf-8 Date: Fri, 23 Oct 2026 13:38:24 GMT Server: gunicorn/19.9.0 exit: 4 $ xh -h :8080/status/503; echo "exit: $?" HTTP/1.1 503 SERVICE UNAVAILABLE Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * Connection: keep-alive Content-Length: 0 Content-Type: text/html; charset=utf-8 Date: Fri, 23 Oct 2026 13:38:24 GMT Server: gunicorn/19.9.0 exit: 5 $ xh -b :8080/redirect/1; echo "exit: $?" xh: warning: HTTP 302 FOUND <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>Redirecting...</title> <h1>Redirecting...</h1> <p>You should be redirected automatically to target URL: <a href="/get">/get</a>. If not click the link. exit: 3 $ xh -q :8080/status/200 && echo "service is up" service is up $ xh -b :8080/status/404 > /dev/null; echo "exit: $?" xh: warning: HTTP 404 NOT FOUND exit: 4
Redirects
xh does not follow redirects by default. The -F
(--follow) option follows them, and --all shows the
intermediate responses.
xh -F -b :8080/redirect/2 | jq -r .url xh -F --all -h :8080/redirect/2 | grep -E '^(HTTP|Location)' xh -F --max-redirects=1 :8080/redirect/3; echo "exit: $?"
The first command ends at /get after two redirects. With
--all, each hop is printed, and grep keeps the
status lines and the Location headers. --max-redirects
limits the chain; exceeding it is an error with exit status 6.
$ xh -F -b :8080/redirect/2 | jq -r .url http://localhost:8080/get $ xh -F --all -h :8080/redirect/2 | grep -E '^(HTTP|Location)' HTTP/1.1 302 FOUND Location: /relative-redirect/1 HTTP/1.1 302 FOUND Location: /get HTTP/1.1 200 OK $ xh -F --max-redirects=1 :8080/redirect/3; echo "exit: $?" xh: error: Too many redirects (--max-redirects=1) exit: 6
Authentication
The -a (--auth) option sends credentials, and
-A selects the scheme: basic (the default),
digest, or bearer.
xh -b -a jan:secret :8080/basic-auth/jan/secret xh -a jan:wrong :8080/basic-auth/jan/secret; echo "exit: $?" xh -b -A digest -a jan:secret :8080/digest-auth/auth/jan/secret xh -b -A bearer -a my-token :8080/bearer
A wrong password gives 401 Unauthorized and exit status 4. With
-a jan and no password, xh asks for it, so it does
not end up in the shell history. Bearer tokens are used by most OAuth2 and
token-based APIs; -A bearer -a TOKEN is equivalent to the
header 'Authorization:Bearer TOKEN'. Credentials can also come
from ~/.netrc.
$ xh -b -a jan:secret :8080/basic-auth/jan/secret
{
"authenticated": true,
"user": "jan"
}
$ xh -a jan:wrong :8080/basic-auth/jan/secret; echo "exit: $?"
HTTP/1.1 401 UNAUTHORIZED
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 0
Date: Fri, 23 Oct 2026 13:38:24 GMT
Server: gunicorn/19.9.0
Www-Authenticate: Basic realm="Fake Realm"
exit: 4
$ xh -b -A digest -a jan:secret :8080/digest-auth/auth/jan/secret
{
"authenticated": true,
"user": "jan"
}
$ xh -b -A bearer -a my-token :8080/bearer
{
"authenticated": true,
"token": "my-token"
}
Sessions
A session file keeps cookies, custom headers, and credentials between requests.
xh -h --session=./api.json :8080/cookies/set/theme/dark xh -b --session=./api.json :8080/cookies cat api.json
The first request receives a cookie through Set-Cookie, and
xh saves it in api.json. The second request sends
it back, as httpbin confirms. This makes it possible to log in once and
then call protected endpoints. The file is plain JSON; protect it like a
password if it holds tokens. --session-read-only uses a session
without updating it.
$ xh -h --session=./api.json :8080/cookies/set/theme/dark
HTTP/1.1 302 FOUND
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 223
Content-Type: text/html; charset=utf-8
Date: Fri, 23 Oct 2026 13:38:24 GMT
Location: /cookies
Server: gunicorn/19.9.0
Set-Cookie: theme=dark; Path=/
$ xh -b --session=./api.json :8080/cookies
{
"cookies": {
"theme": "dark"
}
}
$ cat api.json
{
"__meta__": {
"about": "xh session file",
"xh": "0.26.3"
},
"auth": {
"type": null,
"raw_auth": null
},
"cookies": [
{
"name": "theme",
"value": "dark",
"path": "/",
"domain": "localhost"
}
],
"headers": []
}
Downloading files
The -d (--download) option saves the body to a file
and shows a progress bar; -o sets the file name.
xh -d :8080/image/png xh -d -o logo.png :8080/image/png ls -l *.png
Without -o, the name comes from the
Content-Disposition header or from the URL, with an extension
based on the content type, here png.png. The response headers
are printed to the terminal, and the progress bar is replaced by a summary
when the download finishes. Downloads follow redirects automatically.
$ xh -d :8080/image/png HTTP/1.1 200 OK Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * Connection: keep-alive Content-Length: 8090 Content-Type: image/png Date: Fri, 23 Oct 2026 13:38:24 GMT Server: gunicorn/19.9.0 Downloading 7.90 KiB to "png.png" Done. 7.90 KiB in 0.00012s (62.30 MiB/s) $ xh -d -o logo.png :8080/image/png HTTP/1.1 200 OK Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * Connection: keep-alive Content-Length: 8090 Content-Type: image/png Date: Fri, 23 Oct 2026 13:38:24 GMT Server: gunicorn/19.9.0 Downloading 7.90 KiB to "logo.png" Done. 7.90 KiB in 0.00017s (45.89 MiB/s) $ ls -l *.png -rw-rw-r-- 1 jano jano 8090 Oct 23 15:38 logo.png -rw-rw-r-- 1 jano jano 8090 Oct 23 15:38 png.png
Resuming a download
The -c (--continue) option resumes an
interrupted download. It requires -d and -o.
ls -l data.bin xh -d -c -o data.bin :8080/range/20000 ls -l data.bin
The first 8000 bytes of the file were already present. xh
requested the rest with a Range header, and the server answered
with 206 Partial Content and the remaining 12000 bytes. The
server must support range requests; otherwise, the whole file is downloaded
again.
$ ls -l data.bin -rw-rw-r-- 1 jano jano 8000 Oct 23 15:38 data.bin $ xh -d -c -o data.bin :8080/range/20000 HTTP/1.1 206 PARTIAL CONTENT Accept-Ranges: bytes Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * Connection: keep-alive Content-Length: 12000 Content-Range: bytes 8000-19999/20000 Content-Type: application/octet-stream Date: Fri, 23 Oct 2026 13:38:25 GMT Etag: range20000 Server: gunicorn/19.9.0 Downloading 11.72 KiB to "data.bin" Done. 11.72 KiB in 0.00021s (55.23 MiB/s) $ ls -l data.bin -rw-rw-r-- 1 jano jano 20000 Oct 23 15:38 data.bin
Streaming responses
The -S (--stream) option prints the response as it
arrives instead of waiting for the whole body.
xh -S -b :8080/stream/3 | jq -c '{id, url}'
The /stream/3 endpoint sends three JSON documents, one per
line. Streaming is useful for long-running responses such as log tails,
server-sent events, and APIs that send newline-delimited JSON.
$ xh -S -b :8080/stream/3 | jq -c '{id, url}'
{"id":0,"url":"http://localhost:8080/stream/3"}
{"id":1,"url":"http://localhost:8080/stream/3"}
{"id":2,"url":"http://localhost:8080/stream/3"}
Timeouts
By default, xh waits indefinitely. The --timeout
option sets a limit in seconds.
xh --timeout=2 :8080/delay/5; echo "exit: $?"
The server delays the response by five seconds, so the request fails after two seconds with exit status 2. Set a timeout in scripts and health checks, so a hanging server cannot block them.
$ xh --timeout=2 :8080/delay/5; echo "exit: $?"
xh: error: error sending request for url (http://localhost:8080/delay/5)
Caused by:
operation timed out
exit: 2
HTTPS and HTTP/2
xhs is the same program with HTTPS as the default scheme. The
--https option does the same for xh.
xhs -h example.com | head -n 1 xh -h --http-version=1.1 https://example.com | head -n 1 xh -m https://example.com
Over HTTPS, xh negotiates HTTP/2 when the server supports it;
--http-version forces a version. HTTP/2 header names are
lowercase, so the headers look different from HTTP/1.1. The metadata shows
the time of the request and the server address.
$ xhs -h example.com | head -n 1 HTTP/2.0 200 OK $ xh -h --http-version=1.1 https://example.com | head -n 1 HTTP/1.1 200 OK $ xh -m https://example.com Elapsed time: 0.06172s Remote address: 172.66.147.243:443
Certificate verification
xh verifies TLS certificates and refuses connections to servers
with invalid ones. --verify=no disables the check.
xh https://self-signed.badssl.com/; echo "exit: $?" xh -h --verify=no https://self-signed.badssl.com/
The test site uses a self-signed certificate, which no trusted authority
has issued. Disable verification only for testing your own servers. A
better solution for an internal certificate authority is
--verify=/path/to/ca.pem. Client certificates are set with
--cert and --cert-key.
$ xh https://self-signed.badssl.com/; echo "exit: $?"
xh: error: failed to verify TLS certificate: invalid peer certificate: UnknownIssuer
xh: error: error sending request for url (https://self-signed.badssl.com/)
Caused by:
0: client error (Connect)
1: invalid peer certificate: UnknownIssuer
exit: 1
$ xh -h --verify=no https://self-signed.badssl.com/
HTTP/1.1 200 OK
Cache-Control: no-store
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html
Date: Fri, 23 Oct 2026 13:38:28 GMT
Etag: W/"6ad7d675-1f6"
Last-Modified: Tue, 20 Oct 2026 21:00:37 GMT
Server: nginx/1.10.3 (Ubuntu)
Transfer-Encoding: chunked
Overriding DNS
The --resolve option connects to a given IP address for a host
name, while the request still uses that name.
xh -b --resolve=shop.example.com:127.0.0.1 http://shop.example.com:8080/headers
The request goes to the local server, but with the Host header
shop.example.com. This tests a virtual host or a new server
before the DNS records point to it, without editing
/etc/hosts. For HTTPS, the certificate is checked against the
name, as in a real request.
$ xh -b --resolve=shop.example.com:127.0.0.1 http://shop.example.com:8080/headers
{
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Connection": "keep-alive",
"Host": "shop.example.com:8080",
"User-Agent": "xh/0.26.3"
}
}
Unix domain sockets
Some services expose an HTTP API on a Unix socket instead of a TCP port.
The --unix-socket option connects to such a socket.
xh -b --unix-socket=/var/run/docker.sock :/version | jq '{Version, ApiVersion, Os}'
The Docker daemon listens on /var/run/docker.sock; the user
needs to be in the docker group to access it. The host name in
the URL is irrelevant, so the :/version shorthand is enough.
$ xh -b --unix-socket=/var/run/docker.sock :/version | jq '{Version, ApiVersion, Os}'
{
"Version": "29.1.3",
"ApiVersion": "1.52",
"Os": "linux"
}
Standard input in scripts
xh reads the request body from standard input whenever the
input is not a terminal. In scripts, cron jobs, and CI pipelines, this can
change a request unexpectedly.
xh -h :8080/get < /dev/null; echo "exit: $?" xh -I -h :8080/get < /dev/null; echo "exit: $?"
With redirected input, the request gets a body, the method changes to POST,
and the server rejects it with 405 Method Not Allowed. If the input is a
pipe that is never closed, xh waits for it forever. The
-I (--ignore-stdin) option disables reading from
standard input; always use it when xh runs in a script.
$ xh -h :8080/get < /dev/null; echo "exit: $?" HTTP/1.1 405 METHOD NOT ALLOWED Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * Allow: HEAD, OPTIONS, GET Connection: keep-alive Content-Length: 178 Content-Type: text/html Date: Fri, 23 Oct 2026 13:38:28 GMT Server: gunicorn/19.9.0 exit: 4 $ xh -I -h :8080/get < /dev/null; echo "exit: $?" HTTP/1.1 200 OK Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * Connection: keep-alive Content-Length: 271 Content-Type: application/json Date: Fri, 23 Oct 2026 13:38:28 GMT Server: gunicorn/19.9.0 exit: 0
xh and curl
For a JSON API call, the difference between the two tools is mostly typing.
curl -s -X POST http://localhost:8080/post -H 'Content-Type: application/json' -d '{"name":"Jan","age":42}' | jq -c .json
xh -b :8080/post name=Jan age:=42 | jq -c .json
Both commands send the same JSON. With curl, the JSON is written by hand,
with careful quoting; xh builds it from the items and sets the
headers itself.
$ curl -s -X POST http://localhost:8080/post -H 'Content-Type: application/json' -d '{"name":"Jan","age":42}' | jq -c .json
{"age":42,"name":"Jan"}
$ xh -b :8080/post name=Jan age:=42 | jq -c .json
{"age":42,"name":"Jan"}
Comparison of xh and curl
The table summarizes how common tasks are written in the two tools.
| Task | curl | xh |
|---|---|---|
| Send JSON | -H 'Content-Type: application/json' -d '{"a":1}' | a:=1 |
| Query parameter | encode it in the URL, or -G --data-urlencode | q==value |
| Header | -H 'Name: value' | Name:value |
| Show headers and body | -i | default |
| Fail on HTTP errors | -f | default (--check-status) |
| Follow redirects | -L | -F |
| Formatted JSON output | pipe to jq | default in a terminal |
| Protocols | HTTP, FTP, SMTP, SFTP, and many more | HTTP and HTTPS only |
curl remains the better choice for non-HTTP protocols, for fine control over
the transfer, and in minimal environments where it is already installed.
xh is more pleasant for exploring and testing HTTP APIs
interactively, and its exit codes make it reliable in scripts.
Best practices for xh
- Preview with --offline: Check complex requests before sending them to a real API.
- Use := for typed values: Numbers, booleans, and arrays
need
:=;=always sends strings. - Use -I in scripts: Ignore standard input so cron and CI jobs do not send unexpected bodies.
- Rely on exit codes: Check
$?or use-qwith&&for health checks, and set--timeout. - Keep secrets out of history: Read keys with
Header:@fileand letxhprompt for passwords. - Use --curl for sharing: Translate a working request for colleagues and scripts that use curl.
Source
In this article, we have used the xh command to send HTTP
requests with query parameters, headers, JSON, forms, and files, to control
the output, to authenticate, keep sessions, download files, work with
HTTPS, DNS overrides, and Unix sockets, and to use xh safely in
scripts.
Author
List all Linux tutorials.