Flight Hub API

One REST API to search, book and ticket flights from several providers. Every provider answers in the same JSON shape, so your code never branches on provider IDs. Extras that a provider cannot sell are reported up front, so you never send a request that is bound to fail.

Download Markdown spec (for AI agents)

Quick start

Six calls take you from search to a ticket. Replace $BASE with this site's address. Test gateways (mock-card) need no real payment.

1
Search, then pick an outbound offer.
2
Ask which return offers fit it, pick one.
3
Send both selected offers and passenger data in one order request. The backend confirms price and creates the booking plan.
4
Optional: choose seats, baggage, meals.
5
Checkout and pay.
6
Read the order: it is CONFIRMED with every PNR and ticket, or failed and refunded.
BASE=https://your-host
curl -s -X POST $BASE/v1/searches -H 'content-type: application/json' -d '{
  "origin":"KWI","destination":"DXB",
  "departureDate":"2027-03-10","returnDate":"2027-03-17",
  "passengers":{"adults":1,"children":0,"infants":0}}'

Core concepts

TermWhat it is
FlightOne direction of travel (outbound or inbound) with its segments.
OfferA fare on a flight: price, refund/exchange rules, baggage, meal. Belongs to one provider.
QuoteYour selection, re-priced live by the provider. It expires.
OrderThe purchase: passengers, extras, payment, bookings and tickets.
BookingOne provider reservation (one PNR). A package round trip is one booking; two separate one-way offers are two bookings, each with its own PNR and tickets.
Indicator{status, timing, reason} on every extra of every offer: is it sold, and when can you list it.

Passengers are referenced by ref (P1, P2...), assigned by the quote. Types: ADT 12+, CHD 2-11, INF under 2.

A value of null means the provider did not say. Do not treat it as false.

Language (Arabic / English)

Every response comes in English (en) or Arabic (ar). Choose with, in priority order: ?lang=ar, the X-Language header, "language" in the JSON body, then Accept-Language. The default is English. The response says which language it used in the Content-Language header and the top-level language field.

What is translatedHow
Error and validation messageserror.message and every details[].message. error.code never changes, so branch on it.
Human text from the APIAvailability reasons, fare notes, meal types, extra names, and passenger labels on extras.
CodesCodes stay as they are (economy, CONFIRMED, KWI, J9). Every object that holds codes also gets a labels object with readable text: airport, city and country names, airline names, cabin, statuses, passenger types, refund/exchange/baggage/meal summaries, formatted prices.
Provider errorsfailure.message is translated. The provider's own words stay in failure.providerMessage (they are only in English).
Customer pagesThe payment and ticket pages follow the order's language (or ?lang=) and render right-to-left in Arabic.
Provider sideProvider messages may remain in the language supplied by that provider; use the translated API message for customers.
curl -s -X POST $BASE/v1/searches -H 'Accept-Language: ar' -H 'content-type: application/json' \
  -d '{"origin":"KWI","destination":"DXB","departureDate":"2026-11-21","passengers":{"ADT":1}}'
# segment.labels -> {"origin":"مطار الكويت الدولي","originCity":"مدينة الكويت","marketingCarrier":"طيران الجزيرة", ...}
# offer.conditions.refundable.labels.summary -> "غير قابل للاسترداد"
Names come from an open dataset of about 9,000 airports and 1,100 airlines. If a name is unknown, its label is missing; the code is always there.

Currency

Send X-Currency (or ?currency=) to get every price in that currency: SAR (default), USD or EGP. Any other value returns 400 currency_not_supported with the allowed list in error.details.allowed.

RuleHow
BaseEvery provider price is converted to SAR first, then from SAR to the chosen currency. Orders are held and charged in SAR.
RatesFrom ExchangeRate-API, cached for 12 hours at most. If the source is down, built-in static rates are used (exchange.source = static).
MarkupA percent (server setting FX_MARKUP_PERCENT) added to the rate when converting from SAR to another currency.
ResponseHeader X-Currency and a top-level exchange: {currency, baseCurrency, rate, markupPercent, source, fetchedAt, expiresAt}.
curl -s -X POST $BASE/v1/searches -H 'X-Currency: USD' -H 'content-type: application/json' \
  -d '{"origin":"KWI","destination":"CAI","departureDate":"2026-12-10","passengers":{"ADT":1}}'
