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", "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 /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 day ranges. 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)
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 /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 } ] }}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).
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') +-- 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@updateTravelerDataComponents
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": "Día 1-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”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 market410- Offer is Active but within the 5-day booking lead time (error: "offer_expired")
GET /api/{market}/{lang}/checkout
Section titled “GET /api/{market}/{lang}/checkout”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 Type | Field | Description |
|---|---|---|
| hotel_selections | upsell_hotel_id | ID of the upsell hotel |
nights_start / nights_end | Tour day numbers for this stay | |
price_difference | Total price for the room upgrade (per room, not per person) | |
hotel_name | Name of the selected upsell hotel | |
location | City/location of the hotel | |
| activity_selections | activity_id | ID of the activity |
day_number | Tour day number | |
price | Per-person price | |
activity_name | Name of the activity | |
location | Destination/location of the activity | |
| transfer_selections | transfer_id | ID of the transfer |
day_number | Tour day number | |
price | Per-trip price (flat rate, not per person) | |
transfer_name | Name of the transfer service | |
location | Destination/location of the transfer |
PUT /api/{market}/{lang}/checkout/flights
Section titled “PUT /api/{market}/{lang}/checkout/flights”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_classmust 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:
| Field | Type | Description |
|---|---|---|
id | string | Unique key: {hotelId}-{startDay}-{endDay} |
hotelId | int | The actual hotel ID |
description | string|null | Hotel description text from SupplierHotel |
imageUrl | string|null | First hotel image URL (legacy single-image field, retained for backward compatibility) |
imageUrls | string[] | All hotel image URLs in admin-configured order. Empty array when the hotel has no images |
priceDifference | float|null | Extra cost in EUR, null if pricing unavailable |
guaranteedHotelName | string|null | Name of the included hotel being replaced |
nights.start / nights.end | int | Tour day numbers for this stay |
imageUrls is additive — imageUrl 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_typequery parameter if provided (1A,2A,3A,4A), otherwise falls back to session’sactual_room_type, then to2A - Converts to EUR via exchange rates
- Returns
nullif 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:
| Field | Type | Description |
|---|---|---|
included_activities | array | Activities included in the tour (names only) |
extra_activities | array | Optional add-on activities available for purchase |
substitution_activities | array | Upgrade replacement activities available for purchase |
image_url | string|null | First activity image URL (legacy single-image field, retained for backward compatibility) |
image_urls | string[] | All activity image URLs in admin-configured order. Empty array when the activity has no images |
price | float|null | Per-person price in EUR, null if unavailable |
image_urls is additive — image_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_personroom type for pricing - Converts to EUR via exchange rates
- Returns
nullif 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:
| Field | Type | Description |
|---|---|---|
included_transfers | array | Transfers included in the tour (names only) |
available_transfers | array | Upsell transfers available for purchase |
vehicle_type | string|null | Type of vehicle (e.g., luxury_sedan, minivan) |
duration_minutes | int|null | Estimated transfer duration |
price | float|null | Per-trip price in EUR, null if unavailable |
Price Calculation:
- Looks up per-trip rates from the transfer’s SupplierService
- Uses
per_triproom type for pricing (not per-person) - Converts to EUR via exchange rates
- Returns
nullif 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_idmust be a positive integerday_numbermust 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:
| Field | Type | Description |
|---|---|---|
offer_id | int | The offer ID |
offer_sku | string | The offer SKU code |
tour_name | string|null | Localized tour name from product translation |
product_url | string|null | Frontend product page path |
final_price | float | Total price for all travelers |
price_per_person | float | Price divided by pax count |
departure_date | string | Tour start date (Y-m-d format) |
return_date | string|null | Tour end date (Y-m-d format) |
pax_count | int | Number of travelers |
currency | object | Currency 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:
| Field | Type | Required | Description |
|---|---|---|---|
first_name | string | Yes | First name (max 100 chars) |
last_name | string | Yes | Last name (max 100 chars) |
nationality | string | Yes | ISO 2-letter country code |
passport_number | string | Yes | Passport number (max 50 chars) |
passport_expiry | date | Yes | Passport expiry (must be future date) |
birth_date | date | Yes | Date of birth (must be in past) |
phone | string | Yes | Phone number |
email | string | Yes | Valid email address |
Validation Rules:
- Number of travelers must match the offer’s
pax_count passport_numberandpassport_expiryare both required for each travelerpassport_expirymust be a future datebirth_datemust be a past date
Pricing:
- Traveler data does NOT affect pricing
extras_priceandtotal_priceremain 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
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);