# Flight Hub API - specification for AI agents

Purpose: search, book and ticket flights from several providers through ONE API with ONE data shape.
Base URL: the origin that serves this file. All API paths start with `/v1`.
Format: JSON request and response bodies, `Content-Type: application/json`. Dates `YYYY-MM-DD`; date-times `YYYY-MM-DDTHH:mm:ss` (airport local time, no zone). Amounts are JSON numbers in the currency stated next to them.
Auth: none in this version.
Machine-readable endpoint list: `GET /openapi.json`.

## 0. Rules you must follow (read first)

1. Follow the flow in section 2 in order. Do not skip steps and do not invent ids; every id comes from a previous response.
2. IDs are opaque strings. Never parse or construct them.
3. A field that is `null` means "the provider did not say". Never treat `null` as `false` or as included.
4. Before selecting an extra (seat, baggage, meal), read `offer.ancillaries.<kind>.status`. If it is `unavailable`, do not request that kind: the API returns 422.
5. Two offers can be booked together only if `compatible` allows it (section 4). Always use `GET .../compatible` to choose a return offer; never pair offers yourself.
6. Prices can change between search and booking. Use the price in the quote and the order, never the search price, for anything shown or charged.
7. Do not retry a failed `checkout` or payment result blindly. Read the order (`GET /v1/orders/{id}`) and act on `status`.
8. Currency: send `X-Currency: SAR | USD | EGP` (or `?currency=`); default `SAR`. Every price in the response (search, quote, order, extras, payment, refund) comes back in that currency. Any other value is refused with `400 currency_not_supported`. Orders are held and charged in SAR; the chosen currency is for display.
9. Error responses always have the shape in section 8. Branch on `error.code`, not on `message`.
10. Language: send `Accept-Language: ar` (or `?lang=ar`, `X-Language: ar`, body `"language":"ar"`; priority in that order: query, X-Language, body, Accept-Language) to get Arabic; default `en`. Only human text changes (`message`, `reason`, `note`, `name`, `type` of meal, and every `labels` object). Codes, enums, ids and numbers are identical in both languages. Never compare against translated text; read codes. Show users `labels.*` and `message`.

## 1. Concepts

| Term | Meaning |
|---|---|
| provider | An airline/GDS integration. Provider IDs are discovered through `GET /v1/providers` and must be treated as opaque. |
| flight | One journey leg (outbound or inbound) made of 1+ `segments`. |
| offer | A purchasable fare on a flight (brand, price, baggage, rules). Belongs to exactly one flight and one provider. |
| pairing | Whether an offer may be combined with offers on the other leg. `open` = with any other `open` offer (any provider). `package` = only with offers having the same `packageKey` from the same provider. |
| quote | A re-priced, confirmed selection (one offer, or outbound+inbound). Expires. |
| order | A customer purchase built from a quote. Holds passengers, extras, payment, bookings, tickets. |
| booking | One provider reservation (PNR). An order has 1..N bookings. A package round trip = 1 booking. Two `open` offers = 2 bookings (one per leg, even from different providers), each with its own PNR and tickets. |
| extras (ancillaries) | Optional paid items: `seats`, `baggage`, `meals`. |
| indicator | `{status, timing, reason}` telling whether an extra is available for an offer. |

Passenger types: `ADT` adult (12+), `CHD` child (2-11), `INF` infant (under 2, no seat, travels with an adult).
Passenger `ref`: ids `P1`, `P2`, ... assigned by the quote (adults first, then children, then infants). Use them everywhere a passenger is referenced.

## 2. Flow

