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);
Service Purpose
AerticketCabinetService HTTP client, authentication, environment switching
AerticketSearchService Flight search
AerticketSearchUpsellService Fare upgrade search
AerticketVerifyService Fare verification
AerticketBookService Booking creation
CheckoutFlightBookingService Per-leg checkout flight booking (international and domestic, economy and business)
AerticketTicketIssueService Ticket issuance
AerticketFastlaneTicketingService Fast-track ticket issuance
AerticketRePriceService Booking re-pricing
AerticketVoidService Booking void
AerticketAncillaryService Ancillary services (baggage, meals)
AerticketFareRulesService Fare rules and restrictions
AerticketRetrieveService PNR retrieval
AerticketCancelService Booking cancellation
FlightRouteValidationService Validate 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.

Bucket Config key Env var Default
search_upsell aerticket.daily_budgets.search_upsell AERTICKET_SEARCH_UPSELL_DAILY_LIMIT 80
available_fare_ancillaries aerticket.daily_budgets.available_fare_ancillaries AERTICKET_ANCILLARIES_DAILY_LIMIT 80

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
# Use `stderr` (or the app's `stack`) so Aerticket logs reach Loki; the
# .env.example default is `stderr` (config/aerticket.php falls back to `single`
# only when the var is unset).
# AERTICKET_LOG_CHANNEL=stderr

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

These guards are gated by EnvironmentGuard::isStagingOrUat() (the app environment, not the AERTICKET_ENVIRONMENT config value) and are enforced only in the manual BookFlight Filament page (app/Filament/Pages/BookFlight.php:383,412) — the Flydubai check runs before verify, the instant-purchase check after. They are not applied globally across the automated checkout booking path.

How AERTiCKET prices flights, what they invoice us, and the one rule every price parser must follow. Read this before touching any Aerticket price calculation — a subtle misreading of these fields once caused a ticket-fee double-counting bug.

For each passenger, AERTiCKET reports the price we pay in a priceList:

Field Meaning
AGENCY_PURCHASE_PRICE The net fare we pay AERTiCKET. Already includes the ticketing/service fee.
TOTAL_TAX Airline taxes and surcharges.
BASE_FARE Raw fare excluding the ticketing fee. Informational; used only as a fallback.

Separately, each passenger may carry a surchargeInfoList (TICKET_FEE_INFORMATION, SERVICE_FEE, BOOKING_FEE). This is for information only. AERTiCKET’s own API documentation states, verbatim:

surchargeInfoList — Use this parameter for information only! This is AERTiCKET ticketing fee, which is already included in the agencyPurchasePrice from priceList.

The net total is therefore always:

total = Σ passengers (AGENCY_PURCHASE_PRICE + TOTAL_TAX) × count

Do:

  • Sum only AGENCY_PURCHASE_PRICE + TOTAL_TAX, per passenger, multiplied by passenger count.
  • Trust AGENCY_PURCHASE_PRICE as the net price — it already contains the ticketing fee.

Don’t:

  • Never add surchargeInfoList on top of AGENCY_PURCHASE_PRICE. The fee is already inside it; adding it double-counts.
  • Never multiply by number of flights/segments — prices are per passenger, not per leg (see below).

The one exception: if a response omits AGENCY_PURCHASE_PRICE and carries only BASE_FARE, the raw fare genuinely excludes the fee, so the fallback is BASE_FARE + TOTAL_TAX + surchargeInfoList. This is the only place surcharges are legitimately added (VerifiedFare::fromArray()).

Royal Jordanian round trip MAD→AMM→MAD, 4 adults. AERTiCKET reports per adult:

  • AGENCY_PURCHASE_PRICE = 156.00 (= 149.00 fare + 7.00 ticketing fee, folded in)
  • TOTAL_TAX = 253.39
  • surchargeInfoList.TICKET_FEE_INFORMATION = 7.00 (reference only — the same €7 already inside 156.00)

Correct total: (156.00 + 253.39) × 4 = 1,637.56 — matches the binding invoice Total factura.

