Skip to content

Multi-Market API

Path-based routing API for market and language-specific product discovery with localized content.

The Multi-Market API provides endpoints for accessing products and configuration data scoped to specific geographical markets and languages. All product endpoints use path-based routing with both market code and language code in the URL path.

Base URL Pattern: /api/{market}/{lang}/...

Key Features:

  • Case-insensitive market and language codes (ES, es, Es all work)
  • Language-specific product content via locale
  • Content from ProductByMarketTranslation (no fallback to template)
  • Active product filtering with locale matching
  • Market configuration with supported languages
  • Structured error responses for invalid/inactive markets
Terminal window
curl -X GET "https://api.example.com/api/es/ca/products" \
-H "Accept: application/json"
Terminal window
curl -X GET "https://api.example.com/api/es/es/products/10" \
-H "Accept: application/json"
Terminal window
curl -X GET "https://api.example.com/api/es/ca/products/slug/tour-de-barcelona" \
-H "Accept: application/json"
Terminal window
curl -X GET "https://api.example.com/api/es/ca/products/sku/ES-2CMB10-CA1" \
-H "Accept: application/json"
Terminal window
curl -X GET "https://api.example.com/api/de/config" \
-H "Accept: application/json"

List all active products for a market and language with localized content.

Parameters:

Name In Type Required Description
market path string Yes Market code (case-insensitive, e.g., “es”, “US”, “De”)
lang path string Yes Language code (e.g., “en”, “es”, “ca”)

Response: 200 OK

{
"data": [
{
"id": 10,
"product_template_id": 5,
"sku": "ES-5CMB10-CA1",
"locale": "ca_ES",
"status": "active",
"sort_order": 0,
"trip_duration_days": 10,
"title": "Tour de Sri Lanka",
"subtitle": "Descobreix l'illa maragda",
"short_description": "Una aventura increible...",
"long_description": "Descripció completa del tour...",
"highlights": ["Sigiriya", "Kandy", "Yala"],
"destination_info": "Sri Lanka",
"url_slug": "tour-sri-lanka",
"hero_image": "https://cdn.example.com/images/sri-lanka.jpg",
"departure_airports": [
{
"iata_code": "BCN",
"name": "Barcelona-El Prat Airport",
"city": "Barcelona",
"country": "Spain"
}
]
}
],
"meta": {
"market": "ES",
"locale": "ca_ES"
}
}

Get a single product by its ProductByMarket ID.

Parameters:

Name In Type Required Description
market path string Yes Market code (case-insensitive)
lang path string Yes Language code
id path integer Yes ProductByMarket ID

Detail-only data: Detail endpoints (by ID, slug, SKU, and preview) return additional data not present on the listing endpoint:

  • accommodations – Hotel assignments grouped by tier (selection, luxury, grand luxury) with night ranges (the days field is a label like Noches 1 a 3). Non-selection tiers include a price_from field per hotel (per-person upgrade price in EUR, or null when pricing is unavailable). See Accommodation Pricing below
  • itinerary[].days[].activities – Per-day activities from the SupplierTour, grouped by tier (see Activity Shape below)
  • included_in_price – Fixed-key object with the six customer-facing “What’s included” categories, resolved per key from template defaults plus locale overrides
  • not_included – Free-text “What’s not included” block resolved from translation with fallback to the template
  • countries – Ordered list of every CMS country linked to the product ({name, slug, iso_code} per entry), in pivot sort_order ASC. Multi-country tours surface every visited country here so the frontend can render headers like "Camboya y Vietnam"
  • country_name, country_slug, country_code, region_name – Scalar mirrors of countries[0] plus the first country’s region. Provided for single-value consumers (breadcrumb link, GA4 categories) that don’t iterate the array

GET /api/{market}/{lang}/products/slug/{slug}

Section titled “GET /api/{market}/{lang}/products/slug/{slug}”

Get a product by its URL slug. First checks translated slug, then falls back to base product template slug.

GET /api/{market}/{lang}/products/slug/{slug}/canonical

Section titled “GET /api/{market}/{lang}/products/slug/{slug}/canonical”

