# Flight booking flow

```
 search ──► price (optional) ──► hold ──► issue ──► ticketed
   │            │                  │         │
   │            │                  │         └─► issuing ──(webhook/poll)──► ticketed | failed
   │            │                  └─► cancel (free, before holdExpiresAt)
   │            └─► priceChanged: show the new price first
   └─► offers expire (expiresAt): search again
```

| Step | Endpoint | Scope | Money |
|---|---|---|---|
| Search | `POST /flights/search` | `flights:read` | — |
| Re-price one offer | `POST /flights/offers/{offerId}/price` | `flights:read` | — |
| Fare rules | `GET /flights/offers/{offerId}/fare-rules` | `flights:read` | — |
| Hold | `POST /flights/bookings` | `flights:book` | nothing charged |
| Issue | `POST /flights/bookings/{id}/issue` | `flights:book` | `amountDue` taken from wallet |
| Cancel a hold | `POST /flights/bookings/{id}/cancel` | `flights:book` | nothing charged |
| Read | `GET /flights/bookings/{id}`, `GET /flights/bookings` | `flights:read` | — |

## 1. Search

- `slices`: 1 for one-way, 2 for a return, up to 6 for multi-city. Each has `origin`, `destination` (IATA airport or city codes) and `departureDate` (`yyyy-MM-dd`, local).
- `passengers`: `adults` (1–9), `children` (2–11 years), `infants` (under 2, not more than adults).
- Optional: `cabin` (`economy`, `premium_economy`, `business`, `first`), `directOnly`, `preferredAirline`, `nationality`, `domestic`, `currency`, `maxResults` (1–200, default 50).
- Offers are sorted cheapest first. Each fare family (brand) of the same flights is its own offer.
- An empty `offers` list is a normal result (no availability), not an error. `warnings` tells you when some airlines did not answer.

**Offers expire.** Use an `offerId` before its `expiresAt` (usually 15–30 minutes). An expired or unknown offer returns `flights.offer_expired` / `flights.offer_not_found`: search again.

Only live searches count towards your [search quotas and look-to-book ratio](https://quickota.com/developers/guides/rate-limits-and-look-to-book.md). Cache results for identical searches.

## 2. Price (recommended before booking)

`POST /flights/offers/{offerId}/price` asks the airline for the current price of one offer. If `priceChanged` is `true`, the offer now carries the new price — show it to your customer. Booking then uses the new price.

`GET /flights/offers/{offerId}/fare-rules` returns refund/change/no-show conditions (`penalties`) and, when available, the airline's full rule text. Show at least refundability and baggage before the customer pays.

## 3. Hold

`POST /flights/bookings` with the `offerId`, the passengers (same counts and types as the search), a contact and optionally your `agencyReference`. **An `Idempotency-Key` header is required.**

Passenger rules:

- Names in Latin letters exactly as on the passport. `firstName` may include middle names.
- `birthDate` is required for children and infants (and recommended for all).
- On international trips a passport (`document`) is required, valid beyond the travel date.
- The first adult is the lead passenger.

Result: `201` with the booking in status `held`. Common errors:

| Code | Meaning | What to do |
|---|---|---|
| `flights.price_changed` (409) | The airline repriced above tolerance. Nothing was booked. | Price or search again, confirm the new price, hold again. |
| `flights.fare_not_available` (409) | Sold out since the search. | Search again. |
| `flights.offer_already_booked` (409) | You already held this offer (with a different `Idempotency-Key`). | Use the existing booking: `context.bookingId`. |
| `flights.passenger_data_invalid` (422) | Names, dates or documents rejected. | Fix the fields in `errors`. |
| `flights.duplicate_booking` (409) | Same passenger on the same flight already. | Use or cancel the existing booking. |
| `flights.supplier_timeout` / `supplier_unavailable` | Airline did not answer. `retryable: true`. | Retry with the **same** `Idempotency-Key`. |

## 4. Issue

`POST /flights/bookings/{id}/issue` with a new `Idempotency-Key`. Before that, you can check `GET /account/balance`.

| Outcome | Status | Meaning |
|---|---|---|
| `200`, `status: ticketed` | Done | `pnr`, `ticketNumbers`, `passengers[].ticketNumber` filled. |
| `200`, `status: issuing` | Waiting on the airline | Wait for the webhook or poll — see [Managing booking states](https://quickota.com/developers/guides/managing-booking-states.md). |
| `402 account.insufficient_balance` | Not enough money | `context.shortfall` says how much. **The hold is kept**; top up and issue again before `holdExpiresAt`. |
| `409 flights.hold_expired` | Too late | Search again. |
| `409 request.invalid_state_transition` | Wrong status | `context.currentStatus` and `context.allowedActions` tell you what is possible. |

Calling issue on a booking that is already `issuing` or `ticketed` returns the booking (`200`) and never tickets twice.

## 5. Cancel a hold

`POST /flights/bookings/{id}/cancel` releases the seats of a `held` booking; nothing is charged. A **ticketed** booking cannot be cancelled this way (`flights.use_post_booking_request`): request a refund or void instead — see [Refunds, changes & schedule changes](https://quickota.com/developers/guides/servicing-and-schedule-changes.md).

## 6. Read and list

- `GET /flights/bookings/{id}` — the full booking with `status`, `nextActions`, `deadlines`, passengers, slices, price and open servicing.
- `GET /flights/bookings/{id}/events` — the timeline (held, payment reserved, ticketed, cancellations, servicing, schedule changes).
- `GET /flights/bookings` — newest first, filters: `status`, `needsAction=true`, `agencyReference`, `pnr`, `createdFrom`/`createdTo`, `departureFrom`/`departureTo`, `page`, `pageSize` (≤ 100).
- `GET /flights/bookings/{id}/documents/e-ticket` — PDF, once ticketed.
