Skip to content

Checkout API

Multi-step checkout flow for booking tour packages. Handles session management, selection updates, payment processing, and booking finalization.

The checkout API provides a stateful multi-step flow:

  1. Start session - Initialize checkout with offer data
  2. Select flights - Choose outbound/inbound flights (economy or business)
  3. Select hotels - Optional hotel upgrades
  4. Select activities - Optional activity add-ons
  5. Select transfers - Optional transfer upgrades, plus optional travel insurance
  6. Enter contact - Booking contact person (client data)
  7. Enter travelers - Passenger personal data
  8. Payment - Process deposit via Stripe
  9. Confirmation - View booking summary

Quotation branch (non-standard occupancy): Bookings where actual_pax_count != 2 OR a non-default room configuration is selected (session['is_non_standard_pax'] === true) cannot be self-served, because suppliers only grant allotment for the 2A baseline. These stop after the Main Contact step (PUT /checkout/contact): the booking transitions to quotation_requested, a Volāre agent confirms availability with the DMC and moves it to quotation_confirmed, then invites the customer back via a continuation link to finish at the Travelers + Payment steps. See Quotation Flow.

Session storage: Server-side PHP session (checkout key)

Selection persistence: The frontend preserves user selections (flights, hotels, activities, transfers) when navigating back through checkout steps, so users don’t lose their choices.

Funnel tracking: A checkout-flow booking is created when checkout starts (initial status: checkout), tracking which step each user reaches. See Checkout Funnel Tracking for details.

Base URL: /api/{market}/{lang}/checkout

Locale-aware content: Hotel, activity, and transfer descriptions, names, and amenities are returned in the market’s locale when translations exist. The ResolveMarket middleware resolves the locale from the URL path and checkout services use $entity->translated('field', $locale) with automatic fallback to the source language. See Supplier Entity Translations for details.

Offer summary fields for GA4: Every option-fetching response (flight options, business flights, hotels, activities, transfers, contact, travelers) includes pbm_sku, country_name, region_name, and country_code on the offer summary. These are the geographic and identity fields the frontend GA4 funnel needs to keep item_id, item_category2/3, item_list_name, and location_id consistent across begin_checkoutpurchase. Resolved via ProductByMarket::getPrimaryCountryName(), getPrimaryRegionName(), and getPrimaryCountryIsoCode(). See GA4 Ecommerce Tracking.

POST /checkout/{offerId} (start - always creates fresh session)
├─► POST /checkout/{offerId}/search-economy-flights
│ (live re-pricing during loading screen)
├─► GET /checkout (view session, auto-refreshes price)
├─► PUT /checkout/flights
├─► PUT /checkout/hotels
├─► PUT /checkout/activities
├─► PUT /checkout/transfers
├─► PUT /checkout/insurance-selection
├─► PUT /checkout/contact
│ │
│ ├─ standard 2A: advance to Travelers
│ └─ non-standard pax: requestQuotation()
│ → status quotation_requested (STOP)
│ → agent confirms DMC → quotation_confirmed
│ → customer returns via continuation link
│ → POST /checkout/{offerId}/resume (rehydrate session)
├─► PUT /checkout/travelers
├─► POST /checkout/payment/intent
├─► POST /checkout/payment/confirm
└─► GET /checkout/confirmation/{reference}

Start checkout session for an offer. Always creates a fresh session, discarding any previous one. Creates a checkout booking via BookingFunnelService::createDraft() (status checkout) for funnel tracking and returns the booking_id in the response.

Bookability check: The offer must pass the bookable() scope (Active status AND departure date >= today + 5 days). If the offer is Active but too close to departure, returns 410 Gone with error: "offer_expired". If the offer doesn’t exist at all, returns 404. See Bookability for details.

Request (optional):

{
"travelers": 4,
"room_configuration": [
{ "type": "2A", "count": 2 }
]
}

Request validation: See StartCheckoutRequest.

  • travelers (sometimes|integer|min:1|max:8): party size, defaults to 2 when omitted (NOT the offer’s pax_count). Counts 5-8 are only supported on package tours, priced as a single per-person occupancy tier ({N}A) with no customer-facing room distribution
  • room_configuration (sometimes|array|min:1): the room split, each entry { type, count } where type is an adult-only room code in 1A8A and count is 14. The total pax summed across rooms must not exceed 8

actual_pax_count and actual_room_type are not request inputs — the controller derives them from travelers / room_configuration and exposes them on the session and response (child-inclusive labels like "2A+1CH" can appear on the derived actual_room_type).

If the requested room type / pax tier cannot be priced, returns 422 Unprocessable Entity with error room_type_unavailable. Two paths converge on this same response:

  • Non-package tours: a per-hotel pre-check verifies the room type exists in supplier_service_rate_prices for the tour’s base hotels before the session starts.
  • Package tours (tours with linked package services via SupplierTour::hasPackageServices()): the per-hotel pre-check is skipped — the package’s flat base pricing already covers hotel inclusions, so per-selection-hotel room-type rows are not required. Instead, the land-price recalculation in CheckoutSessionService::start() throws RoomTypeUnavailableException (mapped to the same 422 room_type_unavailable) when the package rate has no price for the requested {N}A tier. This keeps an unpriceable party size from surfacing as an uncaught 500.

Pricing for non-standard pax (not 2A):

When actual_pax_count differs from the offer’s 2A default:

  1. Land price is recalculated using AutoOfferGeneratorService::calculateLandPrice() for the actual room type. If no rate covers that room type (e.g. a package tour with no 1A tier), the request returns 422 room_type_unavailable instead of erroring
  2. Flight price scales linearly (offer’s per-person flight price × actual_pax_count)
  3. Per-pax marketing rounding applied: rawPerPax → roundToMarketingPrice → multiply by paxCount
  4. Session stores is_non_standard_pax: true to control price refresh behavior

Response: 201 Created

