Skip to content

AerTicket Integration

Interface to AerTicket API for flight search, verification, booking, ticket issuance, void, ancillaries, retrieval, and cancellation. Supports both UAT and Production environments with automatic authentication, retry logic, and comprehensive error handling.

Terminal window
# Environment: uat (development) or production
AERTICKET_ENVIRONMENT=uat
# API credentials provided by AERTICKET
AERTICKET_LOGIN=your_api_login
AERTICKET_PASSWORD=your_api_password
# Optional timeout/retry overrides
# AERTICKET_UAT_TIMEOUT=30
# AERTICKET_UAT_RETRY_ATTEMPTS=3
Terminal window
./vendor/bin/sail artisan aerticket:test-connection
use App\Services\AerticketCabinetService;
use App\Services\Flights\Aerticket\AerticketSearchService;
$cabinet = app(AerticketCabinetService::class);
$search = new AerticketSearchService($cabinet);
$results = $search->search([
'origin' => 'BCN',
'destination' => 'MAD',
'departure_date' => '2025-12-15',
'return_date' => '2025-12-20',
'adults' => 2,
'cabin_class' => 'economy',
]);
foreach ($results->getFares() as $fare) {
echo "Price: {$fare->getTotalPrice()} {$fare->getCurrency()}\n";
}
use App\Services\Flights\Aerticket\AerticketSearchService;
use App\Services\Flights\Aerticket\DTOs\Request\PassengerType;
use App\Services\Flights\Aerticket\DTOs\Request\SearchOptions;
use App\Services\Flights\Aerticket\DTOs\Request\SearchRequest;
// Define multi-city segments (2-6 segments allowed)
$segments = [
['departure' => 'BCN', 'destination' => 'BKK', 'date' => '2025-12-20'],
['departure' => 'BKK', 'destination' => 'CNX', 'date' => '2025-12-25'],
['departure' => 'CNX', 'destination' => 'BCN', 'date' => '2026-01-05'],
];
// Build passenger types
$passengers = [
PassengerType::adult(2),
PassengerType::child(1),
];
// Create multi-city search request
$searchRequest = SearchRequest::multiCity(
segments: $segments,
passengerTypeList: $passengers,
searchOptions: new SearchOptions(cabinClassList: ['ECONOMY'])
);
// Execute search
$searchService = new AerticketSearchService();
$response = $searchService->search($searchRequest);
ServicePurpose
AerticketCabinetServiceHTTP client, authentication, environment switching
AerticketSearchServiceFlight search
AerticketSearchUpsellServiceFare upgrade search
AerticketVerifyServiceFare verification
AerticketBookServiceBooking creation
CheckoutFlightBookingServicePer-leg checkout flight booking (international and domestic, economy and business)
AerticketTicketIssueServiceTicket issuance
AerticketFastlaneTicketingServiceFast-track ticket issuance
AerticketRePriceServiceBooking re-pricing
AerticketVoidServiceBooking void
AerticketAncillaryServiceAncillary services (baggage, meals)
AerticketFareRulesServiceFare rules and restrictions
AerticketRetrieveServicePNR retrieval
AerticketCancelServiceBooking cancellation
FlightRouteValidationServiceValidate flight route availability for supplier tours

AerticketSearchUpsellService (/search-upsell) and AerticketAncillaryService::availableFareAncillaries() (/available-fare-ancillaries) are contractually capped by Aerticket at 100 calls per day each. Automated cache population, checkout display, and booking now stay within the single /search response and do not consume these low-volume endpoints; they are reserved for explicit/manual tooling.

BucketConfig keyEnv varDefault
search_upsellaerticket.daily_budgets.search_upsellAERTICKET_SEARCH_UPSELL_DAILY_LIMIT80
available_fare_ancillariesaerticket.daily_budgets.available_fare_ancillariesAERTICKET_ANCILLARIES_DAILY_LIMIT80

