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. Cache-population traffic (the DynamicFlightCachePopulatorService upsell/ancillary flow) enforces a lower internal ceiling via config/aerticket.phpdaily_budgets so ad-hoc operator actions retain headroom at the end of the day. Customer-facing calls (booking, verification) are never budget-limited.

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. Callers are expected to catch it and degrade gracefully (skip the upsell/ancillary tier, keep caching the rest of the fares) rather than propagate it.

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

Related: Dynamic Flight Cache — Baggage Upsell Resolution

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/

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, so any side calls (/search-upsell, /availableFareAncillaries) trigger naturally and get captured.

A typical economy round-trip capture produces:

  • 1 × search-{hash12}.json — the main flight response (~5–6 MB).
  • N × search-upsell-{hash12}.json — one per no-bag fare-family the populator probed for bag (~330 KB each).
  • M × available-fare-ancillaries-{hash12}.json — fallback per fare when no upsell sibling exists (~2 KB each).
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.