```
POST /v1/searches                                   -> searchId, outbound[], inbound[]
(optional) GET /v1/searches/{searchId}/offers/{offerId}/compatible   -> valid return flights/offers
(optional) POST /v1/quotes                          -> quoteId, price, passengers (refs)
POST /v1/orders                                     -> submit both offers directly, or a quoteId; one orderId (status DRAFT)
(optional extras)
  GET  /v1/orders/{orderId}/ancillaries             -> catalogue + availability
  POST /v1/orders/{orderId}/hold                    -> only if catalogue says requiresHold=true
  PUT  /v1/orders/{orderId}/ancillaries             -> choose extras, returns new total
POST /v1/orders/{orderId}/checkout {gateway}        -> payment.redirectUrl (status PENDING_PAYMENT)
customer pays at redirectUrl; gateway then calls POST /v1/payments/{paymentId}/result {outcome}
GET  /v1/orders/{orderId}                           -> final status CONFIRMED | FAILED_REFUNDED | PAYMENT_FAILED | ...
POST /v1/orders/{orderId}/cancel                    -> cancel + refund
```

Fulfilment happens inside the payment-result call: book -> add extras -> verify every extra is confirmed -> verify price -> ticket. If any step fails, everything is undone (holds cancelled, tickets voided or refunded) and the customer is refunded.

## 3. Endpoints

### GET /v1/providers
Response `200`: `{ "providers": [{ "id": string, "name": string, "capabilities": Capabilities }] }`
`Capabilities`: `holdAndTicketSeparate` (bool), `packages` (bool), `singleBookingRoundTrip` (bool), `ancillaries.{seats|baggage|meals}` = `{ "options": "pre_booking"|"after_hold"|"none", "apply": "at_booking"|"after_hold" }`, `voidTickets` (bool), `refundTickets` (bool), `cancelHold` (bool).

### GET /v1/payment-gateways
Response `200`: `{ "gateways": [{ "id": "mock-card", "name": string }, ...] }`. Use an `id` in checkout.

### POST /v1/searches
Request:
| field | type | required | constraint |
|---|---|---|---|
| origin | string | yes | 3-letter IATA code |
| destination | string | yes | 3-letter IATA code, different from origin |
| departureDate | string | yes | `YYYY-MM-DD` |
| returnDate | string | no | `YYYY-MM-DD`, not before departureDate. Present = round trip |
| passengers.adults | integer | yes | >= 1 |
| passengers.children | integer | no (0) | >= 0 |
| passengers.infants | integer | no (0) | >= 0, <= adults |
| | | | adults+children+infants <= 9 |

Response `201`:
```json
{
  "searchId": "srch_...",
  "tripType": "round_trip",
  "expiresAt": "ISO-8601",
  "request": { },
  "outbound": [ Flight ],
  "inbound":  [ Flight ],
  "providers": [{ "provider": "provider-id", "ok": true, "ms": 3, "offers": 8, "error": null }]
}
```
`inbound` is empty for one-way. `providers[]` reports each provider; `ok:false` means that provider failed and its flights are missing (the search still succeeds with the others). Flights are sorted by cheapest offer.

`Flight`:
```json
{
  "flightId": "MA100-KWI-DXB-2027-03-10@provider-id",
  "provider": "provider-id", "direction": "outbound|inbound",
  "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",
    "marketingCarrier": "MA", "operatingCarrier": "MA", "origin": "KWI", "destination": "DXB",
    "departureAt": "...", "arrivalAt": "...", "aircraft": "A320",
    "terminalDeparture": "1", "terminalArrival": null }],
  "offers": [ Offer ]
}
```
`segmentId` is the key used for seats, baggage and meals.

`Offer`:
```json
{
  "offerId": "off_...", "provider": "provider-id", "direction": "outbound",
  "brandName": "Flex", "cabin": "economy", "fareBasis": "YFLEX",
  "price": { "total": 150, "base": 120, "taxes": 30, "currency": "USD",
             "perPassengerType": [{ "type": "ADT", "count": 1, "unitTotal": 150 }] },
  "conditions": {
    "refundable":   { "allowed": true|false|null, "fee": {"amount":25,"currency":"USD"}|null, "note": string|null },
    "exchangeable": { "allowed": true|false|null, "fee": Money|null, "note": string|null } },
  "baggage": {
    "checked": { "included": bool|null, "pieces": int|null, "weightKg": number|null, "description": string|null },
    "cabin":   { "included": bool|null, "pieces": int|null, "weightKg": number|null, "description": string|null } },
  "meal": { "included": bool|null, "type": string|null },
  "seatsRemaining": int|null,
  "pairing": { "mode": "open|package", "packageKey": string|null, "requiresBookingTogether": bool,
               "packageTotal": number (only for package) },
  "ancillaries": { "seats": Indicator, "baggage": Indicator, "meals": Indicator },
  "expiresAt": "ISO-8601"|null
}
```
`price.total` is for ALL passengers of the search. For `package` offers the package price is split between the two legs; the two leg prices add up to the package total (split may be an estimate).