Double-counting the fee gives (156.00 + 253.39 + 7.00) × 4 = 1,665.56 — €28.00 too high (€7 × 4 passengers).

  • One invoice per PNR per leg. Each FlightBooking row (leg_index) is one PNR and one AERTiCKET invoice. A round trip is a single leg / PNR / invoice; a domestic connection is a separate leg with its own PNR, invoice, and fee.
  • The ticketing fee is per ticket, not per flight. A round trip is one ticket per passenger → one fee per passenger. The outbound and return directions do not each carry a fee. 4 passengers → 4 tickets → 4 × €7 = €28.
  • Prices scale per passenger, never per segment. One search returns the whole journey bundled and priced per passenger type; multiply by passenger count, not by number of flights.
  • The confirmed invoice is binding. AERTiCKET’s terms make the confirmed Total factura the authoritative price; our stored total_amount must always reconcile to it.

The same net-price rule applies across the three response shapes the parsers handle:

Shape Endpoint(s) Structure
passengerList retrieve / reprice one entry per passenger, no count
passengerTypeFareList search / verify / booking / subfare one entry per passenger type, multiply by count
legacy priceList / totalPrice old fixtures flat fallback

Where the rule lives (all parsers must agree)

Section titled “Where the rule lives (all parsers must agree)”

The net-price rule is currently implemented in seven places. Any new or changed Aerticket price calculation MUST follow it, and you should check the others for consistency:

Parser Path
FareResult::fromArray() search
VerifiedFare::fromArray() verify (holds the only legitimate BASE_FARE surcharge fallback)
RePriceResponse::getTotalPrice() reprice within tolerance
RePriceResponse::calculateSubFarePrice() reprice subfare (outside tolerance)
CheckoutFlightBookingService::extractPriceData() checkout booking creation (domestic + international)
AerticketCreateBookingJob booking creation
BookFlight (Filament) manual admin booking

Repricing at ticket-issue time (AerticketIssueTicketJobRePriceResponse) recomputes and persists the price to flight_bookings:

  • previous_amount — the quoted price before repricing
  • total_amount — the recomputed net price (what a parsing bug corrupts)
  • reprice_differencetotal_amount − previous_amount
  • repriced_at — timestamp of the reprice

Because these are persisted, a price-parsing bug corrupts stored financial data, not just a display — and fixing the code does not retroactively correct already-written rows. Those require a separate, deliberate data correction.

To check a stored cost against AERTiCKET’s invoice, open the PNR in the cabinet web tool (see Cabinet Web Tool Access) and confirm that (AGENCY_PURCHASE_PRICE + TOTAL_TAX) × pax equals the invoice Total factura. If it is higher by exactly fee × pax, the ticketing fee was double-counted.

Source: backend/app/Services/Flights/Aerticket/DTOs/Response/{FareResult,VerifiedFare,RePriceResponse}.php, backend/app/Services/Checkout/CheckoutFlightBookingService.php

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');

After successful checkout payment, bookings with flights move to pending_flight_booking. Each flight leg is booked independently with its own FlightBooking record and PNR.

Triggers:

  • Automatically, on confirmed payment. Reserving costs nothing at the supplier — only issuing a ticket does — but it holds the fare, and fares have been seen to move hundreds of euros in the hour it took an operator to press the button. So PaymentService reserves as soon as the money is confirmed, and the booking passes straight through pending_flight_booking to flight_booking_in_progress.
  • Filament booking actions on the booking detail page (Book All Flights / Book Flight per-leg / Retry), unchanged. They remain the way in when the automatic path is skipped or fails.

Both go through FlightBookingDispatchService::dispatchPendingLegs(), so when to reserve is the only thing that differs between them. Two properties of that dispatch are worth knowing:

  • Jobs are queued afterCommit. The payment router runs inside a transaction and the queue connections use after_commit=false, so a worker starting early would reserve against a payment that had not landed yet — or one that then rolled back.
  • A leg that already holds a FlightBooking is never re-sent, and a booking whose every leg is reserved keeps its current status. Moving it to flight_booking_in_progress with nothing queued would show neither Book Flight nor Retry and lock the operator out.

Only a Redsys payment reserves on its own. This is an allow-list, not “anything that is not offline”: a gateway enabled later must be considered on its own merits before it starts committing us to suppliers, so silence means “not yet” rather than “go ahead”. An offline confirmation (bank transfer, or a card an operator charged by hand) never triggers it: that is a recovery flow with somebody already inside it — often building the booking from a checkout that never completed — so the reservation stays their call and the booking waits at pending_flight_booking. A payment the router cannot identify is treated the same way, since reserving commits us to a supplier and must not run on a guess.

