Recommendations Guide
When searching for public transport tickets, the API returns numerous offers for the same journey, varying by flexibility, fare class, accommodation, and other properties. Recommendations group these offers into categories and select the best offer in each category. For journeys with multiple legs, recommendations can also combine offers across legs to cover the entire journey.
The API does not guarantee recommendations for every requested category. A category may be empty if, for example, the product does not exist for that departure (e.g., sleeper is only available on night trains), capacity is sold out, or your organisation is not authorised to sell a particular operator's products.
Any search endpoint that takes geographical input supports recommendations (e.g., POST /trip-patterns, POST /stop-places, POST /zones). Add a recommendationConfig to your search request to receive recommendations organised by the requested criteria.
Table of Contents
Recommended Configuration
Minimal configuration
To get started, add a recommendationConfig to your search request. Without this config, the API returns offers but no recommendations.
This returns the cheapest offer for the entire journey. All other settings use defaults.
Production configuration
For production use, enable the recommended flags. This configuration covers most use cases.
These are the recommended flag values for most integrations. See CategorySpec and RuleSpec for details on each field, and Examples for behaviour illustrations.
If you sell long-distance train tickets, also add fareClasses and facilitySet to the categorySpec to get recommendations split by fare class (e.g., standard vs. premium) and accommodation type (e.g., sleeper, couchette). See CategorySpec for available values.
Response structure
The API response contains two related arrays:
offers[]— All offer objects with full details (price, conditions, validity)recommendations[]— Categories pointing to offers viaoffersToBuy
In this example, the recommendation selects offer-1 as the cheapest option in the FLEXIBLE + STANDARD_CLASS category. Both offers cover the same journey with the same flexibility, but differ in price.
Key fields in a recommendation:
typeOfRecommendation— The flexibility category (CHEAPEST, NON_FLEXIBLE, etc.)geographicalValidityCovered— Which part of the journey this covers:coversEntireJourney— Boolean indicating if this recommendation covers the full journeyserviceJourneys— List of service journey IDs covered, only applicable when requesting offers for a specific TripPatternpointToPointValidity— Start and end stop places, for tickets valid between specific stopszonalValidity— Zone information, for tickets valid in specific zones
offersToBuy— The offers to reserve in the Sales API, with quantity and possible traveller assignments.selectableProductIds— When populated, include these selectable IDs in the reservation request. They refer to optional products (found in the response underoptionalProducts) that are available with the recommended offers.possibleTravellerIds— Who can be assigned to each purchase. See Traveller Assignment.
combineWithToCoverEntireJourney— When this recommendation doesn't cover the entire journey, other recommendations are provided that can be combined with this one to cover the full journey. Select one item from the list, then purchase all OffersToBuy from all items inrecommendationsToCoverEntireJourney, together with the parent recommendation. Only populated whenonlyIncludeMostCompleteInCategorySpec: true. More details can be found in Partial journey coverage.
To display a recommendation, look up each offersToBuy[].id in the offers[] array to get price and details.
RuleSpec
The ruleSpec controls how recommendations are calculated and filtered.
| Setting | Default | Recommended | Description |
|---|---|---|---|
journeyOrganizeAlgorithm | SUBSEQUENT_COMBINATIONS | SUBSEQUENT_COMBINATIONS | How multi-leg journeys are split into recommendation groups. |
priceComparisonAlgorithm | BEFORE_SDR | TOTAL_PRICE | How offers are compared when selecting the best offer. |
onlyIncludeRecommendedOffers | false | true | Only return offers referenced by at least one recommendation. |
onlyIncludeRecommendationsWithOffersToBuy | true | true | Omit recommendation categories with no matching offers. When true, every returned recommendation is guaranteed to have a non-empty offersToBuy. |
mixinOffersWithHigherFlexibility | false | true | Allow higher-flexibility offers in lower categories if cheaper or the requested flexibility is unavailable on some legs. At least one offer in the recommendation must match the requested level. See example. |
mixInOffersWithOtherFacilitySets | false | true | Allow mixing facility types to cover the full journey. For example, a SLEEPER recommendation may include a SEATING offer for legs where sleeper isn't available. At least one offer must match the requested facility. See example. |
removeSplitRailReplacementOffers | false | true | When a rail ServiceJourney has been partially replaced by alternative transport, recommendations will only be provided for the full ServiceJourney. |
onlyKeepUpgradeProductSalesPackages | false | false | Only include upgrade products. Use when client has an entitlement (e.g., monthly pass) and searches for upgrades. |
onlyIncludeMostCompleteInCategorySpec | false | false | BETA: Filter out subset recommendations. See details below. |
Algorithm Details
Journey Organize Algorithms
The algorithm controlling how recommendations should be combined spanning multiple legs.
Example: For a journey with three legs (SJ1 → SJ2 → SJ3):
| Algorithm | Recommendation groups created |
|---|---|
SUBSEQUENT_COMBINATIONS | [SJ1], [SJ2], [SJ3], [SJ1, SJ2], [SJ2, SJ3], [SJ1, SJ2, SJ3] (not [SJ1, SJ3]) |
FOR_EACH_AND_GROUPED_COMBINATIONS | [SJ1], [SJ2], [SJ3], [SJ1, SJ2, SJ3] |
COMBINATIONS_FROM_OFFERS | Only combinations matching actual offers |
The choice of algorithm matters most on multi-operator journeys. Usually, there exists no tickets covering all operators, hence no single offer covers the full journey.
Consider a two-leg journey: A → B with Operator X, then B → C with Operator Y.
With COMBINATIONS_FROM_OFFERS, the API only creates groups matching existing offers — one per leg, but no full-journey group:
The client receives two separate CHEAPEST recommendations but must figure out on its own that buying both covers the full journey. With onlyIncludeMostCompleteInCategorySpec (BETA), each recommendation gets combineWithToCoverEntireJourney links pointing to the other — but the client still does the composition.
With SUBSEQUENT_COMBINATIONS, the API also creates a group for the full journey and fills it with the cheapest combination of offers:
The third recommendation has coversEntireJourney: true and lists both offers — the client gets a ready-made full-journey recommendation without combining offers itself.
Key behaviours:
SUBSEQUENT_COMBINATIONSonly creates consecutive leg groups — non-adjacent combinations like [SJ1, SJ3] are never generatedFOR_EACH_AND_GROUPED_COMBINATIONSmay miss optimal price combinations (no intermediate groups like [SJ1, SJ2])COMBINATIONS_FROM_OFFERSonly creates groups matching actual product scopes — no full-journey group is created unless a single product covers it. This is useful when your client handles journey composition itself. Combined withonlyIncludeMostCompleteInCategorySpec(BETA), partial recommendations getcombineWithToCoverEntireJourneylinks that help clients find complementary offers
Price Comparison Algorithms
| Algorithm | Compares | Use case |
|---|---|---|
TOTAL_PRICE | What the user actually pays (after discounts) | Recommended for most use cases |
BEFORE_SDR | Original price before discounts | Employee tickets (personalbillett) |
For both algorithms, the price comparison is done per traveller.
BEFORE_SDR is useful for employee tickets where all offers cost the same after discount. It selects the cheapest original product for a lower tax basis.
Example: A customer with an employee ticket searches Oslo → Bergen.
| Offer | Original price | After discount |
|---|---|---|
| Offer A | kr 200 | kr 0 |
| Offer B | kr 100 | kr 0 |
TOTAL_PRICE: Both are kr 0 — tie, so selection is arbitraryBEFORE_SDR: Selects Offer B (kr 100 < kr 200) — lower tax basis
Filtering Subset Recommendations (BETA)
When onlyIncludeMostCompleteInCategorySpec: true, the API filters out recommendations that are strict subsets of other recommendations within the same category specification.
For a multi-leg journey with recommendations for [A], [B], and [A, B] (all with identical typeOfRecommendation, durationType, fareClass, and facilitySet), only the recommendation for [A, B] is returned.
Key behaviours:
- Only applies to multi-leg journeys; single-leg journeys are unaffected
- Subset filtering only considers recommendations with
offersToBuy - Recommendations without offers are preserved when
onlyIncludeRecommendationsWithOffersToBuy: false - Enables
combineWithToCoverEntireJourneyon incomplete recommendations to help clients find complementary offers (see Partial journey coverage)
CategorySpec
The categorySpec defines which types of recommendations to generate.
| Field | Required | Values |
|---|---|---|
typesOfRecommendation | Yes | FLEXIBLE, SEMI_FLEXIBLE, NON_FLEXIBLE, CHEAPEST |
durationTypes | No | SINGLE_TRIP, RETURN_TRIP, CARNET, DAY_PASS, WEEKLY_PASS, WEEKEND_PASS, MONTHLY_PASS, SEASON_TICKET, PROFILE_MEMBERSHIP, OPEN_ENDED, OTHER |
fareClasses | No | FIRST_CLASS, SECOND_CLASS, THIRD_CLASS, ECONOMY_CLASS, BUSINESS_CLASS, TURISTA, PREFERENTE, PREMIUM_CLASS, ANY, STANDARD_CLASS, UNKNOWN |
facilitySet | No | SLEEPER, SEATING, COUCHETTE, RECLINING_SEAT, ANY_FACILITY_SET |
When a field is omitted from categorySpec, it acts as an implicit wildcard — recommendations will include all offers regardless of that attribute. You can also use explicit wildcard values (ANY, ANY_FACILITY_SET) to include a wildcard alongside specific values.
For example, requesting fareClasses: ["STANDARD_CLASS", "ANY"] generates separate recommendations for STANDARD_CLASS offers and for the cheapest across all fare classes. typesOfRecommendation is the only required field and must contain at least one value.
These two requests are functionally equivalent — omitting a field has the same effect as specifying its wildcard value:
Flexibility Levels
| Level | Refundable | Exchangeable | Description |
|---|---|---|---|
CHEAPEST | — | — | Lowest price regardless of flexibility |
NON_FLEXIBLE | No | No | Cannot be refunded or exchanged |
SEMI_FLEXIBLE | Either | Either | Either refundable or exchangeable, but not both |
FLEXIBLE | Yes | Yes | Both refundable and exchangeable |
Traveller Assignment
The possibleTravellerIds field in offersToBuy tells you who to assign to each purchase. It is a list of lists — each inner list represents one purchase, and the number of inner lists always equals numberToBuy.
Individual tickets — each inner list contains one traveller:
Buy offer-a twice: once for traveller-1, once for traveller-2.
Group tickets — the inner list contains all travellers sharing one ticket (e.g. a sleeper compartment):
Buy offer-b once for all four travellers together. The underlying product may have per-user-type constraints (e.g. minimum 1 adult, up to 3 children) — the recommendation system has already solved the assignment for you.
Mixed — the system may combine individual and group tickets when that is cheapest:
Summary:
| possibleTravellerIds | numberToBuy | Meaning |
|---|---|---|
[["A"], ["B"]] | 2 | Two individual purchases |
[["A", "B"]] | 1 | One group purchase |
[["A"], ["B", "C", "D"]] | 2 | One individual + one group |
You do not need to inspect the offer's travellerMapping to use the recommendation — possibleTravellerIds is the ready-made grouping.
Examples
How categorySpec dimensions combine
Each categorySpec dimension multiplies the number of recommendation slots. The API generates one recommendation per unique combination and filters out empty slots (when onlyIncludeRecommendationsWithOffersToBuy: true). Note that the same offer can appear in multiple recommendations — for example, the cheapest NON_FLEXIBLE offer may also be the overall CHEAPEST.
Example offers:
| Id | Price | Flexibility | DurationType | FacilitySet |
|---|---|---|---|---|
| NonFlexibleSeating | 10 | NON_FLEXIBLE | SINGLE_TRIP | SEATING |
| NonFlexibleCouchette | 20 | NON_FLEXIBLE | SINGLE_TRIP | COUCHETTE |
| SemiFlexibleSeating | 30 | SEMI_FLEXIBLE | SINGLE_TRIP | SEATING |
| SemiFlexibleSleeper | 40 | SEMI_FLEXIBLE | SINGLE_TRIP | SLEEPER |
| FlexibleCouchette | 50 | FLEXIBLE | SINGLE_TRIP | COUCHETTE |
| FlexibleSleeper | 60 | FLEXIBLE | SINGLE_TRIP | SLEEPER |
| WeekPass | 70 | NON_FLEXIBLE | WEEKLY_PASS | — |
| MonthPass | 80 | NON_FLEXIBLE | MONTHLY_PASS | — |
One dimension — flexibility only:
typesOfRecommendation: [CHEAPEST, NON_FLEXIBLE, SEMI_FLEXIBLE, FLEXIBLE]
| typeOfRecommendation | Offer recommended |
|---|---|
| CHEAPEST | NonFlexibleSeating |
| NON_FLEXIBLE | NonFlexibleSeating |
| SEMI_FLEXIBLE | SemiFlexibleSeating |
| FLEXIBLE | FlexibleCouchette |
4 slots requested, 4 filled. Each picks the cheapest offer matching that flexibility level.
Two dimensions — flexibility × facilitySet:
typesOfRecommendation: [CHEAPEST, NON_FLEXIBLE, SEMI_FLEXIBLE, FLEXIBLE]
facilitySet: [ANY_FACILITY_SET, SEATING, COUCHETTE, SLEEPER]
This creates 4 × 4 = 16 possible slots. Only 13 have matching offers:
| typeOfRecommendation | facilitySet | Offer recommended |
|---|---|---|
| CHEAPEST | ANY_FACILITY_SET | NonFlexibleSeating |
| CHEAPEST | SEATING | NonFlexibleSeating |
| CHEAPEST | COUCHETTE | NonFlexibleCouchette |
| CHEAPEST | SLEEPER | SemiFlexibleSleeper |
| NON_FLEXIBLE | ANY_FACILITY_SET | NonFlexibleSeating |
| NON_FLEXIBLE | SEATING | NonFlexibleSeating |
| NON_FLEXIBLE | COUCHETTE | NonFlexibleCouchette |
| SEMI_FLEXIBLE | ANY_FACILITY_SET | SemiFlexibleSeating |
| SEMI_FLEXIBLE | SEATING | SemiFlexibleSeating |
| SEMI_FLEXIBLE | SLEEPER | SemiFlexibleSleeper |
| FLEXIBLE | ANY_FACILITY_SET | FlexibleCouchette |
| FLEXIBLE | COUCHETTE | FlexibleCouchette |
| FLEXIBLE | SLEEPER | FlexibleSleeper |
Three combinations have no matching offers and are omitted: NON_FLEXIBLE + SLEEPER, SEMI_FLEXIBLE + COUCHETTE, and FLEXIBLE + SEATING. With onlyIncludeRecommendationsWithOffersToBuy: false, these would appear as empty recommendations.
Adding a third dimension (e.g., durationTypes: [SINGLE_TRIP, WEEKLY_PASS, MONTHLY_PASS]) would multiply the slots further: 4 × 4 × 3 = 48 possible slots, with only the non-empty ones returned.
Mixing flexibility levels (mixinOffersWithHigherFlexibility)
Scenario: User searches Oslo → Bodø with typesOfRecommendation: ["NON_FLEXIBLE"], but a FLEXIBLE offer is cheaper than the NON_FLEXIBLE alternative on one leg.
| Offer | Flexibility | Price | Leg |
|---|---|---|---|
| Offer A | NON_FLEXIBLE | kr 200 | Oslo → Trondheim |
| Offer B | FLEXIBLE | kr 150 | Oslo → Trondheim |
| Offer C | NON_FLEXIBLE | kr 300 | Trondheim → Bodø |
Result: NON_FLEXIBLE uses Offer A (kr 200) + Offer C (kr 300) = kr 500. Only NON_FLEXIBLE offers are considered.
Result: NON_FLEXIBLE uses the cheaper FLEXIBLE Offer B (kr 150) for leg 1 + NON_FLEXIBLE Offer C (kr 300) for leg 2 = kr 450. The user saves kr 50 and gets higher flexibility on one leg.
Key behaviours:
- Flexibility hierarchy (low → high):
NON_FLEXIBLE→SEMI_FLEXIBLE→FLEXIBLE - Higher-flexibility offers can replace lower on individual legs, but not vice versa
- At least one offer in
offersToBuymust match the requested flexibility level — if no NON_FLEXIBLE offers exist for any leg, no NON_FLEXIBLE recommendation is created. This prevents recommendations labelledNON_FLEXIBLEthat contain only flexible offers - The
CHEAPESTcategory is not affected by this flag
Mixing facility types (mixInOffersWithOtherFacilitySets)
Scenario: User searches Oslo → Bodø with facilitySet: ["SLEEPER"], but sleeper is only available for part of the journey.
| Offer | Facility | Leg |
|---|---|---|
| Offer A | SLEEPER | Oslo → Trondheim |
| Offer B | SEATING | Trondheim → Bodø |
Result: The SLEEPER category is empty because sleeper alone can't cover the full journey.
Result: SLEEPER recommendation combines sleeper (where available) with seating (where necessary).
Key behaviours:
- The algorithm tries the requested facility first, only mixing in others when necessary
- Recommendations consisting entirely of non-requested facilities are never created
- All other dimensions are preserved. For instance, a SEMI_FLEXIBLE recommendation will only look for offers also matching this dimension
Duration types
Scenario: Products are available as single trips, day passes, monthly passes and more. You want to present the cheapest option per duration type.
categorySpec:
typesOfRecommendation: [CHEAPEST]
durationTypes: [SINGLE_TRIP, DAY_PASS, MONTHLY_PASS]
| typeOfRecommendation | durationType | Offer recommended |
|---|---|---|
| CHEAPEST | SINGLE_TRIP | Single ticket |
| CHEAPEST | DAY_PASS | Day pass |
| CHEAPEST | MONTHLY_PASS | Monthly pass |
The API returns one recommendation per duration type, each pointing to the cheapest offer of that duration. This lets clients present options like "Single trip: kr 39", "Day pass: kr 109", "Monthly pass: kr 790" side by side.
You can combine durationTypes with other categorySpec dimensions. For example, adding typesOfRecommendation: [CHEAPEST, NON_FLEXIBLE, FLEXIBLE] generates up to 3 × 3 = 9 recommendations (one per flexibility level per duration type). Empty combinations (where no matching offers exist) are filtered out when onlyIncludeRecommendationsWithOffersToBuy: true.
Rail replacement filtering
Scenario: Service journey A is partially replaced by bus (BFT A), followed by another rail leg B.
| Leg | Type | Note |
|---|---|---|
| A | Rail | Remaining rail portion of original SJ |
| BFT A | Rail replacement | Bus replacing part of A |
| B | Rail | Separate service journey |
With removeSplitRailReplacementOffers: false:
Recommendations for: [A], [BFT A], [B], [A, BFT A], [BFT A, B], [A, BFT A, B]
With removeSplitRailReplacementOffers: true:
Recommendations for: [A], [B], [A, BFT A], [BFT A, B], [A, BFT A, B]
The standalone [BFT A] recommendation is removed because it contains the rail-replacement leg without its corresponding rail leg (A).
Key behaviour: When a service journey is partially replaced by bus (i.e., both rail and rail-replacement portions exist), any recommendation group that contains a rail-replacement leg without its corresponding rail leg is removed. If the entire service journey is replaced by bus, no filtering occurs.
Partial journey coverage
When a recommendation doesn't cover the entire journey (coversEntireJourney: false), the combineWithToCoverEntireJourney field lists other recommendations that can be combined to cover the full journey.
Scenario: A two-leg journey with two operators. No single ticket covers both legs.
How to use:
- Check if
coversEntireJourneyisfalse - If so, look at
combineWithToCoverEntireJourneyfor complementary recommendations - Select one item from the list — each item covers a specific geographical gap
- Purchase offers from all recommendations in the selected item's
recommendationsToCoverEntireJourney, together with the parent recommendation, to cover the full journey
Troubleshooting
Unexpected offers in recommendations
- Check if
mixinOffersWithHigherFlexibilityis enabled. This may place FLEXIBLE offers in NON_FLEXIBLE categories - Check if
mixInOffersWithOtherFacilitySetsis enabled. This may mix SEATING into SLEEPER categories - Verify
priceComparisonAlgorithm—BEFORE_SDRmay select different offers thanTOTAL_PRICE
Missing flexibility levels
Not all products support all flexibility options. If no FLEXIBLE offers exist for a route, no FLEXIBLE recommendation is returned.
NON_FLEXIBLE recommendation contains a mix of flexible and non-flexible offers
This happens when mixinOffersWithHigherFlexibility: true and a flexible offer is cheaper on one or more legs. This is expected behaviour; the system found a better deal by using a higher-flexibility offer where it's cheaper, while keeping at least one non-flexible offer in the recommendation. See example.
Large response size
Enable onlyIncludeRecommendedOffers: true to exclude offers not part of any recommendation.