Ask in plain language.
“quiet place near the Trevi Fountain with a rooftop”

One call instead of dozens of searches and page reads. Each hotel comes back with the reason it was picked.
Search docsSearch the whole market in plain language. The only API that returns what hotels charge on their own websites, and books it programmatically.
We read it from each hotel’s own booking engine, wherever it has one. A booking site’s API can only show you the booking site’s price.1
Booking through the API is free. When a hotel pays commission on a stay, half is yours, paid monthly by bank transfer. The guest pays the hotel directly, so you’re never the merchant of record.
Ask for a quiet room with a balcony and search finds the hotels whose photos and reviews show one. Each result says why it made the list.
Web side measured on 10 October 2026: 20 runs, median and range shown. VistaLink side estimated from published prices until it can be measured the same way.
“quiet place near the Trevi Fountain with a rooftop”

One call instead of dozens of searches and page reads. Each hotel comes back with the reason it was picked.
Search docsThe full offer list from the hotel’s own booking engine, not just the cheapest price: the benchmark to hold your own against.
Rates docsRX48213Agentic checkout on Stripe for consumer apps, and fully headless for agent-to-agent: the booking returns a secure checkout link, and webhooks report the payment. The guest always pays the hotel; half the commission is yours.
Bookings docs| 20,000 | searches in plain languageat $0.012 | $240 |
| 40,000 | hotel pagesat $0.005 | $200 |
| 100,000 | direct prices from the cacheat $0.002 | $200 |
| 500 | bookingsfree | $0 |
| The month | $640 | |
| On Scale, −15% | $544 |
Add hotel search: a traveller’s sentence (or filters) in, ranked hotels out.
# VistaLink: search
Brief for a coding agent. Add hotel search: a traveller’s sentence (or filters) in, ranked hotels out.
## Before you write code
- The API key comes from the environment as `VISTALINK_API_KEY`. Never hard-code it, never send it to a browser: call VistaLink from server code only.
- The user creates the key at https://vistalink.com/developers/onboarding. Test keys hit the real catalogue and spend credit; they never create Stripe charges.
- Base URL: https://api.vistalink.com. Every call sends `Authorization: Bearer $VISTALINK_API_KEY`.
## The call: POST /v2/search
Search hotels.
```bash
curl -X POST 'https://api.vistalink.com/v2/search' \
-H "Authorization: Bearer $VISTALINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "quiet hotel near the Trevi Fountain with a rooftop",
"stay": {
"check_in": "2026-11-14",
"check_out": "2026-11-16"
}
}'
```
```typescript
const res = await fetch("https://api.vistalink.com/v2/search", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VISTALINK_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"query": "quiet hotel near the Trevi Fountain with a rooftop",
"stay": {
"check_in": "2026-11-14",
"check_out": "2026-11-16"
}
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```
For the next page, send the same request with the `search_id` you got back; pages of one search are free.
## What comes back
- `hotels[]`: cards with `hotel_id`, name, stars, review score, photos, location and, when you sent dates, a cached `rate` (`status` is `observed`, `stale` or `unknown`).
- `why[]` on each card under semantic ranking: the criterion, the assessment and the evidence (photos or reviews).
- `search_id`: send it back for the next page (free) or to hotel detail (warm).
- `status: needs_clarification` with a question when the query is ambiguous; answering costs nothing.
## Rules that trip people up
- Free text goes in `query`; hard constraints go in `filters`. Where they disagree, filters win and `resolved.overrides` says so.
- `ranking: "semantic"` reads each hotel's photos and reviews; `"filters"` ranks the matches by review score and leaves `why[]` empty.
- A `budget` is per room per night and needs a `currency`. A `stay` without `rooms` means one room for two adults.
- Send `check_in` and `check_out` in `stay` if you want prices on the cards.
## Price
- `search.structured.filters`: $0.003 per search
- `search.loose.filters`: $0.005 per search
- `search.structured.semantic`: $0.008 per search
- `search.loose.semantic`: $0.012 per search
## When it fails
- Errors come back as `{ "error": { "code", "message", "request_id", "details" } }`. Log `request_id`; support needs it.
- On `rate_limited` (429), wait the `Retry-After` seconds; on `details.reason: "daily_hotel_cap"` that is until 00:00 UTC, so stop and report rather than retry. Retry `upstream_error` (502) or `unavailable` (503) with backoff. Don't retry other 4xx: fix the request.
- Each team also has a daily allowance of different hotels it can receive data for: hotel detail, rates and batches count the hotels requested, search counts the hotels it returns. A hotel counts once a day however often you fetch it, so re-reading a shortlist never uses it up. Test keys count too. The allowance resets at 00:00 UTC. Free: 2,000, Build: 50,000, Scale: 250,000, Enterprise: no limit.
- `insufficient_credit` (402) means the account needs a top-up; `forbidden` (403) with `details.reason: "tier_required"` means the call needs a higher tier; with `details.reason: "not_yet_available"` it means the feature isn't open in production yet, and no upgrade changes that.
## Done when
- A real query with dates returns at least one card, and each card shows its `rate.status`.
- A vague query ("a hotel in Cambridge") gets its clarifying question shown to the user, not an error.
- The key appears nowhere in the client bundle (search the build output for `vl_`).
## If you are an agent runtime rather than writing code
Connect the MCP server instead: `claude mcp add --transport http vistalink https://api.vistalink.com/mcp --header "Authorization: Bearer $VISTALINK_API_KEY"` (the shell expands the key into the stored config). Tools for this task: `search_hotels_v2`. With an API key, `tools/list` returns 16 tools: the 13 v2 tools below and the 3 v1 tools (`search_hotels`, `get_hotel_details`, `chat_about_hotels`), which also serve key-less hosts such as ChatGPT and Claude. Build on the v2 tools. The tools ending in `_v2` share a name with a v1 tool (`search_hotels`, `get_rates`, `get_booking`, `chat_about_hotels`), so they carry the suffix. It stays until v1 is retired, so agents built on v1 keep working.
## Full reference
- Docs: https://vistalink.com/developers/docs/search
- OpenAPI: https://api.vistalink.com/v2/openapi.json
- Every brief: https://vistalink.com/developers/agents
Show a full hotel page: gallery, rooms, policies, reviews and amenities for one hotel.
# VistaLink: hotel content
Brief for a coding agent. Show a full hotel page: gallery, rooms, policies, reviews and amenities for one hotel.
## Before you write code
- The API key comes from the environment as `VISTALINK_API_KEY`. Never hard-code it, never send it to a browser: call VistaLink from server code only.
- The user creates the key at https://vistalink.com/developers/onboarding. Test keys hit the real catalogue and spend credit; they never create Stripe charges.
- Base URL: https://api.vistalink.com. Every call sends `Authorization: Bearer $VISTALINK_API_KEY`.
## The call: GET /v2/hotels/{hotel_id}
Hotel detail.
```bash
curl 'https://api.vistalink.com/v2/hotels/43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19?sections=core%2Cimages%2Crooms%2Crates&check_in=2026-11-14&check_out=2026-11-16&adults=2¤cy=EUR&search_id=srch_3f9c2a7e4b1d4c8a9e6f0b2d5a7c1e93' \
-H "Authorization: Bearer $VISTALINK_API_KEY"
```
```typescript
const res = await fetch("https://api.vistalink.com/v2/hotels/43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19?sections=core%2Cimages%2Crooms%2Crates&check_in=2026-11-14&check_out=2026-11-16&adults=2¤cy=EUR&search_id=srch_3f9c2a7e4b1d4c8a9e6f0b2d5a7c1e93", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.VISTALINK_API_KEY}`,
},
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```
Starting from a link rather than a `hotel_id`? Call `POST /v2/hotels/resolve` first (free) and use the `hotel_id` it returns.
## What comes back
- `images`: a deduplicated, labelled gallery with the hero first.
- `rooms[]`: each room with its `listings[]` (where else it is sold), already matched.
- `policies`, `facts`, `reviews` (category scores and items), `location`, and cached `rates` when you pass dates.
## Rules that trip people up
- Ask only for what you render: `sections=core,images,rooms` keeps the response small.
- Pass the `search_id` from a recent search and the call is warm, at the same price.
- Start from a link instead of a `hotel_id`? Resolve it first with `POST /v2/hotels/resolve` (free): a listing URL, the hotel's own site, or a name and a city.
## Price
- `hotel.detail`: $0.005 per call
## When it fails
- Errors come back as `{ "error": { "code", "message", "request_id", "details" } }`. Log `request_id`; support needs it.
- On `rate_limited` (429), wait the `Retry-After` seconds; on `details.reason: "daily_hotel_cap"` that is until 00:00 UTC, so stop and report rather than retry. Retry `upstream_error` (502) or `unavailable` (503) with backoff. Don't retry other 4xx: fix the request.
- Each team also has a daily allowance of different hotels it can receive data for: hotel detail, rates and batches count the hotels requested, search counts the hotels it returns. A hotel counts once a day however often you fetch it, so re-reading a shortlist never uses it up. Test keys count too. The allowance resets at 00:00 UTC. Free: 2,000, Build: 50,000, Scale: 250,000, Enterprise: no limit.
- `insufficient_credit` (402) means the account needs a top-up; `forbidden` (403) with `details.reason: "tier_required"` means the call needs a higher tier; with `details.reason: "not_yet_available"` it means the feature isn't open in production yet, and no upgrade changes that.
## Done when
- A hotel page renders its gallery in the order returned, hero first, with no duplicate photos.
- Each room shows where else it is sold, from `rooms[].listings[]`.
- Only the sections you render are requested (`sections=`).
## If you are an agent runtime rather than writing code
Connect the MCP server instead: `claude mcp add --transport http vistalink https://api.vistalink.com/mcp --header "Authorization: Bearer $VISTALINK_API_KEY"` (the shell expands the key into the stored config). Tools for this task: `get_hotel`, `resolve_hotel`. With an API key, `tools/list` returns 16 tools: the 13 v2 tools below and the 3 v1 tools (`search_hotels`, `get_hotel_details`, `chat_about_hotels`), which also serve key-less hosts such as ChatGPT and Claude. Build on the v2 tools. The tools ending in `_v2` share a name with a v1 tool (`search_hotels`, `get_rates`, `get_booking`, `chat_about_hotels`), so they carry the suffix. It stays until v1 is retired, so agents built on v1 keep working.
## Full reference
- Docs: https://vistalink.com/developers/docs/hotels
- OpenAPI: https://api.vistalink.com/v2/openapi.json
- Every brief: https://vistalink.com/developers/agents
Show the price each hotel charges on its own booking engine for the traveller’s dates.
# VistaLink: rates
Brief for a coding agent. Show the price each hotel charges on its own booking engine for the traveller’s dates.
## Before you write code
- The API key comes from the environment as `VISTALINK_API_KEY`. Never hard-code it, never send it to a browser: call VistaLink from server code only.
- The user creates the key at https://vistalink.com/developers/onboarding. Test keys hit the real catalogue and spend credit; they never create Stripe charges.
- Live rates, batch rates, room reconciliation, quotes and bookings need Build or above. On Free they answer `403` `forbidden` with `details.reason: "tier_required"` and `details.required_tier: "build"`. On Free, a test key (`vl_test_`) can still try them: quotes run in the sandbox for free, cached rate batches take up to 25 hotels for 1 stay and room reconciliation up to 2 sources, paid from the monthly credit. Larger requests answer `403` with `details.free_trial` saying the limit.
- Base URL: https://api.vistalink.com. Every call sends `Authorization: Bearer $VISTALINK_API_KEY`.
## The call: POST /v2/rates
Rates for a stay.
```bash
curl -X POST 'https://api.vistalink.com/v2/rates' \
-H "Authorization: Bearer $VISTALINK_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"hotels": [
"43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
{
"website_url": "https://umilta36.com"
}
],
"stay": {
"check_in": "2026-11-14",
"check_out": "2026-11-16",
"rooms": [
{
"adults": 2,
"children_ages": []
}
]
},
"channel": "direct",
"freshness": "cached",
"max_age_hours": 24,
"currency": "EUR"
}'
```
```typescript
const res = await fetch("https://api.vistalink.com/v2/rates", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VISTALINK_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
"hotels": [
"43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
{
"website_url": "https://umilta36.com"
}
],
"stay": {
"check_in": "2026-11-14",
"check_out": "2026-11-16",
"rooms": [
{
"adults": 2,
"children_ages": []
}
]
},
"channel": "direct",
"freshness": "cached",
"max_age_hours": 24,
"currency": "EUR"
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```
A live read answers `202` with a job: poll `GET /v2/jobs/{job_id}` (below) or subscribe to `rates.completed`.
Make one new `Idempotency-Key` per operation, and reuse it only to retry that same operation.
## What comes back
- `results[]`, one per hotel: the `engine`, `observed_at`, and `rooms[].offers[]` with rate name, board, cancellation terms, nightly average and total.
- With `freshness: "live"` the call answers `202` with a job; poll `GET /v2/jobs/{job_id}` or subscribe to the `rates.completed` webhook.
## Rules that trip people up
- Cached is the default and answers straight away; `max_age_hours` sets how old a cached price may be.
- Live reads the hotel's engine while you wait on a job and is billed only when a price comes back.
- A hotel whose engine we can't read answers `unsupported` with a reason; the rest of the batch still prices.
## Price
- `rates.direct.cached`: $0.002 per hotel
- `rates.direct.live`: $0.040 per hotel
## When it fails
- Errors come back as `{ "error": { "code", "message", "request_id", "details" } }`. Log `request_id`; support needs it.
- On `rate_limited` (429), wait the `Retry-After` seconds; on `details.reason: "daily_hotel_cap"` that is until 00:00 UTC, so stop and report rather than retry. Retry `upstream_error` (502) or `unavailable` (503) with backoff. Don't retry other 4xx: fix the request.
- Each team also has a daily allowance of different hotels it can receive data for: hotel detail, rates and batches count the hotels requested, search counts the hotels it returns. A hotel counts once a day however often you fetch it, so re-reading a shortlist never uses it up. Test keys count too. The allowance resets at 00:00 UTC. Free: 2,000, Build: 50,000, Scale: 250,000, Enterprise: no limit.
- `insufficient_credit` (402) means the account needs a top-up; `forbidden` (403) with `details.reason: "tier_required"` means the call needs a higher tier; with `details.reason: "not_yet_available"` it means the feature isn't open in production yet, and no upgrade changes that.
## Jobs and webhooks
- Poll `GET /v2/jobs/{job_id}` every few seconds (start near `estimated_seconds`) until `status` is `succeeded`, `failed` or `canceled`; the result is in `result`.
- Or register a webhook once with `POST /v2/webhooks` (`url`, `events`) and keep the `secret` it returns.
- Verify each delivery: `X-VistaLink-Signature` is `t=<unix>,v1=<hex>`, where `v1` is HMAC-SHA256 of `"<t>.<raw body>"` with your secret. Compare in constant time and reject anything older than 5 minutes.
## Done when
- A cached read for a hotel and two dates shows offers with `observed_at`, or a clear "no price yet" for `unknown`.
- A live read shows a waiting state, then the offers when the job succeeds, and stops waiting on `failed`.
- Every offer you show keeps its `offer_id`, ready for booking within 30 minutes.
## If you are an agent runtime rather than writing code
Connect the MCP server instead: `claude mcp add --transport http vistalink https://api.vistalink.com/mcp --header "Authorization: Bearer $VISTALINK_API_KEY"` (the shell expands the key into the stored config). Tools for this task: `get_rates_v2`, `get_job`. With an API key, `tools/list` returns 16 tools: the 13 v2 tools below and the 3 v1 tools (`search_hotels`, `get_hotel_details`, `chat_about_hotels`), which also serve key-less hosts such as ChatGPT and Claude. Build on the v2 tools. The tools ending in `_v2` share a name with a v1 tool (`search_hotels`, `get_rates`, `get_booking`, `chat_about_hotels`), so they carry the suffix. It stays until v1 is retired, so agents built on v1 keep working.
## Full reference
- Docs: https://vistalink.com/developers/docs/rates
- OpenAPI: https://api.vistalink.com/v2/openapi.json
- Every brief: https://vistalink.com/developers/agents
Match one hotel’s rooms across its own site, its booking engine, booking sites and your own room list.
# VistaLink: room matching
Brief for a coding agent. Match one hotel’s rooms across its own site, its booking engine, booking sites and your own room list.
## Before you write code
- The API key comes from the environment as `VISTALINK_API_KEY`. Never hard-code it, never send it to a browser: call VistaLink from server code only.
- The user creates the key at https://vistalink.com/developers/onboarding. Test keys hit the real catalogue and spend credit; they never create Stripe charges.
- Live rates, batch rates, room reconciliation, quotes and bookings need Build or above. On Free they answer `403` `forbidden` with `details.reason: "tier_required"` and `details.required_tier: "build"`. On Free, a test key (`vl_test_`) can still try them: quotes run in the sandbox for free, cached rate batches take up to 25 hotels for 1 stay and room reconciliation up to 2 sources, paid from the monthly credit. Larger requests answer `403` with `details.free_trial` saying the limit.
- Base URL: https://api.vistalink.com. Every call sends `Authorization: Bearer $VISTALINK_API_KEY`.
## The call: POST /v2/rooms/reconcile
Reconcile room types across sources.
```bash
curl -X POST 'https://api.vistalink.com/v2/rooms/reconcile' \
-H "Authorization: Bearer $VISTALINK_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"hotel_id": "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
"sources": [
{
"kind": "website_url",
"url": "https://umilta36.com/rooms"
},
{
"kind": "rooms",
"label": "partner_pms",
"rooms": [
{
"key": "SUP-K",
"name": "Superior King",
"size_m2": 28,
"description": "City view",
"max_occupancy": 2,
"bed_config": [
{
"type": "king",
"count": 1
}
],
"images": [
"https://partner.example/sup-k.jpg"
]
}
]
}
],
"min_confidence": 0.5
}'
```
```typescript
const res = await fetch("https://api.vistalink.com/v2/rooms/reconcile", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VISTALINK_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
"hotel_id": "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
"sources": [
{
"kind": "website_url",
"url": "https://umilta36.com/rooms"
},
{
"kind": "rooms",
"label": "partner_pms",
"rooms": [
{
"key": "SUP-K",
"name": "Superior King",
"size_m2": 28,
"description": "City view",
"max_occupancy": 2,
"bed_config": [
{
"type": "king",
"count": 1
}
],
"images": [
"https://partner.example/sup-k.jpg"
]
}
]
}
],
"min_confidence": 0.5
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```
With any URL source the call answers `202` with a job: poll `GET /v2/jobs/{job_id}` (below) or subscribe to `rooms.reconcile.completed`.
Make one new `Idempotency-Key` per operation, and reuse it only to retry that same operation.
## What comes back
- `rooms[]`: each physical room once, with every listing tied to it and a `confidence` on the match.
- `shared[]` for a listing that sells several rooms under one name, and `unmatched` for listings below `min_confidence`.
## Rules that trip people up
- If every source is a room list you send (`kind: rooms`) and the `hotel_id` is known, the call answers at once; any URL source turns it into a job.
- Set `min_confidence` to the lowest match you would show a traveller.
## Price
- `rooms.reconcile.supplied`: $0.08 per reconciliation
- `rooms.reconcile.urls`: $0.25 per reconciliation
## When it fails
- Errors come back as `{ "error": { "code", "message", "request_id", "details" } }`. Log `request_id`; support needs it.
- On `rate_limited` (429), wait the `Retry-After` seconds; on `details.reason: "daily_hotel_cap"` that is until 00:00 UTC, so stop and report rather than retry. Retry `upstream_error` (502) or `unavailable` (503) with backoff. Don't retry other 4xx: fix the request.
- Each team also has a daily allowance of different hotels it can receive data for: hotel detail, rates and batches count the hotels requested, search counts the hotels it returns. A hotel counts once a day however often you fetch it, so re-reading a shortlist never uses it up. Test keys count too. The allowance resets at 00:00 UTC. Free: 2,000, Build: 50,000, Scale: 250,000, Enterprise: no limit.
- `insufficient_credit` (402) means the account needs a top-up; `forbidden` (403) with `details.reason: "tier_required"` means the call needs a higher tier; with `details.reason: "not_yet_available"` it means the feature isn't open in production yet, and no upgrade changes that.
## Jobs and webhooks
- Poll `GET /v2/jobs/{job_id}` every few seconds (start near `estimated_seconds`) until `status` is `succeeded`, `failed` or `canceled`; the result is in `result`.
- Or register a webhook once with `POST /v2/webhooks` (`url`, `events`) and keep the `secret` it returns.
- Verify each delivery: `X-VistaLink-Signature` is `t=<unix>,v1=<hex>`, where `v1` is HMAC-SHA256 of `"<t>.<raw body>"` with your secret. Compare in constant time and reject anything older than 5 minutes.
## Done when
- Sending the hotel's own room list returns each physical room once, with a `confidence` on every match.
- Listings below your `min_confidence` show up as unmatched, not silently dropped.
## If you are an agent runtime rather than writing code
Connect the MCP server instead: `claude mcp add --transport http vistalink https://api.vistalink.com/mcp --header "Authorization: Bearer $VISTALINK_API_KEY"` (the shell expands the key into the stored config). Tools for this task: `reconcile_rooms`, `get_job`. With an API key, `tools/list` returns 16 tools: the 13 v2 tools below and the 3 v1 tools (`search_hotels`, `get_hotel_details`, `chat_about_hotels`), which also serve key-less hosts such as ChatGPT and Claude. Build on the v2 tools. The tools ending in `_v2` share a name with a v1 tool (`search_hotels`, `get_rates`, `get_booking`, `chat_about_hotels`), so they carry the suffix. It stays until v1 is retired, so agents built on v1 keep working.
## Full reference
- Docs: https://vistalink.com/developers/docs/rooms
- OpenAPI: https://api.vistalink.com/v2/openapi.json
- Every brief: https://vistalink.com/developers/agents
Book an offer on the hotel’s own engine, with the guest paying the hotel.
# VistaLink: bookings
Brief for a coding agent. Book an offer on the hotel’s own engine, with the guest paying the hotel.
## Before you write code
- The API key comes from the environment as `VISTALINK_API_KEY`. Never hard-code it, never send it to a browser: call VistaLink from server code only.
- The user creates the key at https://vistalink.com/developers/onboarding. Test keys hit the real catalogue and spend credit; they never create Stripe charges.
- Live rates, batch rates, room reconciliation, quotes and bookings need Build or above. On Free they answer `403` `forbidden` with `details.reason: "tier_required"` and `details.required_tier: "build"`. On Free, a test key (`vl_test_`) can still try them: quotes run in the sandbox for free, cached rate batches take up to 25 hotels for 1 stay and room reconciliation up to 2 sources, paid from the monthly credit. Larger requests answer `403` with `details.free_trial` saying the limit.
- Bookings aren't open in production yet: `POST /v2/bookings` answers `403` `forbidden` with `details.reason: "not_yet_available"`. That isn't `tier_required`, and no upgrade changes it. Build the flow, handle that answer as "booking isn't on yet", and keep search, hotel detail and cached rates as the working path.
- Base URL: https://api.vistalink.com. Every call sends `Authorization: Bearer $VISTALINK_API_KEY`.
## The call: POST /v2/bookings
Create a booking.
```bash
curl -X POST 'https://api.vistalink.com/v2/bookings' \
-H "Authorization: Bearer $VISTALINK_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"hotel_id": "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
"offer_id": "off_t3c8e1a9b4f2d7e6a5c0b",
"stay": {
"check_in": "2026-11-14",
"check_out": "2026-11-16",
"rooms": [
{
"adults": 2,
"children_ages": []
}
]
},
"guests": [
{
"room": 0,
"lead": {
"first_name": "Ada",
"last_name": "Rossi",
"email": "[email protected]",
"phone": "+390600000000"
},
"occupants": []
}
],
"payment": {
"mode": "handoff",
"locale": "en"
},
"special_requests": "Late arrival"
}'
```
```typescript
const res = await fetch("https://api.vistalink.com/v2/bookings", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VISTALINK_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
"hotel_id": "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
"offer_id": "off_t3c8e1a9b4f2d7e6a5c0b",
"stay": {
"check_in": "2026-11-14",
"check_out": "2026-11-16",
"rooms": [
{
"adults": 2,
"children_ages": []
}
]
},
"guests": [
{
"room": 0,
"lead": {
"first_name": "Ada",
"last_name": "Rossi",
"email": "[email protected]",
"phone": "+390600000000"
},
"occupants": []
}
],
"payment": {
"mode": "handoff",
"locale": "en"
},
"special_requests": "Late arrival"
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```
The `offer_id` comes from `POST /v2/rates` (`results[].rooms[].offers[].offer_id`) and is valid for 30 minutes.
Follow the booking with `GET /v2/bookings/{booking_id}` or the `booking.updated` webhook. On `requires_user_action`, send the guest to `handoff.url`; on `requires_payment`, show `payment.checkout_url`.
Cancel with `POST /v2/bookings/{booking_id}/cancel` while it hasn't been submitted to the hotel.
It ends in `confirmed` (with the engine's code in `confirmation`), or `confirmation_pending`, `failed`, `expired` or `canceled`.
Make one new `Idempotency-Key` per operation, and reuse it only to retry that same operation.
## What comes back
- A booking that moves through `queued`, `processing`, `requires_user_action` or `requires_payment`, then `confirmed` with the engine's own confirmation code.
- Every status change as a `booking.updated` webhook.
## Rules that trip people up
- Send an `Idempotency-Key` header on every create, so a retry never books twice.
- The offer is checked again before booking; `revalidation.price_changed` says if the price moved.
- `payment.mode` decides how the guest pays: `handoff` (they finish on a hosted page), `checkout` (they pay the property through a link) or `external`. The payee is always the hotel.
- Build against the booking sandbox: with a test key (`vl_test_`, any plan, Free included) every booking is simulated end to end — no hotel is contacted and no card is charged — and confirms with a `TEST-` code. A live key reaches the hotel's own engine; live booking is in early access (request it from [email protected]).
## Price
- `booking.confirmed`: Free
## When it fails
- Errors come back as `{ "error": { "code", "message", "request_id", "details" } }`. Log `request_id`; support needs it.
- On `rate_limited` (429), wait the `Retry-After` seconds; on `details.reason: "daily_hotel_cap"` that is until 00:00 UTC, so stop and report rather than retry. Retry `upstream_error` (502) or `unavailable` (503) with backoff. Don't retry other 4xx: fix the request.
- Each team also has a daily allowance of different hotels it can receive data for: hotel detail, rates and batches count the hotels requested, search counts the hotels it returns. A hotel counts once a day however often you fetch it, so re-reading a shortlist never uses it up. Test keys count too. The allowance resets at 00:00 UTC. Free: 2,000, Build: 50,000, Scale: 250,000, Enterprise: no limit.
- `insufficient_credit` (402) means the account needs a top-up; `forbidden` (403) with `details.reason: "tier_required"` means the call needs a higher tier; with `details.reason: "not_yet_available"` it means the feature isn't open in production yet, and no upgrade changes that.
## Done when
- One booking reaches `confirmed` and the confirmation code is shown to the guest.
- Retrying the same create with the same `Idempotency-Key` returns the same booking, not a second one.
- `failed`, `expired` and `canceled` each show the guest what happened.
## If you are an agent runtime rather than writing code
Connect the MCP server instead: `claude mcp add --transport http vistalink https://api.vistalink.com/mcp --header "Authorization: Bearer $VISTALINK_API_KEY"` (the shell expands the key into the stored config). Tools for this task: `book_hotel`, `get_booking_v2`. With an API key, `tools/list` returns 16 tools: the 13 v2 tools below and the 3 v1 tools (`search_hotels`, `get_hotel_details`, `chat_about_hotels`), which also serve key-less hosts such as ChatGPT and Claude. Build on the v2 tools. The tools ending in `_v2` share a name with a v1 tool (`search_hotels`, `get_rates`, `get_booking`, `chat_about_hotels`), so they carry the suffix. It stays until v1 is retired, so agents built on v1 keep working.
## Full reference
- Docs: https://vistalink.com/developers/docs/bookings
- OpenAPI: https://api.vistalink.com/v2/openapi.json
- Every brief: https://vistalink.com/developers/agents
vl_test_.npm i -g @vistalink/cli
vistalink login
vistalink search run --query 'quiet place near the Trevi Fountain with a rooftop'export VISTALINK_API_KEY=vl_test_…
curl -X POST 'https://api.vistalink.com/v2/search' \
-H "Authorization: Bearer $VISTALINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"quiet place near the Trevi Fountain with a rooftop"}'const res = await fetch("https://api.vistalink.com/v2/search", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VISTALINK_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"query": "quiet place near the Trevi Fountain with a rooftop"
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();import os
import requests
resp = requests.post(
"https://api.vistalink.com/v2/search",
headers={"Authorization": f"Bearer {os.environ['VISTALINK_API_KEY']}"},
json={
"query": "quiet place near the Trevi Fountain with a rooftop",
},
)
resp.raise_for_status()
data = resp.json()claude mcp add --transport http vistalink https://api.vistalink.com/mcp --header "Authorization: Bearer $VISTALINK_API_KEY"MCP setup