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.
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
| Term | What it is |
|---|---|
| Flight | One direction of travel (outbound or inbound) with its segments. |
| Offer | A fare on a flight: price, refund/exchange rules, baggage, meal. Belongs to one provider. |
| Quote | Your selection, re-priced live by the provider. It expires. |
| Order | The purchase: passengers, extras, payment, bookings and tickets. |
| Booking | One 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.
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 translated | How |
|---|---|
| Error and validation messages | error.message and every details[].message. error.code never changes, so branch on it. |
| Human text from the API | Availability reasons, fare notes, meal types, extra names, and passenger labels on extras. |
| Codes | Codes 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 errors | failure.message is translated. The provider's own words stay in failure.providerMessage (they are only in English). |
| Customer pages | The payment and ticket pages follow the order's language (or ?lang=) and render right-to-left in Arabic. |
| Provider side | Provider 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 -> "غير قابل للاسترداد"
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.
| Rule | How |
|---|---|
| Base | Every provider price is converted to SAR first, then from SAR to the chosen currency. Orders are held and charged in SAR. |
| Rates | From ExchangeRate-API, cached for 12 hours at most. If the source is down, built-in static rates are used (exchange.source = static). |
| Markup | A percent (server setting FX_MARKUP_PERCENT) added to the rate when converting from SAR to another currency. |
| Response | Header 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"}}
1. Search
POST /v1/searches asks every provider at once. If one provider is down, you still get the others and see why in providers[].
{
"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
}
{
"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
| status | Meaning |
|---|---|
available | Can be bought for this offer. |
unavailable | Not sold. Hide it, show reason if useful. Requesting it returns 422. |
unknown | Provider 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.
| Outbound | Return | Allowed? |
|---|---|---|
open | open | Yes, even from different providers (two bookings). |
package | same provider and same packageKey | Yes (one booking, combined price). |
| anything else | No: 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:
GET /v1/orders/{id}/ancillaries returns seat maps, baggage and meal options with fees and an indicator per kind.requiresHold is true, call POST /v1/orders/{id}/hold, then fetch the catalogue again (the lists are now filled).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.
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 status | What to show |
|---|---|
DRAFT | Still editable. |
PENDING_PAYMENT | Waiting for payment. |
FULFILLING | "Confirming your booking..." |
CONFIRMED | Success and tickets. |
PAYMENT_FAILED | Payment did not go through; nothing was booked. |
FAILED_REFUNDED | Booking failed after payment; everything undone and refunded. See failure. |
CANCELLED | Cancelled and refunded per rules. |
REQUIRES_ATTENTION | A 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 wrong | What the service does | Order ends as |
|---|---|---|
| Payment declined | Cancels any holds. | PAYMENT_FAILED |
| Provider rejects the booking | Undoes earlier bookings of the order, refunds. | FAILED_REFUNDED |
| A seat, bag or meal is not confirmed | Cancels the whole order, refunds. | FAILED_REFUNDED, failure.code = ancillary_not_confirmed |
| The provider does not issue the tickets | Cancels the booking, refunds. | FAILED_REFUNDED, failure.code = ticketing_failed |
| Final price differs from the quote | Cancels, refunds. | failure.code = price_changed |
| Ticketing fails | Cancels 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 } }
| HTTP | code | Fix |
|---|---|---|
| 400 | bad_json | Send valid JSON. |
| 404 | not_found | Check the id. |
| 409 | search_expired / quote_expired | Search or quote again. |
| 409 | offers_not_combinable | Use the /compatible endpoint. |
| 409 | order_not_editable | Only DRAFT orders accept extras or holds. |
| 409 | catalog_missing | Load GET .../ancillaries before PUT. |
| 409 | hold_expired | Create a new order. |
| 409 | feature_unavailable | This provider cannot do that (e.g. early hold). |
| 409 | order_busy | Fulfilment running; retry in 1-2 s. |
| 422 | validation_failed | Fix each entry of details. |
| 502 | provider_error | Retry only if details.retryable is true. |
All endpoints
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: