ZetCode

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

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.

Taskcurlxh
Send JSON-H 'Content-Type: application/json' -d '{"a":1}'a:=1
Query parameterencode it in the URL, or -G --data-urlencodeq==value
Header-H 'Name: value'Name:value
Show headers and body-idefault
Fail on HTTP errors-fdefault (--check-status)
Follow redirects-L-F
Formatted JSON outputpipe to jqdefault in a terminal
ProtocolsHTTP, FTP, SMTP, SFTP, and many moreHTTP 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

Source

xh GitHub repository

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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Linux tutorials.