Two client-side settings account for nearly every integration problem we have seen. Both are about how your HTTP client behaves, not about our API:
Both are shown in the worked example at the end.
Two calls to confirm your key works before you write anything. Both were run against production while writing this page; the timings below are those runs.
curl -X POST https://api.krawlx.io/v1/hotels-search -H "X-API-Key: krwlx_live_YOUR_KEY_HERE" -H "Content-Type: application/json" -H "Accept-Encoding: gzip" --compressed -d '{
"regionId": "178279",
"check_in": "2026-11-10",
"check_out": "2026-11-12",
"country_code": "GB",
"page": 1
}'
curl -X POST https://api.krawlx.io/v1/hotel-details -H "X-API-Key: krwlx_live_YOUR_KEY_HERE" -H "Content-Type: application/json" -H "Accept-Encoding: gzip" --compressed -d '{
"url": "https://www.expedia.com/Hotel-Name.h124299825.Hotel-Information?chkin=2026-09-17&chkout=2026-09-18",
"country_code": "US"
}'
PowerShell strips the inner quotes from a single-quoted string before handing it
to a native executable, so an inline -d '{…}' arrives as malformed
JSON and returns 400. Write the body out first:
'{"regionId":"178279","check_in":"2026-11-10","check_out":"2026-11-12","country_code":"GB","page":1}' |
Set-Content body.json -Encoding ascii -NoNewline
curl.exe -X POST https://api.krawlx.io/v1/hotels-search `
-H "X-API-Key: krwlx_live_YOUR_KEY_HERE" `
-H "Content-Type: application/json" `
--compressed -d "@body.json"
Note curl.exe, not curl — bare curl in
PowerShell is an alias for Invoke-WebRequest and takes entirely
different arguments.
Send your key in the X-API-Key header on every request.
X-API-Key: krwlx_live_…
Authorization: Bearer <key> also works. An apikey field in the
JSON body is accepted for compatibility, but the header is strongly preferred — a key
in the body ends up in request logs.
Keep the key secret. It identifies your account and meters your usage. If it leaks, tell us and we will issue a replacement.
Returns hotels in a region for a date range.
{
"regionId": "178279",
"check_in": "2026-11-10",
"check_out": "2026-11-12",
"country_code": "GB",
"page": 1,
"starRating": 5
}
| Field | Required | Notes |
|---|---|---|
| regionId | yes | Expedia region id — e.g. 178279 is London |
| check_in | yes | YYYY-MM-DD |
| check_out | yes | YYYY-MM-DD |
| country_code | yes | Selects the storefront, and therefore the currency |
| page | no | 1-based, defaults to 1 |
| starRating | no | Filter, 1–5 |
{
"status": 200,
"message": "Success",
"regionId": "178279",
"hotels": { "data": { "propertySearch": { … } } },
"pagination_info": {
"current_page": 1,
"total_pages": 6,
"total_properties": 507,
"properties_per_page": 100
}
}
Hotel records live under
hotels.data.propertySearch.propertySearchListings. That array also carries
sponsored placements and banners, so filter on
__typename === "LodgingCard" to get actual properties.
Returns full detail, rooms and rates for a single hotel.
{
"url": "https://www.expedia.co.uk/Some-Hotel.h91827909.Hotel-Information?chkin=2026-11-10&chkout=2026-11-12",
"country_code": "GB"
}
The URL must contain the Expedia property id in .h<digits>. form.
Check-in and check-out are read from its query string when present; without them,
pricing detail is limited.
Returns propertyId, html (the rendered page, typically
~2.5 MB), json (the batched GraphQL results) and
has_room_offers.
json is an array of three results, and
two of them are data.propertyOffers. Only one
carries rooms.
| Index | Payload | Size | Rooms? |
|---|---|---|---|
| [0] | data.propertyOffers — ancillary | ~6 KB | no |
| [1] | data.offersTravelerSelector | ~11 KB | no |
| [2] | data.propertyOffers — rooms and rates | ~370 KB | yes |
json[2].data.propertyOffers.categorizedListings[] ├── header.text → room name, e.g. "Double Room" ├── unitId ├── features / roomAmenities └── primarySelections[0].propertyUnit → rates
The array is categorizedListings, not
units. Reading index [0] returns a valid
propertyOffers object with no rooms in it — the most common
mistake with this endpoint.
Prefer locating it by content rather than position, so your code survives any upstream reordering:
def rooms(body):
for result in body.get("json") or []:
offers = (result.get("data") or {}).get("propertyOffers") or {}
if offers.get("categorizedListings"):
return offers["categorizedListings"]
return [] # no offers -- check has_room_offers
has_room_offersEvery response carries this boolean. Check it before parsing.
| Value | Meaning |
|---|---|
| true | Rooms are present at the path above |
| false | No room offers. The html is still returned and usable |
false is frequently a correct answer, not an error.
Each hotel publishes its own rate calendar, and Expedia returns no offers for
dates beyond it. This is per-property: one hotel stops returning
rooms from 2027-01-01 while another returns them normally into late January 2027.
There is no fixed cut-off to filter on — has_room_offers is the
signal.
We retry automatically when the upstream returns an empty offers payload, so a
false you receive has already survived several attempts. Those retries
push the latency tail higher, which is another reason for the 120 s+ timeout.
If the page loads but the GraphQL call fails entirely, you get
200 with "json": null,
"has_room_offers": false and a Partial success message.
Treat that as usable-but-incomplete rather than a failure.
If the page loads but the pricing call fails, you get 200
with "json": null and a message of
Partial success — HTML fetched but JSON unavailable. Treat that as
usable-but-incomplete rather than a failure.
If you only need availability and pricing across many hotels, hotel search is far more efficient — one call returns 100 properties.
country_code selects which Expedia storefront is queried, which determines
currency and pricing. GB returns GBP from expedia.co.uk;
US returns USD from expedia.com.
AR AU BE BR CA DE DK ES FR GB HK IE IN IT JP KR MX MY NL NO NZ SE SG TH US
An unsupported code returns 400 listing the valid options.
Every response — success or failure — carries an X-Request-Id
header. Quote it in any support request. It is the key to the full
server-side trace of your call.
{
"status": 400,
"error": "BadRequest",
"message": "Unsupported country code: 'ZZ'. Supported: AR, AU, BE, …",
"request_id": "3f6fa683d2514e888adc234753dab62d"
}
| Status | Meaning | Retry | What to do |
|---|---|---|---|
| 400 | Invalid parameters | No | Fix the request — the answer will not change |
| 401 | Missing or invalid key | No | Check the X-API-Key header |
| 404 | Hotel page not found | No | The listing no longer exists on Expedia. Expected in bulk runs — skip it |
| 429 | Rate limited or at capacity | Yes | Back off briefly, then retry. Reduce concurrency if sustained |
| 502 | Upstream request failed | Yes | Retry with exponential backoff and jitter |
| 503 | Upstream unavailable | Yes | Retry after a short pause |
| 504 | Upstream timed out | Yes | Retry with backoff |
Retry only 429 and 5xx, with exponential backoff
and jitter. Never retry 4xx — the answer will not change,
and it still counts against your usage.
Jitter matters more than it sounds. A fixed backoff makes every worker retry in lockstep, so a brief upstream wobble turns into a synchronised wave that is far harder to recover from than the original problem.
A 429 is deliberately fast: we shed load rather than queue you behind a long timeout.
| Response | Counts toward usage | Why |
|---|---|---|
| 2xx | Yes | You received data |
| 4xx | Yes | You received a definitive answer |
| 5xx | No | A failure on our side |
| 429 | No | We declined to serve you |
This is the shape we recommend. The three commented lines are the ones that matter.
import random, time
import requests
from requests.adapters import HTTPAdapter
API_KEY = "krwlx_live_…"
session = requests.Session()
# 1. Reuse connections. Without this, every request opens a new TCP+TLS
# connection and your own network will start dropping them under load.
session.mount("https://", HTTPAdapter(pool_connections=64, pool_maxsize=64))
session.headers.update({"X-API-Key": API_KEY, "Accept-Encoding": "gzip"})
def search(payload, attempts=4):
for attempt in range(attempts):
r = session.post(
"https://api.krawlx.io/v1/hotels-search",
json=payload,
timeout=180, # 2. NOT the default. Well above p99.
)
if r.status_code < 500 and r.status_code != 429:
return r # 2xx or a definitive 4xx: done either way
# 3. Exponential backoff WITH jitter, so concurrent workers do not
# retry in lockstep and turn a wobble into a thundering herd.
time.sleep((2 ** attempt) * (0.5 + random.random()))
r.raise_for_status()
body = {
"regionId": "178279",
"check_in": "2026-11-10",
"check_out": "2026-11-12",
"country_code": "GB",
"page": 1,
}
result = search(body).json()
listings = result["hotels"]["data"]["propertySearch"]["propertySearchListings"]
hotels = [x for x in listings if x.get("__typename") == "LodgingCard"]
print(len(hotels), "of", result["pagination_info"]["total_properties"])
Responses are large — roughly 2 MB uncompressed for a full page of hotels.
Send Accept-Encoding: gzip and they arrive about 13×
smaller. Most HTTP clients do this by default; the example sets it explicitly.
Include the X-Request-Id and the approximate time of the
request. With those two things we can trace exactly what happened on our side, down to
the individual upstream call.