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",
"country_name": "Sri Lanka",
"country_slug": "sri-lanka",
"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

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

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 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, and room types — 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. Offers where all allotment is consumed are silently excluded, so the configurator only shows bookable dates. 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" }
],
"labels": {
"information": "...",
"origin": "...",
"travelers": "...",
"rooms": "...",
"choose_date": "...",
"configure_trip": "...",
"help_text": "...",
"per_person": "..."
},
"currency": { "code": "EUR", "symbol": "" }
}
}

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

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/{id}/configurator -> ProductByMarketController@configurator
+-- 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}/contact -> CheckoutController@contact
+-- 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 /contact -> CheckoutController@updateClientData
+-- 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
Allotment Serviceapp/Services/Allotment/AllotmentService.phpBatch-check offer availability for configurator
Middlewareapp/Http/Middleware/ResolveMarket.phpValidate market, resolve locale
Product Resourceapp/Http/Resources/ProductByMarketResource.phpTransform product with translations
Configurator Resourceapp/Http/Resources/TripConfiguratorResource.phpTransform configurator wizard data
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:

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

FieldTypeDescription
idintSupplierActivity ID
namestringActivity name
descriptionstring|nullActivity description
tierstringOne of: extra, substitution
tier_labelstringHuman-readable tier label (e.g., “Extra”, “Substitution”)
image_urlstring|nullFull URL to first activity image
citystring|nullCity name formatted as “City, Country”
reasonsstring[]|nullWhy-visit reasons
amenitiesobject[]|nullEach 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": "Día 1-3",
"price_from": 154.0
}
]
}
]
}

Image fields:

FieldTypeDescription
imagestring|nullFirst hotel image (legacy single-image field, retained for backward compatibility)
imagesstring[]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

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. The offer must be bookable (Active + departure >= today + 5 days).

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": 1700.00,
"extras_price": 0.00,
"total_price": 1700.00,
"currency": {
"code": "EUR",
"symbol": ""
},
"pax_count": 2
}
}

Errors:

  • 404 - Offer not found, not bookable, or belongs to different market
  • 410 - Offer is Active but within the 5-day booking lead time (error: "offer_expired")

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.00,
"hotel_name": "Luxury Safari Lodge",
"location": "Nairobi"
}
],
"activity_selections": [
{
"activity_id": 5,
"day_number": 2,
"price": 50.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"
}
],
"client_data": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+34612345678"
},
"traveler_data": [...],
"base_price": 1700.00,
"extras_price": 340.00,
"total_price": 2040.00,
"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": 1700.00,
"extras_price": 1000.00,
"total_price": 2700.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": 1700.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": 1700.00,
"cabinClass": "BUSINESS",
"source_type": "live_search",
"has_flights": true,
"outbound_options": [
{
"id": "fare-abc-outbound-1",
"finalPrice": 2900.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": 1700.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",
"imageUrls": [
"http://example.com/hotel-1.jpg",
"http://example.com/hotel-2.jpg"
],
"isIncluded": false,
"priceDifference": 170.00,
"guaranteedHotelName": "Standard Safari Hotel"
}
]
}
}

Fields:

FieldTypeDescription
idstringUnique key: {hotelId}-{startDay}-{endDay}
hotelIdintThe actual hotel ID
descriptionstring|nullHotel description text from SupplierHotel
imageUrlstring|nullFirst hotel image URL (legacy single-image field, retained for backward compatibility)
imageUrlsstring[]All hotel image URLs in admin-configured order. Empty array when the hotel has no images
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

imageUrls is additiveimageUrl continues to point at the first image for existing clients. The endpoint uses camelCase throughout (matching imageUrl, hotelId, isIncluded).

Price Calculation:

  • Looks up per-night rates for both upsell and guaranteed hotels
  • Uses room_type query parameter if provided (1A, 2A, 3A, 4A), otherwise falls back to session’s actual_room_type, then to 2A
  • Converts to EUR via exchange rates
  • Returns null if pricing data is unavailable for the selected room type (hotel shown as non-selectable)

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": 1700.00,
"price_per_person": 850.00,
"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" }
],
"extra_activities": [
{
"id": 5,
"name": "Safari Quad Excursion",
"description": "Explore the savanna...",
"image_url": "http://example.com/activity.jpg",
"image_urls": [
"http://example.com/activity-1.jpg",
"http://example.com/activity-2.jpg"
],
"price": 50.00
}
],
"substitution_activities": []
}
]
}
}

Fields:

FieldTypeDescription
included_activitiesarrayActivities included in the tour (names only)
extra_activitiesarrayOptional add-on activities available for purchase
substitution_activitiesarrayUpgrade replacement activities available for purchase
image_urlstring|nullFirst activity image URL (legacy single-image field, retained for backward compatibility)
image_urlsstring[]All activity image URLs in admin-configured order. Empty array when the activity has no images
pricefloat|nullPer-person price in EUR, null if unavailable

image_urls is additiveimage_url continues to point at the first image for existing clients. The endpoint uses snake_case throughout (matching image_url, included_activities); the hotels endpoint uses camelCase. Each endpoint matches its own pre-existing naming convention rather than unifying across endpoints.

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": 1700.00,
"price_per_person": 850.00,
"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": 100.00, "transfer_name": "VIP Airport Pickup", "location": "Mombasa" }
],
"base_price": 1700.00,
"extras_price": 220.00,
"total_price": 1920.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": 1700.00,
"price_per_person": 850.00,
"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, not bookable, 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",
"passport_number": "AB1234567",
"passport_expiry": "2030-01-01",
"birth_date": "1990-05-15",
"phone": "+34612345678",
"email": "john@example.com"
},
{
"first_name": "Jane",
"last_name": "Doe",
"nationality": "ES",
"passport_number": "CD7654321",
"passport_expiry": "2031-06-15",
"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": [...],
"client_data": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+34612345678"
},
"traveler_data": [
{
"first_name": "John",
"last_name": "Doe",
"nationality": "ES",
"passport_number": "AB1234567",
"passport_expiry": "2030-01-01",
"birth_date": "1990-05-15",
"phone": "+34612345678",
"email": "john@example.com"
}
],
"base_price": 1700.00,
"extras_price": 0.00,
"total_price": 1700.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
passport_numberstringYesPassport number (max 50 chars)
passport_expirydateYesPassport expiry (must be future date)
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
  • passport_number and passport_expiry are both required for each traveler
  • 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);