# offer.price -> {"total":1428.16,"currency":"USD","labels":{"total":"USD 1,428.16"}}

POST /v1/searches asks every provider at once. If one provider is down, you still get the others and see why in providers[].

Request
{
  "origin": "KWI",            // 3-letter IATA code
  "destination": "DXB",
  "departureDate": "2027-03-10",
  "returnDate": "2027-03-17", // omit for one-way
  "passengers": { "adults": 1, "children": 0, "infants": 0 }  // infants <= adults, max 9 total
}
Response 201 (trimmed)
{
  "searchId": "srch_0_lcpcWjJhz3",
  "tripType": "round_trip",
  "expiresAt": "2026-10-25T15:40:00.000Z",
  "outbound": [{
    "flightId": "MA100-KWI-DXB-2027-03-10@provider-1",
    "provider": "provider-1", "origin": "KWI", "destination": "DXB",
    "departureAt": "2027-03-10T08:00:00", "arrivalAt": "2027-03-10T11:00:00",
    "durationMinutes": 180, "stops": 0,
    "segments": [{ "segmentId": "MA100-KWI-DXB-2027-03-10", "flightNumber": "MA100", "aircraft": "A320" }],
    "offers": [ /* see next section */ ]
  }],
  "inbound": [ /* same shape */ ],
  "providers": [
    { "provider": "provider-1", "ok": true,  "ms": 3, "offers": 8, "error": null },
    { "provider": "provider-2", "ok": true,  "ms": 0, "offers": 4, "error": null } ]
}

Validation problems return 422 with one entry per bad field:

{ "error": { "code": "validation_failed", "message": "Invalid search request",
  "details": [ { "field": "destination", "message": "Use a 3-letter airport code, e.g. DXB" } ] } }

2. Offers and pairing

Each flight lists its offers. This is everything you need to show the fare choice screen:

{
  "offerId": "off_A09g7tl3qmN5", "provider": "provider-1", "brandName": "Flex", "cabin": "economy",
  "price": { "total": 150, "base": 120, "taxes": 30, "currency": "USD" },
  "conditions": {
    "refundable":   { "allowed": true, "fee": { "amount": 25, "currency": "USD" } },
    "exchangeable": { "allowed": true, "fee": null } },
  "baggage": { "checked": { "included": true, "pieces": 2, "weightKg": 23 },
               "cabin":   { "included": true, "pieces": 1, "weightKg": 7 } },
  "meal": { "included": true, "type": "hot meal" },
  "pairing": { "mode": "open", "packageKey": null },
  "ancillaries": {
    "seats":   { "status": "available",   "timing": "pre_booking", "reason": null },
    "baggage": { "status": "available",   "timing": "after_hold",  "reason": null },
    "meals":   { "status": "unavailable", "timing": null, "reason": "Not offered by this provider" } }
}

Extras indicators

statusMeaning
availableCan be bought for this offer.
unavailableNot sold. Hide it, show reason if useful. Requesting it returns 422.
unknownProvider could not tell; the catalogue call gives the final answer.

timing: pre_booking = list options right after creating the order. after_hold = the provider needs a reservation first (section 5).

Choosing a return flight

Some providers sell a cheaper combined fare only when both flights come from the same package. After the customer picks an outbound offer, ask which returns are valid:

GET /v1/searches/{searchId}/offers/{outboundOfferId}/compatible

200 { "searchId": "...", "offerId": "...", "direction": "inbound", "flights": [ /* only compatible offers */ ] }

Returns that combine with any fare of the selected flight are listed. Each one carries pairWith: { offerId, substituted, price }: the fare of the selected flight that is booked with that return. When substituted is true, show pairWith.price for the selected leg. Submitting the original offer ID or pairWith.offerId books the same thing.

OutboundReturnAllowed?
openopenYes, even from different providers (two bookings).
packagesame provider and same packageKeyYes (one booking, combined price).
anything elseNo: 409 offers_not_combinable.

3. Quote

Re-prices with the provider and confirms the offers are still bookable.

POST /v1/quotes
{ "searchId": "srch_...", "outboundOfferId": "off_A09g...", "inboundOfferId": "off_hUgn..." }

