DEV Community

Cover image for Building a real-time travel search engine: lessons from integrating with GDS APIs
Adamo Software
Adamo Software

Posted on

Building a real-time travel search engine: lessons from integrating with GDS APIs

If you have ever tried to integrate with a Global Distribution System API (Amadeus, Sabre, or Travelport), you know it is not like calling a typical REST endpoint. GDS APIs were architected decades ago, carry SOAP/XML legacy patterns even in their modern REST wrappers, and enforce aggressive rate limits that can break your search experience during a traffic spike. This article shares what we learned building a travel search service that queries multiple GDS providers in parallel, normalizes their responses into a unified schema, and caches results intelligently to stay within quota limits.

The project context

We were building a custom booking platform for a mid-size tour operator. The core requirement: let travelers search flights, hotels, and packages from a single search bar, with results aggregated from Amadeus Self-Service APIs, a Sabre REST endpoint, and two direct hotel suppliers via proprietary APIs. The stack was Node.js (Fastify) for the search service, Redis for caching, and Elasticsearch for indexing normalized inventory.

The naive approach would be to call each supplier sequentially, merge results, and return them. That gives you 3 to 5 second response times. Travelers leave after 2 seconds. So the real engineering challenge was not "can we connect to a GDS" but "can we make multi-supplier search feel instant."

Authentication: the part nobody warns you about

Every GDS has its own auth flow, and they all have quirks.

Amadeus Self-Service uses OAuth 2.0 with client credentials. You get an access token that expires in 30 minutes. Straightforward, except the token refresh has a subtle gotcha: if you refresh too aggressively under load, you can hit a secondary rate limit on the auth endpoint itself. We solved this by implementing a singleton token manager that refreshes proactively at the 25-minute mark, not on expiry.

// Token manager with proactive refresh
class AmadeusTokenManager {
  constructor(clientId, clientSecret) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.token = null;
    this.expiresAt = 0;
  }

  async getToken() {
    // Refresh 5 minutes before expiry
    if (Date.now() < this.expiresAt - 300000) {
      return this.token;
    }

    const res = await fetch(
      'https://api.amadeus.com/v1/security/oauth2/token',
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          grant_type: 'client_credentials',
          client_id: this.clientId,
          client_secret: this.clientSecret,
        }),
      }
    );

    const data = await res.json();
    this.token = data.access_token;
    // Amadeus tokens last 1799 seconds (~30 min)
    this.expiresAt = Date.now() + data.expires_in * 1000;
    return this.token;
  }
}
Enter fullscreen mode Exit fullscreen mode

Sabre uses a similar OAuth flow but requires a base64-encoded composite of client ID and secret in the authorization header, not in the body. It is a small difference that costs you an hour of debugging if you miss it.

The lesson: do not assume GDS auth flows are interchangeable. Read each provider's docs carefully, and build a provider-specific auth module from the start.

Parallel search with timeout control

The search service fires requests to all suppliers simultaneously using Promise.allSettled. This is critical. Promise.all would fail the entire search if one supplier times out. With allSettled, we return whatever results come back within the timeout window.

async function searchAllSuppliers(query, timeoutMs = 2000) {
  const suppliers = [
    searchAmadeus(query),
    searchSabre(query),
    searchDirectHotelA(query),
    searchDirectHotelB(query),
  ];

  // Wrap each with a timeout
  const withTimeout = suppliers.map((p, i) =>
    Promise.race([
      p.then(res => ({ supplier: i, status: 'ok', data: res })),
      new Promise(resolve =>
        setTimeout(
          () => resolve({ supplier: i, status: 'timeout', data: [] }),
          timeoutMs
        )
      ),
    ])
  );

  const results = await Promise.allSettled(withTimeout);

  return results
    .filter(r => r.status === 'fulfilled' && r.value.status === 'ok')
    .flatMap(r => r.value.data);
}
Enter fullscreen mode Exit fullscreen mode

We set the timeout at 2000ms. If Amadeus responds in 800ms and Sabre takes 3 seconds, the user sees Amadeus results immediately. We log the Sabre timeout and investigate later. In practice, Amadeus Self-Service APIs responded in 400 to 1200ms for flight searches. Sabre was more variable, ranging from 300ms to 2500ms depending on route complexity.

The caching strategy that saved our quota

This is where most GDS integrations either succeed or blow their budget. Amadeus Self-Service provides a free monthly quota per API, with per-call charges of roughly $0.001 to $0.025 once you exceed it (Amadeus Pricing). At 10,000 searches per day, you exhaust free quotas fast.

Our caching strategy has three layers:

Layer 1: Exact-match cache (Redis, TTL 5 minutes). Same origin, destination, dates, passengers. This catches repeated searches from the same user session and from multiple users searching popular routes.

function buildCacheKey(query) {
  const { origin, destination, departDate, returnDate, pax } = query;
  return `search:${origin}:${destination}:${departDate}:${returnDate || 'oneway'}:${pax}`;
}

async function cachedSearch(query) {
  const key = buildCacheKey(query);
  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  const results = await searchAllSuppliers(query);
  // Cache for 5 minutes. Flight prices change,
  // but not every 30 seconds.
  await redis.setex(key, 300, JSON.stringify(results));
  return results;
}
Enter fullscreen mode Exit fullscreen mode

Layer 2: Fuzzy-match cache (Redis, TTL 15 minutes). If a user searches HAN to NRT on June 15, and another searches HAN to NRT on June 14 or 16, the price structure is usually similar. We serve the cached result with a "prices may vary" indicator while firing a background refresh. This cut our API calls by roughly 40%.

Layer 3: Prewarming popular routes (cron job, every 30 minutes). We identified the top 50 route pairs from booking history and pre-fetched their availability on a schedule. This means the first search of the day for a popular route hits cache, not the GDS.

Combined, these three layers reduced our actual GDS API calls by approximately 65%, keeping us well within free quotas during the first months of operation.

Data normalization: the unglamorous but essential part

Each GDS returns data in wildly different formats. Amadeus returns a nested JSON structure with dictionaries for airline and location codes. Sabre returns a flatter structure but uses different field names and embeds fare rules differently. Direct hotel APIs return whatever the hotel decided to put in their XML feed.

We built a normalization layer with a simple interface:

// Each supplier implements this interface
function normalizeAmadeusFlightOffer(raw) {
  return {
    id: `amadeus_${raw.id}`,
    supplier: 'amadeus',
    type: 'flight',
    origin: raw.itineraries[0].segments[0].departure.iataCode,
    destination: raw.itineraries[0].segments.at(-1).arrival.iataCode,
    departureTime: raw.itineraries[0].segments[0].departure.at,
    arrivalTime: raw.itineraries[0].segments.at(-1).arrival.at,
    stops: raw.itineraries[0].segments.length - 1,
    airline: raw.validatingAirlineCodes[0],
    price: {
      amount: parseFloat(raw.price.grandTotal),
      currency: raw.price.currency,
    },
    cabinClass: raw.travelerPricings[0]
      .fareDetailsBySegment[0].cabin,
    rawSupplierData: raw, // Keep original for booking step
  };
}
Enter fullscreen mode Exit fullscreen mode

The rawSupplierData field is important. When the user selects a result and proceeds to booking, the booking service needs the original supplier payload, not our normalized version. Normalizing for display and keeping raw data for transactions is a pattern that saved us from dozens of edge case bugs.

NDC support: plan for it now

If your platform handles flights, you need to account for NDC (New Distribution Capability). Airlines like Lufthansa, American Airlines, and Singapore Airlines increasingly distribute their best fares through NDC channels rather than traditional GDS. Amadeus supports NDC through their Flight Offers Search API, but the response structure has subtle differences from GDS results. Our normalization layer handles both, but it required explicit branching logic.

The practical impact: if you only integrate with traditional GDS channels, you may miss 15 to 30% of available fares on NDC-forward airlines. Build your normalizer to handle both from the start.

Lessons learned

1. GDS integration is 25 to 35% of total development effort. We underestimated it on our first project. Authentication, rate limiting, error handling, data normalization, and edge cases (codeshare flights, multi-city itineraries, mixed-cabin fares) consume far more time than the booking flow itself. Our team at Adamo Software now plans GDS integration as a dedicated workstream, not a subtask.

2. Cache aggressively, but with clear invalidation rules. Stale pricing leads to booking failures. We learned to set TTLs based on the product type: 5 minutes for flights (prices change frequently), 30 minutes for hotels (rates are more stable), and 2 hours for activities (rarely change intraday).

3. Never trust a GDS response blindly. Validate prices, check that segments connect logically, and verify that the fare class is actually bookable before showing it to the user. We encountered phantom availability, where the GDS reports seats available but the airline rejects the booking, roughly 2 to 3% of the time on certain carriers.

4. Build supplier-agnostic from day one. Your search interface, result schema, and booking flow should not know or care which GDS provided the data. When we added a fourth supplier six months later, it took two days instead of two weeks because the architecture supported it.

5. Monitor your look-to-book ratio. GDS providers watch this metric. If you are making thousands of search calls but very few bookings, your pricing terms can change. Our ratio stabilized at around 1:25 (one booking per 25 searches) after implementing the caching layers.

Wrapping up

Building a real-time travel search engine is less about connecting to an API and more about engineering around its constraints. Rate limits, response format inconsistencies, authentication quirks, and NDC fragmentation are the real challenges. The caching architecture alone saved us from blowing our API budget and delivered sub-second response times to users. If you are building an AI-powered travel booking platform and planning GDS integration, invest your time in the data layer and caching strategy first. The API calls themselves are the easy part.

Top comments (0)