{
"success": true,
"data": {
"offer_id": 123,
"booking_id": 456,
"booking_reference": "BK-A1B2C3D4",
"started_at": "2026-02-15T10:30:00Z",
"base_price": 2499.00,
"extras_price": 0.00,
"total_price": 2499.00,
"pax_count": 2,
"actual_pax_count": 3,
"actual_room_type": "2A+1CH",
"is_non_standard_pax": true,
"currency": { "code": "EUR", "symbol": "EUR" }
}
}

After receiving the response, the TripConfigurator stores booking_id in Astro.session via POST /api/checkout/session for SSR step tracking.

Get current session state. Returns 404 if no active session.

Auto-refreshes base_price and total_price if the offer’s final_price has changed since the session was created (e.g., after a live economy flight search updated the price via EconomyFlightCacheUpdateService).

Important: For non-standard pax sessions (is_non_standard_pax: true), the base_price is NOT refreshed from the offer’s 2A-based final_price. The non-standard quote is computed once in CheckoutSessionService::start() and stored on the booking; the live economy/business search never re-derives it. The booking’s stored quote is the single source of truth.

Return the offer’s available economy flight options for the FlightSelector, including the canonical identity of the option the backend is currently bound to.

Response shape (relevant fields):

{
"success": true,
"data": {
"offer_id": 123,
"source_type": "cache",
"has_flights": true,
"outbound_options": [
{ "signature": "IB1581+LP2387", "...": "..." }
],
"inbound_options": [
{ "signature": "LP2344", "...": "..." }
],
"cabinClass": "ECONOMY",
"baggageIncluded": { "checkedBag": true, "weight": 23 },
"bound_flight_signature": "IB1581+LP2387|LP2344"
}
}
Field Type Description
bound_flight_signature string Root-level canonical identity of the offer’s currently-bound round-trip option. Composed as outbound|inbound from the bound cache row’s PRIMARY itinerary per leg (OfferFlightSignature::forBoundOffer). Empty string when the offer has no resolvable international binding (draft, manual flight, missing cache row) — the FE then falls back to “recommended” / first option.
outbound_options[].signature string Per-leg OPERATOR+FLIGHTNUMBER (joined by + for connections), built from the live FlightSegment DTOs via OfferFlightSignature::fromFlightSegments.
inbound_options[].signature string Same shape as outbound_options[].signature.

FE reconciler invariant. The frontend mirrors the user’s UI selection to bound_flight_signature unless the user has explicitly picked a different option AND that option still exists in the refreshed list. This prevents the worst-duration card from staying “Seleccionado” after a background live-search auto-upgrade rebinds the offer to a better flight.

Source: backend/app/Http/Resources/CheckoutFlightOptionsResource.php, backend/app/Services/Flights/Domain/OfferFlightSignature.php

POST /checkout/{offerId}/search-economy-flights

Section titled “POST /checkout/{offerId}/search-economy-flights”

Trigger a live economy flight search during the checkout loading screen. Calls the Aerticket API to re-price the offer’s international and domestic flights before the user proceeds.

Rate limit: 5 requests per minute.

Behavior:

  1. Extracts route info (departure, destination, dates) from the offer’s cached flight segments
  2. Applies admin-configured search filters from Settings page (airlines, max stops, fare sources, baggage, latest arrival time) with forced ECONOMY cabin class
  3. Detects multi-city vs round-trip based on whether inbound departure differs from outbound destination
  4. Searches domestic flight legs (one-way per leg) alongside international – domestic failures are graceful with cached prices as fallback
  5. Selects the best fare under FlightRankingPolicy: hard filters first (baggage on both international legs, max stops, layover, return departure, departure window, nights), then the best available stop-count tier, then total round-trip duration, price, stops, and outbound-arrival tiebreaks. Economy live search orders /search results first and allows at most one /search-upsell lookup for the best unresolved baggage candidate. “Seleccionado” is the live policy winner; the previous bound fare is not pinned if it is worse or violates policy. The frontend FlightSelector exposes a client-side “Ordenar por” toggle defaulting to duration (preserves API order) with a price option that re-sorts by ascending totalExtraPrice
  6. Updates DynamicFlightCache with fresh results (top 5 fares) for both international and domestic legs
  7. Re-links the offer’s OfferFlight records and updates individual leg prices
  8. Recalculates the offer’s final_price (international + domestic total)
  9. Stores economy search metadata in session for building the per-leg breakdown during flight selection: international_fare_price (from the selected fare’s totalPrice), domestic legs enriched with searched_price (live API price when available), and international route info

Standard 2A vs non-standard pax: Steps 6–8 (cache update, offer re-link, final_price recalculation via updateCacheAndOfferPrice()) run only when actual_pax_count === 2. For non-standard occupancy the offer and its shared 2A cache are never mutated — EconomyFlightSearchService::buildInternationalSelection() picks a display fare only (skipping refreshSignatureMatchedRows and bindFirstDisplayFare). The session itself is still repriced live: the all-legs live flight total (selected fare totalPrice + domestic searched_price, falling back to the cached 2-pax total scaled to the session’s pax count) is fed to CheckoutSessionService::repriceNonStandardPaxFromLiveSearch(), which rebuilds flight_base_price and base_price on the actual-pax axis (same margin, margin basis and marketing rounding as start()) using the session’s persisted land_price/insurance_price components. The price the customer commits to is therefore always the live one — flight drift between offer pricing and checkout is charged to the client, never absorbed into margin. The reprice is non-fatal: on failure the session keeps its stored price. Exception: sessions restored from a quotation snapshot carry quoted_price_locked = true and are never silently repriced — the customer pays exactly what the agent confirmed.

Response (success):

{
"success": true,
"data": {
"has_flights": true,
"price_changed": true,
"original_price": 2499.00,
"new_price": 2549.00
}
}

Note: For non-standard pax, original_price/new_price report the session’s base_price before/after the live reprice (the offer’s final_price is never touched). Quotation-restored sessions (quoted_price_locked) always report price_changed: false.

Response (fallback — API timeout or error):

{
"success": true,
"data": {
"has_flights": true,
"fallback": true,
"price_changed": false
}
}

On fallback, the checkout continues with existing cached flight data. The cache update failure is non-fatal.

