Hotel guides

Following search best practices

A good search integration asks precise questions, shows results as they arrive, and books inside the quote's lifetime. These practices get travellers accurate prices quickly and keep bookings from failing at checkout.

Resolve the destination before you search

Free text is slow and ambiguous. “Springfield” matches dozens of cities, and a search that has to guess either returns the wrong one or returns nothing. Resolve the place with GET /catalog/suggest as the traveller types, then search with IDs.

Avoid
{
  "productType": "hotel",
  "destination": "Springfield"
}
Prefer
{
  "productType": "hotel",
  "destination": "Dubai",
  "destinationCountryCode": "AE",
  "destinationCityRegionId": 6053839
}
  • A city: send its cityRegionId as destinationCityRegionId, plus destinationCountryCode. Some cities come back with several region IDs in cityRegionIds (a city and its surrounding area); send them all as destinationCityRegionIds to cover the whole area.
  • One hotel: send its canonicalId as hotelId. Only that property is priced, so the search is much faster.
  • Near a point: send near with lat, lng and a radiusKm between 0.5 and 50.
Single-hotel search
{
  "productType": "hotel",
  "destination": "Dubai",
  "hotelId": "c9b1e2f4a7d34b0e",
  "checkIn": "2026-11-10",
  "checkOut": "2026-11-13",
  "occupancy": [{ "adults": 2, "childrenAges": [] }],
  "currency": "USD",
  "market": "GB",
  "residency": "GB"
}

Search in the currency you'll pay in

Bookings are paid from your agency wallet, and the wallet doesn't convert between currencies. A credit booking whose currency differs from your wallet fails with 409 wallet_currency_mismatch. Search doesn't convert prices: hotel rates that aren't in the currency you searched in are left out of your results, while flight, activity and transfer results come back in the inventory's own currency — so check each row's sellCurrency and hide the ones that don't match.

Set currency to your wallet currency on every search. If you show travellers another currency, convert for display on your side and keep settling in the wallet currency.

Describe the real party

Price depends on who is travelling, and the booking must match the search exactly. Get these right up front rather than discovering the problem at checkout:

  • One occupancy entry per room. Two rooms is two entries, never one entry with four adults.
  • Every child's age in childrenAges, as of the check-in date. Send [] when there are none. Many hotels price children by age band, and a wrong age can change the room.
  • residency is the lead guest's passport country — not where your agency is, and not where the traveller lives. Rates can differ by nationality, and the hotel may check the passport at check-in.
  • market is your point of sale — the country you're selling in.
Two rooms: a couple, and one adult with two children
"occupancy": [
  { "adults": 2, "childrenAges": [] },
  { "adults": 1, "childrenAges": [4, 9] }
]
Hotel search limitValue
Rooms per search9
Adults per room6
Children per room4, aged 0–17
Length of stay30 nights
How far ahead730 days

Show results as they arrive

A hotel search fans out to many sources at once, and they answer at different speeds. POST /search returns as soon as the first batch is priced, or after about 4 seconds if that takes longer; the rest arrives in the background. Don't make travellers wait for the slowest source.

  1. Call GET /search/:id/results straight after the search and render what's there.
  2. While meta.session.status is partial, poll every 1–2 seconds. meta.session.batchProgress tells you how many batches are done if you want a progress bar.
  3. When meta.session.lateArrivals goes up, rates from a slower source have been added. Re-read from the first page: a cursor never revisits earlier rows.
  4. Stop at completed or failed, or after 90 seconds, whichever comes first. A search can still be partial at 90 seconds; show what you have.
Polling loop
async function waitForResults(searchId) {
  const deadline = Date.now() + 90_000;
  let seenLate = 0;
  while (Date.now() < deadline) {
    const { data, meta } = await call('GET', '/search/' + searchId + '/results?groupByProduct=true&limit=50');
    const late = meta.session.lateArrivals ?? 0;
    render(data, { reset: late > seenLate }); // late arrivals: re-read from page one
    seenLate = late;
    if (meta.session.status === 'completed' || meta.session.status === 'failed') return;
    await sleep(1500);
  }
}