Issuing the ticket is never automatic.

Once a leg holds a PNR, a separate Issue Flight / Issue All Flights action (#2206) sends it to Aerticket for ticket issuance (app/Filament/Resources/Bookings/Pages/ViewBooking.php). It is guarded by FlightBooking::canIssueTicket() (app/Models/FlightBooking.php): only non-manual bookings that hold a PNR and are not already issued, ticketing, voided, or cancelled are issuable.

Process (per leg):

  1. The trigger transitions the 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: a cache lock on checkout-flight-booking:{bookingId}:{legIndex}, held across the booking call, with a per-leg FlightBooking::where(booking_id, leg_index)->exists() check inside it. The check on its own does not cover concurrency: its row lock is released when its transaction commits, which happens before the supplier call, so two concurrent deliveries of the job would both find no booking and both create a PNR. See Queue System for the jobs that run longer than retry_after and how each is guarded.

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

A reservation holds a price, and the airline holds it for less time than anyone assumed. Every pnrRuleSet — on the booking response and on every retrieve — carries two deadlines, and they expire different things:

Field Stored as What runs out Observed
fareTicketTimeLimit flight_bookings.fare_expires_at The price we reserved at Midnight of the booking day
ticketTimeLimit flight_bookings.reservation_expires_at The seat — the airline cancels the PNR for want of a ticket 3 to 21 days, airline-dependent
automatedTicketTimeLimitCancellation.text flight_bookings.reservation_expiry_note The airline’s own wording for that cancellation e.g. ET CANCELLATION DUE TO NO TICKET

Losing the first costs money; losing the second costs the seat.

Aerticket sends local wall-clock times plus a timezone (Europe/Madrid). They are stored in UTC — reading them as UTC would place a 23:59 Madrid deadline two hours after the price was already gone. Both are refreshed on every retrieve, because an airline can move a ticketing time limit after booking. A PNR that publishes no limit leaves the columns null, which reads as unknown, never as expired.

The sweep. flights:protect-expiring-fares runs hourly (routes/console.php) and queues ProtectExpiringFareJob for every unissued reservation whose fare dies within 12 hours. Twelve rather than the one hour originally proposed: the fare typically dies at midnight, and an approval request raised at 23:00 reaches nobody until the price is already lost. Pass --hours= to change the window or --dry-run to see what would be queued.

The three outcomes, all recorded and all visible to operations:

  1. Within the ±5 EUR tolerance (SettingsService::getRepricingTolerance()) — the new price is taken, repriced_at stamped, and a notification sent. Announced even when small: re-pricing without a cap is only acceptable while every instance is visible to somebody.
  2. Outside tolerance — the booking enters the same approval flow issuance uses: the same pending_reprice_approval status and reprice_* columns, so the approval action already in Arkana picks it up with no second mechanism beside it. The price is not taken; a person decides.
  3. The fare is gone — reported at once with the airline’s own wording rather than after three futile retries, since a fare the airline no longer recognises will not recognise it later either. The reservation is deliberately left untouched. Cancelling it to re-enter the market risks the seat, and a seat lost in high season cannot be bought back at any price, where a fare increase can. The moment is stamped on the reservation (fare_lost_at, fare_lost_reason), because a bell notification is read once and gone.

Those bookings are listed under the Fare expired, needs attention filter on the Flight Bookings table, which has two ways in: the fare we established was gone (fare_lost_at), and the deadline that passed with no re-price (fare_expires_at). They do not always arrive together — a PNR the airline has already cancelled reports its fare gone while its own clock still runs — and a filter watching only the clock would leave those invisible until it ran out.

Past ticketTimeLimit an airline cancels an unissued PNR itself, and a retrieve is how we find out. This is the one case where acting carries no risk, and it is the exception to everything above: the seat is already gone, so re-booking cannot lose anything and the sooner it runs the closer the market is to the price the customer was quoted.

AerticketRetrieveBookingJob queues RebookCancelledReservationJob, which marks the dead reservation, frees its leg, and books a replacement through the same FlightBookingDispatchService the operator’s button uses — inheriting the search filters, the same-flight requirement and the re-pricing tolerance, with a price beyond it still stopping for human approval.

It fires only for cancellations the airline made. The two are told apart by our own record: cancelling from Arkana writes booking_status = 'cancelled' before calling Aerticket, so a PNR the supplier reports as gone while we still consider it live can only have been dropped on their side. Re-buying a seat an operator deliberately released would be a booking nobody asked for, and the supplier reports both cancellations identically.

Two consequences worth knowing:

  • flights_confirmed → flight_booking_in_progress is a permitted transition, because a trip whose airline dropped a leg genuinely is back to booking flights. Without the way back it would sit in flights_confirmed holding a flight that no longer exists.
  • A cancelled reservation no longer counts as a held leg, which is what lets the dispatcher see the leg as open again.

A booking with no passengers attached is refused by the status machine rather than re-booked — loudly, which is the right outcome for a trip missing the data the supplier needs.

Source: app/Jobs/RebookCancelledReservationJob.php, FlightBooking::wasCancelledByTheAirline()

Re-pricing itself lives in FareRepricingService, shared with AerticketIssueTicketJob, so “the price moved” cannot come to mean two different things depending on which path asked.

Source: app/Console/Commands/ProtectExpiringFaresCommand.php, app/Jobs/ProtectExpiringFareJob.php, app/Services/Flights/Aerticket/DTOs/Response/TicketingDeadlines.php, app/Services/Flights/Aerticket/FareRepricingService.php

Ticketing is now observed end-to-end rather than assumed (#2199). Aerticket’s ticket/fastlane endpoints return a PNR locator but no reliable “ticketed” status in the immediate response, so the issue, fastlane, and create-booking jobs no longer mark ticket_status = 'issued' directly.

  • Interim ticketing state. AerticketIssueTicketJob, AerticketFastlaneTicketingJob, and AerticketCreateBookingJob (instant-ticket path) set the booking to an interim ticket_status = 'ticketing' after a successful call. The full set of states is pending | not_issued | pending_reprice_approval | ticketing | issued | voided | failed (app/Models/FlightBooking.php).
  • Retrieve is the authoritative promoter. A confirming AerticketRetrieveBookingJob reconciles against RetrievePlus and is the only place ticketing → issued happens: a TICKETED PNR promotes to issued; a locally-issued booking that is not actually TICKETED is corrected back to ticketing.
  • ISSUED pnrStatus recognized. RetrievePlus may return ISSUED (not TICKETED) for a fully ticketed PNR; RetrievedBooking::isTicketed() treats both as ticketed (STATUS_TICKETED, STATUS_ISSUED).
  • Structured per-stage logging. AerticketLogContext::for($flightBooking, $stage, $extra) builds a stable, greppable log context (stage, PNR locator, booking ids, ticket/pnr status) with the request’s apihubflowid merged in (app/Support/Aerticket/AerticketLogContext.php). Logs ship via LOG_STACK=stderr,nightwatch so they reach both Loki and Nightwatch.
  • “Flights Logs” Filament resource. A read-only Filament resource (app/Filament/Resources/FlightLogs/) surfaces the captured ticketing logs in the admin panel.

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);
}
Exception Description
AerticketValidationException Invalid input
AerticketTimeoutException Request timeout
AerticketSearchException Search error
AerticketVerifyException Fare verification error
AerticketBookingException Booking error
AerticketTicketIssueException Ticket issuance error
AerticketRePricingException Re-pricing error
AerticketFareExpiredException Fare expired (410)
AerticketPriceChangeException Price changed
AerticketDailyBudgetExceededException Internal 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).

Mode Behaviour
off (default) Production. Always hit AerTicket. Never read or write fixtures.
replay Read-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_record Replay 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.
record Always 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 the relevant timeout in .env (there is no single AERTICKET_TIMEOUT; timeouts are per environment and per operation):

Terminal window
# Per environment (config/aerticket.php)
AERTICKET_UAT_TIMEOUT=60
AERTICKET_PROD_TIMEOUT=60
# Search operations override the environment timeout
AERTICKET_SEARCH_TIMEOUT=60

Check logs:

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

The apihubflowid is required when contacting AerTicket support.