Search for business class flight options during checkout. Performs a LIVE search via the Aerticket API and calculates final prices including margin.

Requirements: Must have an active checkout session (protected by stateful.api middleware).

Behavior:

  1. Extracts route from offer’s cached flight segments (including inbound departure/destination for multi-city detection)
  2. Applies admin-configured search filters from Settings page via SettingsService::buildCheckoutSearchOptions() (airlines, max stops, fare sources, baggage) with forced BUSINESS cabin class
  3. Detects multi-city vs round-trip: multi-city when inbound departure differs from outbound destination
  4. Performs live Aerticket search using session’s actual_pax_count
  5. Searches domestic flight legs (one-way BUSINESS per leg) alongside international – if no business fares found for a domestic leg, falls back to cached economy price (already available from the economy search that runs first)
  6. Domestic total is added to every business fare card’s “+x€” extra price calculation. The BUSINESS tab itself no longer surfaces an aggregate price delta on the cabin-class switcher (operator rule #7: business price is shown per-card on the selector, not as a tab badge). Fares are ranked by FlightRankingPolicy (same hard filters, then duration → price → stops → arrival; no Pareto prune) and capped to the top 10. Per-card durations are computed via LegDuration::forLeg using IANA airport timezones — the previous segment-sum fallback was incorrect for long-haul wallclock segments and ignored layovers
  7. Business flights are fetched on demand when the user switches to the BUSINESS tab. The previous background prefetch (useEffect in CheckoutPage.tsx that fired on every checkout open) has been removed so the live Aerticket call only happens when the customer actually opts into the upgrade
  8. Skips incomplete fares that do not contain both outbound and inbound legs
  9. Returns fareId on each outbound/inbound leg option so frontend can pair legs by fare identity (not by array index)
  10. Stores per-leg search metadata in session (legs_metadata) for later use when user selects a business fare – includes domestic leg details, fare-to-price mapping, fare details keyed by fare ID (fare_price, total_extra_price, outbound/inbound flight numbers), and international route info. The total_extra_price per fare is used during flight selection to compute business_extra_price_per_person server-side (see PUT /checkout/flights).
  11. For non-standard pax, passes session context (actual_pax_count, actual_room_type, base_price) to service
  12. Calculates prices:
  • Standard 2-pax: Uses offer’s land_base_price and final_price directly
  • Non-standard pax: Recalculates land price for actual room type, applies per-pax marketing rounding

Response:

{
"success": true,
"data": {
"outbound_options": [...],
"inbound_options": [...],
"cabinClass": "BUSINESS",
"has_flights": true,
"source_type": "live_search",
"original_final_price": 2499.00,
"pax_count": 3,
"apihubflowid": "..."
}
}

Source: backend/app/Services/Checkout/BusinessFlightSearchService.php

Update flight selection.

Request:

{
"cabin_class": "ECONOMY",
"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": [{"code": "EK", "name": "Emirates"}],
"stops": 1,
"stopover_airports": ["DXB"],
"arrivalDayOffset": 1
},
"inbound": { /* same structure */ },
"business_extra_price_per_person": null,
"fare_id": null
}

Validation: See UpdateFlightSelectionRequest for full rules. Key constraint: fare_id is required when cabin_class=BUSINESS (returns 400 if missing).

Server-side price validation (business class):

When cabin_class=BUSINESS, the backend validates and computes the upgrade price server-side instead of trusting the client-provided business_extra_price_per_person. This prevents price manipulation (e.g., sending business_extra_price_per_person: 0 to get a free upgrade).

The validation chain in enrichWithLegsBreakdown():

  1. Rejects if business_search_metadata is missing from the session (no prior business search)
  2. Rejects if fare_id is empty
  3. Rejects if fare_id is not found in the stored fare_details metadata (stale or unknown fare)
  4. Rejects if total_extra_price is missing from the fare details
  5. Overrides business_extra_price_per_person with: total_extra_price / paxCount (server-computed)

All rejections return 422 Unprocessable Entity with descriptive validation messages prompting the user to search again.

The fare_id also canonicalizes outbound/inbound flight numbers from server-side search metadata, preventing mismatches when multiple fares share the same outbound leg.

Source: backend/app/Services/Checkout/CheckoutSessionService.php (enrichWithLegsBreakdown)

Server-side price & identity validation (economy class):

Economy pricing is likewise driven by the selected fare_id, mirroring the business anti-fraud model. Each economy search stores per-fare details in economy_search_metadata.fare_details (fareId => { fare_price, total_extra_price, outbound_flight_numbers, inbound_flight_numbers }), built from the exact itinerary rendered for that fare.

When cabin_class=ECONOMY, buildEconomyLegs():

  1. Looks up the submitted fare_id in fare_details.
  2. Prices the international leg from that fare’s fare_price and stores a signed economy_extra_price_per_person = total_extra_price / paxCount (negative for a cheaper alternative, positive for a pricier one). Without this, the delta was display-only and lost on the next step, so the customer was charged the base price rather than the one shown.
  3. Canonicalizes the outbound/inbound flight numbers from the matched fare, so price and booked-flight identity always come from the same server-verified fare — a client cannot pair a cheap fare_id with a pricier flight.

Difference vs business: economy does not require an explicit fare pick. If fare_id is absent or unknown (or the search returned no fares), it falls back to the recommended fare with a zero delta — it does not return 422.

Source: backend/app/Services/Checkout/CheckoutSessionService.php (buildEconomyLegs, calculateEconomyFlightDelta); EconomyFlightSearchService.php (searchEconomyFlightsWithMetadata / transformResults).

Get available hotel options for an offer, grouped by time period. Returns all 3 tiers: Selection (base, included in price), Luxury (upgrade), and Grand Luxury (upgrade).

Session-aware: This endpoint runs under stateful.api (StartSession) so it can read the checkout session’s room_configuration and price upgrades for the whole group — matching what PUT /checkout/hotels charges. The frontend fetchHotelOptions sends credentials: 'include' so the session cookie reaches it.

