Skip to main content
Back to Blog
Guide
2026-06-30

Hurl HTTP Testing CLI: The Complete .hurl File Guide (2026)

Master the Hurl HTTP testing CLI: write plain-text .hurl files with GET/POST requests, captures, asserts, JSONPath queries, and run them in CI pipelines.

Hurl HTTP Testing CLI: The Complete .hurl File Guide (2026)

Hurl is a command-line tool that runs HTTP requests defined in a simple, plain-text format and chains them together into runnable, version-controlled tests. If you have ever wanted the request-chaining power of Postman without the GUI, the JSON sprawl, or the proprietary export format, the Hurl HTTP testing CLI is the answer. A .hurl file reads almost like the raw HTTP traffic it describes: you write the method and URL, optional headers and body, then a block of captures and assertions. Hurl sends the request, validates the response, and exits with a non-zero status code if anything fails — which is exactly what your CI pipeline wants.

Built on libcurl and written in Rust, Hurl is fast, dependency-free (a single static binary), and equally happy testing a REST API, scraping HTML with XPath, or running a full load test with --repeat. Because .hurl files are just text, they diff cleanly in pull requests, live next to your application code, and never suffer the merge conflicts that plague exported Postman collections. In this guide you will learn the full .hurl file syntax — GET and POST requests, query parameters, form data, JSON bodies, captures that feed later requests, JSONPath and header assertions, variables, and the CLI flags that matter in continuous integration. Every example is real and runnable. If you are weighing tools, compare this with Postman vs Playwright for API testing and browse the QA skills directory for related automation recipes.

Installing Hurl

Hurl ships as a single binary for macOS, Linux, and Windows. Pick the channel that matches your environment and you are testing within a minute.

# macOS (Homebrew)
brew install hurl

# Linux (Debian/Ubuntu .deb)
curl -LO https://github.com/Orange-OpenSource/hurl/releases/download/6.0.0/hurl_6.0.0_amd64.deb
sudo apt install ./hurl_6.0.0_amd64.deb

# Windows (winget)
winget install hurl

# Verify the install
hurl --version

Once installed you have two binaries: hurl runs files and prints the last response body, and hurlfmt formats and lints .hurl files. There is nothing to configure, no runtime to install, and no Node or Python dependency to manage.

Your First .hurl File: A Simple GET

A .hurl file is a sequence of entries. Each entry is one request and its optional response checks. The simplest possible test is a single GET with an implicit status assertion.

# basic-get.hurl
GET https://api.example.com/health

HTTP 200

Run it:

hurl --test basic-get.hurl

The HTTP 200 line tells Hurl to assert the response status is 200. The --test flag switches Hurl into test mode: instead of dumping the response body, it prints a pass/fail summary and sets the exit code. Without --test, Hurl behaves like curl and prints the body — useful for quick debugging.

GET Requests with Query Parameters and Headers

Real requests carry headers and query strings. Hurl gives both first-class sections so you never hand-encode a URL.

# search.hurl
GET https://api.example.com/v1/products
Accept: application/json
Authorization: Bearer {{token}}
[QueryParams]
category: books
limit: 10
sort: price_asc

HTTP 200
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.products" count >= 1
jsonpath "$.products[0].category" == "books"
jsonpath "$.meta.limit" == 10
duration < 1000

The [QueryParams] section URL-encodes each pair and appends it to the URL. The {{token}} placeholder is a variable — pass it on the command line with --variable token=abc123 or load it from a file with --variables-file vars.env. The [Asserts] block runs after the response arrives; duration < 1000 even lets you assert the request completed in under a second.

POST Requests with a JSON Body

POSTing JSON is where Hurl shines. Use a triple-backtick fenced block tagged json for the request body and Hurl sets the Content-Type header for you.

