> 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.

# Same flights from several sellers (journey key)

**What you'll build:** a results list that shows each journey once, as "from ₹7,000 · 3 options",
and opens to show every seller's option.

Kitionz returns every offer every supplier sends. The same flights often come back several times:
from different sellers, at different prices, with different seats, baggage and rules. Kitionz never
removes a copy and never picks a seller for you.

Instead, every search offer carries a **journey key** (`journeyKey`). Offers with the same journey
key are the same flights.

## What the key promises

Two offers with the same `journeyKey` state the same flights at the same start and end times:

* every flight has the same airline and flight number;
* every flight uses the same airports, with every stop on the way;
* every leg leaves and arrives at the same local time.

> **Critical:** the key means **"these sellers state the same flights at the same start and end
> times"**. It does not mean "proven to be the same planes". Kitionz compares what each seller
> states. It does not check the flights with the airline, and sellers type flight numbers by hand.

A codeshare sold under another airline's number has a different key.

Inside one group, each offer still has its own price, seats, cabin, baggage, refund and change
rules, and its own way to book (`booking`). Times at a stop, terminals and aircraft can also differ.

## The fields

| Field                     | Type      | Meaning                                                                                                                                     |
| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `journeyKey`              | string    | `jk1_` + 32 hex characters, over every leg in order. Group offers by it.                                                                    |
| `journeyKeyComparable`    | boolean   | `true` only when every leg is comparable. When `false`, `journeyKey` is unique to this offer and matches nothing.                           |
| `legs[].legKey`           | string    | `lk1_` + 32 hex characters, over that leg alone. Use it to see that a round-trip package's outbound is the same flights as a one-way offer. |
| `legs[].legKeyComparable` | boolean   | `false` when the seller did not state everything the key needs. The `legKey` is then unique to this leg of this offer.                      |
| `legs[].legKeyGaps`       | string\[] | Why the leg cannot be compared. `[]` when it can (see the gap codes below).                                                                 |

All five are always present on a search offer. A key is never `null`. An offer that cannot be
compared gets a key no other offer has, so ordinary grouping code (`Map`, `groupBy`, SQL
`GROUP BY`) keeps it on its own.

Format: at most 64 characters, letters, digits and `_` (today always 36). Compare whole values.
Never parse them.

A one-way offer from search, trimmed to the journey key fields:

```json
{
  "offerId": "floffer_8Kd2pLx7RqT1vNz4Wc6YbH",
  "tripType": "ONE_WAY",
  "journeyKey": "jk1_8a9bacde0040d6e9cfcc81fb9648fb4a",
  "journeyKeyComparable": true,
  "legs": [
    {
      "legType": "OUTBOUND",
      "departureTime": "2026-11-07T00:15:00+05:30",
      "arrivalTime": "2026-11-07T12:35:00+08:00",
      "legKey": "lk1_8a9bacde0040d6e9cfcc81fb9648fb4a",
      "legKeyComparable": true,
      "legKeyGaps": []
    }
  ]
}
```

## Step 1 — Group offers by journey key

Offers come sorted by price, cheapest first. So the first offer you meet for a key is the cheapest.

```js
const groups = new Map();
for (const offer of offers) {
  const group = groups.get(offer.journeyKey) ?? [];
  group.push(offer);
  groups.set(offer.journeyKey, group);
}
```

You need no special case for an offer that cannot be compared. Its key is unique, so it gets a group
of its own.

## Step 2 — Show each group once

Show what every offer in the group shares: airlines and flight numbers, airports and stops, and each
leg's departure and arrival. Then show the lowest price and the number of options.

```js
for (const options of groups.values()) {
  const cheapest = options[0]; // offers come sorted by price
  const price = cheapest.price.totalAmount.toLocaleString("en-IN");
  const count = options.length === 1 ? "1 option" : `${options.length} options`;
  const label = `from ₹${price} · ${count}`; // "from ₹7,000 · 3 options"
}
```

## Step 3 — Open each option

List every option with its own price, seats, cabin, baggage, refund and change rules, and how it is
booked (`booking`). Times at a stop, terminals and aircraft can also differ: show them from the
option the customer picks.

> **Critical:** never add up `availableSeats` across a group. Sellers often resell the same seats,
> so booking one option can sell out another. Show each option's own seats.