Response shape per hotel entry:

Field Type Description
id string Unique key: {hotelId}-{tier}-{startDay}-{endDay}
hotelId number|null Supplier hotel ID (null for package fallback entries)
name string Hotel name (or package name for legacy fallback)
location string|null City (null for package fallback entries)
tier string selection, luxury, or grand_luxury
tier_label string Human-readable tier label
nights object { start: number, end: number }
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 (including package fallback entries, which always return [] since they have no underlying SupplierHotel)
description string|null Hotel description text from SupplierHotel
isIncluded boolean true for Selection tier, false for upgrades
priceDifference number|null Upgrade price vs Selection tier (full stay), null for Selection. Includes the fixed 20% extras margin — the converted supplier cost is margined via Offer::priceFromCost(cost, EXTRAS_MARGIN, offer basis) (÷ 0.80 on sale-basis offers, × 1.20 on legacy cost-basis) before rounding
selectionHotelName string|null Base hotel name (for upgrades), null for Selection

imageUrls is additiveimageUrl still returns the first image for existing clients. This endpoint uses camelCase throughout (matching imageUrl, hotelId, isIncluded); the sibling /checkout/{offerId}/activities endpoint uses snake_case (image_url, image_urls). Each endpoint matches its own pre-existing naming convention rather than unifying across endpoints.

Hotels are grouped by consecutive nights at the same Selection hotel. Each group contains one Selection entry and optional Luxury/Grand Luxury upgrade entries. Selection-only days (no upgrades available) are included as standalone entries.

Package tours: When a package tour has selection hotels assigned in the admin, the API returns actual hotel names, images, and locations — same as non-package tours. Only legacy package tours without selection hotels fall back to using the package service name as a placeholder.

Room type pricing: Hotel upgrade price differences are priced across the session’s full room_configuration (e.g. [{type: "2A", count: 2}] for a 4-pax group split into two 2A rooms). Each upgrade card’s priceDifference is the whole-group supplement — the sum of upgradePrice(type) × count for every room (HotelPriceCalculatorService::calculateUpgradePriceForConfiguration()), so multi-room and 5-8 pax groups now see the correct full price instead of a single room’s. If any room type has no rate, the whole upgrade returns priceDifference: null and is shown as unavailable (non-selectable in the frontend). This mirrors what PUT /checkout/hotels charges.

The session’s room data is only trusted when it belongs to this offer (session['offer_id'] === {offerId}), mirroring the guard in transfers(). A stale session for a different offer (old tab, bookmark, browser back/forward) does not price this offer’s upgrades; it falls through to the room_type query-param fallback below. This preserves display/submit parity even across offers.

Query parameters:

Param Type Description
room_type string (optional) Fallback only — used when there is no checkout session for this offer (absent, or a session belonging to a different offer). Prices a single room of that type (count 1). When a session for this offer exists, its room_configuration is the source of truth and this param is ignored. The old 1A/2A/3A/4A allowlist has been removed.

Source: backend/app/Services/Checkout/CheckoutHotelService.php

Update hotel upgrade selections. Supports Luxury and Grand Luxury tier upgrades.

Request:

{
"advance": true,
"hotel_selections": [
{
"upgrade_hotel_id": 6,
"nights_start": 1,
"nights_end": 3
}
]
}

Notes:

  • advance (optional, default true): When true, calls advanceStep to record funnel progression. Frontend sends false on backward navigation so the funnel step is not advanced.
  • upgrade_hotel_id refers to a Luxury or Grand Luxury tier hotel
  • price_difference is calculated server-side (not accepted from client), priced across the full room_configuration (sum of upgradePrice(type) × count per room, via HotelPriceCalculatorService::calculateUpgradePriceForConfiguration()) so it matches the display endpoint. It includes the fixed 20% extras margin (supplier cost margined per room via Offer::priceFromCost(cost, EXTRAS_MARGIN, offer basis), then rounded). Legacy sessions with no room_configuration fall back to a single room of actual_room_type.
  • See Service Tiers for tier details

Update activity upsell selections. Prices calculated server-side. Each activity price is per person and includes the fixed 20% extras margin (converted supplier cost margined via Offer::priceFromCost(cost, EXTRAS_MARGIN, offer basis) (÷ 0.80 on sale-basis offers, × 1.20 on legacy cost-basis), then rounded to the nearest €10).

Request:

{
"advance": true,
"activity_selections": [
{ "activity_id": 5, "day_number": 2, "participants": 2 }
]
}
  • advance (optional, default true): When false, skips advanceStep (used on backward navigation).
  • participants (optional, integer ≥ 1): how many of the party join this activity. Absent means the full party — the behavior of sessions and frontends predating the field. The value is clamped server-side to [1, actual_pax_count] and returned on each stored selection, so session restore round-trips it. The activity’s charge is price × participants (no per-traveler identity and no adult/child distinction — activities are priced with the per_person rate only).

Interval-overlap validation: The customer’s selected extras must not time-overlap each other on the same day. Each selected activity occupies the interval [start_time, start_time + duration_hours) (both fields are exposed per upgrade on the GET /checkout/{offerId}/activities response). Two selections conflict only when their intervals strictly overlap — touching endpoints (one ends exactly when the next starts) never conflict, and no buffer is required between them. Activities without a parseable start_time (“anytime”) or without a positive duration never participate and can always be added; a full-day extra naturally overlaps everything else that day. The check compares selected extras against each other only — included activities are never involved, as the supplier guarantees they fit (see Time Slots).

Enforced by ActivityScheduleOverlapValidator, on by default. The frontend opts out via validate_schedule: false only on backward navigation (the “previous” button), so going back is never blocked by a 400. Committing saves (next, and save-and-return when editing from the summary) and any direct API call validate by default — note this is independent of advance, since save-and-return sends advance: false but must still validate. On conflict the endpoint returns 400 with error: "activity_time_conflict" and an errors array of per-day messages. The frontend also blocks overlapping selections client-side; this is the defense-in-depth backstop.

  • validate_schedule (optional, default true): When false, skips the overlap check. The frontend sends false only on backward navigation so a partially-overlapping draft can be parked without a 400.