# create-user.hurl
POST https://api.example.com/v1/users
Authorization: Bearer {{token}}
\`\`\`json
{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "role": "engineer"
}
\`\`\`

HTTP 201
[Asserts]
jsonpath "$.id" exists
jsonpath "$.name" == "Ada Lovelace"
jsonpath "$.email" matches /.+@.+\..+/
header "Location" exists

Note the fenced body block uses three backticks with a json tag. Hurl reads everything between the fences verbatim, so your JSON stays exactly as written. The matches assertion runs a regular expression against the captured value — here verifying the returned email looks like a valid address.

Capturing Values to Chain Requests

The real superpower of the Hurl HTTP testing CLI is capturing a value from one response and reusing it in the next. This is how you build login-then-act workflows entirely in text.

# login-then-fetch.hurl

# Step 1: authenticate and capture the token + user id
POST https://api.example.com/v1/auth/login
\`\`\`json
{ "username": "ada", "password": "secret" }
\`\`\`

HTTP 200
[Captures]
auth_token: jsonpath "$.access_token"
user_id: jsonpath "$.user.id"

# Step 2: use the captured token on a protected route
GET https://api.example.com/v1/users/{{user_id}}/profile
Authorization: Bearer {{auth_token}}

HTTP 200
[Asserts]
jsonpath "$.id" == {{user_id}}
jsonpath "$.profile.active" == true

The [Captures] section stores values from the first response into variables. Those variables are then available in every subsequent entry in the file. Captures are not limited to JSONPath — you can capture from headers, cookies, regex matches, XPath on HTML, or the raw response body.

The Capture and Assert Query Reference

Hurl exposes a consistent set of query types that work in both [Captures] and [Asserts]. The table below is the cheat sheet you will return to most often.

QueryTargetsExample
statusHTTP status codestatus == 200
header "Name"A response headerheader "ETag" exists
jsonpath "$.x"JSON body via JSONPathjsonpath "$.total" == 42
xpath "//h1"HTML/XML via XPathxpath "string(//title)" contains "Home"
cookie "name"A response cookiecookie "session" exists
regex "id=(\\d+)"Regex capture groupregex "v(\\d+)" capture
bodyRaw response bodybody contains "OK"
bytesRaw bytes (sha256)bytes count > 0
durationResponse time (ms)duration < 500

Each query pairs with a predicate. The most common predicates are listed next.

PredicateMeaning
== / !=Equality / inequality
> >= < <=Numeric comparison
containsSubstring or array membership
startsWith / endsWithString prefix / suffix
matchesRegular expression match
exists / not existsPresence check
isInteger / isString / isBooleanType assertions
countLength of an array or string

Form Data, Multipart, and File Uploads

Not every API speaks JSON. Hurl supports URL-encoded forms, multipart uploads, and raw file bodies with dedicated sections.

# url-encoded login form
POST https://api.example.com/login
[FormParams]
username: ada
password: secret

HTTP 302

# multipart upload with a file
POST https://api.example.com/v1/avatars
[MultipartFormData]
user_id: 42
avatar: file,profile.png; image/png

HTTP 201
[Asserts]
jsonpath "$.url" contains "avatars"

The file,profile.png; image/png syntax tells Hurl to read profile.png from disk relative to the .hurl file and send it with the given content type. There is no boundary string to manage — Hurl handles the multipart encoding.

Options, Retries, and Conditional Steps

Each entry can carry an [Options] section that tunes behavior for that request only. This is essential for polling an asynchronous job until it completes.

# poll a job until it reports done, retrying up to 10 times
GET https://api.example.com/v1/jobs/{{job_id}}
[Options]
retry: 10
retry-interval: 2000

HTTP 200
[Asserts]
jsonpath "$.status" == "completed"

With retry: 10, Hurl re-runs the entire entry — including its assertions — up to ten times, waiting two seconds between attempts, until the assertions pass or the retry budget is exhausted. This replaces the fragile sleep-then-hope pattern that makes other test suites flaky. For background on eliminating timing flakiness across tools, see the guide on fixing flaky tests.

Running .hurl Files: The CLI Flags That Matter

The hurl binary has a focused set of flags. These are the ones you will reach for in day-to-day testing and CI.

# Run a whole directory in test mode, glob all .hurl files
hurl --test tests/**/*.hurl

# Inject variables and a base URL
hurl --test \
  --variable token=abc123 \
  --variable host=https://staging.example.com \
  tests/api/*.hurl

# Load variables from a dotenv-style file
hurl --test --variables-file ci.env tests/api/*.hurl

# Emit a JUnit XML report and an HTML report for CI dashboards
hurl --test \
  --report-junit results/junit.xml \
  --report-html results/html \
  tests/api/*.hurl

# Verbose output to debug a failing request
hurl --very-verbose tests/api/create-user.hurl

The --report-junit flag is the bridge to CI: GitHub Actions, GitLab, and Jenkins all parse JUnit XML to display per-test pass/fail. The --report-html flag produces a clickable report with the full request and response of every entry, which is invaluable when a test fails in a pipeline you cannot reproduce locally.

Running Hurl in GitHub Actions

Because Hurl is a single binary with a meaningful exit code, wiring it into CI takes only a few lines.

# .github/workflows/api-tests.yml
name: API Tests
on: [push, pull_request]

jobs:
  hurl:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Hurl
        run: |
          curl -LO https://github.com/Orange-OpenSource/hurl/releases/download/6.0.0/hurl_6.0.0_amd64.deb
          sudo apt install ./hurl_6.0.0_amd64.deb
      - name: Run API tests
        run: |
          hurl --test \
            --variable token=${{ secrets.API_TOKEN }} \
            --report-junit results/junit.xml \
            tests/api/*.hurl
      - name: Publish results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: hurl-results
          path: results/

The ${{ secrets.API_TOKEN }} reference pulls a secret from the repository and feeds it to Hurl as a variable, so no credential is hardcoded in the .hurl files. Because Hurl exits non-zero on the first failed assertion, the job fails automatically and blocks the merge.

Hurl vs Other API Testing Tools

Hurl occupies a specific niche: text-first, CLI-native HTTP testing. The table contrasts it with the tools teams most often compare it against.

ToolFormatBest forTrade-off
HurlPlain-text .hurlFast CI API checks, request chainingNo assertions on browser/UI
Postman / NewmanJSON collectionsManual exploration, teams in a GUIBulky exports, merge conflicts
Playwright requestTypeScriptMixed API + browser E2ENeeds a Node project
curl + jq scriptsShellOne-off probesNo structured assertions
PactCode + brokerConsumer-driven contractsHeavier setup

If your needs grow toward consumer-driven contracts, pair Hurl with a tool like Pact — see contract testing with Pact in Python. For mixed browser-and-API suites, Postman vs Playwright covers the trade-offs in depth.

Best Practices for Maintainable .hurl Suites

A few conventions keep a growing Hurl suite readable and reliable. First, parameterize every environment-specific value — base URLs, tokens, and IDs — through variables rather than hardcoding, so the same file runs against local, staging, and production. Second, keep one logical workflow per file (login then act, create then read then delete) so captures flow naturally and failures are easy to localize. Third, run hurlfmt --check in CI to enforce consistent formatting and catch syntax errors before they reach a test run. Fourth, lean on [Options] retry for anything asynchronous instead of fixed sleeps. Finally, commit the .hurl files alongside the code they test so the request contract evolves in the same pull request as the implementation.

Variables, Environments, and Secrets

A maintainable Hurl suite never hardcodes a host or credential. Hurl resolves variables from three sources, in increasing priority: a --variables-file, repeated --variable name=value flags, and inline [Options] variable entries within a single entry. This layering lets you keep a checked-in defaults file while overriding the host and token per environment at run time.

# defaults checked into the repo
cat tests/defaults.env
# host=https://api.example.com
# limit=10

# run against staging, overriding host and injecting a secret token
hurl --test \
  --variables-file tests/defaults.env \
  --variable host=https://staging.example.com \
  --variable token=$STAGING_TOKEN \
  tests/api/*.hurl

Inside a file you reference {{host}} everywhere a base URL appears, so the same .hurl file runs unchanged across local, staging, and production. You can also compute values mid-file. Hurl supports filters on captured values, letting you transform a captured string before reusing it.

GET {{host}}/v1/config

HTTP 200
[Captures]
# capture and lowercase a region code for use in the next URL
region: jsonpath "$.region" toLowerCase
# capture the count and use it as a number later
total: jsonpath "$.items" count

GET {{host}}/v1/regions/{{region}}/summary

HTTP 200
[Asserts]
jsonpath "$.itemCount" == {{total}}

Filters such as toLowerCase, toInt, split, replace, and format run during capture, so the variable is already in the shape the next request needs. This keeps the workflow declarative — there is no glue script massaging values between requests.

Asserting on Errors and Negative Paths

Testing only the happy path leaves your most dangerous bugs uncovered. Hurl makes negative testing first-class: you assert the exact failure status and error shape the API should return for bad input, missing auth, or not-found resources.

# Expect 401 when the Authorization header is absent
GET {{host}}/v1/account

HTTP 401
[Asserts]
jsonpath "$.error" == "unauthorized"

# Expect 422 for an invalid payload
POST {{host}}/v1/users
Authorization: Bearer {{token}}
\`\`\`json
{ "email": "not-an-email" }
\`\`\`

HTTP 422
[Asserts]
jsonpath "$.errors" count >= 1
jsonpath "$.errors[0].field" == "email"

# Expect 404 for a missing resource
GET {{host}}/v1/users/does-not-exist
Authorization: Bearer {{token}}

HTTP 404

By codifying these expectations, a regression that accidentally returns a 500 instead of a clean 422, or leaks data on an unauthenticated route, fails the build immediately. This is the same contract-first mindset behind contract testing with Pact, applied at the raw HTTP layer.

Filters, JSONPath Tricks, and Complex Assertions

JSONPath in Hurl is more expressive than a simple dotted path, and combining it with filters covers the assertions most APIs demand. You can index into arrays, filter by predicate, select nested fields, and assert across collections. The examples below cover the patterns you will reach for repeatedly when validating list endpoints and aggregate responses.

GET {{host}}/v1/orders?status=open
Authorization: Bearer {{token}}

HTTP 200
[Asserts]
# every order in the array has status "open"
jsonpath "$.orders[*].status" includes "open"
# the array has between 1 and 50 entries
jsonpath "$.orders" count >= 1
jsonpath "$.orders" count <= 50
# the first order total is a positive number
jsonpath "$.orders[0].total" > 0
jsonpath "$.orders[0].total" isFloat
# a deeply nested field exists
jsonpath "$.pagination.next" exists
# a string field matches a UUID pattern
jsonpath "$.orders[0].id" matches /^[0-9a-f-]{36}$/

The includes predicate checks array membership, isFloat and isInteger assert numeric types, and matches runs a regular expression. Because these run inside the [Asserts] block, a single entry can verify both the shape and the contents of a response in one pass — no external assertion library required.

Debugging Failures and Reading Hurl Output

When an assertion fails, Hurl prints the file, line number, the expected value, and the actual value, so you usually know the cause immediately. For deeper inspection, escalate through the verbosity flags.

# show request/response headers for every entry
hurl --verbose tests/api/create-user.hurl

# show full request and response bodies plus timing
hurl --very-verbose tests/api/create-user.hurl

# run a single failing file and stop on first error with full context
hurl --very-verbose --error-format long tests/api/create-user.hurl

The --error-format long flag expands an assertion failure into the full surrounding context, which is the fastest way to diagnose why a JSONPath did not match. In CI, the HTML report generated with --report-html captures all of this for every run, so you can inspect a failed pipeline without re-running anything locally. Treat a failing Hurl assertion the way you would a failing unit test: the expected-versus-actual diff is your starting point, not a reason to add a blind retry.

Frequently Asked Questions

What is the Hurl HTTP testing CLI used for?

Hurl runs HTTP requests defined in plain-text .hurl files and validates the responses with built-in assertions. Teams use it for fast API integration tests in CI, smoke-testing endpoints after deploy, chaining authenticated request workflows, and lightweight load testing. Because it is a single binary built on libcurl, it needs no runtime and exits non-zero on failure, making it ideal for pipelines.

How do I capture a token in Hurl and reuse it?

Add a [Captures] section after the login response and store the value with a query such as auth_token: jsonpath "$.access_token". The captured variable is then available in every later entry in the same file. Reference it as {{auth_token}} in headers or URLs of subsequent requests to chain an authenticated workflow without manual copying.

Can Hurl assert on JSON response bodies?

Yes. The [Asserts] section supports JSONPath queries combined with predicates like ==, contains, count, exists, and matches. For example, jsonpath "$.products" count >= 1 checks the array length and jsonpath "$.user.email" matches /.+@.+/ validates a field with a regular expression. You can also assert on status, headers, cookies, duration, and raw bytes.

How is Hurl different from Postman?

Postman stores tests as JSON collections edited in a GUI, while Hurl uses plain-text .hurl files edited in any code editor. Hurl files diff cleanly in pull requests and avoid the merge conflicts of exported collections. Postman is stronger for manual, exploratory work; Hurl is stronger for version-controlled, CLI-driven CI testing. Newman is Postman's CLI equivalent but carries the JSON format with it.

How do I run Hurl tests in CI?

Install the Hurl binary, then run hurl --test --report-junit results/junit.xml tests/*.hurl. The --test flag sets a proper exit code and prints a summary, while --report-junit produces XML that GitHub Actions, GitLab, and Jenkins display as per-test results. Pass secrets with --variable token=$TOKEN so credentials stay out of the files.

Does Hurl support retries for asynchronous APIs?

Yes. Add an [Options] section to an entry with retry and retry-interval values. Hurl re-runs the entire entry, including its assertions, until they pass or the retry count is exhausted. This polls an asynchronous job to completion without fragile fixed sleeps, which is a common source of flaky API tests.

Can Hurl test HTML pages, not just JSON APIs?

Yes. Hurl supports XPath queries in both captures and assertions, so you can scrape and validate HTML or XML responses. For example, xpath "string(//title)" contains "Dashboard" asserts on a page title. This makes Hurl useful for lightweight HTML smoke tests in addition to JSON REST API testing.

Is Hurl free and open source?

Yes. Hurl is open source under the Apache 2.0 license, maintained by Orange and a community of contributors. It is distributed as prebuilt binaries for macOS, Linux, and Windows, plus Docker images, with no paid tier or account required.

Conclusion

The Hurl HTTP testing CLI gives you a text-first, dependency-free way to test HTTP APIs that fits naturally into version control and continuous integration. With GET and POST requests, captures that chain workflows, a rich set of JSONPath and header assertions, and retries for asynchronous endpoints, a handful of .hurl files can replace a sprawling Postman collection while staying readable in every pull request. Start with a single hurl --test against your health endpoint, layer in authentication and assertions, then wire it into GitHub Actions so every push verifies your API contract automatically.

Ready to go deeper on API and integration testing? Explore the QA skills directory for ready-to-use automation recipes, and compare approaches in Postman vs Playwright for API testing and contract testing with Pact.

Hurl HTTP Testing CLI: The Complete .hurl File Guide (2026) | QASkills.sh