Google Hotel Center integration


Integrating Nuitee Connect with Google Hotel Center

A practical guide for engineers building a price-pull bridge between Nuitee Connect (the rate supplier) and Google Hotel Center (the metasearch destination). It walks through the architecture you'll need, the JSON → XML mapping, the business rules that keep your inventory eligible, and the gotchas that bite first.

The guide is implementation-agnostic. Examples are in Node.js because the XML protocol is verbose enough to benefit from a real syntax, but nothing here is Node-specific.


Overview

Google Hotel Center (GHC) does not accept pushed inventory. Instead, it polls an HTTPS endpoint you operate, sending XML queries and expecting XML responses within a tight deadline (commonly 3–6 seconds). Your job is to translate those queries into supplier calls, select a rate per property, and shape the response into GHC's schema.

You'll need to build and operate four things:

  1. A property feed uploaded to GHC, which declares the hotels Google is allowed to ask about.
  2. An HTTPS endpoint (one URL, multiple query shapes) that receives Google's XML and responds with XML.
  3. An adapter that translates GHC pricing queries into Nuitee Connect POST /hotels/rates calls and the JSON response back into GHC XML.
  4. A metadata responder that returns room/photo/capacity data when Google asks for it.

Everything else — caching, persistence, monitoring, multi-currency — is an optimization on top of these four pieces.


Prerequisites

Before any of this matters:

  • A Nuitee Connect account with an API key authorized for POST /hotels/rates against the property IDs you intend to publish.
  • A Google Hotel Center account with the Pricing API enabled. Get this through your Google partner contact.
  • A list of property IDs that exist in both systems. The cleanest setup is to use the Nuitee Connect hotel ID as your GHC property ID — they pass through unchanged, and there's nothing to translate at runtime.
  • A public HTTPS endpoint with a valid TLS certificate. Google will refuse to poll a self-signed or HTTP endpoint.
  • A datastore for static hotel/room metadata (names, photos, capacity). Nuitee Connect returns rates but not the rich room descriptions GHC wants to render. You'll need somewhere to hold this — Postgres, MongoDB, a flat JSON file refreshed nightly, whatever fits.

Architecture

                              ┌──────────────────────────┐
   Google Hotel Center ──XML──▶│   POST /query            │
                              │   (your HTTPS endpoint)  │
                              └────────────┬─────────────┘
                                           │
                ┌──────────────────────────┼──────────────────────────┐
                ▼                          ▼                          ▼
       Pricing query?             Metadata query?              Neither
       <Checkin> present       <HotelInfoProperties>         → 400 / 501
                │                          │
                ▼                          ▼
   Translate to Nuitee Connect         Read rooms from your
   POST /hotels/rates           static hotel datastore
                │                          │
                ▼                          ▼
   Apply business rules:        Build <Transaction>
   • Filter rates                with <PropertyDataSet>
   • Pick a rate per hotel               │
   • apture offerId for booking          │
   • Enrich rooms                        │
                │                        │
                ▼                        │
   Build <Transaction>                   │
   with <Result> per property            │
                │                        │
                └──────────┬─────────────┘
                           ▼
                XML response (application/xml)
                  inside Google's deadline

Why one URL for everything

GHC's contract sends three different XML shapes to a single configured URL. The shape of the body — not the path — tells you what kind of query it is. Trying to split this across multiple URLs will fight the schema; just dispatch internally.


How Google's pull model works

Google sends three distinct query shapes. Dispatch on which elements appear inside <Query>:

Query typeTrigger element(s)PurposeTypical deadline
Pricing (cached)<Checkin>, <Nights>, <PropertyList>Bulk price pull for many hotels and dates.6 s
Pricing (live)Same, plus latencySensitive="true" and <DeadlineMs>Real-time price for a user actively searching.3 s
Metadata<HotelInfoProperties>One-off pull of room metadata.Lax.

A representative pricing query Google will send you:

<?xml version="1.0" encoding="UTF-8"?>
<Query latencySensitive="true">
  <Checkin>2026-07-01</Checkin>
  <Nights>2</Nights>
  <DeadlineMs>3000</DeadlineMs>
  <PropertyList>
    <Property>lp10249d</Property>
    <Property>lp1af45</Property>
  </PropertyList>
  <Context>
    <Occupancy>2</Occupancy>
    <OccupancyDetails>
      <NumAdults>2</NumAdults>
    </OccupancyDetails>
    <UserCountry>US</UserCountry>
    <UserDevice>mobile</UserDevice>
  </Context>