Update transfer selections. Prices calculated server-side.

Request:

{
"advance": true,
"transfer_selections": [
{ "transfer_id": 1, "day_number": 1 }
]
}
  • advance (optional, default true): When false, skips advanceStep (used on backward navigation).

Persist (or clear) the chosen travel insurance policy in the checkout session. Offered on the transfers (extras) step. Send insurance as null (or omit it) to deselect. Recalculates extras_price/total_price and returns the updated CheckoutSessionResource.

Insurance is priced by a live Intermundial quote re-run server-side on every change (the client-sent retail_price is discarded). The quoted retail_price is the whole-party total and is added to the totals as-is — except for the included base policy, which adds nothing and whose amount is never exposed: the session response returns its retail_price as null.

Request:

{
"insurance": {
"supplier_insurance_id": 1,
"policy_id_dyn": 24319,
"price_list_params_values_1_id_dyn": 1,
"price_list_params_values_2_id_dyn": 1,
"base_prices_id_dyn": 5,
"effect_date": "2026-06-15",
"unsuscribe_date": "2026-06-25",
"retail_price": 89.0,
"product_name": "Multitravel",
"currency": "EUR"
}
}

Validation: See UpdateInsuranceSelectionRequest.

The dedicated insurance integration (policies, live quote, post-payment emission) is documented in Travel Insurance (Intermundial).

Get offer summary for the client contact data entry page. Returns the same offer metadata as the travelers endpoint.

Response: 200 OK — Same structure as GET /checkout/{offerId}/travelers.

Errors:

  • 404 - Offer not found, not bookable (expired or within lead time), or belongs to different market

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

Store client contact data (the booking contact person). Separate from traveler data — the client is the person responsible for the booking, not necessarily a traveler.

The saved client_data is also reused by the frontend traveler step to prefill traveler #1 (name, email, phone) when the user advances to /travelers. This is a UX convenience only: traveler consent and passport / nationality fields must still be entered explicitly on the traveler step.

Request:

{
"client": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+34612345678",
"marketing_consent": true
}
}

Validation: See StoreClientDataRequest for rules. No ASCII-only restriction on names (client data is not sent to airline APIs).

Field Type Required Description
first_name string Yes First name (max 100 chars)
last_name string No Last name (optional; min 2 chars when provided, max 100)
email string Yes Valid email (max 255 chars)
phone string Yes Phone number (max 30 chars)
marketing_consent boolean No Optional email marketing opt-in (Confianza Online). Persisted on the client’s marketing_consent as { "email": <bool> }, merged into any existing per-channel consent by BookingFunnelService::captureContact(). Absent leaves the stored consent untouched; it never blocks the step.

Pricing: Client data does NOT affect pricing.

Quotation branch: When the session has is_non_standard_pax === true, this endpoint does NOT advance to Travelers. Instead it calls BookingFunnelService::requestQuotation(), which snapshots the full session to booking.checkout_session_data, extends expires_at by booking.expiration.quotation_days (30), transitions Checkout → QuotationRequested, and fires QuotationRequestedNotification. The funnel pointer stays at Main Contact. See Quotation Flow.

Errors:

  • 400 - No checkout session (error: "no_checkout_session")
  • 400 - Validation error

Rehydrate a confirmed quotation’s checkout session from its stored snapshot, so a non-standard-pax customer can finish checkout days later with the originally quoted price intact (no fresh flight search). Throttled 10/min, under the stateful.api group.

Request:

{ "token": "<booking.resumption_token>" }

Finds the booking by resumption_token + offer_id, requires status QuotationConfirmed, then restores the session via CheckoutSessionService::restore() (re-binding booking_id) and returns the session resource.

Errors:

Code Error Description
400 missing_token No token in request body
404 invalid_token No booking matches the token + offer
409 not_resumable Booking is not in quotation_confirmed (e.g. still pending, or already paid)
410 snapshot_missing Booking has no checkout_session_data snapshot to restore

Source: backend/app/Http/Controllers/Api/CheckoutController.php (resumeQuotation)

Store traveler personal data. Count must match the session’s actual_pax_count (falling back to pax_count for legacy sessions).

Age-composition validation: Each traveler’s DOB-derived category (adult / child / infant, via AgeCategory::fromDateOfBirth) must match the booking’s composition. The expected composition is summed across the full room_configuration — each room type’s composition (2A → 2 adults, 1A1CH → 1 adult + 1 child, etc.) multiplied by its count — so a multi-room booking expects the whole group rather than a single room (fixes false rejections like “expected 2 adult, but 4 provided”). Legacy sessions with no room_configuration fall back to parsing actual_room_type. A mismatch returns 400 with error: "validation_error". Source: StoreTravelerDataRequest (expectedPaxComposition / parseRoomTypeComposition).

Name validation: Names are validated using the same PassengerValidationRules as the Filament admin panel, enforcing AerTicket API requirements to prevent booking failures:

  • ASCII letters and spaces only (/^[a-zA-Z\s]+$/) – no accents, numbers, or symbols
  • No + character
  • Last name minimum 2 characters
  • Each name maximum 57 characters
  • Combined first + last name: 2-57 characters total

Validation source: StoreTravelerDataRequest reuses PassengerValidationRules::firstName() and ::lastName().

Request:

{
"travelers": [
{
"first_name": "John",
"last_name": "Doe",
"nationality": "ES",
"birth_date": "1990-05-15",
"phone": "+34612345678",
"email": "john@example.com",
"passport_number": "AB1234567",
"passport_expiry": "2030-01-01"
}
]
}

Get booking data for confirmation page after successful payment.

duration_days is the door-to-door trip length derived from the bound international flight via Offer::getTravelDates() — the same source as the trip page and booking emails — counting from the outbound departure day to the return arrival-home day, inclusive. duration_nights is duration_days - 1. When no flight is bound, both fall back to the product’s land-tour duration.

Response:

{
"success": true,
"data": {
"booking_reference": "VOL-2026-00001",
"tour_name": "Aventura en Japon",
"duration_days": 10,
"duration_nights": 9,
"travelers": 2,
"hero_image": "https://cdn.example.com/japan.jpg",
"trip_url": "https://..."
}
}

Prices update automatically as selections change:

total_price = base_price + extras_price
extras_price = sum of:
+ business_extra_price_per_person * actual_pax_count (offer margin, on basis)
+ economy_extra_price_per_person * actual_pax_count (signed fare delta, offer margin on basis)
+ hotel_price_differences (priced across the session's full room_configuration; fixed 20% extras margin)
+ activity_prices * actual_pax_count (fixed 20% extras margin)
+ transfer_prices (fixed 20% extras margin, like hotels/activities)
+ insurance_retail_price (whole-party total; paid upgrades carry the session margin)

Margin invariant (margin on the whole sale): every cost component is priced as Offer::priceFromCost(cost, margin, basis), so the total is always Σ(costᵢ ÷ (1 − m/100)) = (Σ costᵢ) ÷ (1 − m/100) — i.e. the configured margin is a real share of the entire selling price, not just the package. There are no pass-through (zero-margin) lines: the package uses the offer’s margin; hotels, activities, and transfers use the fixed Offer::EXTRAS_MARGIN; business and economy flight upgrades use the offer’s margin (a signed economy fare delta is marked up on the offer’s basis so a pricier/cheaper flight keeps the booking at margin-on-sale); paid insurance upgrades use the session margin via CheckoutInsuranceService::clientPriceFor(). The included insurance policy is the sole exception (retail as-is — it is bundled free into the package, contributing 0 to the total). Post-purchase flight drift (actual fare differs from the priced one at ticketing) is a separate, deliberate risk model — charged/absorbed by Volāre, never re-margined — so a booking’s realized P&L can still dip below the configured margin after ticketing. See Extras Pricing & Rounding and Margin Basis.

Margin basis in the session: start() freezes both the offer’s margin and its margin_basis (cost | sale) into the session, and both travel into the booking snapshot at finalization. Every reprice path (checkout resume, non-standard-pax live reprice, insurance quotes) reads the basis back with MarginBasis::fromSnapshot(); sessions or snapshots persisted before the field existed resolve to the legacy cost basis, so a checkout started before the margin-on-sale switch completes at exactly the price the customer saw.

Security: All upgrade prices are calculated server-side. Activity and transfer prices come from the database. Business class business_extra_price_per_person is computed from server-stored total_extra_price metadata (see PUT /checkout/flights); economy applies the same model — the selected fare’s signed total_extra_price drives economy_extra_price_per_person, and the fare’s flight numbers are canonicalized so price and flight identity cannot diverge. Client-provided prices are ignored for all extras except insurance, whose retail_price comes from a live Intermundial quote and is added as-is (see Travel Insurance).

Variable pax count: The actual_pax_count from the session is used for flight and activity extras calculation. Hotel upgrade pricing sums each room’s supplement across the session’s full room_configuration, so a group split into multiple rooms is charged for every room.

Activity rate date: Activity price and cost rate lookups use the date the activity actually happens (departure_date + day_number − 1), not the departure date, so an activity later in the trip that crosses a seasonal rate boundary is priced from the correct rate window.

Supplier costs captured at selection: Alongside the customer price, the session records the raw pre-margin supplier cost of each selection — supplier_cost on hotel selections (HotelPriceCalculatorService::calculateUpgradeCostForConfiguration()) and per-person supplier_unit_cost on activity selections (ActivityPriceCalculatorService::getActivityCost()). The session also persists the pre-margin land_price and insurance_price components set at start(). These purchase-time figures feed the Arkana booking cost breakdown (Bookings — Cost Breakdown) and are immutable: a later offer reprice never rewrites a historical booking’s P&L.

Bookings with actual_pax_count != 2 OR a non-default room configuration set session['is_non_standard_pax'] === true. Suppliers only grant allotment for the 2A baseline, so these cannot be paid self-service and follow a quotation-first flow:

  1. Customer completes checkout up to Main Contact (PUT /checkout/contact).
  2. BookingFunnelService::requestQuotation() snapshots the session to booking.checkout_session_data, extends expires_at (quotation_days, 30), and transitions Checkout → QuotationRequested. The funnel does NOT advance to Travelers.
  3. QuotationRequestedNotification alerts the reservations inbox (mail) and sales agents/admins (Arkana bell). A Volāre agent confirms availability with the DMC.
  4. Agent moves the booking to QuotationConfirmed and sends CheckoutContinuationNotification to the customer, whose CTA links to Booking::getCheckoutContinuationUrl() ({frontend}/{market}/checkout/{offerId}/travelers?resume={resumption_token}&sig={per-send signature}).
  5. The frontend calls POST /checkout/{offerId}/resume, which rehydrates the snapshotted session. The customer finishes at Travelers + Payment with the originally quoted price. Loading the page with ?resume= also records a CheckoutLinkOpened touchpoint via the internal link-open endpoint, surfaced in the booking’s Preview Views section.

Payment gate: PaymentController::createIntent only promotes bookings in Draft | Checkout | QuotationConfirmed. A QuotationRequested booking cannot be paid until an agent confirms.

Session resource fields (CheckoutSessionResource): exposes is_non_standard_pax and requires_quotation booleans, display fields product_title, product_country, departure_date, departure_airport, volare_phone, plus computed price_per_person and base_price_per_person (the frontend renders per-person figures from these instead of dividing the total).

Related notifications: See Quotation Notifications.

See Payment Gateway System for full payment documentation.

Key points:

  1. createIntent computes the deposit as raw_flight_cost + margin% × total_price (capped at total) via PaymentCalculatorService::depositFromCheckout(), using the session’s total_price (including extras). See Deposit/Balance Payments
  2. Both payment endpoints accept an optional electronic_invoice boolean (Confianza Online opt-in from the summary step, persisted as bookings.electronic_invoice_accepted): createIntent records it before branching by gateway — Redsys redirects the customer away right after, so intent is its only capture point — while confirm re-sends the value chosen at actual submission (the checkbox stays interactive while the Stripe form is mounted), superseding the intent-time capture. Absent keeps the paper default (false)
  3. confirm creates/updates Client from client_data (not traveler_data[0]), then calls BookingFinalizationService::finalizeFromCheckoutSession()
  4. Booking stores payment_method_code and payment_gateway_code for operational tracking
  5. Status is routed via PaymentService::updateBookingPaymentStatus():
    • Flight booking (ECONOMY / BUSINESS) -> pending_flight_booking
    • Land-only booking -> pending_land_confirmation
  6. Session cleanup: After successful payment (PaymentStatus::Succeeded), the checkout session is cleared via CheckoutSessionService::clear() to prevent session reuse
  7. After payment, redirect to /confirmation/{reference}

After a successful checkout payment, flight bookings move to pending_flight_booking and are dispatched manually by admins from the booking view page. Each flight leg is booked independently with its own FlightBooking record.

Trigger condition: booking status is pending_flight_booking (or retry from flight_booking_failed).

Process:

  1. Admin clicks Book All Flights / Book Flight (or Retry All Flights / Retry) in Filament
  2. Booking transitions to flight_booking_in_progress
  3. Action computes which legs are still unbooked (skips legs with existing FlightBooking records)
  4. Dispatches one CreateCheckoutFlightBookingJob per unbooked leg to aerticket-bookings queue (each with $legIndex parameter)
  5. Each job independently:
    • International legs: Re-searches via EconomyFlightSearchService::searchInternationalOnly() (economy) or BusinessFlightSearchService::searchBusinessFlightsRaw() (business), matches round-trip by flight numbers + price
    • Domestic legs: Searches one-way via AerticketSearchService, matches by flight numbers + price
  6. Verifies availability with Aerticket
  7. Creates booking via AerticketBookService
  8. Creates FlightBooking record linked to Booking via booking_id FK with leg_index and flight_type
  9. Dispatches AerticketRetrieveBookingJob to fetch PNR details
  10. All-legs-booked check: Only transitions to flights_confirmed when ALL legs have FlightBooking records. Handles concurrent transition race with try/catch on InvalidArgumentException.
  • Partial success -> admin notification “Leg N booked (M/N legs)”
  • All legs booked -> flights_confirmed
  • Failure -> flight_booking_failed (with error metadata including leg_index)

Business class specifics:

  • Business fares are bundled (1 fare = both legs, itinerary index 1 only), unlike economy mix-and-match
  • BookingUpsell with type flight_upgrade continues to be created for financial tracking

Idempotency: Per-leg check via FlightBooking::where(booking_id, leg_index)->exists() with row-level lock. The job implements ShouldBeUnique with uniqueId = "checkout-flight:{bookingId}:{legIndex}".

Error handling:

Retry behavior depends on the failure type, classified by CheckoutFlightBookingFailure enum:

  • noMatchingFare (fare no longer available): Released back to queue after 300 seconds (5 minutes) to allow fare inventory to refresh. If still failing on second attempt, the job is explicitly failed. This delay gives the airline fare cache time to update.
  • Other failures (searchFailed, verifyFailed, bookingFailed): Standard retry with 120 seconds backoff via framework $backoff property.

Job configuration: $tries = 3, $maxExceptions = 2, $timeout = 420, $backoff = [120], $uniqueFor = 480.

  • Admin users receive Filament database notifications on success or failure (per-leg and all-legs-complete)
  • Failed bookings can be retried from booking view actions (top-level or per-leg)

Structured error context:

CheckoutFlightBookingException carries a context array with structured details about WHY a booking failed. This context is spread into the booking_status_transitions.metadata JSON on failure, making error details visible to admins in the status timeline.

Context is automatically extracted from Aerticket exception types via extractContext():

Aerticket Exception Context Fields
AerticketPriceChangeException sub_type, original_price, new_price, currency, percentage_change
AerticketFareExpiredException sub_type, fare_id, expired_at
AerticketTimeoutException sub_type (timeout)
AerticketValidationException sub_type, validation_errors
AerticketVerifyException sub_type, fare_id
AerticketBookingException sub_type, fare_id

API error responses (verify/book returning provider errors) include sub_type: api_error and provider_errors array.

The noMatchingFare factory also accepts context with flight_numbers, cabin_class, total_fares_searched, expected_price, and leg_type/leg_index for debugging fare matching failures.

The status timeline Blade component displays the message field from failed transition metadata directly under the status badge.

Matching logic:

  • International (economy and business): Per-leg fare_price * pax_count must match fare’s totalPrice (within 0.01 precision, for 2-pax only) AND outbound/inbound flight numbers must match exactly. Non-2-pax bookings match by flight numbers only. fare_price is always stored per-person in flight_selection.legs.
  • Domestic: One-way search, matched by flight numbers + price (fare_price * pax_count for 2-pax). findMatchingOneWayFare() matches on outbound leg only.
  • If no matching fare found, job releases with 5-minute delay before failing

Source:

  • Job: backend/app/Jobs/CreateCheckoutFlightBookingJob.php
  • Exception: backend/app/Exceptions/CheckoutFlightBookingException.php
  • Failure enum: backend/app/Enums/CheckoutFlightBookingFailure.php
  • Service: backend/app/Services/Checkout/CheckoutFlightBookingService.php
  • Status transitions: backend/app/Services/Booking/BookingStatusService.php

Related: AerTicket Integration

After successful payment, PaymentController creates/updates the Client from client_data, then BookingFinalizationService finalizes the booking:

  1. Creates Passenger records from traveler_data
  2. Attaches passengers to booking via booking_passenger pivot (first = lead)
  3. Attaches passengers to client via client_passenger pivot
  4. Persists flight selection to booking.flight_selection with enriched airport data (both economy and business), including per-leg breakdown with fare_price, flight_numbers, route, type, and for domestic legs: departure_time, arrival_time, airline_names, stopover_airports
  5. Creates BookingUpsell records from session selections (hotels, activities, transfers, business class flights, insurance). Each row also persists its cost_price — the raw supplier cost captured at selection (hotel supplier_cost, activity supplier_unit_cost × travelers, transfer supplier_cost, insurance quoted whole-party retail) — so the booking’s cost breakdown reads real purchase-time costs instead of backing the margin out of retail
  6. Persists a pending InsuranceContract when insurance was selected, then dispatches ContractInsuranceJob after commit to emit the real (irreversible) Intermundial policy out-of-band. See Travel Insurance.

Source: backend/app/Services/Booking/BookingFinalizationService.php

Idempotency: If booking already has passengers attached, finalization is skipped.

Flight storage:

  • Both economy and business flights: Stored in booking.flight_selection column with city names, duration, and legs array (per-leg fare_price, flight_numbers, route, type, cabin_class; domestic legs also include departure_time, arrival_time, airline_names, stopover_airports)
  • Business class: Additionally stored in booking_upsells.flight_search_params JSON (for financial tracking via BookingUpsell)
  • Per-leg data enables independent booking of each leg during admin-driven flight booking (see Flight Selection Storage)
Code Error Description
400 no_checkout_session No active session, call start first
400 client_data_required Client contact must be entered before payment
400 traveler_data_required Travelers must be entered before payment
404 offer_not_found Offer not found or not active in market
404 booking_not_found Booking reference not found
410 offer_expired Offer is Active but within the 5-day booking lead time (departure too soon). Only returned by POST /checkout/{offerId} (start).
422 validation_error Business flight selection rejected: missing search metadata, stale/unknown fare_id, or missing pricing data. Returned by PUT /checkout/flights when server-side business class validation fails.

Frontend handling: All checkout step pages redirect to /{market}/home when receiving no_checkout_session error to prevent rendering with missing data.

Checkout step tracking uses an internal server-to-server API authenticated via Sanctum Bearer token.

PATCH /api/internal/bookings/{id}/checkout-step

Section titled “PATCH /api/internal/bookings/{id}/checkout-step”

Track which checkout step the user is currently viewing. Called by Astro SSR during page rendering.

Authentication: Sanctum Bearer token with internal:read ability (INTERNAL_API_TOKEN env var).

Request:

{
"step": "hotels"
}

Valid step values: flights, hotels, activities, transfers, main_contact, travelers, summary

Response: 200 OK

{
"success": true
}

Error: 422 if step value is invalid.

Record a CheckoutLinkOpened touchpoint when a checkout page is loaded with a ?resume= token (the continuation link emailed by the “Send Checkout Link” admin action, or the same URL copied/shared elsewhere). Called by Astro SSR (trackCheckoutLinkOpenSSR()), which forwards the real visitor signals as X-Client-* headers, mirroring trip-preview view tracking. The opens are surfaced in the booking admin page’s Preview Views section.

Authentication: Sanctum Bearer token with internal:read ability (INTERNAL_API_TOKEN env var).

Request:

{
"token": "c52f8a7b-3b5d-4f0b-a94f-38586f51dca6",
"offer_id": 20970,
"link_signature": "UvzP147ZhvrE",
"market": "es",
"locale": "es"
}

The booking is located by resumption_token + offer_id. link_signature is the per-send sig minted by CheckoutContinuationNotification: when present the open is attributed to that email (origin: email); when absent it is recorded as origin: shared (copied/forwarded link).

Response: 200 OK

{
"success": true
}

Errors: 404 (booking_not_found) for an unknown token/offer pair, 422 on validation failure.

User navigates to /checkout/{offerId}/hotels (full page load)
-> Astro SSR: reads booking_id from Astro.session
-> Astro SSR: trackCheckoutStepSSR() calls PATCH /api/internal/...
-> BookingFunnelService::trackStep():
- Advances checkout_step forward-only (furthest step reached)
- Sets checkout_snapshot['current_step'] to actual step (even backward)
- Records timestamp if first visit to that step
-> Page renders (tracking already complete)

Every checkout step page calls trackCheckoutStepSSR(Astro.session, '<step>') in its frontmatter. Since every checkout navigation is a full page load (Astro MPA), this is 100% reliable with no client-side JS dependency.

The booking_id is bridged from React to Astro.session via POST /api/checkout/session (Astro API route) after checkout session creation.

Backward navigation: When a user navigates back (e.g., from Activities to Hotels), the frontend sends advance: false on the selection save request so advanceStep is skipped. The SSR trackStep call updates current_step to show the user’s actual position while checkout_step remains at the furthest step reached.

Source:

  • Tracking utility: frontend/src/features/checkout/server/trackCheckoutStepSSR.ts
  • Session bridge: frontend/src/pages/api/checkout/session.ts
  • Internal API client: frontend/src/shared/config/internalApiClient.ts
  • Controller: backend/app/Http/Controllers/Api/BookingController.php
Component File
Controller backend/app/Http/Controllers/Api/CheckoutController.php
Payment Controller backend/app/Http/Controllers/Api/PaymentController.php
Session Service backend/app/Services/Checkout/CheckoutSessionService.php
Funnel Tracking Service backend/app/Services/Checkout/BookingFunnelService.php
Flight Options Service backend/app/Services/Checkout/CheckoutFlightService.php
Hotel Options Service backend/app/Services/Checkout/CheckoutHotelService.php
Economy Search Service backend/app/Services/Checkout/EconomyFlightSearchService.php
Business Search Service backend/app/Services/Checkout/BusinessFlightSearchService.php
Economy Cache Update Service backend/app/Services/Checkout/EconomyFlightCacheUpdateService.php
Checkout Flight Booking Service backend/app/Services/Checkout/CheckoutFlightBookingService.php
Checkout Flight Booking Job backend/app/Jobs/CreateCheckoutFlightBookingJob.php
Finalization Service backend/app/Services/Booking/BookingFinalizationService.php
Insurance Controller backend/app/Http/Controllers/Api/InsuranceController.php
Form Requests backend/app/Http/Requests/Api/Checkout/