`Indicator`:
| field | values |
|---|---|
| status | `available`, `unavailable`, `unknown` |
| timing | `pre_booking` (options can be listed now), `after_hold` (a hold must exist first), `null` |
| reason | string explaining `unavailable` or a hint, else `null` |

Errors: `422 validation_failed` (details list `{field,message}`), `422` when no provider is enabled.

### GET /v1/searches/{searchId}/offers/{offerId}/compatible
Returns the offers on the OTHER leg that may be booked with `offerId`.
Response `200`: `{ "searchId": string, "offerId": string, "direction": "inbound", "flights": [ Flight ] }` where each flight lists only compatible offers.
The selection is treated as the customer's choice of a flight, not of one fare: returns that combine with any fare of the selected flight are listed. Each returned offer carries `pairWith: { "offerId": string, "substituted": boolean, "price": Price }`, the fare of the selected flight that will be booked with that return. When `substituted` is `true`, show `pairWith.price` as the price of the selected leg for that combination.
Errors: `404 not_found`, `409 search_expired`.

### POST /v1/quotes
Request: `{ "searchId": string, "outboundOfferId": string, "inboundOfferId": string }`. `inboundOfferId` is required for round trips and forbidden for one-way.
Behaviour: validates pairing, re-prices at each provider, plans bookings.
Response `201`:
```json
{
  "quoteId": "qt_...", "tripType": "round_trip", "expiresAt": "ISO-8601",
  "passengers": [{ "ref": "P1", "type": "ADT" }],
  "bookings": [{ "bookingKey": "bk1", "provider": "provider-id", "strategy": "single|split",
                 "legs": ["outbound","inbound"], "providerTotal": { "amount": 300, "currency": "USD" } }],
  "price": { "total": { "amount": 300, "currency": "USD" } },
  "flights": [ ... ], "ancillaries": { "seats": Indicator, "baggage": Indicator, "meals": Indicator }, "note": string|null
}
```
Quote `ancillaries.<kind>.status` may be `partial` (available on some legs only).
Errors: `404`, `409 search_expired`, `409 offers_not_combinable`, `409` offer no longer available, `422 validation_failed`.

### GET /v1/quotes/{quoteId}
Same body as quote creation. `404` if unknown.

### POST /v1/orders
Creates one DRAFT customer order for the complete selected trip. The backend validates compatibility, determines whether the trip needs one or several provider bookings, and keeps every resulting PNR under this order. No provider booking is created until hold or successful payment.

Preferred request - submit the selected offers once, without calling `POST /v1/quotes` first:
```json
{
  "searchId": "srch_...",
  "outboundOfferId": "off_...",
  "inboundOfferId": "off_...",
  "contact": { "email": "a@b.com", "phone": "+96550000000" },
  "passengers": [{
    "firstName": "Sara", "lastName": "Haddad", "gender": "F|M",
    "dateOfBirth": "1988-05-04", "nationality": "KW",
    "passport": { "number": "P123450", "issuingCountry": "KW", "expiryDate": "2031-01-01" } }]
}
```
If the frontend already displayed a separately created quote, it may send `quoteId` instead of the three selection fields. In that form, passenger `ref`s should match the quote. Otherwise passengers are matched by position.

