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:

NameInTypeRequiredDescription
marketpathstringYesMarket code (case-insensitive, e.g., “es”, “US”, “De”)
langpathstringYesLanguage 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:

NameInTypeRequiredDescription
marketpathstringYesMarket code (case-insensitive)
langpathstringYesLanguage code
idpathintegerYesProductByMarket ID

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/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 price from active offers for a product. Use this to display “From $XXX” pricing.

Response: 200 OK

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

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

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 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
}
]
}
}

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

{
"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/slug/{slug} -> ProductByMarketController@showBySlug
+-- GET /products/sku/{sku} -> ProductByMarketController@showBySku
|
+-- Route::prefix('checkout')
+-- GET /{offerId}/flights -> CheckoutController@flights
+-- GET /{offerId}/hotels -> CheckoutController@hotels
+-- GET /{offerId}/activities -> CheckoutController@activities
+-- GET /{offerId}/transfers -> CheckoutController@transfers
+-- GET /{offerId}/travelers -> CheckoutController@travelers
+-- POST /{offerId}/business-flights -> CheckoutController@businessFlights
|
+-- Route::middleware('stateful.api') // Session-based
+-- POST /{offerId} -> CheckoutController@start
+-- GET / -> CheckoutController@show
+-- PUT /flights -> CheckoutController@updateFlightSelection
+-- PUT /hotels -> CheckoutController@updateHotelSelection
+-- PUT /activities -> CheckoutController@updateActivitySelection
+-- PUT /transfers -> CheckoutController@updateTransferSelection
+-- PUT /travelers -> CheckoutController@updateTravelerData
ComponentFilePurpose
Controllerapp/Http/Controllers/Api/ProductByMarketController.phpHandle product requests
Checkout Controllerapp/Http/Controllers/Api/CheckoutController.phpHandle checkout flow
Checkout Session Serviceapp/Services/Checkout/CheckoutSessionService.phpManage checkout session state
Checkout Hotel Serviceapp/Services/Checkout/CheckoutHotelService.phpExtract upsell hotel options
Hotel Price Calculatorapp/Services/Checkout/HotelPriceCalculatorService.phpCalculate upsell price differences
Checkout Transfer Serviceapp/Services/Checkout/CheckoutTransferService.phpExtract transfer options from itineraries
Transfer Price Calculatorapp/Services/Checkout/TransferPriceCalculatorService.phpCalculate per-trip transfer pricing
Middlewareapp/Http/Middleware/ResolveMarket.phpValidate market, resolve locale
Product Resourceapp/Http/Resources/ProductByMarketResource.phpTransform product with translations
Checkout Session Resourceapp/Http/Resources/CheckoutSessionResource.phpTransform checkout session
Market Resourceapp/Http/Resources/MarketResource.phpTransform 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:

->with(['productTemplate', 'translation', 'departureAirports'])
-- ProductByMarket queries
INDEX (market_id, status, sort_order)
-- Locale-based lookups
UNIQUE (product_template_id, market_id, locale)
-- Translation lookups
UNIQUE (locale, url_slug)

Endpoints for the checkout flow, including flight selection and session management.

POST /api/{market}/{lang}/checkout/{offerId}

Section titled “POST /api/{market}/{lang}/checkout/{offerId}”

Start a checkout session for an offer. Overwrites any existing checkout session.

Middleware: stateful.api (requires session cookies)

Response: 201 Created

{
"success": true,
"data": {
"offer_id": 123,
"started_at": "2026-01-21T10:30:00+00:00",
"flight_selection": null,
"base_price": 1699.00,
"extras_price": 0.00,
"total_price": 1699.00,
"currency": {
"code": "EUR",
"symbol": ""
},
"pax_count": 2
}
}

Errors:

  • 404 - Offer not found, inactive, or belongs to different market

Get the current checkout session state.

Middleware: stateful.api (requires session cookies)

Response: 200 OK - Same structure as POST response

Errors:

  • 404 - No active checkout session (error: "no_checkout_session")

Full Session Response Structure:

{
"success": true,
"data": {
"offer_id": 123,
"started_at": "2026-01-21T10:30:00+00:00",
"flight_selection": {
"cabin_class": "BUSINESS",
"business_extra_price_per_person": 500.00,
"outbound": { ... },
"inbound": { ... }
},
"hotel_selections": [
{
"upsell_hotel_id": 4,
"nights_start": 1,
"nights_end": 3,
"price_difference": 170.29,
"hotel_name": "Luxury Safari Lodge",
"location": "Nairobi"
}
],
"activity_selections": [
{
"activity_id": 5,
"day_number": 2,
"price": 45.00,
"activity_name": "Safari Quad Excursion",
"location": "Nairobi"
}
],
"transfer_selections": [
{
"transfer_id": 8,
"day_number": 1,
"price": 120.00,
"transfer_name": "Private Luxury Transfer",
"location": "Nairobi"
}
],
"traveler_data": [...],
"base_price": 1699.00,
"extras_price": 335.29,
"total_price": 2034.29,
"currency": { "code": "EUR", "symbol": "" },
"pax_count": 2
}
}

Selection Item Fields:

Selection TypeFieldDescription
hotel_selectionsupsell_hotel_idID of the upsell hotel
nights_start / nights_endTour day numbers for this stay
price_differenceTotal price for the room upgrade (per room, not per person)
hotel_nameName of the selected upsell hotel
locationCity/location of the hotel
activity_selectionsactivity_idID of the activity
day_numberTour day number
pricePer-person price
activity_nameName of the activity
locationDestination/location of the activity
transfer_selectionstransfer_idID of the transfer
day_numberTour day number
pricePer-trip price (flat rate, not per person)
transfer_nameName of the transfer service
locationDestination/location of the transfer

Update the flight selection in the current checkout session. Recalculates pricing for business class.

Middleware: stateful.api (requires session cookies)

Request Body:

{
"cabin_class": "BUSINESS",
"outbound": {
"departure_date": "2026-02-15",
"departure_time": "09:10",
"arrival_time": "15:15",
"departure_airport": "MAD",
"arrival_airport": "NBO",
"flight_numbers": ["EK123", "EK456"],
"airlines": ["EK"],
"stops": 1,
"stopover_airports": ["DXB"]
},
"inbound": {
"departure_date": "2026-02-22",
"departure_time": "08:00",
"arrival_time": "14:30",
"departure_airport": "NBO",
"arrival_airport": "MAD",
"flight_numbers": ["EK789"],
"airlines": ["EK"],
"stops": 0,
"stopover_airports": []
},
"business_extra_price_per_person": 500.00
}

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"started_at": "2026-01-21T10:30:00+00:00",
"flight_selection": { ... },
"base_price": 1699.00,
"extras_price": 1000.00,
"total_price": 2699.00,
"currency": { "code": "EUR", "symbol": "" },
"pax_count": 2
}
}