Counters are keyed aerticket:daily_budget:<bucket>:<YYYY-MM-DD> and reset at local midnight via RateLimiter decay. Setting a limit to 0 disables the check for that bucket.

When a budget is exhausted, AerticketDailyBudgetExceededException is thrown. Manual callers are expected to catch it and degrade gracefully rather than propagate it.

Source: backend/config/aerticket.php, backend/app/Exceptions/Services/Flights/Aerticket/Exceptions/AerticketDailyBudgetExceededException.php

Related: Dynamic Flight Cache — Baggage Resolution

Every fare returned by /search carries a top-level contentSource field with one of two values:

  • GDS — sourced through Aerticket’s GDS pipeline (Amadeus / Sabre / Travelport). Ancillaries and seats are exposed via API.
  • nonGDS — sourced through Aerticket’s NDC + LCC pipelines. The supplier does not expose ancillaries or seats via API; calls to /available-fare-ancillaries always return Method not supported by supplier.

FareResult::$contentSource parses this field ('GDS' | 'nonGDS', defaulting to 'GDS' for missing/unknown values so legacy fixtures keep ancillary support). Two call sites consume it:

  • AerticketAncillaryService::availableFareAncillaries(string $fareId, array $itineraryIdList, string $contentSource = 'GDS') — when $contentSource !== 'GDS', returns an empty success response without issuing the HTTP request. The skip is logged as Skipping available-fare-ancillaries for nonGDS fare.
  • BaggageResolutionService::resolveBaggageForFare() — short-circuits the ancillary tier for nonGDS fares before consuming the daily budget, so nonGDS traffic doesn’t penalise the GDS budget counter. STRICT policy returns null (discard); UPSELLABLE / OFF returns ResolvedBaggage::policyOff(...) so the offer stays usable at its native price.

fareSource (FSC_IATA / FSC_NEGO / FSC_CONSO / FSC_WEB) is the commercial channel and is orthogonal to GDS/nonGDS — the same airline can appear under both. Always gate on contentSource, never on fareSource.

Source: backend/app/Services/Flights/Aerticket/DTOs/Response/FareResult.php, backend/app/Services/Flights/Aerticket/AerticketAncillaryService.php, backend/app/Services/Flights/Baggage/BaggageResolutionService.php

AerticketAuthenticationService::getSanitizedHeaders() powers the auth_headers field in API request/response logs. The Aerticket login is the account number (e.g. 082526 for the GDS account, 082836 for the NDC + LCC account) — it’s used for L2B accounting and request routing, not as a credential. It is logged in clear so operators can tell which account answered each request. Only the password is masked.

// Example output
['login' => '082526', 'password' => 'Ip********2j']

The same policy is applied to Telescope: TelescopeServiceProvider::hideSensitiveRequestDetails() keeps password in the hidden-headers list but does not hide login.

Source: backend/app/Services/AerticketAuthenticationService.php, backend/app/Providers/TelescopeServiceProvider.php

Terminal window
# Environment: "uat" or "production" (determines base URLs automatically)
AERTICKET_ENVIRONMENT=uat
# Credentials (shared across environments)
AERTICKET_LOGIN=your_api_login
AERTICKET_PASSWORD=your_api_password
# Optional: Override default timeout settings (seconds)
# AERTICKET_UAT_TIMEOUT=30
# AERTICKET_PROD_TIMEOUT=30
# Optional: Configure retry settings
# AERTICKET_UAT_RETRY_ATTEMPTS=3
# AERTICKET_UAT_RETRY_DELAY=100
# Optional: Logging configuration
# AERTICKET_LOGGING_ENABLED=true
# AERTICKET_LOG_CHANNEL=single

Base URLs are configured automatically per environment in config/aerticket.php:

  • UAT: https://apihub-uat.aerticket-it.de/cabinet/ and /api/v1/
  • Production: https://apihub.aerticket-it.de/cabinet/ and /api/v1/