Constraints: one passenger per searched passenger slot. All fields are required, including passport. Date of birth must match the passenger type at travel date. Passport must be valid at least 6 months after the last flight. `contact.phone` must include country code. Names are normalised to upper case; `title` is derived.
Response `201`: one Order (section 5), whose `bookings[]` contains every planned provider reservation. Errors: `409 search_expired`, `409 quote_expired`, `409 offers_not_combinable`, `409 offer_unavailable`, `422 validation_failed` with per-field `details`.

### GET /v1/orders/{orderId}/ancillaries
Catalogue of extras with fees. Only while status is `DRAFT`.
Response `200`:
```json
{
  "availability": { "seats": Indicator, "baggage": Indicator, "meals": Indicator },
  "requiresHold": true,
  "seats":   [{ "segmentId": "...", "aircraft": "A320", "seats": [{ "seat": "1A", "row": 1, "column": "A",
               "status": "free|occupied|blocked", "fee": {"amount":15,"currency":"USD"}, "features": ["window"] }] }],
  "baggage": [{ "optionId": "...", "name": "Extra bag 20kg", "weightKg": 20, "pieces": 1, "segmentId": "...",
               "fee": Money, "maxPerPassenger": 2, "bookingKey": "bk1" }],
  "meals":   [{ "optionId": "...", "name": "...", "segmentId": "...", "fee": Money, "bookingKey": "bk1" }],
  "fetchedAt": "ISO-8601"
}
```
If `requiresHold` is `true`, some kind has `timing: "after_hold"`; its list is empty until `POST /hold` is called, then call this endpoint again. Empty list + `status: "unavailable"` means the provider does not sell it.

### POST /v1/orders/{orderId}/hold
Creates the provider reservations now (unpaid, they expire) so `after_hold` extras can be listed. Body: none. Response `200`: Order with `bookings[].status = "HELD"`.
Only call when `requiresHold` is true. Providers where hold and ticket are one step cannot be held early; their extras are `pre_booking`.
Errors: `409 order_not_editable` (order not DRAFT), `409 feature_unavailable`, `502 provider_error`.

### PUT /v1/orders/{orderId}/ancillaries
Replaces the whole extras selection (send everything again each time). Send `{}` or empty arrays to clear.
Request:
```json
{
  "seats":   [{ "passengerRef": "P1", "segmentId": "...", "seat": "1A" }],
  "baggage": [{ "passengerRef": "P1", "optionId": "...", "quantity": 1 }],
  "meals":   [{ "passengerRef": "P1", "optionId": "..." }]
}
```
Rules: one seat per passenger per segment; a seat cannot be chosen twice; infants (`INF`) cannot have any extras; seat must have `status: "free"`; `quantity` 1..`maxPerPassenger` (total per passenger per option); the kind must not be `unavailable`; the catalogue must have been loaded first.
Response `200`: Order with `extras[]` and updated `price`.
Errors: `409 catalog_missing`, `409 order_not_editable`, `422 validation_failed` (details `{field,message}` per invalid line; nothing is saved).

### POST /v1/orders/{orderId}/checkout
Request: `{ "gateway": "mock-card" }`. Response `200`: Order plus `payment`:
`{ "paymentId", "gateway", "status": "PENDING", "amount", "currency", "redirectUrl" }`. Redirect the customer to `redirectUrl`. The order is now `PENDING_PAYMENT` and extras are locked.
Errors: `422` gateway missing, `409 unknown_gateway`, `409 hold_expired`, `409 order_not_editable`.

### POST /v1/payments/{paymentId}/result
Called by the payment gateway (webhook). The mock gateway page calls it for you.
Request: `{ "outcome": "success" | "failed" }`.
- `failed`: order -> `PAYMENT_FAILED`; any holds are cancelled.
- `success`: order -> `FULFILLING` -> `CONFIRMED` (tickets issued) or `FAILED_REFUNDED` (everything undone, payment refunded).
Idempotent: a repeated call on a finished payment does not repeat the work. Response `200`: Order.
Errors: `409 order_busy` while fulfilment is running.