201 {
  "quoteId": "qt_yoJp3EE8uKgP", "expiresAt": "...",
  "passengers": [ { "ref": "P1", "type": "ADT" } ],
  "bookings": [ { "bookingKey": "bk1", "provider": "provider-1", "strategy": "single",
                  "legs": ["outbound","inbound"], "providerTotal": { "amount": 300, "currency": "USD" } } ],
  "price": { "total": { "amount": 300, "currency": "USD" } } }

If prices come from providers with different currencies, the server converts them using its configured rates; without a rate the quote is rejected. Show the quote price to the customer, not the search price.

4. Order and passengers

Submit both selected offers once. The backend creates the quote, decides whether one or several provider bookings are required, and owns every provider call and rollback.

POST /v1/orders
{ "searchId": "srch_...", "outboundOfferId": "off_A09g...", "inboundOfferId": "off_hUgn...",
  "contact": { "email": "sara@example.com", "phone": "+96550000000" },
  "passengers": [ {
    "firstName": "Sara", "lastName": "Haddad", "gender": "F",
    "dateOfBirth": "1988-05-04", "nationality": "KW",
    "passport": { "number": "P123450", "issuingCountry": "KW", "expiryDate": "2031-01-01" } } ] }

201 { "orderId": "ord_uu_GT9ZMANso", "status": "DRAFT",
      "price": { "fare": {"amount":300,"currency":"USD"}, "ancillaries": {"amount":0,...}, "total": {"amount":300,...} }, ... }

All fields are required, passport included. Date of birth must fit the passenger type on the travel date; the passport must be valid 6 months after the last flight. Errors list every bad field at once (passengers[P1].passport.expiryDate, ...). Nothing is booked yet: the order is a draft. A separately created quoteId is still accepted for clients with a dedicated quote screen.

5. Seats, baggage, meals

Extras are optional, and availability varies by provider and even by fare. The flow:

1
GET /v1/orders/{id}/ancillaries returns seat maps, baggage and meal options with fees and an indicator per kind.
2
If requiresHold is true, call POST /v1/orders/{id}/hold, then fetch the catalogue again (the lists are now filled).
3
PUT /v1/orders/{id}/ancillaries with the full selection. The response has the new total.
GET /v1/orders/ord_.../ancillaries
{ "availability": {
    "seats":   { "status": "available", "timing": "pre_booking" },
    "baggage": { "status": "available", "timing": "after_hold",
                 "reason": "Place a hold first (POST /orders/{id}/hold), then reload this list" },
    "meals":   { "status": "unavailable", "reason": "Not offered by this provider" } },
  "requiresHold": true,
  "seats": [ { "segmentId": "MA100-KWI-DXB-2027-03-10", "aircraft": "A320",
      "seats": [ { "seat": "1A", "status": "free", "fee": { "amount": 15, "currency": "USD" }, "features": ["window"] } ] } ],
  "baggage": [ { "optionId": "BAG20-MA100-KWI-DXB-2027-03-10", "name": "Extra bag 20kg",
      "fee": { "amount": 30, "currency": "USD" }, "maxPerPassenger": 2 } ],
  "meals": [] }

PUT /v1/orders/ord_.../ancillaries
{ "seats":   [ { "passengerRef": "P1", "segmentId": "MA100-KWI-DXB-2027-03-10", "seat": "1A" } ],
  "baggage": [ { "passengerRef": "P1", "optionId": "BAG20-MA100-KWI-DXB-2027-03-10", "quantity": 1 } ],
  "meals":   [] }

200 { "extras": [ { "kind": "seats", "seat": "1A", "fee": {"amount":15,"currency":"USD"}, "label": "Seat 1A", "status": "CONFIRMED" },
                  { "kind": "baggage", "quantity": 1, "fee": {"amount":30,"currency":"USD"}, "label": "Extra bag 20kg" } ],
      "price": { "fare": {"amount":300,...}, "ancillaries": {"amount":45,...}, "total": {"amount":345,"currency":"USD"} } }

The selection replaces the previous one, so always send everything. If one line is invalid (seat taken, unknown option, kind unavailable) you get 422 with the line's path and nothing is saved. Infants cannot buy extras.

After payment every extra is checked at the provider. If any is missing, the whole order is cancelled and refunded (see Handling failures).

6. Payment and tickets

POST /v1/orders/ord_.../checkout     { "gateway": "mock-card" }
200 { "status": "PENDING_PAYMENT",
      "payment": { "paymentId": "pay_oEKlMLIOd7E3", "amount": 345, "currency": "USD",
                   "redirectUrl": "/mock-gateway/pay/pay_oEKlMLIOd7E3" } }

Send the customer to redirectUrl. When the gateway has a result it calls POST /v1/payments/{paymentId}/result with {"outcome":"success"} or "failed". On success the service books, adds the extras, verifies them, then tickets. Then read the order:

GET /v1/orders/ord_...
{ "status": "CONFIRMED",
  "bookings": [ { "bookingId": "bkg_...", "bookingKey": "bk1", "provider": "provider-1", "pnr": "A5KSUT", "status": "TICKETED",
      "tickets": [ { "number": "11119590260", "passengerRef": "P1", "passengerName": "SARA HADDAD", "status": "ISSUED" } ] } ],
  "payment": { "status": "PAID", "amount": 345 } }

While status is FULFILLING, poll every 1-2 seconds. A printable ticket page is available for customers at /orders/{orderId}/ticket. One customer order may contain several provider bookings, PNRs, and tickets. These are read-only results: the frontend never books, cancels, voids, or refunds a provider separately.

Order statusWhat to show
DRAFTStill editable.
PENDING_PAYMENTWaiting for payment.
FULFILLING"Confirming your booking..."
CONFIRMEDSuccess and tickets.
PAYMENT_FAILEDPayment did not go through; nothing was booked.
FAILED_REFUNDEDBooking failed after payment; everything undone and refunded. See failure.
CANCELLEDCancelled and refunded per rules.
REQUIRES_ATTENTIONA provider undo step failed; needs a human.

Handling failures

You do not run rollback logic. The service does it and tells you the result.

What went wrongWhat the service doesOrder ends as
Payment declinedCancels any holds.PAYMENT_FAILED
Provider rejects the bookingUndoes earlier bookings of the order, refunds.FAILED_REFUNDED
A seat, bag or meal is not confirmedCancels the whole order, refunds.FAILED_REFUNDED, failure.code = ancillary_not_confirmed
The provider does not issue the ticketsCancels the booking, refunds.FAILED_REFUNDED, failure.code = ticketing_failed
Final price differs from the quoteCancels, refunds.failure.code = price_changed
Ticketing failsCancels the hold or voids/refunds tickets, refunds.FAILED_REFUNDED
{ "status": "FAILED_REFUNDED",
  "failure": { "code": "ancillary_not_confirmed", "message": "Extras not confirmed by provider-1",
               "missing": [ { "kind": "seats", "seat": "1A", "passengerRef": "P1" } ] },
  "payment": { "status": "REFUNDED", "refunded": 345 } }

Use GET /v1/orders/{id}/events to see every step and undo for support cases.

Cancel and refund

POST /v1/orders/ord_.../cancel   { "reason": "customer request" }
200 { "status": "CANCELLED", "cancellation": { "reason": "customer request", "at": "...", "refunded": { "amount": 345, "currency": "USD" } } }

Holds are cancelled. Tickets are voided when the provider supports it (usually same day), otherwise refunded under the fare rules. The customer receives the amount the provider returned.

Error reference

Every error has the same shape. Branch on error.code.

{ "error": { "code": "offers_not_combinable", "message": "...", "details": null } }
HTTPcodeFix
400bad_jsonSend valid JSON.
404not_foundCheck the id.
409search_expired / quote_expiredSearch or quote again.
409offers_not_combinableUse the /compatible endpoint.
409order_not_editableOnly DRAFT orders accept extras or holds.
409catalog_missingLoad GET .../ancillaries before PUT.
409hold_expiredCreate a new order.
409feature_unavailableThis provider cannot do that (e.g. early hold).
409order_busyFulfilment running; retry in 1-2 s.
422validation_failedFix each entry of details.
502provider_errorRetry only if details.retryable is true.

All endpoints

Loading...

Adding a provider

A provider is one class extending BaseProvider with a capabilities object and the methods it supports (search, priceSelection, createBooking, getBooking, ticketing, cancel/void/refund, ancillary options). A method it does not implement shows up as an unavailable indicator, so the API shape and your client code do not change. Register it in the provider registry and enable it with the PROVIDERS setting.

For AI agents

An AI-oriented version of this documentation (strict field tables, rules, flows, error codes) is available as one Markdown file:

Download flight-hub-api.md