The admin panel links directly to Aerticket’s official cabinet web tool as a backup channel for flight validation. AerticketCabinetService::findPnrsUrl(?string $pnrLocator) builds an environment-aware URL to the cabinet’s find-pnrs page; passing a PNR locator pre-fills and executes the search there. The link appears as a global “Aerticket Cabinet” navigation item (Flights group) and as a per-leg “Cabinet” action on the booking view, which deep-links the leg’s PNR. Manually entered flights are excluded since their reference is not an Aerticket locator.

To prevent accidental low-cost carrier bookings in staging/UAT:

  • Flydubai flights (airline code: FZ) are blocked
  • Flights requiring instant purchase are blocked

Controlled by AERTICKET_ENVIRONMENT variable - active when set to "uat".

use App\Services\Flights\Aerticket\AerticketVerifyService;
$verify = new AerticketVerifyService($cabinet);
$response = $verify->verify([
'fare_id' => $fare->getId(),
'session_id' => $searchResponse->getSessionId(),
]);
if ($response->isAvailable()) {
$updatedPrice = $response->getPrice();
}
use App\Jobs\AerticketCreateBookingJob;
AerticketCreateBookingJob::dispatch(
fareId: 'fare-123',
instantTicketOrder: true,
userId: auth()->id()
)->onQueue('aerticket-bookings');

Checkout Flight Booking (Per-Leg, Admin-Triggered)

Section titled “Checkout Flight Booking (Per-Leg, Admin-Triggered)”

After successful checkout payment, bookings with flights move to pending_flight_booking. Admin users trigger flight booking from the booking detail page. Each flight leg is booked independently with its own FlightBooking record and PNR.

Trigger: Filament booking actions (Book All Flights / Book Flight per-leg / Retry).

Process (per leg):

  1. Admin action transitions booking to flight_booking_in_progress
  2. One CreateCheckoutFlightBookingJob dispatched per unbooked leg to aerticket-bookings queue
  3. Each job calls CheckoutFlightBookingService::bookLegForCheckout($booking, $legIndex):
    • International legs: bookInternationalLeg() re-searches round-trip via EconomyFlightSearchService::searchInternationalOnly() (economy) or BusinessFlightSearchService::searchBusinessFlightsRaw() (business), matches by flight numbers + price
    • Domestic legs: bookDomesticLeg() searches one-way via AerticketSearchService, matches by flight numbers + price
  4. Verifies fare availability via AerticketVerifyService
  5. Books via AerticketBookService with instantTicketOrder=false
  6. Creates FlightBooking record with source=CHECKOUT, booking_id, leg_index, and flight_type
  7. Dispatches AerticketRetrieveBookingJob for PNR details
  8. All-legs-booked check: Only transitions to flights_confirmed when ALL legs have FlightBooking records

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

Retry logic:

  • 3 attempts, 2 max exceptions
  • 120s backoff between attempts
  • 420s timeout per attempt
  • ShouldBeUnique with uniqueId = "checkout-flight:{bookingId}:{legIndex}"

Idempotency: Per-leg check via FlightBooking::where(booking_id, leg_index)->exists() with row-level lock.

Error handling:

  • Failures produce structured error context via CheckoutFlightBookingException::$context, which is stored in booking_status_transitions.metadata (including leg_index) and displayed in the admin status timeline
  • Context is automatically extracted from Aerticket exception types (price change, fare expired, timeout, validation, verify error, booking error) — see Checkout API - Structured error context for details
  • API error responses include provider_errors in context for debugging with Aerticket support
  • Admin users receive Filament database notifications on success/failure (per-leg and all-legs-complete)
  • Failed bookings can be retried from the booking detail page (top-level or per-leg)

Limitations:

  • No instant ticketing (manual ticketing required)

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

Related: Checkout API - Admin-Driven Flight Booking

use App\Jobs\AerticketIssueTicketJob;
AerticketIssueTicketJob::dispatch(
bookingReference: 'ABC123',
bookingId: $flightBooking->id,
userId: auth()->id()
)->onQueue('aerticket-tickets');

Faster alternative to regular ticket issuance. Booking must be at least 5 minutes old.