Price Calculation: extras_price = business_extra_price_per_person × pax_count

Errors:

  • 400 - No checkout session or validation error
  • Validation: cabin_class must be ECONOMY or BUSINESS, airport codes must be 3 characters

GET /api/{market}/{lang}/checkout/{offerId}/flights

Section titled “GET /api/{market}/{lang}/checkout/{offerId}/flights”

Get available flight options for an offer (from cache).

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"tour_name": "Safari Adventure",
"final_price": 1699.00,
"cabinClass": "ECONOMY",
"source_type": "cache",
"has_flights": true,
"outbound_options": [...],
"inbound_options": [...]
}
}

POST /api/{market}/{lang}/checkout/{offerId}/business-flights

Section titled “POST /api/{market}/{lang}/checkout/{offerId}/business-flights”

Search for business class flights (live API call). Rate limited to 10 requests/minute.

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"tour_name": "Safari Adventure",
"original_final_price": 1699.00,
"cabinClass": "BUSINESS",
"source_type": "live_search",
"has_flights": true,
"outbound_options": [
{
"id": "fare-abc-outbound-1",
"finalPrice": 2899.00,
"extraPrice": 1200.00,
"departureTime": "10:00",
"arrivalTime": "22:00",
"duration": "12h 00min",
"stops": 0
}
],
"inbound_options": [...]
}
}

Price Calculation: finalPrice = (flightPrice + landBasePrice) × (1 + margin/100)

The extraPrice is the difference between the business class final price and the original economy offer price.

GET /api/{market}/{lang}/checkout/{offerId}/hotels

Section titled “GET /api/{market}/{lang}/checkout/{offerId}/hotels”

Get upsell hotel options for an offer. Returns hotels that can upgrade the guaranteed (included) hotels.

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"offer_sku": "SAFARI-2026",
"tour_name": "Safari Adventure",
"final_price": 1699.00,
"departure_date": "2026-04-20",
"return_date": "2026-04-27",
"pax_count": 2,
"currency": {
"code": "EUR",
"symbol": ""
},
"hotels": [
{
"id": "4-1-2",
"hotelId": 4,
"name": "Luxury Safari Lodge",
"location": "Nairobi",
"nights": { "start": 1, "end": 2 },
"imageUrl": "http://example.com/hotel.jpg",
"isIncluded": false,
"priceDifference": 170.29,
"guaranteedHotelName": "Standard Safari Hotel"
}
]
}
}

