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

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

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/contact
├─► 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):

{
"actual_pax_count": 3,
"actual_room_type": "2A+1CH"
}

Request validation:

  • actual_pax_count (optional): 1-4 passengers, defaults to offer’s pax_count
  • actual_room_type (optional): Must exist in supplier_service_rate_prices for the tour’s base hotels, defaults to room type matching pax count

If actual_room_type is unavailable for the tour’s base hotels, returns 422 Unprocessable Entity with error room_type_not_available.

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
  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. Instead, it uses the recalculated price from session start or from refreshBasePriceFromLiveFlights().

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: cheapest with baggage, fallback to cheapest overall
  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
  10. For non-standard pax: Calls refreshBasePriceFromLiveFlights() to update session’s base_price using live flight total (international + domestic) + recalculated land price

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 and new_price reflect the session’s recalculated base_price (not the offer’s 2A-based final_price).

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
  7. Skips incomplete fares that do not contain both outbound and inbound legs
  8. Returns fareId on each outbound/inbound leg option so frontend can pair legs by fare identity (not by array index)
  9. 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 (outbound/inbound flight numbers keyed by fare ID), and international route info
  10. For non-standard pax, passes session context (actual_pax_count, actual_room_type, base_price) to service
  11. 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
}

Business integrity rule: for cabin_class = BUSINESS, send fare_id from the selected business card. The backend uses this ID to canonicalize outbound/inbound flight numbers from server-side search metadata before persisting booking.flight_selection, preventing mismatches when multiple fares share the same outbound leg.

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

Response shape per hotel entry:

FieldTypeDescription
idstringUnique key: {hotelId}-{tier}-{startDay}-{endDay}
hotelIdnumber|nullSupplier hotel ID (null for package fallback entries)
namestringHotel name (or package name for legacy fallback)
locationstring|nullCity (null for package fallback entries)
tierstringselection, luxury, or grand_luxury
tier_labelstringHuman-readable tier label
nightsobject{ start: number, end: number }
imageUrlstring|nullFirst hotel image URL
descriptionstring|nullHotel description text from SupplierHotel
isIncludedbooleantrue for Selection tier, false for upgrades
priceDifferencenumber|nullPrice difference vs Selection tier, null for Selection
selectionHotelNamestring|nullBase hotel name (for upgrades), null for Selection

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 use the session’s actual_room_type (e.g., 3A for 3 travelers). The room_type query parameter overrides the session value if provided. Hotels without pricing for the selected room type return priceDifference: null and are shown as unavailable (non-selectable in the frontend).

Query parameters:

ParamTypeDescription
room_typestring (optional)Room type for pricing: 1A, 2A, 3A, or 4A. Falls back to session’s actual_room_type, then to 2A.

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)
  • See Service Tiers for tier details

Update activity upsell selections. Prices calculated server-side.

Request:

{
"advance": true,
"activity_selections": [
{ "activity_id": 5, "day_number": 2 }
]
}
  • advance (optional, default true): When false, skips advanceStep (used on backward navigation).

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

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.

Request:

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

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

FieldTypeRequiredDescription
first_namestringYesFirst name (max 100 chars)
last_namestringYesLast name (min 2, max 100 chars)
emailstringYesValid email (max 255 chars)
phonestringYesPhone number (max 30 chars)

Pricing: Client data does NOT affect pricing.

Errors:

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

Store traveler personal data. Count must match offer’s pax_count.

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.

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
+ hotel_price_differences (uses session's actual_room_type)
+ activity_prices * actual_pax_count
+ transfer_prices

Security: Activity and transfer prices are calculated server-side from database. Client-provided prices are ignored.

Variable pax count: The actual_pax_count from the session is used for flight and activity extras calculation. Hotel upgrade pricing uses the session’s actual_room_type to match the selected passenger configuration.

See Payment Gateway System for full payment documentation.

Key points:

  1. createIntent uses session’s total_price (including extras) for deposit calculation
  2. confirm creates/updates Client from client_data (not traveler_data[0]), then calls BookingFinalizationService::finalizeFromCheckoutSession()
  3. Booking stores payment_method_code and payment_gateway_code for operational tracking
  4. Status is routed via PaymentService::updateBookingPaymentStatus():
    • Flight booking (ECONOMY / BUSINESS) -> pending_flight_booking
    • Land-only booking -> pending_land_confirmation
  5. Session cleanup: After successful payment (PaymentStatus::Succeeded), the checkout session is cleared via CheckoutSessionService::clear() to prevent session reuse
  6. 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 ExceptionContext Fields
AerticketPriceChangeExceptionsub_type, original_price, new_price, currency, percentage_change
AerticketFareExpiredExceptionsub_type, fare_id, expired_at
AerticketTimeoutExceptionsub_type (timeout)
AerticketValidationExceptionsub_type, validation_errors
AerticketVerifyExceptionsub_type, fare_id
AerticketBookingExceptionsub_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)

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)
CodeErrorDescription
400no_checkout_sessionNo active session, call start first
400client_data_requiredClient contact must be entered before payment
400traveler_data_requiredTravelers must be entered before payment
404offer_not_foundOffer not found or not active in market
404booking_not_foundBooking reference not found
410offer_expiredOffer is Active but within the 5-day booking lead time (departure too soon). Only returned by POST /checkout/{offerId} (start).

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.

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
ComponentFile
Controllerbackend/app/Http/Controllers/Api/CheckoutController.php
Payment Controllerbackend/app/Http/Controllers/Api/PaymentController.php
Session Servicebackend/app/Services/Checkout/CheckoutSessionService.php
Funnel Tracking Servicebackend/app/Services/Checkout/BookingFunnelService.php
Flight Options Servicebackend/app/Services/Checkout/CheckoutFlightService.php
Hotel Options Servicebackend/app/Services/Checkout/CheckoutHotelService.php
Economy Search Servicebackend/app/Services/Checkout/EconomyFlightSearchService.php
Business Search Servicebackend/app/Services/Checkout/BusinessFlightSearchService.php
Economy Cache Update Servicebackend/app/Services/Checkout/EconomyFlightCacheUpdateService.php
Checkout Flight Booking Servicebackend/app/Services/Checkout/CheckoutFlightBookingService.php
Checkout Flight Booking Jobbackend/app/Jobs/CreateCheckoutFlightBookingJob.php
Finalization Servicebackend/app/Services/Booking/BookingFinalizationService.php
Form Requestsbackend/app/Http/Requests/Api/Checkout/