</Query>

And a representative metadata query:

<?xml version="1.0" encoding="UTF-8"?>
<Query>
  <HotelInfoProperties>
    <Property>lp10249d</Property>
    <Property>lp1af45</Property>
  </HotelInfoProperties>
</Query>

Deadlines are wall-clock

<DeadlineMs> is the time by which Google must have received your response. Network RTT eats into that budget. If you have 3000 ms and ~200 ms RTT each way, you have ~2600 ms to compute + serialize. Forward the deadline to Nuitee Connect as its timeout, in seconds, and never block the response on anything that isn't on the critical path (logging, analytics writes, cache fills — all fire-and-forget).


Step 1 — Publish a property feed

Google can only pull prices for properties you've previously declared in a feed. Upload a CSV with at minimum:

ColumnNotes
idBecomes the <Property> value Google sends back to your endpoint. Use your Nuitee Connect hotel ID.
nameHotel display name.
addr1, city, province, postal_code, countryAddress fields.
latitude, longitudeRequired and must match Google's geocoding within tolerance.
phoneE.164 preferred.
categoryUsually hotel.
star_ratingInteger 1–5. Don't send 0.

If a property is not in the feed, Google will simply never query it. If you change the id, all of Google's cached prices for that property become orphans.


Step 2 — Stand up the endpoint

Two non-obvious requirements:

  1. Accept application/xml as plain text and parse it yourself. Many web frameworks default to JSON middleware and will silently drop the body.
  2. Do not emit an <?xml ... ?> declaration in your responses unless GHC explicitly requires it. The reference fixtures omit it and Google's parser is fine either way; omitting it avoids a category of formatting bugs.

Skeleton:

import express from 'express';
import bodyParser from 'body-parser';
import xml2js from 'xml2js';

const app = express();
app.use(bodyParser.text({ type: 'application/xml' }));

app.post('/query', async (req, res) => {
  const body = await xml2js.parseStringPromise(req.body);
  if (!body.Query) return res.status(400).send('Invalid request');

  if (body.Query.Checkin) {
    const xml = await handlePricingQuery(body);
    res.set('Content-Type', 'application/xml').send(xml);
    return;
  }

  if (body.Query.HotelInfoProperties) {
    const xml = await handleMetadataQuery(body);
    res.set('Content-Type', 'application/xml').send(xml);
    return;
  }

  res.status(501).send('Not implemented');
});

Also expose a health-check route (GET /) that exercises your datastore. Wire it into your load balancer or container health check so you don't keep a broken instance serving Google.


The pricing-query lifecycle

1. Parse the GHC query

You need: the checkin date, number of nights, list of property IDs, optional latency-sensitive flag, optional deadline. Compute checkout = checkin + nights (use a date library — month boundaries will bite you).

You can also harvest <Context> if you want occupancy-aware pricing, but be aware that GHC won't always send it, and many integrations choose to quote a standard "2 adults" rate regardless. Pick a policy and stick with it.

2. Build the Nuitee Connect request

Nuitee Connect's POST https://api.liteapi.travel/v3.0/hotels/rates takes JSON:

const liteApiRequest = {
  hotelIds: propertyList,          // pass through from <PropertyList>
  occupancies: [{ adults: 2 }],    // or derive from <OccupancyDetails>
  currency: 'USD',                 // see "Currency" below
  guestNationality: 'US',
  checkin: '2026-07-01',
  checkout: '2026-07-03',
  maxRatesPerHotel: 100,           // ask for many; filter to one
  roomMapping: true,               // returns mappedRoomId for room enrichment
  timeout: Math.round(deadlineMs / 1000),
};

Two flags are load-bearing:

  • roomMapping: true — without it, Nuitee Connect returns supplier room IDs that don't join cleanly to your static room data. You'll lose <RoomData> enrichment.
  • maxRatesPerHotel: 100 — you want headroom so your filter stage has options. Asking for one rate and getting one you have to drop is a self-inflicted unavailability.

