Skip to navigation

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

FieldTypeMeaning
journeyKeystringjk1_ + 32 hex characters, over every leg in order. Group offers by it.
journeyKeyComparablebooleantrue only when every leg is comparable. When false, journeyKey is unique to this offer and matches nothing.
legs[].legKeystringlk1_ + 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[].legKeyComparablebooleanfalse when the seller did not state everything the key needs. The legKey is then unique to this leg of this offer.
legs[].legKeyGapsstring[]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:

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

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.

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 legKeys 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:

CodeWhen
AIRLINE_NOT_STATEDA flight’s airline code is blank or not an airline designator.
FLIGHT_NUMBER_MISSINGA flight has no number.
FLIGHT_NUMBER_JOINEDNumbers joined in one flight, as some sellers print them (910/997).
FLIGHT_NUMBER_UNREADABLEA number that is not 1–4 digits with an optional letter (6E1607, 12345).
AIRPORT_NOT_EXACTAn airport, or a stop airport, that is not a 3-letter code.
STOP_NOT_NAMEDThe seller says the flight stops but does not name the airport.
TIME_NOT_STATEDThe leg’s departure or arrival time cannot be read to the minute.
ARRIVAL_UNCONFIRMEDThe leg’s arrival time is not trusted (arrivalTimeUnconfirmed).
FLIGHTS_NOT_STATEDThe 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

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

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:

LegLeg stringKey
Outbound2026-11-07T00:15|2026-11-07T12:35|MH195:BOM-KUL,MH715:KUL-DPSlk1_8a9bacde0040d6e9cfcc81fb9648fb4a
Return2026-11-14T13:30|2026-11-14T22:05|MH714:DPS-KUL,MH194:KUL-BOMlk1_18c9cfceb3188b608662f0f19accb521
Offerboth leg strings, joined by \njk1_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:

VectorWhat it showsExpected
A1, A2The BOM↔DPS round trip from two sellersboth jk1_08e989777e95001e81f2a6e79f5a99e4
A3A one-way on the package outboundjk1_8a9bacde0040d6e9cfcc81fb9648fb4a
B12026-11-07T00:40|2026-11-07T16:25|VJ910:BOM-HAN,VJ997:HAN-DPSjk1_1851acae636507cd52f1d744dbd26d66
B2Same first flight, then VJ 993 to DPS at 21:10jk1_0c38744be3de7c30f89b588624a6b300
D1B1’s flights with no times stated at HANjk1_1851acae636507cd52f1d744dbd26d66 (groups with B1)
C1, C2VJ 326 PQC→SGN→HYD as two rows / as one flight with a stopboth jk1_6bda240ba9043cb9b1fca59f8bd3b052
C6The same flights stated leaving at 14:10 instead of 14:20jk1_80dd89d65ba0a2a8e09d06984669bcf4
C3, C4The second flight with no number, two copiesnot comparable (FLIGHT_NUMBER_MISSING), jk1_6f1049fd70acfc600611194b0b83ab6d and jk1_05a08616836656193487e5666dfa4bd6
E1–E36E 5092 + 6E 6059 BOM→BLR→JRG, 1 / 4 / 9 seatsall jk1_344c5d853065ce68d3ee7ea34e7e55e2
S1, S26E 1607 BOM→DPS with a stop at MAA / as two rowsboth jk1_e40f0edfd5b035847fa60b0d729552fa
H1Three-letter airline IGO1607:BOM-MAAjk1_adede66f4df6ce409616e3d45834abc9
H2All-digit airline 12not comparable (AIRLINE_NOT_STATED)
H3, H41607A, and 6e / 01607a (the same string 6E1607A:BOM-MAA)both jk1_12df280f7eacd5330b287ed898ff85d7
H5, H6Flight number 0 and 00 (both 6E0:BOM-MAA)both jk1_6d70eb905452676c1acf5b81fa4ab814
H7Five-digit number 12345not comparable (FLIGHT_NUMBER_UNREADABLE)
H8E’s flights with times ending in Zjk1_344c5d853065ce68d3ee7ea34e7e55e2 (= E1)
G5Outbound comparable, return missing a numberoutbound 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.