> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.kitionz.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.kitionz.com/_mcp/server.

# Full flight booking flow

**What you'll build:** a complete booking — search for flights, confirm a price, take the
money decision, place the booking, and confirm (or issue) the ticket. This is the
money path, so it leans on three Kitionz safety guarantees you'll see throughout:
[**idempotency**](/flights/reference#glossary) (a retry never double-books), the signed
[**quoteToken**](/flights/reference#glossary) (Kitionz never books above the price you quoted), and **safe retries**
(a slow supplier never looks like a failed booking).

> **Note:** New to flight APIs or unsure about a word like `quoteToken` or
> `offerId`? The [Glossary](/flights/reference#glossary) defines every term in plain English — keep it
> open in a second tab.

> The same endpoints can be used for *less* than this. If you only want to **show fares**,
> stop after Search. If you only manage **existing bookings**, start at Step 4. This recipe
> is the full transaction.

## Before you start

On the Live API you need two things:

* Your **Live key**, sent as `Authorization: Bearer <keyId>:<secret>` on every request, from an
  IP on its allow-list, with your organisation's KYC approved.

* On every **write** (`/book`, `/ticket`, `/cancel` …): an `Idempotency-Key: <uuid>` header — a
  **new** UUID for each write, saved with your order before you call; send the same value again
  only when you resend the same request after a timeout — + `Content-Type: application/json`. You're auto-pinned to an API version, so
  you don't send `Kitionz-Version`.

---

## Step 1 — Search

**What:** find bookable offers for a route + date + passenger mix.
**When:** the start of every booking. **Next:** pick one offer and send its `offerId` to the fare quote.

Send `origin`, `destination` and `departureDate`; every other field is optional (`adults` defaults
to 1). A real request and its recorded answer: [Search](/flights/search).

No `tripType` sent, so the answer holds every trip leaving that day: one-way fares and fixed
round-trip packages, each with its own `tripType`. `"tripType": "ROUND_TRIP"` returns round trips
only, and `"tripType": "ONE_WAY"` one-way offers only (`data.meta.filtersApplied` counts the round
trips it left out).

There are two `meta` objects. `data.meta` describes the search (trip kinds, how many suppliers
answered, `filtersApplied`). The outer `meta`, beside `requestId`, carries `diagnostics` when the
answer is empty or an input looks wrong (see "What can go wrong" below).

> **Critical:** a round trip is ONE offer. It comes only from suppliers that sell round trips
> as one two-leg fare: a single offer with `legs: [OUTBOUND, RETURN]`, one
> `price.totalAmount`, one `offerId`. Never staple two one-way offers yourself.

> **Critical:** every offer says which trip it is — `tripType`, `departureDate` and, for a round
> trip, `returnDate`. A round trip's price covers both ways: say so on your screen. The fare quote
> and the booking take them from the offer: you need not send them.

> **Critical:** search prices are indicative. They can move. Never charge your customer off
> a search price — re-price in the next step.

> **Required:** search with the passengers you will book. An offer is quoted and booked only for
> the `adults`, `children` and `infants` it was searched with; another party is refused at the fare
> quote with `400 PASSENGER_COUNTS_CHANGED`, and at the booking with `409 QUOTE_TOKEN_PAX_MISMATCH`. Kitionz accepts an offer's `offerId`
> until its `expiresAt` (about 15 minutes). That is not a price or seat guarantee.

> **Required — international trips:** read the offer's `isInternational` and
> `requiredPassengerFields` before you show a passenger form. `isInternational` is `true` when a
> flight crosses a border (or when Kitionz cannot tell); then every passenger needs `dateOfBirth`, `passportNumber`, `passportExpiryDate` and
> `nationality`, and the passport must be valid at least 6 months after the last flight. Collect
> them before you take payment (Step 3 has the details).

### Show what the seller did not state

Kitionz never makes up a value. A value the seller did not state is `null`, and a refund or change
policy is `NOT_GIVEN`. A `0` the seller did state stays `0`. Live sellers leave many values
unstated, and so does Kitionz Test Air (it is built to look like them), so build every screen for
them:

| Value                                                | Show your agent            | What the agent does                                                     |
| ---------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------- |
| `baggage` is `null`, or no row for a passenger type  | "Baggage: not stated"      | Do not promise an allowance; check the airline's rule before travel     |
| `policy.refund` is `NOT_GIVEN`                       | "Refund terms: not stated" | Do not promise a refund; ask the seller before selling it as refundable |
| `policy.change` is `NOT_GIVEN`                       | "Change terms: not stated" | Do not promise a date change                                            |
| a flight's `cabin` is `null`                         | "Cabin: not stated"        | Do not print "Economy"                                                  |
| `price.baseFare`, `taxes` or `published*` are `null` | the total only             | Never show "Taxes ₹0"                                                   |
| `price.commission` is `null`                         | —                          | Your commission was not stated: use your deal with the seller           |
| `price.infantFare.status` is `NOT_GIVEN`             | "Infant fare not included" | Add your infant charge; the seller or airline may still bill the infant |

Baggage rows use `paxType` `ADT`, `CHD` and `INF` (for `ADULT`, `CHILD`, `INFANT`): match them to
passengers with that table.

> **Important:** a leg with a stop has one segment per flight. Some suppliers give the wait at a
> stop but not the clock times there, so a segment's `departureTime` or `arrivalTime` can be
> `null` at that stop. The wait is in the leg's `layovers` (`layovers[k]` is the stop after
> `segments[k]`); a booking carries it as `segments[].layoverAfterMinutes`. Show "Time not
> stated", and never work out a time or a duration from a `null`. A leg's first departure and
> last arrival always have a time. If the supplier's own times at a stop contradict each other,
> Kitionz shows them as not stated as well; when it has no wait for that stop, the leg has no
> `layovers` and the booking's `layoverAfterMinutes` is `null`.

### More ways to call search

The fields of this call and a real answer are on its page: [Search](/flights/search).

> **Note — What can go wrong here?**
>
> * **Empty `offers` with no error** → read `diagnostics` in the **outer** `meta` (beside `requestId`, not `data.meta`). It names why: `NO_RESULTS_FOR_QUERY` (your suppliers had nothing for that route and date), `FILTERS_REMOVED_ALL_OFFERS` (your filters removed every offer), `UNKNOWN_AIRPORT` (an airport code Kitionz does not know — check it), or a supplier not yet approved for Live. See [Go live](https://kitionz.com/setup).
> * **Your filters removed every offer** → `data.meta.filtersApplied` has one `{ field, removed }` entry per filter you sent. Few suppliers state a fare brand or a refund policy, so `fareBrandsInclude` or `refundableOnly` can remove everything.
> * [`FORBIDDEN`](/errors) (403) → a code in `supplierCodes` is not switched on for your account. Switch it on under Suppliers, or leave `supplierCodes` out.
> * [`VALIDATION_ERROR`](/errors) → a field name/format is off, or the search asks for something Kitionz does not offer (three or more `slices`, or a way back to other airports). Read `error.details.invalidFields`: it names the field.

---

## Step 2 — Fare quote (confirm the price, get a token)

**What:** re-price the chosen offer against the live supplier and get a **signed
`quoteToken`**. **Why it matters:** for about 15 minutes, the token binds this price, offer and
passengers, and Kitionz will not book above that price. It does not hold the seat. Send the
`offerId`: the passengers and the trip are the offer's. `adults`, `children`, `infants`,
`departureDate`, `tripType` and `returnDate` are optional; if you send one it must be the offer's or
the number you searched with (another trip → `400 OFFER_TRIP_MISMATCH`, another party →
`400 PASSENGER_COUNTS_CHANGED`). **Next:** pass the token to Book.

Send `{ "offerId": "floffer_…" }`. A real request and its recorded answer:
[Fare quote](/flights/fare-quote).

> **Note — per-passenger prices:** `price.passengerFares` has one row per passenger type
> (`passengerType`, `count`). `total` is the price of **all** passengers of that type together and
> `perPerson` is one passenger's, to the paisa: two adults on
> `{ "passengerType": "ADULT", "count": 2, "perPerson": 5324, "total": 10648 }` cost ₹5,324 each.
> When every row states a total, the rows add up to `totalAmount`. Charge `price.totalAmount`, never
> a sum of `perPerson`. The booking carries the same `price` block.

> **Required:** book the passengers you searched with. The `quoteToken` binds the offer, the
> passengers and the trip; Book counts the passengers from its `passengers` list (another party →
> `409 QUOTE_TOKEN_PAX_MISMATCH`: search again with them). There is no supplier field to send: the
> `offerId` already names the supplier.

> **Critical:** the price can change here. Always show the customer `price.totalAmount` from
> this response, never the search price. `priceChanged` is `true` when it differs from the offer's
> total at search (`searchTotalAmount`); Kitionz compares the two for every supplier. With an infant,
> read `price.infantFare.status` first: if the infant price was given at only one of the two
> steps, the difference is the infant fare, not a fare change. If `priceChanged` is `true`, tell the
> customer the price moved and book only if they accept. The `quoteToken` carries this price. At booking,
> Kitionz checks again: if the price went up or the seats are gone, nothing is booked
> (`FARE_HIGHER_THAN_QUOTED` / `INSUFFICIENT_SEATS`) and you quote again. If the price moved down,
> some sellers book at the lower price and some refuse any change with
> `409 SUPPLIER_FARE_CHANGED_AT_BOOK` (nothing booked: quote again) — never rely on either. Seats can
> also go at the seller itself: then Book answers `201` with a `FAILED` booking and
> `failure.code` `SUPPLIER_BOOK_FAILED`. The token is valid until `quoteTokenExpiresAt` (about 15
> minutes).

> **Required — offer & quote lifetimes:** two short-lived clocks run here.
>
> * **Search offer, until its `expiresAt` (about 15 min):** after that, quoting or booking its
>   `offerId` returns [`OFFER_EXPIRED`](/errors) → **re-search**. A
>   successful fare quote extends it.
> * **quoteToken \~15 min:** the signed token expires (`quoteTokenExpiresAt`). Booking after
>   that returns [`QUOTE_TOKEN_EXPIRED`](/errors) → **re-quote**.
> * If the live fare rose above the quote at book time you get
>   [`FARE_HIGHER_THAN_QUOTED`](/errors) — re-quote and show
>   the new price; never auto-charge the difference.

### Quote variants

The fields of this call and a real answer are on its page: [Fare quote](/flights/fare-quote).

> **Note — What can go wrong here?**
>
> * [`OFFER_EXPIRED`](/errors) → the offer's `expiresAt` passed. Re-run search, quote a fresh `offerId`.
> * [`PASSENGER_COUNTS_CHANGED`](/errors) → you sent other `adults` / `children` / `infants` than the search. `error.details.searchedParty` and `sentParty` show both. Search again with the passengers you will book.
> * `priceChanged: true` on a 200 → the price moved since search. It is not an error: the NEW `quoteToken` + price are in the body — show the customer the new price before booking with it.
> * [`KYC_NOT_APPROVED`](/errors) (Live key only) → your org's KYC isn't approved yet.

---

## Step 3 — Book

**What:** place the booking. **Why the token:** Book **requires** the `quoteToken` and takes
the price from it. There is no amount field, so a price can't be made up, changed or
replayed. **Why idempotency:** Kitionz reserves the booking before it asks the supplier to book,
and keys it on your `Idempotency-Key`, so a network retry returns the same booking instead of
charging twice.

Send `offerId`, `quoteToken`, `passengers` and your own `externalReference`, with a new
`Idempotency-Key` header saved with your order before you call. A real request and its recorded
answer: [Book](/flights/book).

The HTTP status is `201`. Decide by `data.status` and `data.nextAction`.

> **Required — passengers:**
>
> * **`passengerType`** is `"ADULT"` (12 or older), `"CHILD"` (2–11) or `"INFANT"` (under 2), by
>   the passenger's age on the date of the **last** flight (on a round trip, the return flight).
>   Search with that count: an infant who turns 2 before the return flight travels as a `CHILD`.
>   A passenger whose age does not fit the type is refused before any seller is called with
>   `422 PASSENGER_FIELD_REQUIRED` on `passengers.N.dateOfBirth` — for example "Passenger 2: This
>   infant will be 2 or older on the return flight on 2026-12-20. Search again with this passenger
>   as a child." Fix it with a new search, not by changing Book: the passengers are bound to the
>   search and the quote. No passenger may be born after the departure date. `passengers` must hold
>   exactly the passengers you searched with; if you also send `adults`, `children` or `infants`,
>   they must match it. At most 9 adults and children in one booking.
> * **Infants** sit on an adult's lap: every `INFANT` needs **`guardianPassengerIndex`**, the
>   position in `passengers` (0 = the first) of that `ADULT`. One infant per adult.
> * **Names:** send `firstName` and `lastName` for everyone. A traveller with one name: send it as
>   both.
> * **Titles:** `Mr`, `Mrs` or `Ms` for adults; `Master` (boy) or `Miss` (girl) for children and
>   infants. Kitionz sets `gender` from the title; with any other title (`Dr`, `Prof`), send
>   `gender` (`MALE` or `FEMALE`).
> * **Date of birth:** a `CHILD` or `INFANT` always needs `dateOfBirth`. A domestic `ADULT` sent
>   without one is sent to the seller with 1 January of (this year − 20), because some sellers
>   refuse a booking without a date of birth. The booking never shows that date as the
>   passenger's: it answers `dateOfBirth: null` with `dateOfBirthDefaulted: true` (`false` for
>   everyone else).

> **Required — international trips:** collect what the offer needs **before you take payment**.
> On an offer whose `isInternational` is `true`, every passenger needs
> `dateOfBirth`, `passportNumber`, `passportExpiryDate` and `nationality`. The passport must be
> valid at least 6 months after the last flight. Send countries as two-letter codes (`IN`, not
> `Indian`). Leave `passportIssuingCountry` out unless the passport was issued by another country
> than `nationality`: Kitionz sends the nationality. A missing field answers
> `422 PASSENGER_FIELD_REQUIRED`, a passport that expires too soon `422 PASSPORT_EXPIRY_TOO_SOON`;
> both list every passenger and field in `error.details.invalidFields`, and nothing is sent to the
> seller.

> **Note — who the seller contacts:** the seller receives **your organisation's registered email
> and mobile** as the booking contact (your Kitionz account details), not `booker.email`;
> `booker.phone` is sent only when your organisation has no mobile on record. E-tickets, schedule
> changes and cancellations from the seller or the airline come to you: pass them on to your
> traveller. `booker` is your record of who made the booking; some sellers receive `booker.name` as
> the contact name. Phone numbers: an Indian mobile, 10 digits, `+91`
> optional.

> **Required — other fields:**
>
> * **Send only the fields in the reference.** Any other field is `400 VALIDATION_ERROR`. Seats,
>   meals, extra baggage, payment details, corporate or tour codes and frequent-flyer numbers cannot
>   be sent at booking: no supplier receives them. `bookingMode` is `"INSTANT"` (the default: book
>   now) or `"HOLD"`.
> * **Idempotency is the `Idempotency-Key` HEADER** (required on `/book`) — there's no body
>   field for it. Make a new UUID for each booking, persist it, and send the *same* value only when
>   you resend the same request.
> * **`externalReference`** is your own order number (up to 120 characters). Kitionz stores it and
>   returns it on the booking. Send it on every booking: if you lose Book's answer,
>   `GET /v1/flights/bookings?externalReference=ORDER-1001` finds the booking. Kitionz does not
>   check it for uniqueness.
> * **Test is lenient:** Kitionz Test Air accepts any title, phone or country. Live sellers refuse
>   what they cannot take (below), so follow these rules in Test too.
> * **Names, booker details and passport fields are plain text:** no control characters (tab,
>   line feed and carriage return are allowed) and no half of an emoji. Kitionz refuses such a
>   value with `400 VALIDATION_ERROR` before anything is booked. A value pasted from a document can
>   hide one — ask the customer to retype the field `error.details.invalidFields` names.

> **Critical:** reuse the SAME idempotency value on retries. A new key = a new booking. If your
> `/book` request times out, send the identical request again with the same key: you get the
> first booking back, or `409` while it is still running. The booking is reserved before Kitionz
> asks the supplier to book, so a retry with the same key can never book twice. Book answers `201`
> when a booking was recorded, even one the supplier refused, except a refusal you can act on
> (`402`, `409`, a `400 BOOK_…` code): that comes as the error, and nothing was booked. Decide by `data.status` and do
> what `data.nextAction` says, not by the HTTP code — and never by `pnr` (a `HELD` booking already
> has a PNR and is not ticketed).

### Which id to keep

| From             | Keep                    | For                                                                                                  |
| ---------------- | ----------------------- | ---------------------------------------------------------------------------------------------------- |
| Search           | `offerId` (`floffer_…`) | Fare quote and Book, until the offer's `expiresAt` (about 15 minutes)                                |
| Search           | `searchId`              | support only; never sent back                                                                        |
| Fare quote       | `quoteToken`            | Book, until `quoteTokenExpiresAt`; one booking per quote                                             |
| You, before Book | your `Idempotency-Key`  | sending Book again safely after a timeout                                                            |
| You, on Book     | `externalReference`     | your own order number; finds a booking whose answer you lost                                         |
| Book             | `id` (`bkg_…`)          | every call after booking — key your orders on it                                                     |
| Book             | `pnr`                   | the airline's booking code, for check-in and the airline's website; it can arrive minutes after Book |
| Book             | `supplierReference`     | the seller's own booking number: quote it to the seller's support                                    |

Key your orders on `id`, never on the PNR (two airlines can issue the same PNR). Kitionz does not
return e-ticket numbers or the ticket document: get them from the seller named in the booking's
`seller.name`.

### Book variants — confirm now vs hold-then-ticket

The fields of this call and a real answer are on its page: [Book](/flights/book).

> **Note — What can go wrong here?**
>
> * [`QUOTE_TOKEN_EXPIRED`](/errors) → the token's \~15 min passed. Re-quote, show the (possibly new) price, book with the fresh token.
> * [`QUOTE_TOKEN_ALREADY_USED`](/errors) → this token already made a booking: `error.details.existingBookingId` and `existingBookingStatus` name it. Read that booking. Quote again and book with a new key only if it is `FAILED`.
> * [`IDEMPOTENCY_KEY_REQUIRED`](/errors) → the header is missing. Add `Idempotency-Key: <uuid>` (and persist the uuid on your order row).
> * [`SUPPLIER_INSUFFICIENT_BALANCE`](/errors) (402) → your balance with this seller is too low. Nothing was booked. If the seller refused at the booking call, a `FAILED` booking is left in `GET /v1/flights/bookings` with the seller in `seller.name`; if it refused when Kitionz checked the price again, no booking is left. Top up with the seller, then quote and book again with a new Idempotency-Key.
> * [`SUPPLIER_FARE_CHANGED_AT_BOOK`](/errors), [`FARE_HIGHER_THAN_QUOTED`](/errors), [`INSUFFICIENT_SEATS`](/errors) (409) → the fare or seats changed just before booking. Nothing was booked. Quote again and show the new price.
> * `400 BOOK_CONTACT_EMAIL_REQUIRED`, `BOOK_CONTACT_PHONE_UNSUPPORTED`, `BOOK_PASSENGER_TITLE_UNSUPPORTED`, `BOOK_PASSENGER_COUNTRY_UNSUPPORTED` → this seller cannot take one detail as sent. Nothing was booked. Fix it and book again with a new Idempotency-Key.
> * [`CAPABILITY_NOT_SUPPORTED`](/errors), `HOLD_NOT_SUPPORTED_FOR_THIS_OFFER` (422, Book with `bookingMode: "HOLD"`) → this offer cannot be held. Book with `bookingMode: "INSTANT"`.
> * [`PASSENGER_FIELD_REQUIRED`](/errors), [`PASSPORT_EXPIRY_TOO_SOON`](/errors) (422) → a passenger misses what the offer needs. `error.details.invalidFields` lists each one.

---

## Step 4 — Check the result

> **Critical — when to take the money.** Take your customer's payment just before `/book` (a
> quote lasts 15 minutes). Then act on the booking:
>
> * `CONFIRMED` or `HELD` → keep the payment.
> * `FAILED`, or any other `4xx` from `/book` → refund it: nothing was booked.
> * `RESERVED` or `PENDING_CONFIRMATION` (`nextAction.code: WAIT`), a `5xx`, no answer,
>   `409 BOOKING_IN_PROGRESS` or `409 IDEMPOTENT_REQUEST_IN_PROGRESS` (the first request may still
>   book) or `409 QUOTE_TOKEN_ALREADY_USED` (a booking from this quote exists, in
>   `error.details.existingBookingId`) → **keep it: the booking may exist**. Do not refund and do not
>   book again: send the same request again with the same key, or read the booking, until it reads
>   `CONFIRMED` or `FAILED`. Most settle within 10 minutes; still pending after 20 minutes, contact
>   Kitionz support.
>
> Kitionz takes no money for a booking: you pay the seller from your own account with that seller.

**What:** resolve the real outcome. Every booking carries `nextAction` (never `null`): a `code`, a
plain `description`, and the call to make (`method`, `path`) when there is one. Decide by `status`
and `nextAction.code` — never by `pnr`:

| `status`               | meaning                                          | `nextAction.code` | what you do                                                                                      |
| ---------------------- | ------------------------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------ |
| `CONFIRMED`            | booked and confirmed by the seller; `pnr` is set | `NONE`            | done — skip Step 5                                                                               |
| `HELD`                 | seats held with a `pnr`, **not** ticketed        | `ISSUE_TICKET`    | issue the ticket (Step 5) before `nextAction.deadlineAt`                                         |
| `PENDING_CONFIRMATION` | outcome not known yet                            | `WAIT`            | read the booking again after `nextAction.pollAfterSeconds` (below)                               |
| `PENDING_FULFILMENT`   | a request, nothing booked                        | `SUBMIT_PNR`      | book on the seller's site before `nextAction.deadlineAt`, then POST the PNR to `nextAction.path` |
| `FAILED`               | not booked, nothing charged                      | `SEARCH_AGAIN`    | `failure.code` and `failure.message` say why; search and book again                              |

A booking is `FAILED` only when the supplier clearly refused it (sold out, fare gone) or the
request never reached the supplier. When the supplier's answer is unclear — a timeout, an answer
Kitionz cannot read, or the supplier accepted but Kitionz could not save its answer — the booking
is `PENDING_CONFIRMATION`, never `FAILED`, and Kitionz checks it with the supplier. Do not book
that trip again while it is `PENDING_CONFIRMATION`.

`RESERVED` reads like `PENDING_CONFIRMATION` (`WAIT`). A `FAILED` booking can have empty
`passengers` and `segments`; its `origin`, `destination`, `departureDate`, `policy` and
`baggage` say what was tried. Every status and its next action is in the
[Flights reference](/flights/reference#booking-status-and-next-action). Never book again while a booking is `RESERVED` or
`PENDING_CONFIRMATION`.

> **Critical — Kitionz does not watch confirmed bookings for airline changes yet.**
> `SCHEDULE_CHANGED` is not produced today. On a confirmed booking, `CANCELLED_BY_SUPPLIER` appears
> only after a call that asks the seller, such as `POST /v1/flights/bookings/{id}/refresh`, and the
> seller reports the booking cancelled; its
> `nextAction.code` is `CONTACT_SUPPLIER`. No webhook is sent for either. Sellers and airlines send
> changes to the booking contact — your organisation's registered email and mobile. To catch
> changes, watch each booked flight with Flight Status (`POST /v1/flight-status/monitors`) and
> refresh the booking the day before departure.

> **Note — request bookings expire.** A `PENDING_FULFILMENT` request carries its deadline in
> `nextAction.deadlineAt`: the earlier of 72 hours (by default) after it was booked and the first
> flight's departure. After it, Kitionz may cancel the request at any time: it becomes `CANCELLED`, and `/fulfil` then answers `409 BOOKING_NOT_PENDING`. Book on the seller's site (`seller.website`) straight away — the fare
> there can go much sooner — with the same flights, date and passengers, then send the airline PNR.
> Kitionz records the quoted total; it does not see what you paid on the seller's site.

Read it with [Get a booking](/flights/get-a-booking): its recorded answer is
a booking Kitionz is still confirming (`PENDING_CONFIRMATION`, `nextAction.code` `WAIT`). A booking
the supplier refused is `FAILED`, with `failure.code` (for example `SUPPLIER_BOOK_FAILED`) and
`nextAction.code` `SEARCH_AGAIN`: nothing was booked and nothing charged.

The wording of `description` and `failure.message` can change. Branch on the codes.

### The two failure modes — handle them differently

> **Critical:** Never show the customer an error when a request times out. A real, billable
> booking may exist behind a timeout or a `PENDING_CONFIRMATION`.

* **No answer, a timeout or any `5xx` from `/book`:** you have no booking id yet. Send the identical
  request again with the SAME `Idempotency-Key`: you get the first booking back, or
  `409 BOOKING_IN_PROGRESS` / `IDEMPOTENT_REQUEST_IN_PROGRESS` while it is still running (wait a
  few seconds and send it again), or `409 QUOTE_TOKEN_ALREADY_USED` naming the booking in
  `error.details.existingBookingId` — read that booking. Never use a new key.
* **`500 BOOKING_OUTCOME_UNKNOWN`:** the supplier may have booked, but Kitionz could not record its
  answer. `error.details.bookingId` names the booking when there is one — read it. Do not book that
  trip again; contact Kitionz support with the `request_id`.
* **`PENDING_CONFIRMATION` (you got a response):** the booking may exist. With a
  `supplierReference` the supplier accepted it and Kitionz checks for the PNR; without one, Kitionz
  settles it with the supplier. Read `GET /v1/flights/bookings/{id}` every
  `nextAction.pollAfterSeconds`; most settle within 10 minutes. No webhook is sent for this change.
* **Still pending after 20 minutes:** contact Kitionz support with the booking id and
  `request_id`. Keep the customer's payment and do not book again.

Need a fresh pull from the supplier (not the stored booking)? Use
`POST /v1/flights/bookings/{id}/refresh` — it asks the supplier directly and updates the
stored booking. Use it only now and then, for example when your customer asks; for routine
waiting, the plain `GET` is enough, because Kitionz already checks the supplier for you.

### Read variants — read once, or wait for a pending booking

The fields of this call and a real answer are on its page: [Get a booking](/flights/get-a-booking).

> **Note — What can go wrong here?**
>
> * **Status stuck in `PENDING_CONFIRMATION` past 20 minutes** → contact Kitionz support with the booking id and `request_id`; keep the customer's payment and do not book again.
> * **404 on the booking id** → you're reading with a different key/org than the one that booked, or the id is mistyped (`bkg_…`).

---

## Step 5 — Ticket (only if HELD)

Only Kitionz Test Air supports holds today; with your Live suppliers a booking never comes back `HELD`.
A `HELD` booking must be **ticketed** before a supplier-imposed deadline.
If Step 4 shows `HELD`, issue the ticket:

send `POST /v1/flights/bookings/{id}/ticket` with a new `Idempotency-Key`
([Ticket a hold](/flights/ticket-a-hold)).

A `CONFIRMED` booking needs no ticketing call — skip this step. Asking again for a booking that is
already `CONFIRMED` answers `200` with the booking and does not ask the supplier again. A `202` means the
supplier accepted the request and has not finished: read the booking until it is `CONFIRMED`.

> **Note — What can go wrong here?**
>
> * **`nextAction.deadlineAt` passed** → the seller may have released the seats, and Kitionz does not change the status. Ask the seller; if released, start a fresh search → quote → book.
> * [`BOOKING_NOT_TICKETABLE`](/errors) (409) → the booking is not `HELD` (for example `FAILED` or `CANCELLED`). Read it; nothing to ticket.
> * [`TICKETING_REFUSED`](/errors) (422) → the supplier refused to ticket. The booking stays `HELD`: try again before `nextAction.deadlineAt`, or contact the seller named in `seller.name`.

---

## Cancel and refund in Live

Only Kitionz Test Air cancels and refunds confirmed bookings through the API today. On a Live
booking, `/cancel` answers `422 CANCEL_WITH_SUPPLIER`. A `PENDING_FULFILMENT` request can be withdrawn
with `/cancel` on every supplier. With a Live booking:

* Cancel with the seller named in the booking's `seller.name`, by phone or email. The seller applies
  the fare's penalty and refunds you to your account with them, on their timeline. Kitionz is not
  told and does not track the refund.
* The Kitionz booking stays `CONFIRMED`: record the cancellation in your own system. If you later
  call `/refresh` and the seller reports the cancellation, the booking reads
  `CANCELLED_BY_SUPPLIER` — for a cancellation you asked for, that does not mean the airline
  cancelled.
* The offer's `capabilities.cancellation` says how cancelling works: `REALTIME` — `/cancel` cancels
  through Kitionz (only Kitionz Test Air today); `OFFLINE` — the seller cancels by phone or email;
  `NONE` — Kitionz has no cancellation path for this seller: ask the seller.

---

## Before you go Live

Follow **Going Live** on the [Flights overview](/flights/overview#going-live):
it lists every step, in order. Your integration is ready for Live when:

* **One new `Idempotency-Key` per write** (booking, cancel…), persisted on your side for safe
  retries.
* **Your screens handle "not stated"** (`null`, `NOT_GIVEN`): Live sellers leave many values
  unstated. Test Air does the same, so a screen that works in Test should hold.
* **Your passenger form follows the Live rules** (titles, Indian mobile, two-letter countries,
  passports on international trips): Test Air accepts anything.
* **Handle `PENDING_CONFIRMATION` by reading the booking until it settles** (`nextAction.code: WAIT`) —
  this is the difference between a clean integration and lost bookings.

## When things go wrong

Every 4xx/5xx has the same envelope, so you can handle errors generically: `error.type`,
`error.code`, `error.message`, `error.doc_url` and `error.request_id` (see the
[Introduction](/introduction)).

Give the `request_id` to support when you need help. Follow `doc_url` for help with the error: in
Test it opens the public [Error codes](/errors) page; in Live, the error's own page on the
kitionz.com console (sign in first). The common booking-path codes and what to
**do** about each:

| `code`                                     | what it means                                               | your action                                                                                                                                                               |
| ------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`OFFER_EXPIRED`](/errors)                 | the offer's `expiresAt` passed                              | re-search to get a fresh `offerId`, then re-quote                                                                                                                         |
| [`PASSENGER_COUNTS_CHANGED`](/errors)      | other passengers than the search                            | search again with the passengers you will book                                                                                                                            |
| [`QUOTE_TOKEN_EXPIRED`](/errors)           | the \~15-min quote window passed                            | re-quote; keep the customer's selection, show the new price before charging                                                                                               |
| [`QUOTE_TOKEN_INVALID`](/errors)           | the token was altered or doesn't match the request          | never change the token — re-quote fresh                                                                                                                                   |
| [`FARE_HIGHER_THAN_QUOTED`](/errors)       | the fare rose above the quote at book time                  | re-quote, show the new price; never auto-charge the difference                                                                                                            |
| [`SUPPLIER_FARE_CHANGED_AT_BOOK`](/errors) | the seller refused any fare change at book time             | quote again and show the new price; nothing was booked                                                                                                                    |
| [`SUPPLIER_INSUFFICIENT_BALANCE`](/errors) | your balance with the seller is too low                     | top up with the seller (a `FAILED` booking, when one is left, names it in `seller.name`), then quote and book again                                                       |
| [`PASSENGER_FIELD_REQUIRED`](/errors)      | a passenger misses a field the offer needs                  | fill every field in `error.details.invalidFields`; collect them before payment                                                                                            |
| [`PASSPORT_EXPIRY_TOO_SOON`](/errors)      | a passport expires less than 6 months after the last flight | the traveller needs another passport for this trip                                                                                                                        |
| [`FARE_QUOTE_UNUSABLE`](/errors)           | the supplier's quote failed Kitionz's check                 | choose another fare or search again; the reason is in `error.supplierError.failureClass` (below)                                                                          |
| [`FARE_RECHECK_FAILED`](/errors)           | the fare could not be re-confirmed at book time             | quote again; if the infant price changed, adjust your own infant charge (below)                                                                                           |
| [`SUPPLIER_DISABLED_BY_TENANT`](/errors)   | your organization has this supplier switched off            | switch it on under Suppliers (one switch covers staff + API)                                                                                                              |
| [`IDEMPOTENCY_KEY_REQUIRED`](/errors)      | a write arrived without the header                          | send `Idempotency-Key: <uuid>` on writes                                                                                                                                  |
| [`VALIDATION_ERROR`](/errors)              | a field is missing or not allowed                           | read `error.details.invalidFields`: each entry names the field (`passengers.0.firstName`) and what is wrong; a pasted name can hide a control character — have it retyped |
| [`KYC_NOT_APPROVED`](/errors)              | your org isn't approved for production                      | org-level — finish KYC                                                                                                                                                    |
| [`IP_NOT_ALLOWED`](/errors)                | the caller IP isn't on the key's allow-list                 | add the IP to the key's allow-list (Live keys only)                                                                                                                       |

**`FARE_QUOTE_UNUSABLE` (422).** Kitionz checks every fare quote, and the fare again just before
booking. A fare that fails is not quoted or booked, and no money moves. `error.supplierError.failureClass`
says why: `TOTAL_NOT_POSITIVE` (the total is ₹0 or less, or not a number), `CURRENCY_NOT_INR`,
`TOTAL_ABOVE_CEILING` (far above any real fare), `PRICED_FOR_ANOTHER_PARTY` (priced for a different
number of passengers: search again with the passengers you will book), `PASSENGER_NOT_PRICED` (an adult
or child has no price), `LINES_DO_NOT_ADD_UP` (the per-passenger prices do not add up to the total). A
missing infant price is never this error: the quote comes back with
`price.infantFare.status: NOT_GIVEN`.

**`FARE_RECHECK_FAILED` (409).** Kitionz could not confirm this fare as quoted just before booking.
Nothing was booked or charged. The reason is in `error.supplierError.failureClass`. What to do:

1. Call `/fare-quote` again. If it succeeds, book with the new `quoteToken`.
2. `SUPPLIER_UNAVAILABLE`: the supplier is failing right now, so Kitionz did not ask it. Wait about 30 seconds before you try again.
3. `INFANT_PRICE_WITHDRAWN`: you were quoted an infant price and the supplier gives none now. The new
   quote shows `infantFare.status: NOT_GIVEN`, and its `totalAmount` covers adults and children only,
   so add your own infant charge before you book.
4. `INFANT_PRICE_APPEARED`: your quote said `NOT_GIVEN` and the supplier now gives an infant price. The
   new quote shows `PRICED` with the infant inside `totalAmount`, so remove your own infant charge
   before you book.
5. If `/fare-quote` keeps failing, search again or choose another offer.

See the full [Error codes](/errors) reference for every code, its meaning, and the fix.