Resolve a retired/old product slug to the current url_slug so the frontend can issue a 301 to the canonical URL instead of 404ing (#2161). Looks up a ProductSlugAlias on old_slug + resolved locale whose ProductByMarket is active in this market, then returns that product’s current translated url_slug.

Response: 200 OK

{
"data": { "canonical": "aventura-en-kenia-completo" }
}

Returns 404 with error: "alias_not_found" when no alias matches, or when the alias already resolves to the same slug.

Source: backend/app/Http/Controllers/Api/ProductByMarketController.php (canonicalSlug())

GET /api/{market}/{lang}/products/sku/{sku}

Section titled “GET /api/{market}/{lang}/products/sku/{sku}”

Get a product by its market-specific SKU code.

SKU Format: <MARKET>-<TEMPLATEID><IATA><DAYS>-<LANG><VERSION>

  • Example: ES-5CMB10-CA1 = Spain market, template 5, CMB airport, 10 days, Catalan, version 1

GET /api/{market}/{lang}/products/{id}/leading-price

Section titled “GET /api/{market}/{lang}/products/{id}/leading-price”

Get the minimum per-person price (marketing_price_per_pax) from bookable offers for a product. Use this to display “From $XXX /person” pricing. Only considers offers that are Active AND have departure dates at least 5 days in the future (see Bookability).

Response: 200 OK

{
"data": {
"product_by_market_id": 23,
"price_from": "1249",
"currency": "EUR",
"offers_count": 5
}
}

When no bookable offers exist, price_from is null and offers_count is 0.

GET /api/{market}/{lang}/products/{id}/configurator

Section titled “GET /api/{market}/{lang}/products/{id}/configurator”

Get all data needed to render the trip configurator wizard on the PDP (Product Detail Page). Returns available departure dates grouped by month, departure airports, room types, and the bookable traveler counts (available_pax) — with non-bookable and sold-out offers already removed.

Bookability filtering: Only offers passing the bookable() scope are included — Active status AND departure date at least 5 days in the future (see Bookability). This prevents near-departure offers from appearing in the calendar.

Availability filtering: The controller batch-checks allotment for all bookable offers via AllotmentService::getOffersAvailability() before building the response. Availability is evaluated per departure date: an offer is excluded when any (rate, service_date) it would consume has zero remaining slots. The returned array<int, bool> is keyed by offer id. The payment-time check remains as a safety net for race conditions.

Response: 200 OK

{
"data": {
"product_id": 1,
"departure_airports": [
{ "iata_code": "MAD", "city": "Madrid" }
],
"months": [
{
"year": 2026,
"month": 3,
"label": "MARZO",
"min_price": "1808",
"min_price_per_person": "904",
"dates": [
{
"offer_id": 42,
"date": "2026-03-07",
"day_label": "Sábado, 7",
"price": "1808",
"price_per_person": "904",
"available": true,
"departure_airport": "MAD"
}
]
}
],
"room_types": [
{ "code": "2A", "label": "2 Adultos" }
],
"available_large_group_pax": [7],
"available_pax": [2, 4, 6, 7],
"labels": {
"information": "...",
"origin": "...",
"travelers": "...",
"rooms": "...",
"choose_date": "...",
"configure_trip": "...",
"help_text": "...",
"per_person": "..."
},
"currency": { "code": "EUR", "symbol": "" }
}
}

Note: labels.help_text interpolates the market’s customer_service_phone into the translated configurator.help_text string. When the market has no phone configured, it falls back to +34 647 374 374. See backend/app/Http/Resources/TripConfiguratorResource.php:95.

Note: available_pax is the authoritative list of bookable traveler counts and the single source the frontend uses to build the traveler dropdown. A count appears only when the tour can actually price it: for counts 1-4 at least one room split must yield a land price (via the same calculateLandPrice() checkout runs), and counts 5-8 come from available_large_group_pax. This prevents an unpriceable party size (e.g. a package with no 1A tier) from being offered and then failing checkout with 422 room_type_unavailable. See TripConfiguratorResource::buildAvailablePax().

Note: available_large_group_pax lists the eligible large-group (5-8) traveler counts for the tour. A count N appears only when the package rate has a {N}A per-person price row that produces a valid land price on the offer’s departure date; the list is empty for non-package tours. It is folded into available_pax above and kept as a separate field for the large-group-specific frontend logic (these tiers have no customer-facing room distribution). See TripConfiguratorResource::getLargeGroupPaxCounts().

Source: backend/app/Http/Controllers/Api/ProductByMarketController.php (configurator()), backend/app/Http/Resources/TripConfiguratorResource.php, backend/app/Services/Allotment/AllotmentService.php

ProductByMarketResource returns translated PDP content with two important fallback exceptions:

  • included_in_price is resolved by ProductByMarket::getIncludedInPrice(), merging product_templates.included_in_price with product_by_market_translations.included_in_price per key
  • not_included is resolved by ProductByMarket::getNotIncluded(), falling back to the template when the translation is empty

The six inclusion keys are always stable:

  • flights
  • transfers
  • accommodation
  • gastronomy
  • activities
  • cancellation_insurance

GET /api/{market}/{lang}/products/{id}/preview

Section titled “GET /api/{market}/{lang}/products/{id}/preview”

Preview a product regardless of status (draft/inactive). Requires a signed URL.

Middleware: signed (Laravel signed URL validation)

Usage: Generate signed URLs via ProductByMarket::generatePreviewUrl(). The frontend accepts this as a base64-encoded preview query parameter.

Token Expiration: 60 minutes (configurable)

Source: backend/app/Http/Controllers/Api/ProductByMarketController.php

GET /api/{market}/{lang}/products/{id}/configurator/preview

Section titled “GET /api/{market}/{lang}/products/{id}/configurator/preview”

Signed preview variant of the configurator endpoint. Lets admins exercise the trip configurator on draft/inactive products via the “Preview Draft” admin action. Returns the same shape as /products/{id}/configurator, but relaxes only the product-level status filter — offers themselves still respect their own status and bookability.

Middleware: signed:relative (Laravel signed URL validation)

Source: backend/app/Http/Controllers/Api/ProductByMarketController.php (configuratorPreview())

Get market configuration including locale, currency, timezone, supported languages, and departure airports.

Response: 200 OK

{
"data": {
"code": "ES",
"name": "Spain",
"locale": "es_ES",
"supported_languages": ["es", "ca"],
"tour_path_slugs": { "es": "circuito", "ca": "circuit" },
"currency": {
"code": "EUR",
"name": "Euro"
},
"timezone": "Europe/Madrid",
"departure_airports": [
{
"iata_code": "MAD",
"name": "Adolfo Suarez Madrid-Barajas Airport",
"city": "Madrid",
"is_primary": true
}
],
"customer_service_phone": "+34 91 555 12 34"
}
}

Note: tour_path_slugs provides localized URL path segments for product pages (e.g., /es/circuito/product-slug for Spanish, /es/ca/circuit/product-slug for Catalan).

customer_service_phone is the market’s public customer-service phone number, editable in the admin (Markets -> Management -> Customer Contact). The frontend consumes it as the single source of truth for the navbar phone CTA, the checkout help widget, the configurator footer, and the trip configurator’s “different dates” help message. Returns null when the market hasn’t been configured; the frontend falls back to its own hardcoded defaults in that case.

{
"success": false,
"error": "market_not_found",
"message": "Market 'xyz' not found."
}
{
"success": false,
"error": "market_inactive",
"message": "Market 'FR' is currently not available."
}
{
"success": false,
"error": "language_not_supported",
"message": "Language 'de' is not supported by market 'ES'. Supported languages: es, ca"
}
routes/api.php
|
v
Route::prefix('{market}')
->middleware(['market'])
->whereAlpha('market')
|
+-- GET /config -> ProductByMarketController@config
|
+-- Route::prefix('{lang}')->whereAlpha('lang')
|
+-- GET /products -> ProductByMarketController@index
+-- GET /products/{id} -> ProductByMarketController@show
+-- GET /products/{id}/leading-price -> ProductByMarketController@leadingPrice
+-- GET /products/{id}/configurator -> ProductByMarketController@configurator
+-- GET /products/slug/{slug} -> ProductByMarketController@showBySlug
+-- GET /products/sku/{sku} -> ProductByMarketController@showBySku
|
+-- Route::prefix('checkout')
+-- ... (full checkout route tree documented in the
[Checkout API](/backend/api/checkout/) page)
Component File Purpose
Controller app/Http/Controllers/Api/ProductByMarketController.php Handle product requests
Checkout Controller app/Http/Controllers/Api/CheckoutController.php Handle checkout flow
Checkout Session Service app/Services/Checkout/CheckoutSessionService.php Manage checkout session state
Checkout Hotel Service app/Services/Checkout/CheckoutHotelService.php Extract upsell hotel options
Hotel Price Calculator app/Services/Checkout/HotelPriceCalculatorService.php Calculate upsell price differences
Checkout Transfer Service app/Services/Checkout/CheckoutTransferService.php Extract transfer options from itineraries
Transfer Price Calculator app/Services/Checkout/TransferPriceCalculatorService.php Calculate per-trip transfer pricing
Allotment Service app/Services/Allotment/AllotmentService.php Batch-check offer availability for configurator
Middleware app/Http/Middleware/ResolveMarket.php Validate market, resolve locale
Product Resource app/Http/Resources/ProductByMarketResource.php Transform product with translations
Configurator Resource app/Http/Resources/TripConfiguratorResource.php Transform configurator wizard data
Checkout Session Resource app/Http/Resources/CheckoutSessionResource.php Transform checkout session
Market Resource app/Http/Resources/MarketResource.php Transform market config
// Fetch products for a market and language
async function getMarketProducts(marketCode, langCode) {
const response = await fetch(`/api/${marketCode}/${langCode}/products`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
return response.json();
}
// Get market configuration
async function getMarketConfig(marketCode) {
const response = await fetch(`/api/${marketCode}/config`);
return response.json();
}
// Usage
const config = await getMarketConfig('es');
console.log('Supported languages:', config.data.supported_languages);

All product queries use eager loading to prevent N+1 queries:

  • Listing: productTemplate, translation, flightConfigs.airport
  • Detail: Adds supplierTours.itineraries.{selectionHotel,luxuryHotel,grandLuxuryHotel,extraActivities,substitutionActivities} (each with .poi and .service.rates.prices), cmsCountries.translations
-- ProductByMarket queries
INDEX (market_id, status, sort_order)
-- Locale-based lookups
UNIQUE (product_template_id, market_id, locale)
-- Translation lookups
UNIQUE (locale, url_slug)

Detail endpoints merge per-day activities from the linked SupplierTour into each itinerary day entry. Only extra and substitution tier activities are included – included activities are omitted because their information is already part of the itinerary day description text. Activities are not present on the listing endpoint (index) to avoid N+1 queries. This data is display-only (no pricing) – for purchasable activities with prices, see the checkout activities endpoint.

Each itinerary day’s activities array contains objects with this shape:

Field Type Description
id int SupplierActivity ID
name string Activity name
description string|null Activity description
tier string One of: extra, substitution
tier_label string Human-readable tier label (e.g., “Extra”, “Substitution”)
image_url string|null Full URL to first activity image
city string|null City name formatted as “City, Country”
reasons string[]|null Why-visit reasons
amenities object[]|null Each with icon, label, description

Activity Tiers (product detail):

  • Extra – Optional paid activities (prices shown only in checkout context)
  • Substitution – Alternative activities that replace an included one

Note: Included activities still exist in the data model and are used by checkout, contracts, and offer pricing. They are only excluded from the product detail API response to avoid redundancy with the itinerary day text.

Source: backend/app/Http/Resources/ProductByMarketResource.php (mergeActivitiesIntoItinerary(), buildActivityMap())

Detail endpoints include a price_from field on hotel items within luxury and grand_luxury tiers. This gives the product page a per-person upgrade price reference for each hotel, without requiring offer/date context.

Selection tier items never include price_from – they are the base hotels included in the tour price.

  1. Only hotels with supplement pricing (is_supplement_pricing = true on their SupplierService) return a price
  2. Filters to the standard double room type (2A)
  3. Takes the lowest rate price across all rate periods
  4. Converts to EUR via CurrencyExchangeRate::convert()
  5. Applies display rounding via Offer::roundToDisplayPrice()
  • Hotel service does not use supplement pricing (full room rate hotels cannot derive a per-person upgrade price)
  • Hotel has no linked SupplierService
  • No 2A room type rates exist
  • Currency conversion fails
{
"accommodations": [
{
"tier": "luxury",
"label": "Exclusivo",
"items": [
{
"name": "Fairmont The Norfolk",
"city": "Nairobi",
"image": "...",
"images": ["...", "..."],
"days": "Noches 1 a 3",
"price_from": 154.0
}
]
}
]
}

Image fields:

Field Type Description
image string|null First hotel image (legacy single-image field, retained for backward compatibility)
images string[] All hotel images in admin-configured order. Empty array when the hotel has no images

The images array is additive – existing consumers of image continue to work unchanged. Order matches the admin-set order in the Filament SupplierHotel.images upload. The static-fallback path (when no SupplierTour is linked) emits an empty images array.

The same shape is returned by the admin preview endpoint GET /api/supplier-tours/{id}/preview via SupplierTourPreviewPayloadBuilder, for parity with the public resource.

Source: backend/app/Http/Resources/ProductByMarketResource.php (buildTierHotels(), getHotelLowestPrice()), backend/app/Services/SupplierTourPreviewPayloadBuilder.php

The checkout flow (session start, flight/hotel/activity/transfer/insurance selection, quotation branch, contact/traveler steps, and payment) is documented in full in the Checkout API. The endpoints share this API’s /api/{market}/{lang}/checkout/... route group and market middleware; see that page for the authoritative request/response contracts, pricing rules, and error codes.

The ResolveMarket middleware automatically adds context to all logs:

Context::add('market_code', $market->code);
Context::add('market_id', $market->id);
Context::add('locale', $locale);