### GET /v1/orders/{orderId}
Returns the Order (section 5). Poll this after payment until `status` is not `FULFILLING`.

### POST /v1/orders/{orderId}/cancel
Request body optional: `{ "reason": string }`. Cancels holds; for ticketed bookings voids when the provider allows it and the ticket is same-day, otherwise refunds per fare rules; refunds the customer the amount the provider returned. Response `200`: Order with `cancellation: { reason, at, refunded: Money }`.
Errors: `409` already cancelled / no tickets, `409 order_busy`.

### GET /v1/orders/{orderId}/events
`{ "events": [{ "at": ISO, "type": string, ... }] }` audit trail (`order_created`, `booking_created`, `ancillaries_selected`, ...).

### GET /orders/{orderId}/ticket
HTML ticket page for end customers (not JSON).

## 4. Pairing rules (how to choose a return)

Some outbound and inbound offers are combinable and others are not. Compatibility is determined by the API and may vary by fare, inventory source, or commercial rule. Treat provider identifiers and `pairing` metadata as opaque data; do not hard-code provider names, provider counts, pairing modes, or matching rules in the frontend.

Frontend flow:
1. After the customer selects the first leg, call `GET /v1/searches/{searchId}/offers/{offerId}/compatible`.
2. Enable only the returned opposite-leg offers and disable or hide every other offer.
3. Show each enabled offer with its `pairWith.price` for the selected leg (it can differ from the fare first shown on that flight).
4. Submit both selected offer IDs together to one `POST /v1/orders` request (the originally selected offer ID or `pairWith.offerId`; the backend books the fare given by `pairWith` either way). The backend creates the quote and decides whether the trip needs one provider booking or several.
5. Treat `bookings[]` as read-only results. Never loop over it to create, ticket, cancel, void, or refund providers separately.

If a selected pair is no longer compatible, `POST /v1/orders` returns `409 offers_not_combinable`; refresh the offers and ask the customer to choose again. Payment is taken once for the complete order. After successful payment the backend creates and tickets every required provider booking. If a later booking fails, the backend cancels its hold and voids or refunds every earlier successful booking before refunding the customer. The frontend only reads the final order status.

## 5. Order object

```json
{
  "orderId": "ord_...", "status": "DRAFT",
  "tripType": "round_trip",
  "passengers": [{ "ref": "P1", "type": "ADT", "title": "MS", "firstName": "SARA", "lastName": "HADDAD",
                   "gender": "F", "dateOfBirth": "1988-05-04", "nationality": "KW", "passport": { } }],
  "contact": { "email": "...", "phone": "..." },
  "flights": [{ "direction": "outbound", "origin": "KWI", "destination": "DXB", "departureAt": "...",
                "arrivalAt": "...", "segments": [ Segment ], "offer": Offer }],
  "bookings": [{ "bookingId": "bkg_...", "bookingKey": "bk1", "provider": "provider-id", "strategy": "single",
                 "legs": ["outbound","inbound"], "pnr": "A5KSUT", "status": "TICKETED",
                 "expiresAt": "ISO|null",
                 "tickets": [{ "number": "11119590260", "passengerRef": "P1",
                               "passengerName": "SARA HADDAD", "status": "ISSUED" }] }],
  "extras": [{ "kind": "seats|baggage|meals", "passengerRef": "P1", "segmentId": "...",
               "seat": "1A", "optionId": "...", "quantity": 1,
               "fee": { "amount": 15, "currency": "USD" }, "label": "Seat 1A",
               "status": "SELECTED|CONFIRMED|NOT_CONFIRMED|CANCELLED" }],
  "price": { "fare": Money, "ancillaries": Money, "total": Money },
  "payment": null | { "paymentId", "gateway", "status": "PENDING|PAID|FAILED|REFUNDED", "amount", "currency",
                      "redirectUrl", "refunded": number },
  "failure": null | { "code": string, "message": string, "missing": array|null },
  "cancellation": null | { "reason": string, "at": ISO, "refunded": Money },
  "createdAt": "ISO", "updatedAt": "ISO"
}
```
`bookings[].status`: `PLANNED`, `HELD`, `TICKETED`, `CANCELLED`, `VOIDED`, `REFUNDED`.
`tickets[].status`: `ISSUED`, `VOIDED`, `REFUNDED`. Some providers are ticketless: the ticket `number` is then the PNR plus an index.

