If you're doing social media app development you're wiring up feeds and real-time chat. If you're doing travel app development, sooner or later you hit the same wall: where do actual flight and hotel prices come from? Most solo devs and small teams end up at the same answer — Amadeus for Developers, the self-service API layer on top of the GDS that powers a huge chunk of the travel industry.
This is the guide I wish I'd had before I burned a weekend misreading their docs. We'll cover authentication, flight search, hotel search, pricing, and the gotchas that aren't obvious until you hit them in production.
Why Amadeus (and when it's the wrong choice)
Amadeus's self-service tier is free in the test environment, with real (if occasionally stale) pricing data, and it covers flights, hotels, car rentals, points of interest, airport data, and more under one set of credentials. That's the appeal — one integration instead of five.
The tradeoffs: the test environment uses a cached/synthetic dataset that doesn't always match production pricing, the response shapes are dense (this is enterprise travel-industry JSON, not a clean REST-for-humans API), and moving to production requires a review process and negotiated pricing once you're past the free quota. If you only need flights, something like Skyscanner's or Duffel's API might be simpler. If you need flights and hotels and broader travel data under one roof, Amadeus is usually the pragmatic choice.
Getting Credentials
- Sign up at developers.amadeus.com
- Create an app in the dashboard — you'll get an API Key and API Secret
- You're automatically in the test environment, which is free, rate-limited, and backed by cached data
Keep the key/secret out of source control. If you're prototyping, an .env file is fine; just don't commit it.
Authentication: OAuth2 Client Credentials
Every Amadeus call needs a bearer token. You get one by POSTing your credentials to the auth endpoint:
const axios = require('axios');
const qs = require('qs');
async function getAccessToken() {
const response = await axios.post(
'https://test.api.amadeus.com/v1/security/oauth2/token',
qs.stringify({
grant_type: 'client_credentials',
client_id: process.env.AMADEUS_API_KEY,
client_secret: process.env.AMADEUS_API_SECRET,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return response.data.access_token; // expires in ~30 minutes
}
The token is short-lived (roughly 30 minutes), so don't fetch a new one per request — cache it and refresh when it's close to expiring:
let cachedToken = null;
let tokenExpiresAt = 0;
async function getToken() {
if (cachedToken && Date.now() < tokenExpiresAt) return cachedToken;
const res = await axios.post(
'https://test.api.amadeus.com/v1/security/oauth2/token',
qs.stringify({
grant_type: 'client_credentials',
client_id: process.env.AMADEUS_API_KEY,
client_secret: process.env.AMADEUS_API_SECRET,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
cachedToken = res.data.access_token;
tokenExpiresAt = Date.now() + (res.data.expires_in - 60) * 1000; // refresh 60s early
return cachedToken;
}
If you'd rather skip the raw HTTP calls entirely, Amadeus maintains official SDKs for Node, Python, Java, and a few others that handle token refresh for you:
npm install amadeus
const Amadeus = require('amadeus');
const amadeus = new Amadeus({
clientId: process.env.AMADEUS_API_KEY,
clientSecret: process.env.AMADEUS_API_SECRET,
});
I'll show both raw and SDK versions below since plenty of people end up needing the raw calls anyway (custom caching, unsupported params, non-JS backends).
Flight Search
The core endpoint is Flight Offers Search (/v2/shopping/flight-offers).
Using the SDK:
amadeus.shopping.flightOffersSearch.get({
originLocationCode: 'JFK',
destinationLocationCode: 'CDG',
departureDate: '2026-06-15',
adults: '1',
max: '10',
}).then(response => {
console.log(response.data);
}).catch(error => {
console.error(error.response ? error.response.result : error);
});
Raw HTTP equivalent:
async function searchFlights(origin, destination, date) {
const token = await getToken();
const response = await axios.get(
'https://test.api.amadeus.com/v2/shopping/flight-offers',
{
headers: { Authorization: `Bearer ${token}` },
params: {
originLocationCode: origin,
destinationLocationCode: destination,
departureDate: date,
adults: 1,
max: 10,
},
}
);
return response.data.data;
}
A few parameter details that aren't obvious from the docs:
-
originLocationCode/destinationLocationCodeare IATA codes, not city names — you'll want a separate lookup (Amadeus has an Airport & City Search endpoint) if you're taking free-text input from users -
maxcaps the number of offers returned, but the API may still return fewer if there's limited inventory in the test dataset - Round trips just add
returnDate— there's no separate "round trip" flag -
travelClassacceptsECONOMY,PREMIUM_ECONOMY,BUSINESS,FIRST— omit it and you get mixed cabin results
The response is a data array of flight offers, each containing itineraries (with segments for each leg), a price object, and travelerPricings. This is where the density hits — a single round-trip offer with a connection can be 100+ lines of JSON. Budget real time for writing a normalizer that flattens this into something your frontend can actually render.
Flight Offers Price: Don't Skip This Step
Here's the part that trips people up. The price you get back from Flight Offers Search is not guaranteed — availability and fares shift constantly. Before you show a "book now" button or attempt to create an order, you need to re-confirm the price:
amadeus.shopping.flightOffers.pricing.post(
JSON.stringify({
data: {
type: 'flight-offers-pricing',
flightOffers: [selectedOffer], // the exact offer object from search results
},
})
).then(response => {
console.log(response.data);
});
Skip this step and you'll eventually show users a price that's no longer bookable — which, in a travel app, is the single fastest way to erode trust. Always re-price immediately before checkout, not just at search time.
Hotel Search
Hotel search is a two-step process, which surprises a lot of people coming from the flight endpoints.
Step 1: Find hotel IDs in a city
amadeus.referenceData.locations.hotels.byCity.get({
cityCode: 'PAR',
}).then(response => {
console.log(response.data); // array of hotels with hotelId
});
Step 2: Get live offers for those hotel IDs
amadeus.shopping.hotelOffersSearch.get({
hotelIds: 'MCLONGHM', // comma-separated hotel IDs from step 1
checkInDate: '2026-07-10',
checkOutDate: '2026-07-14',
adults: '2',
}).then(response => {
console.log(response.data);
});
Why two calls instead of one? The city-to-hotels endpoint uses relatively static reference data (cached, fast, cheap), while the offers endpoint hits live pricing/availability (slower, rate-limited more aggressively). Splitting them lets you cache the hotel list per city for hours or days while still hitting live pricing only when a user actually wants rates. If you fetch offers for every hotel in a city on every search, you'll blow through rate limits fast — batch your hotelIds and paginate.
Handling Errors and Rate Limits
The test environment rate-limits aggressively (expect low single-digit requests per second on some endpoints). Build in retry-with-backoff from day one rather than bolting it on after you get your first 429:
async function withRetry(fn, retries = 3, delay = 1000) {
try {
return await fn();
} catch (err) {
if (retries > 0 && err.response?.status === 429) {
await new Promise(r => setTimeout(r, delay));
return withRetry(fn, retries - 1, delay * 2);
}
throw err;
}
}
Also worth knowing: Amadeus error responses come back with a structured errors array (code, title, detail) rather than a single message string — log the whole array during development, not just error.message, or you'll miss the actual reason a request failed.
Moving to Production
A few things change when you go live:
- You apply for production access through the dashboard, which includes a review of your use case
- Production URLs swap
test.api.amadeus.comforapi.amadeus.com - Pricing shifts from free-tier quotas to negotiated/metered rates depending on call volume
- Test-environment hotel and flight data is synthetic-ish; production data reflects real GDS inventory, so re-test your UI against real responses — field completeness (missing images, sparse amenity data for smaller hotels) is more common than the test environment suggests
Where This Fits in a Larger Travel App
If you're building out a full travel app, Amadeus flight and hotel search is usually just one layer. In practice you'll pair it with:
- A caching layer for search results (prices are volatile, but repeated identical searches within a short window don't need to hit the API again)
- A normalization layer that converts Amadeus's nested itinerary/segment structure into whatever shape your frontend and database actually want
- Separate booking/payment infrastructure — Amadeus can create flight orders via their Booking API, but most teams handle payment through Stripe or similar and only use Amadeus for the inventory side
None of that is Amadeus-specific complexity — it's the same architecture challenge you'd hit integrating any third-party inventory API into a real product. But it's worth scoping upfront rather than discovering it mid-sprint.
Building something with this? I'd genuinely like to hear what other travel APIs people are pairing with Amadeus — Duffel, Skyscanner, or something more niche.
Top comments (0)