use App\Jobs\AerticketFastlaneTicketingJob;
AerticketFastlaneTicketingJob::dispatch(
bookingReference: 'ABC123',
bookingId: $flightBooking->id,
userId: auth()->id()
)->onQueue('aerticket-fastlane');
use App\Services\Flights\Aerticket\AerticketRePriceService;
use App\Services\Flights\Aerticket\DTOs\Request\PriceRange;
$rePriceService = new AerticketRePriceService($cabinet);
// Re-pricing with price tolerance (±5 EUR)
$priceRange = new PriceRange(min: 5.0, max: 5.0);
$response = $rePriceService->rePrice('ABC123', $priceRange);
if ($response->hasNewFares()) {
// Price outside tolerance - requires approval
$subFareToken = $response->getSubFareToken();
}
use App\Services\Flights\Aerticket\AerticketAncillaryService;
$ancillaryService = new AerticketAncillaryService($cabinet);
// For bookings (after booking)
$response = $ancillaryService->availableBookingAncillaries(
pnrLocator: 'ABC123',
ancillaryTypes: ['BAGGAGE', 'MEAL']
);
foreach ($response->getAncillariesByType() as $type => $ancillaries) {
foreach ($ancillaries as $ancillary) {
echo "{$ancillary->name}: {$ancillary->getFormattedPrice()}\n";
}
}
use App\Services\Flights\Aerticket\AerticketCancelService;
$cancel = new AerticketCancelService($cabinet);
// Normal cancellation
$result = $cancel->cancelBooking('ABC123');
// Force cancellation (even if tickets issued)
$result = $cancel->cancelBooking('ABC123', forceCancellation: true);

Performs a test multi-city search to verify that a supplier tour’s international flight legs have actual flight availability. Used in the admin panel when creating or editing supplier tours.

How it works:

  1. Extracts international legs from the ProductTemplate itinerary via FlightRouteConfigGenerator
  2. Builds a multi-city SearchRequest with 2 adults, sample dates ~60 days out, origin MAD
  3. Calls AerticketSearchService::search()
  4. Returns structured result with success/failure, route summary, fare count, and cheapest fare

Error types: no_legs, airport_resolution, no_results, timeout, validation, api_error

Admin integration:

  • Create wizard: Checkbox in Step 1 triggers validation on Next (blocks progression on failure)
  • Edit/View pages: Header button with confirmation modal

Source: backend/app/Services/Flights/FlightRouteValidationService.php

Related: Supplier Tours

Per AerTicket API v3.17 specification:

  • ASCII letters and spaces only (/^[a-zA-Z\s]+$/) — no accents, numbers, or symbols
  • No + character (causes booking failures)
  • Last name minimum 2 characters
  • Each name maximum 57 characters
  • Combined firstName + lastName: 2-57 characters total (PassengerNameLength rule)
  • Valid titles: MR, MRS, MS, CHD, INF, DR MR, etc.

These rules are enforced in both the Filament admin (camelCase fields: firstName/lastName) and the checkout API (snake_case fields: first_name/last_name). The PassengerNameLength rule auto-detects the naming convention.

Usage: See PassengerValidationRules::firstName() and ::lastName() for the rule arrays.

Source: backend/app/Rules/PassengerValidationRules.php, backend/app/Rules/PassengerNameLength.php

  • Infant (INF): 0-23 months
  • Child (CHD): 2-15 years
  • Adult (ADT): 16+ years
use App\Enums\AgeCategory;
$ageCategory = AgeCategory::fromDateOfBirth($dateOfBirth);
if (!$ageCategory->isValidForDateOfBirth($dateOfBirth)) {
$errorMessage = $ageCategory->getValidationErrorMessage($dateOfBirth);
}
ExceptionDescription
AerticketValidationExceptionInvalid input
AerticketTimeoutExceptionRequest timeout
AerticketSearchExceptionSearch error
AerticketVerifyExceptionFare verification error
AerticketBookingExceptionBooking error
AerticketTicketIssueExceptionTicket issuance error
AerticketRePricingExceptionRe-pricing error
AerticketFareExpiredExceptionFare expired (410)
AerticketPriceChangeExceptionPrice changed
AerticketDailyBudgetExceededExceptionInternal per-day call budget exhausted for search_upsell or available_fare_ancillaries. Caller degrades gracefully.

