
Debugging API issues always boils down to taking the tests you already have, running them from a different network or region, and comparing what your API receives. But I bet most of us can’t actually do that right away because our test infrastructure itself is fragmented. Your CI could be using a different config entirely from the “correct” one on a local dev machine, and half the collections may still point to a staging URL that changed months ago. Postman doesn’t produce diff-friendly artifacts, so even figuring out what changed is a full-on investigation.
If any of that sounds familiar, I’d recommend finally taking the big step and replacing your Postman collection with Hurl — a command-line HTTP test runner where tests are plain **.hurl** text files that live in Git. For this tutorial, I’ll also walk you through multi-region testing — how to run those tests using a proxy to get a German egress IP, and see what the API actually returns from there.
Everything below is self-contained, you’ll only need a new Git repo, then create the tests (three plaintext files.)
The Test Suite at a Glance
Create a folder (name it whatever you like — hurl-api-tests works) and these are the files we're going to be creating at its root. That folder is your Git repo root; paths in commands and CI are relative to it.
Note how we’re using multiple .env files. If you're new to API testing, you'll find out why in a bit.
hurl-api-tests/
├── tests/
│ ├── health.hurl # smoke test -- Makes sure Hurl works, basic asserts. Skip if you want
│ ├── auth-flow.hurl # Tests chaining + jsonpath capture + Bearer header
│ └── geo-detail.hurl # Tests two egress profiles, real country/ASN diff (direct or proxied)
├── .env.local.example
├── .env.ci.example
├── .env.proxy-de.example
├── run-tests.mjs # cross-platform runner
└── .github/workflows/api-tests.yml
And of course, a .gitignore and package.json.
You’ll npm install once and use two commands to run the whole thing:
npm test # direct, ISP exit
npm run test:proxy-de # same tests but via a Germany exit
Stage 1: Install and the First Green Run
Hurl ships as a native binary.
GitHub - Orange-OpenSource/hurl: Hurl, run and test HTTP requests with plain text.Hurl, run and test HTTP requests with plain text. Contribute to Orange-OpenSource/hurl development by creating an…github.com
If you live in Node land, the npm package downloads the right binary for your platform and exposes hurl under node_modules/.bin/. Afterwards, npx hurlresolves it from there.
npm install --save-dev @orangeopensource/hurl
And then you can get started.
mkdir hurl-api-tests && cd hurl-api-tests
mkdir tests
git init
npm init -y
The first step is to create a tests/health.hurl:
# Smoke test — public status endpoint (uses no auth, no proxy).
GET https://httpbin.org/status/200
HTTP 200
Content-Type: text/html; charset=utf-8
The GET <url> followed by HTTP 200 is an implicit status assert, and the Content-Type: line is an implicit response-header assert. No separate [Asserts] block needed for either.
💡 httpbin.org is convenient for tutorials but has had intermittent availability issues over the years — it’s a community-maintained project rather than a dedicated SLA endpoint. If you see unexpected failures on the smoke check, httpstat.us/200 works as a URL swap — but update the
_Content-Type:_line to match whatever that endpoint actually returns. For your own projects, point this at a_/healthz_or_/status_on your actual staging API.
After you’ve installed hurl, you can run the smoke test like so:
# leave out 'npx' if you installed the binary
npx hurl --test tests/health.hurl
For reference, you’ll find docs for Hurl’s --test option here.
This step just proves Hurl works before you add the runner.
Success tests/health.hurl (1 request(s) in 986 ms)
--------------------------------------------------------------------------------
Executed files: 1
Executed requests: 1
Succeeded files: 1 (100.0%)
Failed files: 0 (0.0%)
That’s pretty much the typical pattern for writing .hurl files: A URL, some sort of status assert, and a header assert — in a file you can git diff easily. The simplicity is the point of Hurl.
Stage 2: The Postman-Style Flow, Without Postman
You know the shape every API test suite needs? Log in, grab a token, hit an authenticated endpoint? Postman handles it with environment variables, pre-request scripts, and a bloated UI that hides where the token came from. Hurl does it in a single text file.
Let’s do it. Create a tests/auth-flow.hurl:
# tests/auth-flow.hurl
# Uses DummyJSON (https://dummyjson.com/docs/auth) — free, no API key.
POST {{dummyjson_base}}/user/login
Content-Type: application/json
{
"username": "{{dummyjson_username}}",
"password": "{{dummyjson_password}}"
}
HTTP 200
[Captures]
token: jsonpath "$.accessToken"
[Asserts]
jsonpath "$.username" == "{{dummyjson_username}}"
GET {{dummyjson_base}}/auth/me
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.username" == "{{dummyjson_username}}"
jsonpath "$.email" exists
Lots to explain here:
- That
{{dummyjson_base}}and friends within curly braces are template variables. In Hurl, you can inject variables from a --variables-file command line option that we'll talk about in the next section. - Hurl’s Captures (read about that here) read
$.accessTokenfrom the response JSON and binds it to{{token}}for the rest of the file. (DummyJSON specifically returns the field asaccessToken, nottoken— when you adapt this to your own API, check what key your auth endpoint actually uses in the response body.) - The second request uses
Authorization: Bearer {{token}}— that's the exact same syntax you'd write by hand withcurl, but the token is now dynamic per run. - Hurl’s Asserts (read about that here) use standard JSONPath.
existsis one of several Hurl predicates; there are also==,contains,matches,count, type checks, and numeric comparisons.
So what makes this reviewable? When the contract for /auth/me ever changes — say, the user's plan tier flips from "free" to "pro" and the test should follow, you can easily inspect it via a simple git diff:
- jsonpath "$.plan" == "free"
+ jsonpath "$.plan" == "pro"
The Postman equivalent for this would be a several-hundred-line minified JSON export with embedded GUIDs that rotate on every save. Even when the behavioral change is one line, the diff never is — it’s just all noise across the whole collection, and whoever is reviewing this will probably stop reading after the third unrelated chunk.
The Hurl diff above, though? Reviewable in five seconds tops. The Postman one on the otehr hand is the reason your team quietly gave up on PR-reviewing API tests two quarters ago. 😅
💡 DummyJSON is used as the test backend here precisely because it’s the kind of API you’d actually want to mock around: real login, real Bearer token, real
/me. Free, no key needed, no rate limits. Swap it for your own staging URL when you adopt the pattern; the.hurlfile barely changes. DummyJSON's current login docs show/auth/login; this file uses/user/login, which still works — if yours 404s, try/auth/logininstead.
Stage 3: Environments That Don’t Hide State
The Postman version of this section would be a series of dropdown screenshots and a slack thread asking “which environment are you in?” The Hurl version is simply three text files, one per profile, with an enforced contract.
Here's.env.local.example for local / direct runs:
dummyjson_base=https://dummyjson.com
dummyjson_username=emilys
dummyjson_password=emilyspass
expected_country=XX
Where XX is a placeholder for your two-letter ISO code (US, NZ, IN, etc.) matching your ISP egress.
💡 Quick Tip: Run
_curl -shttps://geo.brdtest.com/mygeo.json--insecure | grep country_(macOS/Linux) to find yours, then set it in_.env.local_before running_npm test_. On Windows, open the JSON in a browser or use_curl.exe -sk …_and read the_"country"_field manually. A mismatch here is the single most common gotchas on tutorials like this one.
Create .env.ci.example by copying .env.local.example unchanged -- same four keys -- only .env.local needs a real country code. The only functional difference is that the ci profile skips geo-detail entirely, so expected_country is never read or asserted in CI.
Similarly, we’ll create our proxy profile — let’s say, for Germany (DE). Create a .env.proxy-de.example at the root:
dummyjson_base=https://dummyjson.com
dummyjson_username=emilys
dummyjson_password=emilyspass
expected_country=DE
BRIGHT_DATA_PROXY_HOST=brd.superproxy.io
BRIGHT_DATA_PROXY_PORT=33335
BRIGHT_DATA_PROXY_USERNAME=
BRIGHT_DATA_PROXY_PASSWORD=
For the proxies, get your credentials from your control panel/dashboard. If you’re following along, sign up here to get them first.
Bright Data - All in One Platform for Proxies and Web ScrapingAward winning proxy networks, powerful web scrapers, and ready-to-use datasets for download. Welcome to the world's #1…brightdata.com
Copy each to its matching gitignored file (.env.local, .env.ci, .env.proxy-de). In .env.local, replace XX with your real ISP country code.
The .example contract
The idea is you create separate files per profile, with strict roles:
.env.<profile>.example— committed to Git. Defines the schema of the environment: every variable name, with safe placeholder values. For all intents and purposes, this will be the documentation. New team members read it, and PR reviewers diff it when fields are added or removed..env.<profile>— Obviously, this will be gitignored. Contains the real values: your password, your Bright Data credentials, theexpected_countryyou actually live in. Should never be tracked.
The runner refuses to start if the real file is missing — it tells you to copy from .example. That refusal is the contract (the runner enforces it; see below).
While we’re at it, create the .gitignore:
.env.local
.env.ci
.env.proxy-de
node_modules/
.venv/
__pycache__/
*.pyc
Every other env file is a .example template. The schema is always reviewable, and the secrets are never kept in Git.
Add Hurl test scripts to package.json
You already have one from npm init -y in Stage 1, with @orangeopensource/hurl in devDependencies. Paste the block below — or replace the whole file if you prefer a clean slate:
{
"name": "hurl-api-tests",
"private": true,
"scripts": {
"test": "node run-tests.mjs",
"test:ci": "node run-tests.mjs ci",
"test:proxy-de": "node run-tests.mjs proxy-de"
},
"devDependencies": {
"@orangeopensource/hurl": "^8.0.1"
}
}
This should be self explanatory — after npm install, npm test runs the direct profile; npm run test:ci and npm run test:proxy-de select the other env files.
A Simple Test Runner
You’ll need a way to actually run these tests — a simple shell script .sh for bash, and an .mjs file for cross-platform (get this one if you’re on Windows). But this is pure plumbing, and not part of the lesson I’m trying to teach here.
So just copy the full source from my GitHub Gist, put it at the repo root:
- This one for cross-platform (.mjs)
- This one if you prefer bash (.sh)
You can write it yourself, actually — a test runner simply loads the right .env file per profile (direct → .env.local, ci → .env.ci, proxy-de → .env.proxy-de), refuses to start if that file is missing, and on ci skips geo-detail because GitHub runner egress isn't pinned to a country.
NOTE: on proxy-de, http_proxy and https_proxy are set only for the geo-detail.hurl run — health and auth-flow stay direct. Proxied auth against DummyJSON would just test whether Bright Data can reach DummyJSON, not whether your API behaves correctly for German traffic. 😅
🚨 Don’t run
_npm test_yet! The direct profile also runs_tests/geo-detail.hurl_, which you'll create in Stage 4.
The Type of Bug This Stops
How many times have you been exasperated by a test that passes locally, fails in CI, and three hours later you discover that it happened because a local dev laptop had a stale base_url someone forgot to update after the staging URL moved?
The Postman version of this bug is invisible — your environments live inside the GUI, and exports of them are huge. No PR will ever review “the environment file.”
With Hurl’s committed .example templates, base_url is literally just a tracked field in a tracked file. When it changes, the diff WILL show up in a PR, and the fix will be one line. That entire class of bug stops annoying you overnight.
For CI specifically, .env.ci.example ships the shape; the direct job copies it to .env.ci at runtime — no secrets needed for DummyJSON's public test users. When you swap in your own staging API, put real URLs and credentials in GitHub Actions secrets and inject them in the workflow the same way the Bright Data job does below. The committed **.example** file is your schema, so production values never land in Git.
Stage 4: Proxies as Scriptable Egress
What if we could use the same Hurl file, same assertions, but check multiple regions/countries with it?
Why would you want that? Let’s say you wrote those Hurl tests and all your PRs run it. Everything’s green and both the local laptop and the CI runner pass. Then you ship a feature that shows EUR pricing for users in Germany, and three days later a customer in Hamburg reports they’re being charged USD.
Uh oh.
You check staging — it’s fine. You check the admin — pricing is correctly set. You re-run the test suite — nothing pops, everything’s still green.
What went wrong? The bug actually isn’t in your code. It’s in the fact that every machine that ever ran that test suite exited the internet from somewhere your API thinks is the United States. The German behavior was broken the entire time, because nobody could see it, because nobody’s test traffic ever looked German.
Unless you have team members actually living in Germany, the instinct here is to grab a VPN, switch to a German exit, and re-run manually. That will work as a one-off, but it doesn’t run on every PR. It doesn’t run at 2am when someone merges a pricing change, and it certainly doesn’t live in the repo next to the code it’s testing. A VPN you open manually is always going to be a one-off check. What we want is a test.
This is exactly what scriptable egress means:
- Proxy configuration that can be put in an env file,
- Applied selectively to the requests where geography matters,
- Is repeatable by anyone with the credentials.
This is exactly why we use Bright Data proxies for this. The implementation itself can be as simple as a flag you pass to the runner like npm run test:proxy-de from your laptop, or triggered from a GitHub Actions workflow_dispatch. And it'll work for any country Bright Data has an exit for.
The endpoint we use to demonstrate this is Bright Data’s geo test JSON — it returns the country, ASN, and city Bright Data sees for whatever IP hits it.
Create tests/geo-detail.hurl:
# tests/geo-detail.hurl
# Geo fingerprint via Bright Data's test endpoint — same URL, direct or proxied.
GET https://geo.brdtest.com/mygeo.json
[Options]
insecure: true # brdtest.com uses a demo cert — never use this against production endpoints
HTTP 200
[Asserts]
jsonpath "$.country" == "{{expected_country}}"
jsonpath "$.asn.org_name" exists
jsonpath "$.asn.asnum" > 0
jsonpath "$.geo.city" exists
The response you’ll get back is something like:
{
"country": "DE",
"asn": { "asnum": 3320, "org_name": "Deutsche Telekom AG" },
"geo": { "city": "Wetzlar", "region": "HE", "tz": "Europe/Berlin", ... }
}
The point of this file is to see if the endpoint sees a different IP, ASN, and city depending on whether your traffic exits from your office or through a proxy that pins a country.
💡 The assertions in this Hurl test have to be deliberately tight on country (the thing you control) and loose on ASN/city (Bright Data’s exit nodes rotate within a country).
To pin a country, Bright Data accepts a country suffix on the proxy username:
brd-customer-hl_XXXXXXXX-zone-YOURZONE-country-de
Fill BRIGHT_DATA_PROXY_USERNAME and BRIGHT_DATA_PROXY_PASSWORD in .env.proxy-de (copied from .env.proxy-de.example), then:
npm run test:proxy-de
The runner exports http_proxy and https_proxy from your env vars only for geo-detail.hurl — DummyJSON and httpbin keep running direct (as described in Stage 3).
The before/after:
| Profile | Egress | country | ASN | City |
|---|---|---|---|---|
npm test | Local ISP | your code (not XX – see Stage 3) | (your ISP) | (your ISP) |
npm run test:proxy-de | Bright Data DE | DE | Deutsche Telekom AG (AS3320) | Wetzlar |
No custom infrastructure needed.
NOW you can run the full direct suite (because now, all three .hurl files exist):
cp .env.local.example .env.local # Git Bash / macOS / Linux; on Windows, copy the file manually
# replace XX with your ISP country code (e.g. US, IN, DE)
npm test
Where the geo determination actually happens (read this before you copy the pattern)
In this project, the real geo signal is your egress IP, not anything the client sends. Bright Data routes through a German exit; the endpoint inspects the IP it received — i.e. the response says DE. The client doesn't tell the API where it is; the network does.
A real region-aware production API works the same way. It reads the source IP off the connection, optionally consults an API gateway / WAF / GeoIP layer, and decides what to return.
💡 When you point these tests at your own staging API, drop any client-side geo headers and let proxy egress be the signal.
Stage 5: Using GitHub CI Jobs for API Testing
Create .github/workflows/api-tests.yml at the same level as package.json (repo root). The workflow assumes every file from this article lives there — no subfolder, no extra checkout steps.
Before pushing, run npm install locally once and commit **package-lock.json**. The workflow uses npm ci, which requires that lockfile.
name: api-tests
on:
push:
pull_request:
workflow_dispatch:
jobs:
api-tests-direct:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Configure env
run: cp .env.ci.example .env.ci
- name: Run direct tests
run: npm run test:ci
api-tests-brightdata:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Configure env
run: cp .env.proxy-de.example .env.proxy-de
- name: Run Bright Data tests
env:
BRIGHT_DATA_PROXY_USERNAME: ${{ secrets.BRIGHT_DATA_PROXY_USERNAME }}
BRIGHT_DATA_PROXY_PASSWORD: ${{ secrets.BRIGHT_DATA_PROXY_PASSWORD }}
run: |
if [[ -n "$BRIGHT_DATA_PROXY_USERNAME" ]]; then
echo "BRIGHT_DATA_PROXY_USERNAME=$BRIGHT_DATA_PROXY_USERNAME" >> .env.proxy-de
echo "BRIGHT_DATA_PROXY_PASSWORD=$BRIGHT_DATA_PROXY_PASSWORD" >> .env.proxy-de
fi
npm run test:proxy-de
Remember: api-tests-direct runs on every push and PR — health + auth-flow only. It deliberately does not run **geo-detail**. GitHub-hosted runners exit through Azure ranges that rotate by region and over time; asserting country == "US" from CI would pass on Monday and fail on Wednesday. We're not in the business of asserting on infrastructure we don't control. The ci profile in run-tests.mjs skips geo-detail for that reason.
Also note how api-tests-brightdata is workflow_dispatch (GitHub docs here) only — trigger it manually from the Actions tab (or add a cron later). It runs the full suite including geo-detail through a pinned DE exit.
For that job, add two GitHub repo secrets before your first manual run: BRIGHT_DATA_PROXY_USERNAME (with -country-de appended) and BRIGHT_DATA_PROXY_PASSWORD. The workflow appends them to .env.proxy-de at runtime, then throws the runner away. Nothing sensitive will ever be in source control.
The lesson you should take away from all of this is that only the egress you explicitly control is worth asserting on. CI direct verifies that auth and HTTP plumbing work, while CI proxy verifies that geographic behavior works.
Mixing the two would give you a flaky suite that’s worse than no test at all.
The Gotchas
- HTTP proxy env vars are lowercase. Hurl reads
http_proxyandhttps_proxy. NotHTTP_PROXY. PowerShell will helpfully suggest the uppercase versions; ignore that suggestion. 😅 - Localhost is not reachable through Bright Data. If you have a local mock server, set
no_proxy=127.0.0.1,localhostbefore running Hurl, or unset the proxy vars before that specific test. The runner does both. - Free public geo APIs do not love datacenter traffic. My first instinct was to use ip-api.com — it’s the go-to for “what country is this IP” lookups and has a generous free tier. Hooked it up, ran the direct profile, got a clean response. Then I ran it through the Bright Data proxies I had (these) and got a HTTP 402. Turns out ip-api.com’s free tier detects any datacenter egress and refuses to serve it. The proxy was working perfectly; the endpoint just didn’t want to talk to it. This is why I switched to using the
geo.brdtest.com/mygeo.jsonURL. - However,
**geo.brdtest.com**uses a demo TLS cert. This is fine for now, Hurl's[Options] insecure: trueper-request flag handles this without weakening security globally.
GET https://geo.brdtest.com/mygeo.json
[Options]
insecure: true # brdtest.com uses a demo cert — never use this against production endpoints
HTTP 200
- ASN and city rotate within a country. Bright Data’s German exit IPs are not pinned to one ASN or city. This is why your assertion has to be
country == "DE", notcity == "Wetzlar". Assert on the thing you control (country pin), not rotating values, and verify presence/shape on everything else (asn.org_name exists,asn.asnum > 0).
What the Pattern Is Actually Good For
This project demonstrates three uses of proxy-aware API testing:
- Country-specific behavior — same endpoint, two egress profiles, asserts on
countrydiffer. (geo-detail.hurldirect vs proxy.) - IP reputation as a signal — the ASN of your test traffic matters.
geo-detail.hurlasserts onasn.org_name, which is exactly what fraud and risk systems look at to score requests. (Bright Data residential vs datacenter exits will produce different ASN orgs.) - Routing or gateway behavior by region — the geo fingerprint Bright Data sees is the same fingerprint your CDN, API gateway, or partner integration sees. If your test suite always exits from one place, you’re not testing the routing layer; you’re testing the routing layer’s behavior for traffic from your office.
But there are things this pattern enables that this walkthrough does not fully demonstrate:
- Localized pricing or currency
- Tax / VAT eligibility
- Compliance blocks and allowed-market checks
Important use cases, but also things you can’t legitimately demonstrate without your own staging API. 😅 So I’m not covering these — swap DummyJSON and geo.brdtest.com for your endpoints and keep the same .hurl + proxy profile shape.
Biggest lesson you should take away here is that not every API test needs a proxy. Use proxy-aware tests when behavior depends on geography, IP type, routing, or compliance — that’s the population. Adding Bright Data to every smoke test would be silly. Adding it to the tests where regional behavior matters is the entire point, naturally.
Running It Yourself
This is everything:
cd hurl-api-tests
npm install # creates package-lock.json — commit it for CI
cp .env.local.example .env.local # or copy manually on Windows
# Replace XX in .env.local with your real ISP country code (see Stage 3)
npm test # direct: health + auth + geo-detail
cp .env.proxy-de.example .env.proxy-de
# Fill BRIGHT_DATA_PROXY_USERNAME (with -country-de) and BRIGHT_DATA_PROXY_PASSWORD
npm run test:proxy-de
Our npm test runs geo-detail against your own ISP egress — you set expected_country in .env.local, so the country assertion is pinned to something you control. CI skips it because GitHub runner IP ranges rotate; geography is asserted only in the manual Bright Data job.
Sample output from a clean run on both profiles (and the CI profile):
== profile: direct ==
Success tests/auth-flow.hurl (2 request(s) in 727 ms)
Success tests/health.hurl (1 request(s) in 986 ms)
-- geo-detail (direct) --
Success tests/geo-detail.hurl (1 request(s) in 1375 ms)
All tests passed.
== profile: ci ==
Success tests/health.hurl (1 request(s) in 968 ms)
Success tests/auth-flow.hurl (2 request(s) in 1016 ms)
-- geo-detail skipped (CI direct egress is not pinned) --
All tests passed.
== profile: proxy-de ==
Success tests/auth-flow.hurl (2 request(s) in 763 ms)
Success tests/health.hurl (1 request(s) in 1001 ms)
-- geo-detail (via Bright Data DE) --
Success tests/geo-detail.hurl (1 request(s) in 5924 ms)
All tests passed.
That 5924ms on geo-detail in the Bright Data profile is expected — routing through a proxy exit in Germany from wherever you're sitting will obviously add latency. It's not a bug, and I'd actually argue it's useful: if your regional endpoint is asserting on the right response, knowing it takes ~6 seconds from a German IP versus ~1.4 seconds direct, tells you something about your CDN routing or API gateway performance that you'd otherwise never observe from CI.
Why This Beats the Postman Status Quo
There’s a tempting version of this article that just says “Hurl is better than Postman!” But it’s not the interesting version in my experience. Hurl wins on the workflow axis — text files, Git diffs, CI integration, no GUI — but Postman has Newman, Newman runs in CI, and you can hold most of these arguments at arm’s length if you’ve invested in tooling.
If your API’s behavior depends on where the request comes from (and for any business that operates in multiple countries, it usually does) your test suite has literally never tested the thing that readily breaks.
API engineering is hard enough, please don’t make things harder for yourself. 😅
Hurl and Bright Data are your dev force multipliers here. With this combination, you get tests you can actually review like code, running from network origins that actually look like your customers.
The Postman equivalent of this project would have fought me every step of the way. Specifically for my use case, routing some requests through a proxy while keeping auth direct would have meant pre-request scripts or duplicated folders — definitely not a one-line env change.
Comments
Loading comments…