Multi-Market API
Path-based routing API for market and language-specific product discovery with localized content.
Overview
Section titled “Overview”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
Quick Start
Section titled “Quick Start”Get Market Products (with language)
Section titled “Get Market Products (with language)”curl -X GET "https://api.example.com/api/es/ca/products" \ -H "Accept: application/json"Get Single Product by ID
Section titled “Get Single Product by ID”curl -X GET "https://api.example.com/api/es/es/products/10" \ -H "Accept: application/json"Get Single Product by Slug
Section titled “Get Single Product by Slug”curl -X GET "https://api.example.com/api/es/ca/products/slug/tour-de-barcelona" \ -H "Accept: application/json"Get Single Product by SKU
Section titled “Get Single Product by SKU”curl -X GET "https://api.example.com/api/es/ca/products/sku/ES-2CMB10-CA1" \ -H "Accept: application/json"Get Market Configuration
Section titled “Get Market Configuration”curl -X GET "https://api.example.com/api/de/config" \ -H "Accept: application/json"Endpoints
Section titled “Endpoints”GET /api/{market}/{lang}/products
Section titled “GET /api/{market}/{lang}/products”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 /api/{market}/{lang}/products/{id}
Section titled “GET /api/{market}/{lang}/products/{id}”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 (thedaysfield is a label likeNoches 1 a 3). Non-selection tiers include aprice_fromfield per hotel (per-person upgrade price in EUR, ornullwhen pricing is unavailable). See Accommodation Pricing belowitinerary[].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 overridesnot_included– Free-text “What’s not included” block resolved from translation with fallback to the templatecountries– Ordered list of every CMS country linked to the product ({name, slug, iso_code}per entry), in pivotsort_orderASC. 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 ofcountries[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
Product Detail Content Fallbacks
Section titled “Product Detail Content Fallbacks”ProductByMarketResource returns translated PDP content with two important fallback exceptions:
included_in_priceis resolved byProductByMarket::getIncludedInPrice(), mergingproduct_templates.included_in_pricewithproduct_by_market_translations.included_in_priceper keynot_includedis resolved byProductByMarket::getNotIncluded(), falling back to the template when the translation is empty
The six inclusion keys are always stable:
flightstransfersaccommodationgastronomyactivitiescancellation_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 /api/{market}/config
Section titled “GET /api/{market}/config”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.
Error Responses
Section titled “Error Responses”Market Not Found (404)
Section titled “Market Not Found (404)”{ "success": false, "error": "market_not_found", "message": "Market 'xyz' not found."}Market Inactive (403)
Section titled “Market Inactive (403)”{ "success": false, "error": "market_inactive", "message": "Market 'FR' is currently not available."}Language Not Supported (400)
Section titled “Language Not Supported (400)”{ "success": false, "error": "language_not_supported", "message": "Language 'de' is not supported by market 'ES'. Supported languages: es, ca"}Architecture
Section titled “Architecture”Route Structure
Section titled “Route Structure”routes/api.php | vRoute::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)Components
Section titled “Components”| 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 |
Frontend Integration (JavaScript)
Section titled “Frontend Integration (JavaScript)”// Fetch products for a market and languageasync 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 configurationasync function getMarketConfig(marketCode) { const response = await fetch(`/api/${marketCode}/config`); return response.json();}
// Usageconst config = await getMarketConfig('es');console.log('Supported languages:', config.data.supported_languages);Performance
Section titled “Performance”Eager Loading
Section titled “Eager Loading”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.poiand.service.rates.prices),cmsCountries.translations
Database Indexes
Section titled “Database Indexes”-- ProductByMarket queriesINDEX (market_id, status, sort_order)
-- Locale-based lookupsUNIQUE (product_template_id, market_id, locale)
-- Translation lookupsUNIQUE (locale, url_slug)Itinerary Activities
Section titled “Itinerary Activities”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())
Accommodation Pricing
Section titled “Accommodation Pricing”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.
How price_from is Calculated
Section titled “How price_from is Calculated”- Only hotels with supplement pricing (
is_supplement_pricing = trueon their SupplierService) return a price - Filters to the standard double room type (
2A) - Takes the lowest rate price across all rate periods
- Converts to EUR via
CurrencyExchangeRate::convert() - Applies display rounding via
Offer::roundToDisplayPrice()
When price_from is null
Section titled “When price_from is null”- Hotel service does not use supplement pricing (full room rate hotels cannot derive a per-person upgrade price)
- Hotel has no linked SupplierService
- No
2Aroom type rates exist - Currency conversion fails
Response Shape
Section titled “Response Shape”{ "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
Checkout Endpoints
Section titled “Checkout Endpoints”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.
Context Logging
Section titled “Context Logging”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);