Call it with your API key in the X-API-Key header. Treat error code 2001 ("no rates") as an empty array, not a failure. Anything else, throw.

3. Pick a rate per property and apply business rules

Nuitee Connect returns an array of Rate objects, each with roomTypes[], each with rates[]. The rates inside a roomType are different cancellation/board-type variants of the same room.

A defensible default policy:

  1. Drop rates with no taxesAndFees. You cannot reliably split base vs. tax for Google without them.
  2. Pick roomTypes[0].rates[0] after filtering. Nuitee Connect's ordering is intentional; don't try to be clever unless you have a specific commercial rule.
  3. Apply a per-night price ceiling (e.g. total / nights > 10,000 USD → drop). Google de-ranks suppliers that consistently quote garbage prices.
  4. Apply a tax-sanity check (e.g. excluded taxes ≥ 30% of total → drop). This pattern almost always means a supplier data bug.
  5. Compute refundability from cancellationPolicies.refundableTag === 'RFN'. See Refundability rules below — there are several edge cases.
  6. Set an expiration (commonly 3 hours from now) and surface it as <ExpirationTime> so Google knows how long to trust the quote.

If a property survives all checks, emit a priced <Result>. If not, emit <Result> with <Baserate>-1</Baserate> and one of the <Unavailable> reasons (see Unavailability).

4. Enrich with room metadata

Nuitee Connect's mappedRoomId is the join key into your static room data. The metadata you want to surface in <RoomData>:

  • RoomID — your internal room identifier (string).
  • Name — English label, e.g. "Deluxe Queen Room - Non-Smoking".
  • Capacity — max occupancy. Floor this to 2 — Google rejects/de-prioritizes 1-pax rooms in many markets.
  • PhotoURL.URL — one absolute https URL.

If the join misses (no row), still emit the priced <RoomBundle> but omit <RoomData>. The rate is still valid; you just lose the room rendering.

5. Build the XML response

The response wraps one <Result> per property Google asked about, in any order, inside a single <Transaction>:

<Transaction timestamp="2026-05-28T13:30:00.000Z">
  <Result>
    <Property>lp10249d</Property>
    <Checkin>2026-07-01</Checkin>
    <Nights>2</Nights>
    <ExpirationTime>2026-05-28T16:30:00.000Z</ExpirationTime>
    <Baserate currency="USD">312.50</Baserate>
    <Tax currency="USD">41.20</Tax>
    <Refundable available="1"
                refundable_until_days="5"
                refundable_until_time="18:00:00"/>
    <BreakfastIncluded>1</BreakfastIncluded>
    <RoomBundle>
      <RoomData>
        <RoomID>060773</RoomID>
        <Name><Text language="en">Deluxe Queen Room</Text></Name>
        <Capacity>2</Capacity>
        <PhotoURL><URL>https://example.com/r/060773.jpg</URL></PhotoURL>
      </RoomData>
    </RoomBundle>
    <PackageData>
      <PackageID>LITEAPI-OFFER-ID</PackageID>
      <Name><Text language="en">Best Available Rate</Text></Name>
    </PackageData>
  </Result>

  <Result>
    <Property>lp1af45</Property>
    <Checkin>2026-07-01</Checkin>
    <Nights>2</Nights>
    <Baserate>-1</Baserate>
    <Unavailable><NoVacancy/></Unavailable>
  </Result>
</Transaction>

Build it with whatever XML builder you trust — most will accept a JS object tree that maps element/attribute structure 1:1.


The metadata-query lifecycle

When Google sends <HotelInfoProperties>, return one <PropertyDataSet> per requested hotel, populated from your static datastore. No Nuitee Connect call is involved.

<Transaction timestamp="2026-05-28T13:30:00.000Z">
  <PropertyDataSet>
    <Property>lp10249d</Property>
    <RoomData>
      <RoomID>060773</RoomID>
      <Name><Text language="en">Deluxe Queen Room</Text></Name>
      <Capacity>2</Capacity>
      <PhotoURL><URL>https://example.com/r/060773.jpg</URL></PhotoURL>
    </RoomData>
    <RoomData>...</RoomData>
  </PropertyDataSet>
</Transaction>

Google may poll this on a fixed cadence (often daily). It's cheap to serve — a single read against your static data — so don't bother caching it.