Fields:

FieldTypeDescription
idstringUnique key: {hotelId}-{startDay}-{endDay}
hotelIdintThe actual hotel ID
priceDifferencefloat|nullExtra cost in EUR, null if pricing unavailable
guaranteedHotelNamestring|nullName of the included hotel being replaced
nights.start / nights.endintTour day numbers for this stay

Price Calculation:

  • Looks up per-night rates for both upsell and guaranteed hotels
  • Uses room type ‘2A’ (2 Adults)
  • Converts to EUR via exchange rates
  • Returns null if pricing data is unavailable

Grouping Logic:

  • Consecutive nights at the same guaranteed hotel are grouped together
  • Same upsell hotel can appear multiple times for different stays

GET /api/{market}/{lang}/checkout/{offerId}/activities

Section titled “GET /api/{market}/{lang}/checkout/{offerId}/activities”

Get activity options for an offer. Returns included and upsell activities organized by day.

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"offer_sku": "SAFARI-2026",
"tour_name": "Safari Adventure",
"final_price": 1699.00,
"price_per_person": 849.50,
"departure_date": "2026-04-20",
"return_date": "2026-04-27",
"pax_count": 2,
"currency": { "code": "EUR", "symbol": "" },
"days": [
{
"day_number": 1,
"destination": "Nairobi",
"included_activities": [
{ "name": "City Tour" }
],
"available_activities": [
{
"id": 5,
"name": "Safari Quad Excursion",
"description": "Explore the savanna...",
"image_url": "http://example.com/activity.jpg",
"price": 45.00
}
]
}
]
}
}

Fields:

FieldTypeDescription
included_activitiesarrayActivities included in the tour (names only)
available_activitiesarrayUpsell activities available for purchase
pricefloat|nullPer-person price in EUR, null if unavailable

Price Calculation:

  • Looks up per-person rates from the activity’s SupplierService
  • Uses per_person room type for pricing
  • Converts to EUR via exchange rates
  • Returns null if pricing data is unavailable

GET /api/{market}/{lang}/checkout/{offerId}/transfers

Section titled “GET /api/{market}/{lang}/checkout/{offerId}/transfers”

Get transfer options for an offer. Returns included and upsell transfers organized by day.

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"offer_sku": "SAFARI-2026",
"tour_name": "Safari Adventure",
"product_url": "/es/circuito/safari-adventure",
"final_price": 1699.00,
"price_per_person": 849.50,
"departure_date": "2026-04-20",
"return_date": "2026-04-27",
"pax_count": 2,
"currency": { "code": "EUR", "symbol": "" },
"days": [
{
"day_number": 1,
"destination": "Nairobi",
"included_transfers": [
{ "name": "Airport Transfer" }
],
"available_transfers": [
{
"id": 5,
"name": "Private Luxury Transfer",
"description": "Travel in comfort with a private vehicle...",
"image_url": "http://example.com/transfer.jpg",
"vehicle_type": "luxury_sedan",
"duration_minutes": 45,
"price": 120.00
}
]
}
]
}
}

Fields:

FieldTypeDescription
included_transfersarrayTransfers included in the tour (names only)
available_transfersarrayUpsell transfers available for purchase
vehicle_typestring|nullType of vehicle (e.g., luxury_sedan, minivan)
duration_minutesint|nullEstimated transfer duration
pricefloat|nullPer-trip price in EUR, null if unavailable

Price Calculation:

  • Looks up per-trip rates from the transfer’s SupplierService
  • Uses per_trip room type for pricing (not per-person)
  • Converts to EUR via exchange rates
  • Returns null if pricing data is unavailable

Filtering Logic:

  • Only days with upsell transfers are returned
  • Days with only included transfers are hidden (nothing to upgrade)

Source: backend/app/Http/Controllers/Api/CheckoutController.php:631

PUT /api/{market}/{lang}/checkout/transfers

Section titled “PUT /api/{market}/{lang}/checkout/transfers”

Update the transfer selections in the current checkout session.

Middleware: stateful.api (requires session cookies)

Request Body:

{
"transfer_selections": [
{
"transfer_id": 5,
"day_number": 1
},
{
"transfer_id": 8,
"day_number": 3
}
]
}

Security Note: Prices are NOT sent from the client - they are calculated server-side based on the transfer’s SupplierService rates.

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"started_at": "2026-01-21T10:30:00+00:00",
"flight_selection": { ... },
"hotel_selections": [...],
"activity_selections": [...],
"transfer_selections": [
{ "transfer_id": 5, "day_number": 1, "price": 120.00, "transfer_name": "Private Luxury Transfer", "location": "Nairobi" },
{ "transfer_id": 8, "day_number": 3, "price": 95.00, "transfer_name": "VIP Airport Pickup", "location": "Mombasa" }
],
"base_price": 1699.00,
"extras_price": 215.00,
"total_price": 1914.00,
"currency": { "code": "EUR", "symbol": "" },
"pax_count": 2
}
}