### Order status

| status | meaning | your action |
|---|---|---|
| `DRAFT` | built, nothing paid | edit extras, hold, or checkout |
| `PENDING_PAYMENT` | waiting for the customer to pay | wait for payment result |
| `FULFILLING` | paid; booking/ticketing in progress | poll `GET /orders/{id}` every 1-2 s |
| `CONFIRMED` | tickets issued, extras verified | show ticket(s) |
| `PAYMENT_FAILED` | payment declined/abandoned | nothing booked; create a new checkout or order |
| `FAILED_REFUNDED` | booking failed after payment; provider side undone; customer refunded | read `failure`, tell the customer, offer a new search |
| `CANCELLED` | cancelled by request, refunded per rules | show `cancellation` |
| `REQUIRES_ATTENTION` | an undo step failed at a provider (manual action needed) | stop automated actions; escalate to a human |

## 6. Extras: availability logic

For each kind read, in this order:
1. `offer.ancillaries.<kind>.status`: `unavailable` -> never offer it, show `reason`.
2. `unknown` or `available` -> call `GET /v1/orders/{id}/ancillaries`.
3. If `requiresHold` -> `POST /hold`, then fetch the catalogue again.
4. Select with `PUT`. Only options present in the latest catalogue are accepted.
Availability can differ per fare of the same provider and per provider; it is not a constant.
After payment every selected extra is checked at the provider (again after ticketing when the provider confirms paid extras only at ticketing). Each selection line carries `status`: `SELECTED`, `CONFIRMED`, `NOT_CONFIRMED`, `CANCELLED`. If one is missing, the whole order fails with `failure.code = "ancillary_not_confirmed"` and the customer is refunded.

## 7. Failure and rollback guarantees

| Situation | Result |
|---|---|
| Payment fails | `PAYMENT_FAILED`; holds cancelled |
| Provider booking fails | undo all earlier bookings of the order; refund; `FAILED_REFUNDED` |
| Extra not confirmed | `failure.code = ancillary_not_confirmed`, `failure.missing` lists lines; undo; refund |
| Final price differs from quoted | `failure.code = price_changed`; undo; refund |
| Ticketing fails | `failure.code = ticketing_failed`; cancel hold (or void/refund tickets); refund |
| Hold expired before payment | `409 hold_expired` at checkout |
| Undo fails at a provider | `REQUIRES_ATTENTION`; customer refund still attempted |

Every undo step is recorded in `GET /orders/{id}/events`.

## 8. Errors

Shape (all non-2xx):
```json
{ "error": { "code": "validation_failed", "message": "Invalid search request",
             "details": [{ "field": "destination", "message": "Use a 3-letter airport code, e.g. DXB" }] } }
```
`details` is optional (array for 422, object for some 409/502).

| HTTP | code | meaning | what to do |
|---|---|---|---|
| 400 | `bad_json` | body is not valid JSON | fix the request |
| 404 | `not_found` | unknown id or route | check the id |
| 409 | `search_expired` | search too old | search again |
| 409 | `quote_expired` | quote too old | create a new quote |
| 409 | `offers_not_combinable` | pairing rule violated | use `/compatible` |
| 409 | `order_not_editable` | order is not `DRAFT` | read the order |
| 409 | `catalog_missing` | catalogue not loaded | `GET /orders/{id}/ancillaries` first |
| 409 | `hold_expired` | provider hold timed out | new order |
| 409 | `unknown_gateway`, `unknown_provider` | bad id | use list endpoints |
| 409 | `conflict` | generic state conflict (e.g. order already cancelled, offer no longer available) | read the order or re-quote |
| 409 | `feature_unavailable` | provider cannot do this (e.g. hold) | skip that step |
| 409 | `order_busy` | fulfilment running | retry in 1-2 s |
| 422 | `validation_failed` | invalid input | fix each `details[].field` |
| 502 | `provider_error` | provider rejected/failed (`details.retryable`) | retry only if `retryable` is true |
| 500 | `internal_error` | unexpected | retry once, then report |

