# Mini and full hotel data

> Use the compact mini data for search result lists and the full data only for the hotel page, with the size difference measured.

Source: https://fx-port.com/docs/hotels/hotel-data

Hotel content comes in two sizes, and you can use both: the **mini** card for every hotel of a result list, and the **full** document for the one hotel the user opens. Mini is a compact card of 13 fields, about **5 KB** a hotel. Full is the whole document, **35 to 45 KB** a hotel on average and up to 150 KB: **7 to 9 times larger**, and 92% of a mini card is photos and amenities. So build lists from mini data, fetch full data for one hotel when it is opened, and cache both.

## Which one to use

| You are building | Use | Why |
|---|---|---|
| A **list of results** or map pins | `hotels_details[]` of [`search_advanced`](https://fx-port.com/docs/hotels/search.md), joined by hotel id with its `hotels_pricing` | One call gives the cards and the prices. Already mini. |
| Cards for **hotel ids you already have** (favourites, name search hits, a saved list) | `POST /hotels/search?detail_level=minimal` with up to 200 ids | One call, from FX-Port's cache, no price. |
| The card of a hotel in `missing_hotel_ids` | [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) with `hotel_id` and `detail_level=minimal` | The only call that asks the supplier when FX-Port does not hold the hotel yet. |
| The **page of one hotel** the user opened | `get_hotel_info` (full), next to [`get_rates`](https://fx-port.com/docs/hotels/rates.md) | Description, every room type, policies, facts, contact details. |
| The pages of **several hotels** at once (a comparison) | `POST /hotels/search` (full, snake_case) with their ids | One call, from the cache. |
| A copy of the hotel documents in **your own catalogue** | `POST /hotels/details` | The stored document, with `images_ext` and `policy_struct`. |

## Mini data

The mini card has 13 fields in `snake_case`. It is the same card wherever you get it:

- `hotels_details[]` of `search_advanced` (automatic, with every search);
- `POST /hotels/search?detail_level=minimal` (alias `mini`);
- `POST /hotels/details?detail_level=minimal`;
- `get_hotel_info` with `detail_level=minimal`.

| Field | Type | What it holds |
|---|---|---|
| `id` | string | The FX-Port hotel id, a lower-case slug. Send it as `hotel_id`. |
| `hid` | integer | The supplier's numeric hotel id. |
| `name` | string | Name of the hotel. |
| `kind` | string | Type of property: `Hotel`, `Apartment`, `Resort`, `Hostel` ... |
| `address` | string | Street address, in English. |
| `latitude`, `longitude` | number | Position, for the map. |
| `postal_code` | string or `null` | Postal code; `null` for a minority of hotels. |
| `region` | object | `{ id, country_code, iata, name, type, type_v2 }`: the city. |
| `star_rating` | integer | 0 to 5. `0` means the property is not rated. |
| `images` | array or `null` | Photo **URL templates**, best first (see [Images](#images)). `null` for a few hotels (62 of 1,818 around Paris). |
| `serp_filters` | array of strings | Filter tags: `has_internet`, `has_parking`, `has_pool`, `has_spa`, `has_fitness`, `has_pets`, `has_kids`, `has_meal` ... |
| `amenity_groups` | array | `[{ group_name, amenities[], non_free_amenities[] }]`. |

~~~json
{
  "id": "test_hotel_do_not_book",
  "hid": 8473727,
  "name": "Test Hotel (Do Not Book) test",
  "kind": "Apartment",
  "address": "Avenida Roble, Frente al Mall Multiplaza, Tegucigalpa",
  "latitude": 14.079872,
  "longitude": -87.21677,
  "postal_code": null,
  "region": { "id": 3421, "country_code": "HN", "iata": "TGU", "name": "Tegucigalpa", "type": "City", "type_v2": "city" },
  "star_rating": 0,
  "images": [
    "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg",
    "https://cdn.worldota.net/t/{size}/extranet/15/44/15440558c952ec4c3488730d1563dc9e8d25832c.JPEG"
  ],
  "serp_filters": ["has_internet", "has_parking", "has_spa", "has_pets", "has_jacuzzi", "kitchen"],
  "amenity_groups": [
    { "amenities": ["24-hour reception", "Early check-in", "Late check-out"], "non_free_amenities": [], "group_name": "General" }
  ]
}
~~~

The reference describes every field: [The Hotel mini object](https://fx-port.com/docs/api/objects/hotel-mini.md).

A mini card has **no price, no room and no rate**. It is made for a list: where the hotel is, what it looks like, what it offers. The price of the cheapest rate is in `hotels_pricing`, and the rooms and cancellation terms come from [`get_rates`](https://fx-port.com/docs/hotels/rates.md).

## Full data

The full document adds what the hotel page needs: the description, **every room type** with photos and amenities, the policies, the facts, the contact details and the times of check-in and check-out. It comes in three spellings:

| Call | Keys | Notes |
|---|---|---|
| `POST /hotels/search` (default `detail_level=full`) | `snake_case` | 31 fields, from the cache. [The Hotel full object](https://fx-port.com/docs/api/objects/hotel-full.md). |
| [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) (default) | `camelCase` | The same data with the room properties decoded into words. For a hotel that is not cached it asks the supplier. |
| `POST /hotels/details` | `snake_case` | The stored document: the 31 fields plus `images_ext` and `policy_struct`. About 27% larger. |

The fields of the full document (`snake_case` names; the camelCase document has the same data):

| Field | What it holds |
|---|---|
| `id`, `hid`, `name`, `kind`, `hotel_chain` | Identity. `hotel_chain` is `No chain` for an independent hotel. |
| `address`, `postal_code`, `latitude`, `longitude`, `region` | Place. |
| `star_rating`, `star_certificate` | Stars, and the official certificate when there is one (`{ certificate_id, valid_to }`, else `null`). |
| `email`, `phone` | Contact details of the hotel. |
| `check_in_time`, `check_out_time`, `check_in_time_end` | **Times of day** (`14:00:00`, local time), not dates. `check_in_time_end` only for hotels that state it. |
| `front_desk_time_start`, `front_desk_time_end` | Opening hours of the front desk, or `null`. |
| `images`, `amenity_groups`, `serp_filters` | The same as in the mini card. |
| `description_struct` | The description in titled blocks: `[{ title, paragraphs[] }]`. |
| `metapolicy_struct` | The policies: `internet`, `meal`, `children_meal`, `extra_bed`, `cot`, `pets`, `shuttle`, `parking`, `children`, `visa`, `deposit`, `no_show`, `add_fee`, `check_in_check_out`. Read `children` before you assume a child stays free. |
| `metapolicy_extra_info` | The house rules as free text, or `null`. |
| `room_groups` | Every room type: `room_group_id`, `name`, `name_struct`, `rg_ext` (numeric codes), `room_amenities`, `images`. About 80% of the bytes of the document. |
| `facts` | Floors, rooms, year built, year renovated, electricity, registration. Many are `null`. |
| `payment_methods` | Payment methods accepted at the property. |
| `keys_pickup` | How guests get the keys: `type`, `is_contactless`, `phone`, `email`, `apartment_office_address`, `apartment_extra_information`. |
| `is_closed`, `is_gender_specification_required`, `deleted` | Flags. `is_closed: true` means `get_rates` finds no rate. |
| `distance_center`, `giata_code` | Optional: present for some hotels only. |

`get_hotel_info` spells these keys in camelCase (`starRating`, `amenityGroups`, `descriptionStruct`, `roomGroups`), and differs in six places:

| In the snake_case document | In `get_hotel_info` |
|---|---|
| `amenity_groups[].non_free_amenities` | `amenityGroups[].paidAmenities` |
| `check_in_time`, `check_out_time` | `checkIn`, `checkOut` (the snake_case names are returned as twins) |
| `email`, `phone` | `contact: { email, phone }` |
| `front_desk_time_start`, `front_desk_time_end` | `frontDeskHours: { start, end }` |
| `room_groups[].rg_ext` (12 numeric codes) | decoded: `roomClass`, `quality`, `bathroom`, `hasPrivateBathroom`, `capacity`, `balcony`, `view`, `floor`, `bedrooms`, `beddingType`, `isFamily`, `isClub`, `genderRestriction` |
| `description_struct[]` | the same blocks plus `html` |

The entries inside `metapolicyStruct` keep their snake_case keys (`age_start`, `price_unit`, `meal_type`).

## How much smaller is mini

Measured on the sandbox on 2026-11-01 (decoded JSON; "gzip" is what travels over the network):

| What | Mini | Full | Full / mini |
|---|---|---|---|
| **One hotel**, the test hotel (7 photos, 11 amenity groups, 9 room types) | 2.3 KB (0.9 KB gzip) | 11.9 KB (3.2 KB gzip), `get_hotel_info` 13.1 KB | 5.2x (5.7x) |
| **15 hotels** around Tegucigalpa | 79 KB, 5.3 KB a hotel (16.7 KB gzip) | 654 KB, 43.6 KB a hotel (72.8 KB gzip); `get_hotel_info` 723 KB | 8.3x decoded, 4.4x gzip |
| **73 hotels** around Los Angeles | 349 KB, 4.8 KB a hotel (75.7 KB gzip) | 2,493 KB, 34.2 KB a hotel (309 KB gzip); `get_hotel_info` 2,784 KB, 38.1 KB a hotel | 7.1x decoded, 4.1x gzip |

A mini card is also **predictable**: in the 73 hotels it runs from 0.4 KB to 8.3 KB, 4.6 KB in the middle. A full document runs from 3.8 KB to 131 KB, 23 KB in the middle.

Where the bytes go (73 hotels): in a **mini** card, photos are 58% and amenities 34%, so **92% of it is photos and amenities**. In a **full** document the room types are 79%, the photos 8% and the amenities 5%.

A mini card is still larger than what a list shows. What a result card needs is the id, name, kind, address, position, stars, filters, the city name and **one photo**: about **0.5 KB**, or 1.0 KB with six photos (computed on the 15 hotels above; 5.1 KB a hotel in mini form). Keep those fields when you store or forward the cards, and do not send the amenity groups to a phone.


> [!NOTE]
> Mini saves **bytes**, not server time. Both forms read the same cache document and the answer takes 0.2 to 0.7 s for 15 to 73 hotels either way. The saving is on the network, in parsing, and in your own storage: 8.5 MB of search answer for 1,818 hotels around Paris is 93% mini cards.


## Hotels without content

A search prices every hotel the supplier returns, but the content comes from FX-Port's own cache of hotel content. A hotel that is priced but not cached yet (a new property) is **not** in `hotels_details`. The search tells you:

~~~json
{
  "status": "partial",
  "processing_status": { "total_hotels": 15, "cached_hotels": 14, "queued_hotels": 1 },
  "queue_info": { "queued_hotel_ids": ["comfort_inn_tegucigalpa"], "estimated_completion": "about 9 minutes", "estimated_completion_seconds": 541 },
  "missing_hotel_ids": ["comfort_inn_tegucigalpa"],
  "hotels_pricing": { "comfort_inn_tegucigalpa": { "final_price": 109590.36, "b2b_price": 109371.61, "...": "..." } }
}
~~~

- `missing_hotel_ids` lists the hotels that have a price and no card. The price is in `hotels_pricing` (and `missing_hotels_pricing`) as for any other hotel.
- FX-Port queues them for loading in the background (`queue_info`): at most 50 ids a search. They are usually in the cache a few minutes later and then appear in `hotels_details` of the next search.
- `status` is `complete` when every hotel has content, `partial` when some do not, `cache_miss` when none does.

It is rare: 1 hotel of 15 in the example (and cached 9 minutes later), none of 73 around Los Angeles, none of 1,818 around Paris.

**To get the card of a missing hotel now**, call [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) with that one id:

~~~json
{ "hotel_id": "comfort_inn_tegucigalpa", "detail_level": "minimal" }
~~~

It is the only content call that asks the supplier when FX-Port does not hold the hotel (about 0.3 s), and it keeps the hotel for everybody afterwards. It does this for **one** id per call. The calls that read many ids (`POST /hotels/search`, `POST /hotels/details`, `get_hotel_info` with `hotel_ids`) read the cache only: they leave out the ids they do not hold and do not report them.

How to do it well:

- Fill **only the hotels the user can see**, not all of them. Render the others with their price and a placeholder, or leave them out until their content is cached.
- Make at most **five calls at a time**. The supplier answers about 30 hotel content calls a minute for the whole of FX-Port, so a `429`, `502` or `503` answer means you went too fast or the supplier is busy: back off. A `503 supplier_busy` was refused before anything was sent: wait the seconds of `Retry-After` and ask again.
- Send `"language": "en"` when you fetch a hotel that is not cached. The content that is fetched is stored for every customer in the language of that call.
- Remember the result for the hotel id, and remember a failure for a few minutes, so that the next search does not repeat the same calls.

## Images

`images` holds URL **templates**. Replace `{size}` to get a picture:

~~~text
https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg
~~~

| `{size}` | Use | Note |
|---|---|---|
| `120x120` | Thumbnails, map pins | |
| `240x240` | Cards of a result list | 18 KB for the photo above |
| `640x400` | A hotel page, the first screen | |
| `1024x768` | A gallery, a full-width image | 228 KB for the photo above |
| `x500` | 500 px high, the width follows | |

These sizes answer `200`. `orig` does not (it redirects to a web page) and unknown sizes answer `400`. Choose the size by what you display: **one 1024x768 photo can weigh as much as 40 mini cards**, so the size you ask the CDN for matters more than the card.


**JavaScript**

~~~js
const photo = (template, size = "240x240") => template.replace("{size}", size);

const card = search.hotels_details[0];
const thumbnail = card.images ? photo(card.images[0]) : "/no-photo.svg"; // images can be null
~~~

**Python**

~~~python
def photo(template, size="240x240"):
    return template.replace("{size}", size)


card = search["hotels_details"][0]
thumbnail = photo(card["images"][0]) if card["images"] else "/no-photo.svg"  # images can be null
~~~


`images_limit` (0 to 30) on the content calls (`POST /hotels/search`, `POST /hotels/details` and `get_hotel_info`) keeps only the first N photos of each mini card: `images_limit=1` removes about half of a mini card. It applies to the mini card only (`detail_level=minimal`, alias `mini`): with the full document it is ignored and the answer carries a `coerced_value` warning. It does not exist on `search_advanced`, which always returns every photo of every hotel.

## Reading hotel content

Three calls read hotels by id. They read FX-Port's cache, they take up to 200 ids, and they answer a bare JSON array that does **not** follow the order of your ids; an id that is not cached is left out and queued for loading. An id can also be the supplier's numeric `hid`, as a JSON integer or as digits (`[8473727]`): `POST /hotels/search` and `POST /hotels/details` look an id up as an id and then as a `hid`, a hotel reached by both comes back once, and a digits-only id that matches nothing is not queued; `get_hotel_info` takes a `hid` too. Spaces around an id are trimmed.

| Call | Body | Answer |
|---|---|---|
| `POST /hotels/search` | `{ "hotel_ids": [...] }` (a bare list still works) | Mini cards with `?detail_level=minimal`, full documents otherwise |
| `POST /hotels/details` | the same | The stored documents (`?detail_level=minimal` gives mini cards) |
| [`POST /hotels/get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) | `{ "hotel_id": "..." }` or `{ "hotel_ids": [...] }` | `{ "data": [...] }`, camelCase (mini cards with `detail_level`) |


**Request**

```bash
curl 'https://api.fx-port.com/api/v1/hotels/search?detail_level=minimal&images_limit=1' \
  -H "Authorization: Bearer fxp_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "hotel_ids": [
    "test_hotel_do_not_book",
    "hotel_honduras_maya_6"
  ]
}'
```


| Query parameter | Default | Description |
|---|---|---|
| `detail_level` | `full` | `minimal` (alias `mini`) or `full`. Anything else answers `400 invalid_detail_level`. |
| `fields` | none | A comma separated list of top-level fields (`id,name,star_rating`), at most 40. It replaces `detail_level`; `id` is always added. Nested paths are not supported. |
| `images_limit` | none | With `detail_level=minimal` and no `fields`: the most photos of each hotel, 0 to 30 (another number answers `400 invalid_images_limit`). In any other case it is ignored and the answer carries a `coerced_value` warning. `POST /hotels/details` reads it too, with `detail_level=minimal`. |

The answer carries `X-Results-Count`, `X-Detail-Level` and `X-Search-Time` headers. A body that is not a list of ids, or that holds more than 200 ids, answers `400 invalid_body`, and a malformed id `400 invalid_hotel_id`. See the reference: [`POST /hotels/search`](https://fx-port.com/docs/api/get-hotel-content.md), [`POST /hotels/details`](https://fx-port.com/docs/api/get-hotel-details.md) and [`POST /hotels/get_hotel_info`](https://fx-port.com/docs/api/get-hotel-info.md).

### Legacy names

These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)).

| Older name | Use instead |
|---|---|
| `hotelIds`, `ids` (body) | `hotel_ids` |
| `detailLevel` (query) | `detail_level` |
| `imagesLimit` (query) | `images_limit` |

## Caching

- **Cache content by hotel id.** A hotel's content changes rarely: a day is a safe default for a card, and a result list can use content that is a week old. Do not put the language in the key: cached content is **English whatever `language` you send** (`language` only applies to the live fetch of a hotel that is not cached, and to room names in `get_rates`).
- **Never cache prices** beyond the search that returned them. `bookHash` values live 30 minutes, and a price can change at any time.
- **Cache the card, not the answer.** Keep the fields you show and drop `amenity_groups` and all but the photos you display.
- **Do not refetch on every search.** Search again only for prices; read content for the ids you have not seen.

## A results list in code

The answer of `search_advanced` has the cards in `hotels_details` and the prices in `hotels_pricing`. Join them by hotel id, and treat the ids of `missing_hotel_ids` apart:


**Python**

~~~python
search = call("POST", "/hotels/search_advanced", json=request)

cards = {card["id"]: card for card in search["hotels_details"]}
rows = []
for hotel_id, price in search["hotels_pricing"].items():
    card = cards.get(hotel_id)  # None for a hotel in missing_hotel_ids
    rows.append({
        "id": hotel_id,
        "name": card["name"] if card else None,
        "stars": card["star_rating"] if card else None,
        "photo": card["images"][0].replace("{size}", "240x240") if card and card["images"] else None,
        "price": price["final_price"],
        "currency": price["agency_currency"],
    })
rows.sort(key=lambda row: row["price"])

to_fill = [row["id"] for row in rows[:20] if row["name"] is None]  # only what is visible, five at a time
~~~

**JavaScript**

~~~js
const search = await call("POST", "/hotels/search_advanced", request);

const cards = new Map(search.hotels_details.map((card) => [card.id, card]));
const rows = Object.entries(search.hotels_pricing).map(([id, price]) => {
  const card = cards.get(id); // undefined for a hotel in missing_hotel_ids
  return {
    id,
    name: card?.name ?? null,
    stars: card?.star_rating ?? null,
    photo: card?.images?.[0]?.replace("{size}", "240x240") ?? null,
    price: price.final_price,
    currency: price.agency_currency,
  };
});
rows.sort((a, b) => a.price - b.price);

const toFill = rows.slice(0, 20).filter((row) => row.name === null).map((row) => row.id); // only what is visible
~~~


## Related


- [Geo search](https://fx-port.com/docs/hotels/search.md): The search that returns the mini cards and the prices.
- [Hotel details](https://fx-port.com/docs/hotels/hotel-details.md): `get_hotel_info`, the call for the hotel page and for hotels without content.
- [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): Rooms, meals and cancellation terms for the stay.
- [Rate limits](https://fx-port.com/docs/rate-limits.md): How many calls a key may make, and how to stay below.