Price Calculation:

  • Transfer prices are per-trip (flat rate, NOT multiplied by pax_count)
  • Unlike activities which are per-person
  • extras_price = flight_extras + hotel_extras + activity_extras + transfer_extras

Validation:

  • transfer_id must be a positive integer
  • day_number must be at least 1
  • Transfer must exist as an upsell option for the specified day

Errors:

  • 400 - No checkout session (error: "no_checkout_session")
  • 400 - Validation error (missing fields, invalid values)
  • 422 - Transfer not available for specified day

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

GET /api/{market}/{lang}/checkout/{offerId}/travelers

Section titled “GET /api/{market}/{lang}/checkout/{offerId}/travelers”

Get offer summary for the traveler data entry page. This is simpler than other checkout endpoints since there are no upsell options - only offer metadata.

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"offer_sku": "SAFARI-2026",
"tour_name": "Safari Adventure",
"product_url": "/es/circuito/safari-adventure",
"final_price": 1699.00,
"price_per_person": 849.50,
"departure_date": "2026-04-20",
"return_date": "2026-04-27",
"pax_count": 2,
"currency": {
"code": "EUR",
"symbol": ""
}
}
}

Fields:

FieldTypeDescription
offer_idintThe offer ID
offer_skustringThe offer SKU code
tour_namestring|nullLocalized tour name from product translation
product_urlstring|nullFrontend product page path
final_pricefloatTotal price for all travelers
price_per_personfloatPrice divided by pax count
departure_datestringTour start date (Y-m-d format)
return_datestring|nullTour end date (Y-m-d format)
pax_countintNumber of travelers
currencyobjectCurrency code and symbol

Errors:

  • 404 - Offer not found, inactive, or belongs to different market

Source: backend/app/Http/Controllers/Api/CheckoutController.php:754

PUT /api/{market}/{lang}/checkout/travelers

Section titled “PUT /api/{market}/{lang}/checkout/travelers”

Update traveler personal data in the current checkout session. Does NOT affect pricing.

Middleware: stateful.api (requires session cookies)

Request Body:

{
"travelers": [
{
"first_name": "John",
"last_name": "Doe",
"nationality": "ES",
"dni": "12345678A",
"passport_number": null,
"passport_expiry": null,
"birth_date": "1990-05-15",
"phone": "+34612345678",
"email": "john@example.com"
},
{
"first_name": "Jane",
"last_name": "Doe",
"nationality": "ES",
"dni": null,
"passport_number": "AB1234567",
"passport_expiry": "2030-01-01",
"birth_date": "1992-08-20",
"phone": "+34612345679",
"email": "jane@example.com"
}
]
}

Response: 200 OK

{
"success": true,
"data": {
"offer_id": 123,
"started_at": "2026-01-21T10:30:00+00:00",
"flight_selection": { ... },
"hotel_selections": [...],
"activity_selections": [...],
"transfer_selections": [...],
"traveler_data": [
{
"first_name": "John",
"last_name": "Doe",
"nationality": "ES",
"dni": "12345678A",
"passport_number": null,
"passport_expiry": null,
"birth_date": "1990-05-15",
"phone": "+34612345678",
"email": "john@example.com"
}
],
"base_price": 1699.00,
"extras_price": 0.00,
"total_price": 1699.00,
"currency": { "code": "EUR", "symbol": "" },
"pax_count": 2
}
}

Traveler Fields:

FieldTypeRequiredDescription
first_namestringYesFirst name (max 100 chars)
last_namestringYesLast name (max 100 chars)
nationalitystringYesISO 2-letter country code
dnistringConditionalNational ID document
passport_numberstringConditionalPassport number
passport_expirydateConditionalPassport expiry (required if passport_number provided)
birth_datedateYesDate of birth (must be in past)
phonestringYesPhone number
emailstringYesValid email address

Validation Rules:

  • Number of travelers must match the offer’s pax_count
  • At least one of dni or passport_number must be provided for each traveler
  • passport_expiry is required when passport_number is provided
  • passport_expiry must be a future date
  • birth_date must be a past date

Pricing:

  • Traveler data does NOT affect pricing
  • extras_price and total_price remain unchanged

Errors:

  • 400 - No checkout session (error: "no_checkout_session")
  • 400 - Validation error (pax count mismatch, missing fields, invalid dates)

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

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);