All Aerticket API requests and exceptions automatically add context data to Laravel’s Context facade, which is included in Nightwatch error reports for debugging.

Transport-level context (added by AerticketCabinetService):

  • aerticket_endpoint - API operation (e.g., “verify-fare”, “search”, “create-booking”)
  • aerticket_environment - “uat” or “production”
  • aerticket_timeout - Configured timeout in seconds
  • aerticket_apihubflowid - Aerticket’s internal tracking ID from response header

Domain-level context (added by exception classes):

  • aerticket_fare_id - Fare ID (on booking/verify exceptions)
  • aerticket_booking_reference - PNR locator (on retrieve/cancel/ticket exceptions)
  • aerticket_error_details - Error details array when available

This context is automatically captured in Nightwatch reports when exceptions occur, making it easier to debug issues with AerTicket support.

Source: backend/app/Services/AerticketCabinetService.php, backend/app/Exceptions/Aerticket/*.php

try {
$results = $search->search($data);
} catch (AerticketValidationException $e) {
return response()->json(['error' => $e->getMessage()], 422);
} catch (AerticketTimeoutException $e) {
return response()->json(['error' => 'Request timed out'], 504);
} catch (AerticketSearchException $e) {
Log::error('Search failed', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Search failed'], 500);
}

AerTicket /search calls take 5–30s and consume daily API budget, which makes iterating on post-search ranking, signature-match, and cache-update logic painful. The fixture system captures real responses to JSON files on disk and replays them in ~30ms on subsequent calls. The replayed Illuminate\Http\Client\Response is byte-equivalent to a live one — same status, same headers, same body — so callers can’t tell the difference.

Toggle the system with AERTICKET_FIXTURES_MODE in .env. Captures land in storage/app/aerticket-fixtures/ and are gitignored (PNRs, fareIds, and apihubflowids are PII).

ModeBehaviour
off (default)Production. Always hit AerTicket. Never read or write fixtures.
replayRead-only. Hit on the captured fixture, fail loudly on miss. Best for deterministic tests and “find every code path that still tries to reach AerTicket” debug sessions.
replay_or_recordReplay if a fixture exists; otherwise hit live and save the response for next time. First run = slow, every run after = ~30ms. Best for the dev loop.
recordAlways hit live, always overwrite the fixture. Refresh stale captures without manually purging.

Successful responses (2xx) are persisted; transient 5xx errors don’t poison the cache.

The capture command is flight-centric — it operates on DynamicFlightCache rows (the same unit the admin “Re-cache” button works on):

Terminal window
# A specific cache row
sail artisan aerticket:fixtures:capture --flight=1629
# Every row of a route on a date
sail artisan aerticket:fixtures:capture --route=24 --date=2026-09-15
# Every PENDING row
sail artisan aerticket:fixtures:capture --pending
# Skip the reset confirmation prompt
sail artisan aerticket:fixtures:capture --flight=1629 --force

Internally the command:

  1. Resolves the entry/entries from the flag.
  2. Resets non-PENDING rows to PENDING via DynamicFlightCachePopulatorService::resetEntryForRecache (with a confirmation prompt; same operation the admin Re-cache button performs — drops sibling fare positions 2..N, repoints orphaned offer_flights to position 1, clears segments). This avoids the unique-constraint collision on (flight_cache_id, leg_sequence, itinerary_index, segment_number).
  3. Forces aerticket.fixtures_mode = record for the run.
  4. Calls DynamicFlightCachePopulatorService::executeSearches($entries) — same path the populator job uses. Automated baggage resolution stays inside the captured /search response.

A typical economy round-trip capture produces:

  • 1 × search-{hash12}.json — the main flight response (~5–6 MB).
Terminal window
sail artisan aerticket:fixtures:list
+----------------------------+----------------------------------------------+----------+---------------------------+
| Endpoint | File | Size | Captured at |
+----------------------------+----------------------------------------------+----------+---------------------------+
| search | search-562f0a4a66fd.json | 5.6 MB | 2026-04-29T09:10:03+00:00 |
| search-upsell | search-upsell-187482a3f255.json | 331.6 KB | 2026-04-29T09:10:18+00:00 |
| available-fare-ancillaries | available-fare-ancillaries-d454529a5a45.json | 1.8 KB | 2026-04-29T09:10:33+00:00 |
+----------------------------+----------------------------------------------+----------+---------------------------+
13 fixture(s) at /var/www/html/storage/app/aerticket-fixtures
Mode: replay_or_record
Terminal window
# Refresh a single flight against live AerTicket (overwrites the existing fixture)
AERTICKET_FIXTURES_MODE=record sail artisan aerticket:fixtures:capture --flight=1629 --force
# Wipe everything — confirms first unless --force
sail artisan aerticket:fixtures:purge

Three ways to confirm a request is hitting the fixture instead of AerTicket:

  1. Tail the log

    Terminal window
    sail artisan pail --filter=Aerticket

    Replay logs:

    [INFO] Aerticket fixture replay hit endpoint=search hash=562f0a4a66fd mode=replay_or_record

    Live calls log Making API request followed seconds later by API response received.

  2. Telescope HTTP Client tab at localhost/telescope/client-requests. Replay shows zero new outbound requests to apihub.aerticket-it.de.

  3. Stopwatch. Live re-cache: 30s+. Pure replay: 3–8s (the time is now DB writes, not HTTP). The browser’s network tab shows the same.

.env
AERTICKET_FIXTURES_MODE=replay_or_record
# Capture once (slow)
sail artisan aerticket:fixtures:capture --flight=1629
# Iterate on FlightRankingPolicy / EconomyFlightSearchService / cache-update etc.
# Every reload of /es/checkout/<offer> hitting that flight returns from disk in ~30ms.
# When AerTicket data drifts and you need fresh prices:
sail artisan aerticket:fixtures:capture --flight=1629 --force
# Find code paths that still try to talk to AerTicket — switch to strict replay:
# .env
AERTICKET_FIXTURES_MODE=replay
# A miss now throws, pinpointing the offending call site.

AerticketFixtureStore::hashKey($endpoint, $data) computes a SHA-256 of endpoint + sorted-keys-JSON(normalized payload). The normalization:

  • Drops apihubflowid, internalRequestId, requestId, requestSentAt.
  • Sorts segmentList by departureDate so equivalent round-trip requests with different leg order share a hash.

Requests that don’t match any captured fixture either fail (replay) or fall through to live + record (replay_or_record).

config/aerticket.php:

'fixtures_mode' => env('AERTICKET_FIXTURES_MODE', 'off'),
'fixtures_path' => env('AERTICKET_FIXTURES_PATH', storage_path('app/aerticket-fixtures')),

Keep AERTICKET_FIXTURES_MODE=off in production. The default is off precisely so a leaked dev .env can’t accidentally serve cached responses to real customers.

Terminal window
# All AerTicket tests
./vendor/bin/sail artisan test --filter=Aerticket
# Connection test
./vendor/bin/sail artisan aerticket:test-connection
# Re-pricing tests
./vendor/bin/sail artisan test --filter=RePrice
# Ancillary tests
./vendor/bin/sail artisan test --filter=Ancillary
Terminal window
php artisan tinker
>>> config('aerticket.credentials.login')

Increase timeout in .env:

Terminal window
AERTICKET_TIMEOUT=60

Check logs:

Terminal window
./vendor/bin/sail artisan pail --filter="apihubflowid"

The apihubflowid is required when contacting AerTicket support.