## 9. Minimal worked example (curl)

```bash
B=https://HOST/v1
# 1 search
curl -s -X POST $B/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}}'
# 2 compatible returns for chosen outbound offer
curl -s $B/searches/$SEARCH/offers/$OUT/compatible
# 3 one order for both selected offers
curl -s -X POST $B/orders -H 'content-type: application/json' -d '{"searchId":"'$SEARCH'","outboundOfferId":"'$OUT'","inboundOfferId":"'$IN'","contact":{"email":"sara@example.com","phone":"+96550000000"},"passengers":[{"firstName":"Sara","lastName":"Haddad","gender":"F","nationality":"KW","dateOfBirth":"1988-05-04","passport":{"number":"P123450","issuingCountry":"KW","expiryDate":"2031-01-01"}}]}'
# 4 extras
curl -s $B/orders/$O/ancillaries
curl -s -X POST $B/orders/$O/hold
curl -s -X PUT $B/orders/$O/ancillaries -H 'content-type: application/json' -d '{"seats":[{"passengerRef":"P1","segmentId":"MA100-KWI-DXB-2027-03-10","seat":"1A"}]}'
# 5 pay
curl -s -X POST $B/orders/$O/checkout -H 'content-type: application/json' -d '{"gateway":"mock-card"}'
curl -s -X POST $B/payments/$PAY/result -H 'content-type: application/json' -d '{"outcome":"success"}'
# 6 result
curl -s $B/orders/$O
```

## 9b. Language fields

- Response root: `language` (`en` | `ar`); header `Content-Language`.
- Any object holding codes may carry `labels` (string map, display only): `origin`, `originCity`, `originCountry`, `destination*`, `marketingCarrier`, `operatingCarrier`, `validatingCarrier`, `provider`, `cabin`, `status`, `type` (passenger), `direction`, `timing`, `mode`, `features`, `summary` (on `refundable`, `exchangeable`, baggage allowances, `meal`), `total`/`base`/`taxes`/`formatted` (money). A missing label means "unknown name"; fall back to the code.
- `order.failure`: `message` is translated; `providerMessage` is the provider's raw English text (may be null).
- `order.preferredLanguage`: language the order was created in; customer pages (`redirectUrl`, ticket page) use it unless `?lang=` is given.

## 9c. Currency fields

- Request: `X-Currency` header (or `?currency=`), case-insensitive. Allowed: `SAR` (default), `USD`, `EGP`.
- Response header `X-Currency`; response root `exchange`: `{currency, baseCurrency:"SAR", rate, markupPercent, source:"live"|"static", fetchedAt, expiresAt}`. `rate` = units of `currency` per 1 SAR, markup included.
- Every provider price is converted to SAR first (the base), then from SAR to the chosen currency. The markup (`FX_MARKUP_PERCENT`, server setting) is added to the rate only when converting from SAR to another currency.
- Rates come from ExchangeRate-API and are cached for 12 hours at most. If the source cannot be reached, built-in static rates are used (`source:"static"`).

## 10. Provider-independent behavior

- Discover integrations and capabilities from `GET /v1/providers`; never hard-code provider IDs or counts.
- Decide which offers may be paired through `/compatible`, then submit both selected offer IDs in one order request.
- The backend owns provider grouping, reservation creation, ticketing order, PNR storage, rollback, voids, refunds, and the final customer payment state.
- Availability, extras timing, hold support, ticketing, void, and refund behavior come from API fields and capability indicators.
- Adding or removing a provider requires no frontend or documentation change; unsupported features appear as `unavailable` indicators.