Field mapping (Nuitee Connect JSON → Google Hotel Center XML)

The single most useful table in this guide. Source paths are JSONPath-style against the Nuitee Connect data[] array returned by POST /hotels/rates.

GHC XML elementNuitee Connect sourceNotes
Transaction/@timestampServer timeISO 8601, UTC.
Result/Property[].hotelIdSame ID Google sent back.
Result/Checkin, Result/NightsEchoed from request
Result/ExpirationTimenow + N hoursTell Google how long the quote is valid. 1–6 hours is typical; 3 is a good default.
Result/Baserate (@currency)[].roomTypes[0].offerRetailRate.amountTotal stay price, not nightly. Google handles per-night display.
Result/Tax (@currency)Sum of [].roomTypes[0].rates[0].retailRate.taxesAndFees[] where included === false, rounded to 2 dpExcluded taxes only. Included taxes are already inside Baserate.
Result/Refundable/@available[].roomTypes[0].rates[0].cancellationPolicies.refundableTag === 'RFN'"1" or "0".
Result/Refundable/@refundable_until_daysfloor(cancelPolicyInfos[0].cancelTime - today) in daysRequired when available="1". Default to 1 if missing; flip to non-refundable if it would be < 1.
Result/Refundable/@refundable_until_timeTime portion of cancelTime, or 00:00:00HH:MM:SS.
Result/BreakfastIncludedboardType === 'BI' ? "1" : (omit)
Result/RoomBundle/RoomData/RoomIDYour static room ID joined via [].roomTypes[0].rates[0].mappedRoomIdOmit <RoomData> entirely if the join misses.
Result/RoomBundle/RoomData/Name/TextYour room name, language="en"
Result/RoomBundle/RoomData/CapacityYour room occupancy, floored to 2Don't quote 1.
Result/RoomBundle/RoomData/PhotoURL/URLOne absolute https URLOptional but improves CTR.
Result/PackageData/PackageIDDouble-Base32-decoded [].roomTypes[0].rates[0].rateId (first |-segment)Used to deep-link to the bookable rate.
Result/PackageData/Name/Text[].roomTypes[0].rates[0].name, with the leading "Room name - " prefix stripped if present
Result/Unavailable/NoVacancyNo rate from Nuitee Connect for this propertyPair with Baserate = -1.
Result/Unavailable/PriceIssueRate filtered by your business rules (price cap, tax %, decode failure)Pair with Baserate = -1.

Refundability rules

The <Refundable> element is where most integrations get rejected. Two unforced errors to avoid:

  1. available="1" requires both refundable_until_days AND refundable_until_time. If Nuitee Connect returns a refundable rate without a cancelTime, default to refundable_until_days="1" and refundable_until_time="00:00:00". Sending available="1" without these is invalid.
  2. refundable_until_days < 1 should flip to non-refundable. If the cancellation window has effectively passed by the time you respond, mark the rate as non-refundable. Sending 0 confuses Google's display and frustrates users.

Pseudocode:

let refundable = rate.cancellationPolicies?.refundableTag === 'RFN';
let untilDays;
let untilTime;

const cancelTime = rate.cancellationPolicies?.cancelPolicyInfos?.[0]?.cancelTime;
if (refundable && cancelTime) {
  const [date, time] = cancelTime.split(' ');
  untilDays = Math.round(luxon.DateTime.fromFormat(date, 'yyyy-MM-dd').diff(now, 'days').days);
  untilTime = time || '00:00:00';
}

if (refundable && untilDays == null) {
  untilDays = 1;
  untilTime = '00:00:00';
}

if (refundable && untilDays < 1) {
  refundable = false;
}

Unavailability

Always emit a <Result> for every property Google asked about. There are two unavailability shapes:

ShapeUse when
<Unavailable><NoVacancy/></Unavailable>Nuitee Connect returned no rate (genuinely sold out, or no supplier responded in time).
<Unavailable><PriceIssue/></Unavailable>Your business rules dropped the rate (over the price cap, tax % too high, rateId decode failed, etc.).

Pair both with <Baserate>-1</Baserate>. The -1 is required by the schema and tells Google "we know about this property, but there's nothing to show."

