vistalink (Python)
0.0.x reserves the name; the 1.0 release tracks the API v2 launch.
Python client for the VistaLink hotel API v2: a thin sync + async client on
httpx, with pydantic v2 models generated from spec/openapi.v2.json.
pip install vistalink
import os
from vistalink import VistaLink, AsyncVistaLink, VistaLinkError, booking_next_step, parse_ndjson
vl = VistaLink(api_key=os.environ["VISTALINK_API_KEY"]) # base_url defaults to https://api.vistalink.com
Models: generated with datamodel-code-generator, chosen because it emits
plain pydantic v2 models and nothing else, which suits a hand-written thin
client better than openapi-python-client's full generated client with attrs
models. Request bodies accept either a dict or a model
(vistalink.models.SearchRequest(...)). Responses are always parsed into
models (SearchResponse, Job, Booking, …). Unknown fields are kept
(extra="allow").
What the client does for you, same as the TypeScript SDK:
Authorization: Bearer <key>on every call.Idempotency-Keygenerated forbookings.create,rates.batch,rooms.reconcileand liverates. Passidempotency_key=to set it yourself. Retries reuse the same key.- Retries 429 (honouring
Retry-After) and 503 with jittered exponential backoff (max_retries=3). Network errors are retried only for GET/DELETE and POSTs that carry an idempotency key. - Raises
VistaLinkError(code, message, status, request_id, details)for every non-2xx. Client-side codes arenetwork_error,timeout,job_failedandjob_canceled. jobs.wait()/bookings.wait(),jobs.download()/jobs.wait_for_result()forrates.batchNDJSON (signedresult_url, elseGET /v2/jobs/{id}/result),booking_next_step(),jobs.list_all()/search.pages(), andwebhooks.verify().
1. Resolve identifiers (free)
res = vl.hotels.resolve({"identifiers": [
{"booking_url": "https://www.booking.com/hotel/it/umilta-36.en-gb.html"},
{"website_url": "https://umilta36.com"},
{"name": "Umiltà 36", "city": "Rome", "country": "IT"},
]})
for r in res.results:
print(r.input, r.status, r.hotel_id, r.match)
2. Search, the 2×2
# structured + filters ($0.003)
structured = vl.search.run({
"filters": {
"location": {"city": "Rome", "country": "IT"},
"stay": {"check_in": "2026-11-14", "check_out": "2026-11-16", "rooms": [{"adults": 2}]},
"budget": {"max": "400", "currency": "EUR"},
"min_stars": 4,
"amenities": ["SPA"],
},
"ranking": "filters",
})
# loose + semantic ($0.012)
loose = vl.search.run({
"query": "quiet place near the Colosseum with a rooftop, under 400 a night",
"ranking": "semantic",
"limit": 10,
})
if loose.status == "needs_clarification":
q = loose.clarifications[0]
loose = vl.search.answer(loose.search_id, [{"clarification_id": q.id, "option_id": q.options[0].id}])
# query + "filters" = loose.filters ($0.005); filters only + "semantic" = structured.semantic ($0.008)
3. Hotel detail with the search's search_id
top = loose.hotels[0]
hotel = vl.hotels.get(
top.hotel_id,
search_id=loose.search_id, # warmed cache, same price
sections="core,images,rooms,rates",
check_in="2026-11-14", check_out="2026-11-16", adults=2,
)
print(hotel.core.name, len(hotel.rooms or []))
4. Cached rates, then a live job
stay = {"check_in": "2026-11-14", "check_out": "2026-11-16", "rooms": [{"adults": 2}]}
cached = vl.rates.cached({"hotels": [top.hotel_id], "stay": stay, "max_age_hours": 24})
print(cached.summary, cached.results[0].status)
job = vl.rates.live({"hotels": [top.hotel_id], "stay": stay}) # 202 + Idempotency-Key
done = vl.jobs.wait(job.job_id, timeout=120, on_poll=lambda j: print(j.status, j.progress))
print(done.result) # same shape as the cached response
Batch rates: the NDJSON result
job = vl.rates.batch({"hotels": ids, "stays": [stay]})
job, ndjson = vl.jobs.wait_for_result(job.job_id, timeout=1800)
for row in parse_ndjson(ndjson):
print(row["hotel_id"], row["status"])
# jobs.download(job) alone: a signed result_url is fetched without the key;
# when result_url is None it is GET /v2/jobs/{id}/result with the key (free).
5. Booking (the guest always pays the property)
offer_id = cached.results[0].rooms[0].offers[0].offer_id
booking = vl.bookings.create(
{
"hotel_id": top.hotel_id,
"offer_id": offer_id,
"stay": stay,
"guests": [{"room": 0, "lead": {"first_name": "Ada", "last_name": "Rossi", "email": "ada@example.com"}}],
"payment": {"mode": "checkout", "return_url": "https://partner.example/booking/done", "locale": "en"},
},
idempotency_key=f"booking-{order_id}", # reuse on retry; omitted, one is generated
)
ready = vl.bookings.wait(booking.booking_id) # stops when the guest has to act, or final
step = booking_next_step(ready)
if step.url:
show_link(step.url) # checkout: payment.checkout_url; handoff / external: handoff.url
final = vl.bookings.wait(booking.booking_id, until=["confirmed", "confirmation_pending", "failed", "expired", "canceled"])
if final.status == "failed":
print(final.failure.reason) # e.g. payment_unavailable
payment.mode (PAYMENT_MODES) is checkout (stops at requires_payment,
payment.checkout_url), handoff (stops at requires_user_action, hosted
session at handoff.url) or external (stops at requires_user_action, the
engine's own prefilled page at handoff.url). bookings.cancel(id) moves a
booking to canceled. webhooks.delete(id) returns None (204).
Async
async with AsyncVistaLink(api_key=os.environ["VISTALINK_API_KEY"]) as vl:
res = await vl.search.run({"query": "rooftop near the Pantheon"})
job = await vl.rates.live({"hotels": [res.hotels[0].hotel_id], "stay": stay})
done = await vl.jobs.wait(job.job_id)
async for j in vl.jobs.list_all(kind="rates.live"):
...
Errors
try:
vl.rates.cached({"hotels": ["…"], "stay": stay, "channel": "third_party"})
except VistaLinkError as e:
print(e.status, e.code, e.message, e.request_id, e.details)
Webhooks
from vistalink import webhooks, WebhookVerificationError
@app.post("/hooks") # Flask: request.get_data(); FastAPI: await request.body()
def hook():
try:
event = webhooks.verify(os.environ["VISTALINK_WEBHOOK_SECRET"], request.headers, request.get_data())
except WebhookVerificationError:
return "", 400
...
The tolerance is 5 minutes (tolerance=300). Pass the raw body, not
re-serialised JSON.
Development
uv venv .venv && uv pip install -e '.[dev]'
./scripts/generate.sh # models.py + _async_client.py from ../../spec/openapi.v2.json
.venv/bin/pytest # respx-mocked tests, sync + async
Metadata
Release files for vistalink 0.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| vistalink-0.0.1.tar.gz | 40.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vistalink-0.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 86.2 kB
Release files / vistalink-0.0.1.tar.gz
| Download URL | vistalink-0.0.1.tar.gz |
|---|---|
| Size | 40.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f0de4d07bd8cf37ac053975db160d9bcbbac0f90b28181264de3920fe440fb50
|
|
BLAKE2b-256 checksum How to use checksums |
02471c1f174bccf8cb3c3b953e06df2c50155df84d46f671e8892475ecb6b12d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.11
|
Release files / vistalink-0.0.1-py3-none-any.whl
| Download URL | vistalink-0.0.1-py3-none-any.whl |
|---|---|
| Size | 46.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
25baaf8c0f8ac4a2fedae4b54632020396b9ea5aac8a195472da819e88dbf7e8
|
|
BLAKE2b-256 checksum How to use checksums |
398bb08c274e7de82161d82f0b778728b96a126931b5ee74c3e8ee46b8ea7dc2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.11
|