## Step 4 — Book one specific offer

The customer picks one option. Send its `offerId` to `/fare-quote`, then its `quoteToken`
to `/book`. No endpoint accepts a `journeyKey`.

> **Required:** if one option fails at `/fare-quote`, you may offer the next option in the group.
> It is a different fare: quote it, and show its price and rules again before booking. Never move a
> customer to another option without showing it.

## The rules

* **Never add up seats across a group.** Sellers often resell the same seats.
* **Always book one specific offer**, with its own `offerId`.
* **Matching leg keys never split a package or join two one-ways.** Equal `legKey`s never let you
  book a round-trip package's legs separately, or combine two one-way offers into a round trip. A
  package has one price for both ways.
* **A key that cannot be compared never groups.** When `journeyKeyComparable` is `false`, the key is
  unique to that offer. Show the offer on its own, and do not store its key.
* **Treat an unknown gap code as opaque.** New codes may be added. Branch only on
  `legKeyComparable`, and never fail on a code you do not know.

## Gap codes: why a leg cannot be compared

When `legKeyComparable` is `false`, `legKeyGaps` lists why:

| Code                       | When                                                                         |
| -------------------------- | ---------------------------------------------------------------------------- |
| `AIRLINE_NOT_STATED`       | A flight's airline code is blank or not an airline designator.               |
| `FLIGHT_NUMBER_MISSING`    | A flight has no number.                                                      |
| `FLIGHT_NUMBER_JOINED`     | Numbers joined in one flight, as some sellers print them (`910/997`).        |
| `FLIGHT_NUMBER_UNREADABLE` | A number that is not 1–4 digits with an optional letter (`6E1607`, `12345`). |
| `AIRPORT_NOT_EXACT`        | An airport, or a stop airport, that is not a 3-letter code.                  |
| `STOP_NOT_NAMED`           | The seller says the flight stops but does not name the airport.              |
| `TIME_NOT_STATED`          | The leg's departure or arrival time cannot be read to the minute.            |
| `ARRIVAL_UNCONFIRMED`      | The leg's arrival time is not trusted (`arrivalTimeUnconfirmed`).            |
| `FLIGHTS_NOT_STATED`       | The leg lists no flights.                                                    |

New codes may be added later. A code is never reworded or reused.

## Storing a key

You may store a comparable key, for example to follow a journey's price across searches. It is the
same in every search, on every day, for every seller and every tenant, as long as the sellers state
the same flights at the same times. It changes when:

* an airline retimes a flight (a different start or end minute);
* Kitionz learns a fact a seller left out. For example, the airline confirms that two pieces under
  one number are one flight with a stop: the shape changes, so the key may change;
* the recipe version changes (`jk2_`, announced in advance). A `jk1_` key never equals a `jk2_` key.

Never store a key with `journeyKeyComparable: false`. It is unique to that offer in that search.

## Key your own inventory (recipe v1)

You can compute the same key for flights from your other sources. Then they group with Kitionz
offers. Use only the fields below, exactly as the search returns them.

### 1. Read each leg

For a leg, use its `departureTime`, `arrivalTime` and `segments[]`. For each segment, use
`marketingCarrier.iataCode`, `marketingCarrier.flightNumber`, `origin.iataCode`,
`destination.iataCode`, `technicalStops[].airport.iataCode` and `unnamedStops`. On the leg's **last** segment, also use
`arrivalTimeUnconfirmed`.

Before comparing, trim every text value and put it in upper case.

The leg is **comparable** only when all of these hold (otherwise it gets the gap codes above):

* it has at least one segment;
* `departureTime` and `arrivalTime` both start with a valid local date-time `YYYY-MM-DDTHH:mm`;
* the last segment's `arrivalTimeUnconfirmed` is not `true`;
* every airline code is two letters/digits with at least one letter (IATA), or three letters (ICAO);
* every flight number is 1–4 digits with an optional letter, with no `/ , & +` in it;
* no segment has `unnamedStops` above 0, and every technical stop names its airport;
* every airport (origin, each technical stop, destination) is three letters.

### 2. Write the leg string

```text
<DEPARTURE>|<ARRIVAL>|<HOP>,<HOP>,…
HOP = <AIRLINE><NUMBER>:<FROM>-<TO>
```

