Direct prices, cached or live.
Prices from each hotel's own booking engine, from cache now or read live as a job.
Cached: $0.002 / hotel.
freshness: cached · max_age_hours: 24 · 200 straight awayfreshness: cached answers immediately from prices we've already seen, within max_age_hours. freshness: live reads the hotel's booking engine again and returns a job; the result has the same shape as the cached response.
You pay per hotel that comes back with a price. Hotels we can't price (unknown, unsupported, unresolved) are free.
A stay without rooms is one room for two adults. max_age_hours is how old a cached price may be; anything above 168 is treated as 168.
Free keys can read cached rates for up to 100 hotels a call. Live and batch rates need Build or above; on Free they answer 403 forbidden with details.reason: "tier_required".
Channels
direct is the hotel's own booking engine, the price behind its Book direct button. An offer's channel is direct, third_party or expedia; no offers carry expedia yet.
Third-party channel observations are available to Enterprise customers under contract. Contact sales.
Sold out and warnings
When the hotel's engine says a stay is sold out, the offer comes back with availability: sold_out, total: "0" and, when the engine gave no price at all, currency: "XXX", the ISO 4217 code for no currency. Treat a total of 0 as "not bookable", never as free; check availability before showing a price.
warnings[] lists what the response couldn't do: channel_unavailable with channel: "third_party" when that channel couldn't be served, currency_not_converted when prices stay in the hotel's currency, and live_* codes when a live read fell short. Warnings don't change the price.
Rates for a stay
/v2/ratesNeeds the rates scope. freshness: cached is synchronous and bills rates.direct.cached for each hotel that comes back observed, or stale within max_age_hours; freshness: live answers 202 with a rates.live job, needs the Build plan or higher, and bills rates.direct.live for each hotel it observes. channel: third_party is Enterprise-only (403 forbidden, details.reason: enterprise_only otherwise). Honours Idempotency-Key when it creates a job.
Billed per hotel that comes back observed, or stale within max_age_hours. unknown, unsupported and unresolved are free.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Any unique string. Same key and same body returns the original response; same key and a different body is a 409. |
Request body
| Field | Type | Description |
|---|---|---|
hotelsREQUIRED | object[] | Up to 100 hotels, each a hotel_id or an inline identifier resolved as POST /v2/hotels/resolve would; a hotel listed twice is billed once. |
stayREQUIRED | Stay | Dates and rooms of a stay, up to 30 nights and 9 rooms. |
channel | string | direct (the hotel's own booking engine, the default) or third_party (Enterprise only). One of direct, third_party. Default direct. |
freshness | string | cached answers now from stored prices; live reads the booking engines and answers 202 with a job (Build plan or higher). One of cached, live. Default cached. |
max_age_hours | integer | Oldest stored price to accept on cached, in hours; values above 168 (7 days) are treated as 168. Default 24. 1–720. |
currency | string | null | Preferred ISO 4217 currency; prices are never converted, and a currency_not_converted warning says when some differ. |
Example
curl -X POST 'https://api.vistalink.com/v2/rates' \
-H "Authorization: Bearer $VISTALINK_API_KEY" \
-H 'Idempotency-Key: 4f1c2b8e-booking-ada-rossi' \
-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"
}'{
"billing": {
"amount": "0.002",
"currency": "USD",
"sku": "rates.direct.cached",
"units": 1
},
"request_id": "req_8f2c1a9b3d4e5f60",
"results": [
{
"channel": "direct",
"engine": "SYNXIS",
"hotel_id": "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
"input": 0,
"observed_at": "2026-10-07T09:30:00Z",
"rooms": [
{
"listing_key": "engine_room:1234567",
"name": "Superior Room",
"offers": [
{
"availability": "available",
"board": "breakfast",
"channel": "direct",
"currency": "EUR",
"engine": "SYNXIS",
"excluded_charges": [],
"free_cancellation": false,
"includes_taxes": true,
"listing_key": "engine_room:1234567",
"nightly_avg": "481.00",
"observed_at": "2026-10-07T09:30:00Z",
"occupancy": {
"adults": 2,
"children_ages": []
},
"offer_id": "off_t3c8e1a9b4f2d7e6a5c0b",
"rate_name": "Non-refundable, breakfast included",
"room_id": "5b1e2f3a-0c4d-4e5f-8a9b-1c2d3e4f5a6b",
"room_name": "Superior Room",
"total": "962.00",
"url": "https://umilta36.com/book?checkin=2026-11-14&checkout=2026-11-16"
}
],
"room_id": "5b1e2f3a-0c4d-4e5f-8a9b-1c2d3e4f5a6b"
}
],
"status": "observed"
}
],
"summary": {
"observed": 1,
"stale": 0,
"unknown": 0,
"unresolved": 0,
"unsupported": 0
}
}Response 200
Successful Response
| Field | Type | Description |
|---|---|---|
request_idREQUIRED | string | Opaque id of this request, also sent as the X-Request-Id header; quote it when contacting support. |
billing | Billing | null | What a billed call cost. Present on every billed response; billing is null on free ones. |
resultsREQUIRED | RateResult[] | One result per entry in hotels[], in request order. |
results[].hotel_id | string (uuid) | null | The hotel's id; null when the input did not resolve. |
results[].inputREQUIRED | integer | Index of the hotel in the request's hotels[]. |
results[].statusREQUIRED | string | observed (recent prices), stale (older but within max_age_hours), unknown (no prices for this stay), unsupported (no readable booking engine, or the channel is not enabled) or unresolved (the input did not resolve to an indexed hotel). One of observed, stale, unknown, unsupported, unresolved. |
results[].channel | string | null | The channel read, as requested. One of direct, third_party. |
results[].engine | string | null | Code of the hotel's booking engine on the direct channel; null on the third-party channel and when unknown. |
results[].observed_at | string (date-time) | null | When the newest price was observed, RFC 3339 UTC; null unless observed or stale. |
results[].rooms | RateRoom[] | Prices per room, cheapest room first; empty unless observed or stale. |
results[].rooms[].room_id | string (uuid) | null | Catalogue room id when the source's room is matched to one; null otherwise. |
results[].rooms[].name | string | null | Room name as the source shows it. |
results[].rooms[].listing_key | string | null | The source's own key for the room. |
results[].rooms[].offers | Offer[] | The cheapest offer per rate plan, cheapest first, sold-out last. |
summary | object | Count of results per status. |
summary.observed | integer | Results with recent prices. Default 0. |
summary.stale | integer | Results with older prices within max_age_hours. Default 0. |
summary.unknown | integer | Results with no prices for the stay. Default 0. |
summary.unsupported | integer | Results whose hotel cannot be read on this channel. Default 0. |
summary.unresolved | integer | Inputs that did not resolve to an indexed hotel. Default 0. |
warnings | Warning_[] | Things the call could not fully do, such as currency_not_converted, channel_unavailable, live_unavailable or live_partial. |
Response 202
Live job accepted
| Field | Type | Description |
|---|---|---|
request_id | string | null | Id of the request that returned this job; null in lists and webhook payloads. |
job_idREQUIRED | string | Job id, job_ plus 12 letters and digits. |
kindREQUIRED | string | rates.live (from POST /v2/rates or rates=refresh), rates.batch or rooms.reconcile. One of rates.live, rates.batch, rooms.reconcile. |
statusREQUIRED | string | queued, running, then succeeded, failed or canceled. One of queued, running, succeeded, failed, canceled. |
progress | object | Units of work done out of the total. |
progress.done | integer | Units of work finished. Default 0. |
progress.total | integer | Units of work in the job: hotels for rates.live, hotel and stay pairs for rates.batch, 1 for rooms.reconcile. Default 1. |
created_atREQUIRED | string (date-time) | When the job was created, RFC 3339 UTC; jobs expire 7 days later. |
updated_atREQUIRED | string (date-time) | When the job last changed, RFC 3339 UTC. |
estimated_seconds | integer | null | Rough time the job was expected to take when it was created, in seconds. |
result | object | null | The outcome once succeeded: the POST /v2/rates response for rates.live, {summary, lines, format, warnings} for rates.batch, the reconcile result for rooms.reconcile. Null otherwise. |
result_url | string (uri) | null | rates.batch only: where to download the NDJSON result, a signed link or GET /v2/jobs/{job_id}/result; null for other kinds and until the job succeeds. |
error | object | null | Why the job failed; null unless status is failed. |
error.codeREQUIRED | string | Error code, one of the API error codes, usually upstream_error, unavailable (the job stopped and its held credit was returned) or bad_request. |
error.messageREQUIRED | string | Explanation in English. |
billing | Billing | null | What a billed call cost. Present on every billed response; billing is null on free ones. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Validation errors carry details.fields[]. |
| 401 | unauthorized | No key, or the key is unknown or revoked. |
| 402 | insufficient_credit | Not enough credit for the call. Checked before any work starts, so nothing was billed. |
| 403 | forbidden | Missing scope, or details.reason tier_required: this needs the Build plan or higher. |
| 409 | conflict | Idempotency-Key reused with another body. |
| 429 | rate_limited | Over the per-minute limit for the tier. Wait Retry-After seconds. |
| 503 | unavailable | We're temporarily unable to serve the call. Retry with backoff. |
Rates for many hotels and stays
/v2/rates/batchAlways a job: answers 202 with a rates.batch job; needs the rates scope and the Build plan or higher. Billed at the POST /v2/rates unit prices for each hotel and stay that comes back priced, 25% off when the batch has 1,000 hotels or more. The result is NDJSON at result_url, one line per hotel and stay in the results[] shape of POST /v2/rates with its stay, plus a summary. The 10 cheapest available offers of each line can be booked with POST /v2/bookings for 30 minutes; re-quote any other offer with POST /v2/rates. Honours Idempotency-Key.
Same unit prices as POST /v2/rates, 25% off when the batch has 1,000 hotels or more.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Any unique string. Same key and same body returns the original response; same key and a different body is a 409. |
Request body
| Field | Type | Description |
|---|---|---|
hotelsREQUIRED | object[] | Up to 10,000 hotels, each a hotel_id or an inline identifier. |
staysREQUIRED | Stay[] | Up to 10 stays; every hotel is priced for every stay. |
channel | string | direct (the default) or third_party (Enterprise only). One of direct, third_party. Default direct. |
freshness | string | cached reads stored prices; live reads the booking engines. One of cached, live. Default cached. |
max_age_hours | integer | Oldest stored price to accept on cached, in hours; values above 168 are treated as 168. Default 24. 1–720. |
currency | string | null | Preferred ISO 4217 currency; prices are never converted. |
Example
curl -X POST 'https://api.vistalink.com/v2/rates/batch' \
-H "Authorization: Bearer $VISTALINK_API_KEY" \
-H 'Idempotency-Key: 4f1c2b8e-booking-ada-rossi' \
-H "Content-Type: application/json" \
-d '{
"hotels": [
"43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19"
],
"stays": [
{
"check_in": "2026-11-14",
"check_out": "2026-11-16",
"rooms": [
{
"adults": 2,
"children_ages": []
}
]
}
],
"channel": "direct",
"freshness": "cached",
"currency": "EUR"
}'{
"created_at": "2026-10-07T09:30:00Z",
"estimated_seconds": 25,
"job_id": "job_K8s2Lq9Wx4Np",
"kind": "rates.live",
"progress": {
"done": 0,
"total": 1
},
"status": "running",
"updated_at": "2026-10-07T09:30:00Z"
}Response 202
Successful Response
| Field | Type | Description |
|---|---|---|
request_id | string | null | Id of the request that returned this job; null in lists and webhook payloads. |
job_idREQUIRED | string | Job id, job_ plus 12 letters and digits. |
kindREQUIRED | string | rates.live (from POST /v2/rates or rates=refresh), rates.batch or rooms.reconcile. One of rates.live, rates.batch, rooms.reconcile. |
statusREQUIRED | string | queued, running, then succeeded, failed or canceled. One of queued, running, succeeded, failed, canceled. |
progress | object | Units of work done out of the total. |
progress.done | integer | Units of work finished. Default 0. |
progress.total | integer | Units of work in the job: hotels for rates.live, hotel and stay pairs for rates.batch, 1 for rooms.reconcile. Default 1. |
created_atREQUIRED | string (date-time) | When the job was created, RFC 3339 UTC; jobs expire 7 days later. |
updated_atREQUIRED | string (date-time) | When the job last changed, RFC 3339 UTC. |
estimated_seconds | integer | null | Rough time the job was expected to take when it was created, in seconds. |
result | object | null | The outcome once succeeded: the POST /v2/rates response for rates.live, {summary, lines, format, warnings} for rates.batch, the reconcile result for rooms.reconcile. Null otherwise. |
result_url | string (uri) | null | rates.batch only: where to download the NDJSON result, a signed link or GET /v2/jobs/{job_id}/result; null for other kinds and until the job succeeds. |
error | object | null | Why the job failed; null unless status is failed. |
error.codeREQUIRED | string | Error code, one of the API error codes, usually upstream_error, unavailable (the job stopped and its held credit was returned) or bad_request. |
error.messageREQUIRED | string | Explanation in English. |
billing | Billing | null | What a billed call cost. Present on every billed response; billing is null on free ones. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Validation errors carry details.fields[]. |
| 401 | unauthorized | No key, or the key is unknown or revoked. |
| 402 | insufficient_credit | Not enough credit for the call. Checked before any work starts, so nothing was billed. |
| 403 | forbidden | Missing scope, or details.reason tier_required: this needs the Build plan or higher. |
| 409 | conflict | The Idempotency-Key was reused with a different body, or the resource is in a state that forbids the change. |
| 429 | rate_limited | Over the per-minute limit for the tier. Wait Retry-After seconds. |
| 503 | unavailable | We're temporarily unable to serve the call. Retry with backoff. |
Shared objects.
Objects that appear in more than one place. Billing is on the Billing page and ErrorBody under Errors & limits.
Stay
Dates and rooms of a stay, up to 30 nights and 9 rooms.
| Field | Type | Description |
|---|---|---|
check_inREQUIRED | string (date) | Arrival date, YYYY-MM-DD. |
check_outREQUIRED | string (date) | Departure date, YYYY-MM-DD, after check_in and at most 30 nights later. |
rooms | RoomOccupancy[] | One entry per room, 1 to 9; defaults to one room for two adults. At most 9. |
rooms[].adults | integer | Adults in this room, 1 to 16. Default 2. |
rooms[].children_ages | integer[] | Age of each child in this room, 0 to 17, one entry per child; empty for adults only. At most 8. |
Offer
One bookable price for one room and rate, as observed on a channel.
| Field | Type | Description |
|---|---|---|
offer_idREQUIRED | string | Offer id, off_t plus 20 hex characters, specific to your account; book it with POST /v2/bookings within 30 minutes of receiving it. |
channelREQUIRED | string | Where the price was observed: direct for the hotel's own booking engine, third_party for the Enterprise third-party channel. One of direct, third_party, expedia. |
engine | string | null | Code of the hotel's booking engine for direct offers, for example SYNXIS; null on the third-party channel. |
room_id | string (uuid) | null | Catalogue room this offer is for, matching rooms[].room_id on hotel detail; null when the source names the room only by its own code or name. |
room_name | string | null | Room name as the source shows it; null when the source does not name the room. |
listing_key | string | null | The source's own key for the room, such as engine_room:SUP or room_type:1234567; null when the source has none. |
rate_name | string | null | Rate plan name as the source shows it, or one built from the cancellation terms and board, such as Free cancellation, breakfast included. |
totalREQUIRED | string | Price for the whole stay and the whole party, a decimal string in currency; taxes the source quoted separately are added in when their amount is known. "0" when sold out. |
currencyREQUIRED | string | ISO 4217 currency the price was observed in; never converted. |
nightly_avg | string | null | total divided by the number of nights, for the whole party rather than per room; null when sold out. |
includes_taxes | boolean | null | True when total includes taxes and fees, false when some are listed in excluded_charges, null when the source does not say. |
excluded_charges | ExcludedCharge[] | Charges payable on top of total; usually empty. |
excluded_charges[].kindREQUIRED | string | What the charge is; taxes_and_fees (taxes and fees the source quoted separately) is the only kind produced today. |
excluded_charges[].amountREQUIRED | string | Amount of the charge, a decimal string in currency. |
excluded_charges[].currencyREQUIRED | string | ISO 4217 currency of amount. |
excluded_charges[].per | string | null | What amount is charged per: stay (once for the whole stay, the only basis produced today), night, person or person_night. One of night, stay, person, person_night. |
excluded_charges[].note | string | null | Short note on how the charge is applied, for example quoted on top of the total. |
board | string | null | Meals included: none, breakfast, half (breakfast and dinner), full (all meals) or all_inclusive; null when the source does not say. One of none, breakfast, half, full, all_inclusive. |
free_cancellation | boolean | null | True when the rate can be canceled free of charge, false when it is non-refundable, null when unknown. |
cancellation_deadline | string (date-time) | null | Last moment to cancel free of charge, RFC 3339; set only on offers read live, null otherwise. |
payment | string | null | When the guest pays: prepay at booking or pay_at_hotel at the property; null when the source does not say. One of prepay, pay_at_hotel. |
occupancy | object | null | The party this price is for, totalled across all rooms. |
occupancy.adultsREQUIRED | integer | Adults in the room. |
occupancy.children_ages | integer[] | Age of each child in the room; empty for adults only. |
availability | string | available when the room can be booked at this price, sold_out when the source showed it as unavailable, unknown when it did not say. One of available, sold_out, unknown. Default unknown. |
observed_at | string (date-time) | null | When this price was observed, RFC 3339 UTC. |
url | string (uri) | null | Page on the source where this price was observed; null when the source has no linkable page. |
Warning
Something the response could not fully do, without failing it.
| Field | Type | Description |
|---|---|---|
codeREQUIRED | string | Stable warning code: rates_unavailable (some hotels have no stored rate), rates_need_stay (rates asked for without a stay), channel_unavailable (the third-party channel was left out), constraints_relaxed (no exact match, so some constraints were loosened), semantic_unavailable, unresolved (with clarification policy never), search_id_unavailable (the search cannot be paged or referenced), on chat price_redacted (a price in the reply that is not a direct rate was replaced with "a rate on request"), and on rates currency_unavailable. |
message | string | null | Explanation in English. |
channel | string | null | The channel left out, third_party; set only on channel_unavailable. Always third_party. |