Results are limited to 300 requests a minute per key — plenty for one poll every 1.5 seconds across several concurrent searches. Flight, activity and transfer searches finish before POST /search returns, so their first read is already complete.

Treat listing prices as “from” prices

Results carry each hotel's cheapest rates, which is right for a listing page but isn't every room. Group result rows by canonicalProductId and show the first row you meet for each hotel as its “from” price. When a traveller opens a hotel, fetch its full rate sheet with POST /search/:id/hotels/:productId/rates and let them choose from that.

  • Only fetch a rate sheet when a hotel is actually opened. It queries live inventory for that hotel, can take up to about 25 seconds, and is limited to 60 a minute per key.
  • Rates are ranked for you: cheapest first, and when two rates are within 2% of each other the refundable one ranks higher. In search results this ranking applies within each batch of hotels; rows from later batches and slower sources come after.
  • Show the room name, board, cancellation terms and any excludedTaxes (taxes paid at the hotel) for every rate you list.

A new search is slower, counts against a tighter limit (30 a minute), and can return different prices. For the next page, sorting or filtering, work from the search you already have:

  • Page with limit (up to 500) and meta.nextCursor.
  • Results arrive ranked. If you need a different order or filters — star rating, board, refundable only — fetch the rows once and sort or filter them on your side.
  • Only search again when the question changes: new dates, destination or party.

Mind the quote clock

Prices are only good for a short time. Two clocks run, and a booking must beat both:

Quote lifetimesA search and its quotes live 20 minutes. A price-check at minute 6 extends that quote to minute 26 and opens a 10-minute booking window that ends at minute 16.Searchsearch and quotes: 20 minPrice-checkquote extended to 26 minBookbook within 10 min0 min5 min10 min15 min20 min25 min30 min
Example: the traveller reaches checkout six minutes after searching.
  • Searches and their quotes live 20 minutes. After that, rate sheets return 410 search_expired and price-checks 410 quote_expired. Opening a rate sheet extends the search and returns fresh quotes with their own 20 minutes — but quote IDs you already had keep their original expiry, so always price-check a rate from the latest sheet.
  • A price-check resets that quote's expiry to 20 minutes from the check and opens a 10-minute window to book. Book later and you get 409 quote_not_price_checked; price-check again first.

If the traveller comes back after a break, run the search again rather than trying to revive an old one.

Price-check once, at checkout

A price-check asks the inventory source for the live price, so it is slower than reading results and it is the step that catches sold-out rooms. Run it once, when the traveller commits to a rate — not on every page view, and not for every rate on the sheet. Then book straight away. If the price or terms moved, follow Handling price changes.

Cache content, not prices

  • Don't cache prices or availability beyond the 20-minute search. They move constantly, and a stale price is a failed booking waiting to happen.
  • Do cache hotel content — descriptions, photos, amenities — from GET /products/hotel/:id. It changes rarely; refreshing it daily is plenty. Use GET /products/hotel-thumbnails to fetch up to 60 listing images in one call.

Check what's on sale in each market

Products can be switched on or off per market. Ask once per market when your app starts, and cache the answer for the session, instead of letting a search fail.

Request · cURL
curl "https://api.roaveagents.com/api/v1/search/capabilities?market=AE" \
  -H "X-API-Key: $ROAVE_API_KEY"

Checklist

  • Destinations are resolved with suggest, and searches send region or hotel IDs.
  • currency is always your wallet currency, and non-hotel rows in any other sellCurrency are hidden.
  • One occupancy entry per room, with every child's age.
  • residency is the lead guest's passport country.
  • Results render immediately and update while the search is partial.
  • Rate sheets load only when a hotel is opened.
  • Paging uses cursors; sorting and filtering happen client-side.
  • Price-check runs once, at checkout, and booking follows within 10 minutes.
  • Prices are never cached beyond the search; content is.