* `<DEPARTURE>` and `<ARRIVAL>`: the first 16 characters of the leg's `departureTime` and
  `arrivalTime` (`YYYY-MM-DDTHH:mm`). These are local clock times. The time-zone offset after them is
  **ignored**, so `2026-11-07T00:15:00+05:30`, `2026-11-07T00:15:00` and `2026-11-07T00:15:00Z` all
  give `2026-11-07T00:15`.
* Hops: for each segment in order, list its points (origin, each technical stop in order,
  destination). Write one hop per pair of points, all under the segment's own airline and number.
  So one flight `6E1607` BOM→DPS with a stop at MAA, and two rows `6E1607` BOM→MAA and MAA→DPS, both
  give `6E1607:BOM-MAA,6E1607:MAA-DPS`.
* `<NUMBER>`: the digits without leading zeros, then the optional letter (`0326` → `326`,
  `1607A` → `1607A`).

Never part of the string: seller or supplier, price, seats, cabin, fare or brand, baggage,
terminals, operating carrier, times at stops inside the leg, time-zone offsets, `legType`, indexes.

Times at stops are never part of the key. Kitionz decides whether the leg's arrival can be trusted
from the last flight's own times, and an untrusted arrival makes the leg not comparable. So two
sellers that state different times at a stop still group, as long as each leg's first departure and
last arrival agree. A seller whose stop time is empty or unreadable gets `ARRIVAL_UNCONFIRMED` on
that leg, and its offer stands alone.
A stop time that is `null` (not stated) is never part of the key, so such an offer groups with a copy
that states every time.

### 3. Hash

Let `H(text)` be the first 32 lowercase hex characters of SHA-256 over the UTF-8 bytes of `text`.

* **Leg key:** `lk1_` + `H("kitionz-journey-v1\n" + legString)`
* **Journey key** (every leg comparable): `jk1_` + `H("kitionz-journey-v1\n" + legStrings joined by "\n")`

A one-way offer's journey key therefore carries the same 32 characters as its leg key, under `jk1_`.

```js
import { createHash } from "node:crypto";

const H = (text) =>
  createHash("sha256").update(text, "utf8").digest("hex").slice(0, 32);

const legKey = (legString) => "lk1_" + H("kitionz-journey-v1\n" + legString);
const journeyKey = (legStrings) =>
  "jk1_" + H("kitionz-journey-v1\n" + legStrings.join("\n"));
```

In PHP, `H` is `substr(hash('sha256', $text), 0, 32)`.

### 4. A leg or offer that cannot be compared

Kitionz builds a key unique to the offer from the offer's own `offerId` (the `floffer_…`
value you book with), never from the seller:

* leg: `lk1_` + `H("kitionz-journey-v1-unmatched\n" + offerId + "\nleg:" + n)`, where `n`
  is the leg's position (0 for the first);
* offer (any leg not comparable): `jk1_` + `H("kitionz-journey-v1-unmatched\n" + offerId + "\noffer")`.

For your own rows, give a row that cannot be compared any value no other row has (for example your
own row id). It must never equal a comparable key.

## Worked example: Mumbai to Bali round trip

Outbound MH 195 BOM→KUL + MH 715 KUL→DPS, leaving 7 Nov 00:15 (Mumbai) and arriving 12:35 (Bali).
Return MH 714 DPS→KUL + MH 194 KUL→BOM, leaving 14 Nov 13:30 and arriving 22:05. (The times are
illustrative.)

Seller A sells it at ₹52,000 with 1 seat. Seller B sells it at ₹52,300 with 1 seat, typing `mh` and
`0195`, with other terminals and other times at KUL. Both give:

| Leg      | Leg string                                                        | Key                                    |
| -------- | ----------------------------------------------------------------- | -------------------------------------- |
| Outbound | `2026-11-07T00:15\|2026-11-07T12:35\|MH195:BOM-KUL,MH715:KUL-DPS` | `lk1_8a9bacde0040d6e9cfcc81fb9648fb4a` |
| Return   | `2026-11-14T13:30\|2026-11-14T22:05\|MH714:DPS-KUL,MH194:KUL-BOM` | `lk1_18c9cfceb3188b608662f0f19accb521` |
| Offer    | both leg strings, joined by `\n`                                  | `jk1_08e989777e95001e81f2a6e79f5a99e4` |