Silently omitting properties from the response is also accepted by some implementations but it's lossy — Google can't distinguish "no data" from "the integration broke" — so always emit a <Result>.


Operational concerns

Honour the deadline ruthlessly

The two rules:

  1. Pass Math.round(deadlineMs / 1000) to Nuitee Connect as timeout.
  2. Anything that isn't on the critical path — analytics writes, cache fills, audit logging — must be fire-and-forget. Use void promise.catch(log), not await.

If you blow the deadline, Google treats every property in that batch as unavailable. Repeated breaches will get your endpoint throttled.

Persist what you quote

Pick a table (quoted_rates or similar) and append a row per priced <Result> with at least: hotel ID, room ID, checkin, nights, total amount, tax amount, refundable flag, board type, supplier rate ID, timestamp. You will be asked "why did we show $X for this hotel three weeks ago?" and without this you cannot answer.

This write should be fire-and-forget — do not let the database slow Google's deadline.

Caching

Nuitee Connect API calls are not free, and Google often re-queries the same property/date combinations. A simple keyed cache (e.g. sha1(checkin + checkout + hotelId)) with a TTL that matches or undershoots your <ExpirationTime> works well. Two cautions:

  • Don't cache latencySensitive="true" queries, or cache them with a much shorter TTL. They are triggered by a live user search; a stale price defeats the point.
  • Never cache past your <ExpirationTime>. Google trusts that timestamp.

Monitoring

The metrics worth tracking from day one:

  • /query p50/p95/p99 latency, broken out by latencySensitive.
  • Nuitee Connect call duration and timeout rate.
  • % of <Result> that are <Unavailable>, broken out by reason (NoVacancy vs PriceIssue).
  • % of PriceIssue by sub-reason (price cap, tax sanity, decode failure). A sudden spike in any one of these is almost always a supplier-data regression.
  • Property feed staleness — last time you uploaded the CSV, and the diff in property count.

Capture exceptions to whatever your error reporter is (Sentry, Datadog APM, etc.) with the full request body attached — XML reproduction is the fastest way to debug a one-off failure.

Currency

Nuitee Connect takes a currency in the request; GHC takes a @currency attribute on <Baserate> and <Tax>. These must match. Google does not send currency in the pricing query — currency is configured per-feed on the GHC side. If you operate in multiple currencies, you'll need one feed per currency and a way to route Google's query to the right one (typically by giving each feed its own polling URL).

Occupancy

Google sends <Context><OccupancyDetails> on live queries. You have two reasonable policies:

  • Always quote a standard 2-adult double. Simpler, faster, gives Google a stable price to display. Trade-off: families and groups see a price that may differ from what they'd actually pay.
  • Quote the occupancy Google asks for. Higher accuracy, but you must validate that Nuitee Connect returns rates for that occupancy and you must handle the children-with-ages case.

Pick one and document it.


Common pitfalls

A non-exhaustive list of things that have burned real integrations:

  1. Sending Baserate per-night. It must be the total stay price.
  2. Including taxes in Baserate AND Tax. Tax is included === false taxes only.
  3. Forgetting Baserate=-1 on Unavailable. Schema requires it.
  4. Quoting Capacity=1. Google de-prioritizes single-occupancy rooms in many markets. Floor to 2.
  5. Returning refundable rates without refundable_until_days. Invalid. Default to 1.
  6. Using non-decoded rateId as PackageID. Landing-page deep links break and bookings fail.
  7. Awaiting the analytics write before responding. Eats into Google's deadline; first thing to drop under load.
  8. Caching latencySensitive queries with a long TTL. Defeats the point of live pricing.
  9. Returning <?xml ?> declarations inconsistently. Pick a style and stick to it. Headless (no declaration) is the safest default.
  10. Not publishing every property you intend to quote. Google only polls IDs in the feed.

End-to-end skeleton

Glue code for a minimal Node.js implementation. Not production-ready (no caching, no persistence, no observability), but it shows the shape:

import express from 'express';
import bodyParser from 'body-parser';
import xml2js from 'xml2js';
import { DateTime } from 'luxon';

const LITEAPI_KEY = process.env.LITEAPI_KEY;
const MAX_PER_NIGHT_USD = 10000;
const EXPIRATION_HOURS = 3;
const TAX_SANITY_RATIO = 0.30;

const app = express();
app.use(bodyParser.text({ type: 'application/xml' }));

app.post('/query', async (req, res) => {
  try {
    const body = await xml2js.parseStringPromise(req.body);
    if (!body.Query) return res.status(400).send('Invalid request');

    if (body.Query.Checkin) {
      return res.set('Content-Type', 'application/xml').send(await handlePricing(body));
    }
    if (body.Query.HotelInfoProperties) {
      return res.set('Content-Type', 'application/xml').send(await handleMetadata(body));
    }
    return res.status(501).send('Not implemented');
  } catch (err) {
    console.error(err);
    res.status(500).send('Internal error');
  }
});

async function handlePricing(body) {
  const checkin = body.Query.Checkin[0];
  const nights = parseInt(body.Query.Nights[0], 10);
  const deadlineMs = parseInt(body.Query.DeadlineMs?.[0] || '6000', 10);
  const hotelIds = body.Query.PropertyList[0].Property;
  const checkout = DateTime.fromFormat(checkin, 'yyyy-MM-dd').plus({ days: nights }).toFormat('yyyy-MM-dd');

  const rates = await fetchLiteApiRates({
    hotelIds,
    occupancies: [{ adults: 2 }],
    currency: 'USD',
    guestNationality: 'US',
    checkin,
    checkout,
    maxRatesPerHotel: 100,
    roomMapping: true,
    timeout: Math.round(deadlineMs / 1000),
  });

  const rooms = await loadRoomMetadata(rates);
  const now = DateTime.now();

  const results = hotelIds.map((hotelId) =>
    buildResult({ hotelId, rates, rooms, checkin, nights, now })
  );

  return new xml2js.Builder({ headless: true }).buildObject({
    Transaction: { $: { timestamp: now.toISO() }, Result: results },
  });
}

function buildResult({ hotelId, rates, rooms, checkin, nights, now }) {
  const hotel = rates.find((r) => r.hotelId === hotelId);
  const rate = hotel?.roomTypes?.[0]?.rates?.[0];
  if (!rate) return unavailable(hotelId, checkin, nights, 'NoVacancy');

  const total = hotel.roomTypes[0].offerRetailRate.amount;
  const excludedTax = round2((rate.retailRate.taxesAndFees ?? [])
    .filter((t) => !t.included)
    .reduce((acc, t) => acc + t.amount, 0));

  if (total / nights > MAX_PER_NIGHT_USD) return unavailable(hotelId, checkin, nights, 'PriceIssue');
  if (excludedTax / total >= TAX_SANITY_RATIO) return unavailable(hotelId, checkin, nights, 'PriceIssue');

  const { refundable, untilDays, untilTime } = computeRefundability(rate, now);
  const room = rooms.get(`${hotelId}:${rate.mappedRoomId}`);
  const packageId = safeDecodeRateId(rate.rateId);
  if (!packageId) return unavailable(hotelId, checkin, nights, 'PriceIssue');

  return {
    Property: hotelId,
    Checkin: checkin,
    Nights: String(nights),
    ExpirationTime: now.plus({ hours: EXPIRATION_HOURS }).toISO(),
    Baserate: { $: { currency: 'USD' }, _: total },
    Tax: { $: { currency: 'USD' }, _: excludedTax },
    Refundable: {
      $: {
        available: refundable ? '1' : '0',
        ...(refundable ? { refundable_until_days: untilDays, refundable_until_time: untilTime } : {}),
      },
    },
    ...(rate.boardType === 'BI' ? { BreakfastIncluded: '1' } : {}),
    RoomBundle: room ? { RoomData: roomToXml(room) } : {},
    PackageData: {
      PackageID: packageId,
      Name: { Text: [{ $: { language: 'en' }, _: cleanPackageName(rate.name) }] },
    },
  };
}

function unavailable(hotelId, checkin, nights, reason) {
  return {
    Property: hotelId,
    Checkin: checkin,
    Nights: String(nights),
    Baserate: -1,
    Unavailable: { [reason]: undefined },
  };
}

The helper functions (fetchLiteApiRates, loadRoomMetadata, computeRefundability, safeDecodeRateId, cleanPackageName, roomToXml, round2) are the obvious shapes implied by everything above; fill them in to taste.


Reference links