So a search shows them as one journey: "from ₹52,000 · 2 options".

A one-way BOM→DPS offer on the same outbound flights has `legKey`
`lk1_8a9bacde0040d6e9cfcc81fb9648fb4a`, equal to the package outbound. Its `journeyKey` is
`jk1_8a9bacde0040d6e9cfcc81fb9648fb4a`, which is different from the package's. It is a different
journey, and you can never book the package's outbound on its own.

## Test vectors

Check your code against every vector in the frozen vectors file. Each vector gives the offer fields,
the leg strings and the exact keys. The file names the fields as version 1 did: some are old names
from the Renamed fields list, with the same values as their new names.

Download journey-key.v1.vectors.json

A selection:

| Vector | What it shows                                                     | Expected                                                                                                                    |
| ------ | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| A1, A2 | The BOM↔DPS round trip from two sellers                           | both `jk1_08e989777e95001e81f2a6e79f5a99e4`                                                                                 |
| A3     | A one-way on the package outbound                                 | `jk1_8a9bacde0040d6e9cfcc81fb9648fb4a`                                                                                      |
| B1     | `2026-11-07T00:40\|2026-11-07T16:25\|VJ910:BOM-HAN,VJ997:HAN-DPS` | `jk1_1851acae636507cd52f1d744dbd26d66`                                                                                      |
| B2     | Same first flight, then VJ 993 to DPS at 21:10                    | `jk1_0c38744be3de7c30f89b588624a6b300`                                                                                      |
| D1     | B1's flights with no times stated at HAN                          | `jk1_1851acae636507cd52f1d744dbd26d66` (groups with B1)                                                                     |
| C1, C2 | VJ 326 PQC→SGN→HYD as two rows / as one flight with a stop        | both `jk1_6bda240ba9043cb9b1fca59f8bd3b052`                                                                                 |
| C6     | The same flights stated leaving at 14:10 instead of 14:20         | `jk1_80dd89d65ba0a2a8e09d06984669bcf4`                                                                                      |
| C3, C4 | The second flight with no number, two copies                      | not comparable (`FLIGHT_NUMBER_MISSING`), `jk1_6f1049fd70acfc600611194b0b83ab6d` and `jk1_05a08616836656193487e5666dfa4bd6` |
| E1–E3  | 6E 5092 + 6E 6059 BOM→BLR→JRG, 1 / 4 / 9 seats                    | all `jk1_344c5d853065ce68d3ee7ea34e7e55e2`                                                                                  |
| S1, S2 | 6E 1607 BOM→DPS with a stop at MAA / as two rows                  | both `jk1_e40f0edfd5b035847fa60b0d729552fa`                                                                                 |
| H1     | Three-letter airline `IGO1607:BOM-MAA`                            | `jk1_adede66f4df6ce409616e3d45834abc9`                                                                                      |
| H2     | All-digit airline `12`                                            | not comparable (`AIRLINE_NOT_STATED`)                                                                                       |
| H3, H4 | `1607A`, and `6e` / `01607a` (the same string `6E1607A:BOM-MAA`)  | both `jk1_12df280f7eacd5330b287ed898ff85d7`                                                                                 |
| H5, H6 | Flight number `0` and `00` (both `6E0:BOM-MAA`)                   | both `jk1_6d70eb905452676c1acf5b81fa4ab814`                                                                                 |
| H7     | Five-digit number `12345`                                         | not comparable (`FLIGHT_NUMBER_UNREADABLE`)                                                                                 |
| H8     | E's flights with times ending in `Z`                              | `jk1_344c5d853065ce68d3ee7ea34e7e55e2` (= E1)                                                                               |
| G5     | Outbound comparable, return missing a number                      | outbound `lk1_8a9bacde0040d6e9cfcc81fb9648fb4a`; offer not comparable                                                       |

## Test it

Try grouping in Test. A one-way search on Kitionz Test Air returns one journey from two simulated
sellers, plus three offers that cannot be compared: a flight with no number, a stop with no airport,
and a one-number stop the airline has not confirmed.

## Version

This is version 1 (`jk1_` / `lk1_`). The recipe and the vectors are frozen. A change is a new
version, with its own vectors file and keys `jk2_` / `lk2_`, announced as a contract change.