Skip to content

Travel Insurance (Intermundial)

Sells and issues Intermundial travel insurance in the checkout. Each market has a base policy that is included for free on every booking; the customer can upgrade to a higher tier (paid), which replaces the base. Prices are quoted live, and the real, irreversible policy is emitted out-of-band only after the booking is paid — so every booking emits a policy (the base, or the chosen upgrade).

  • A booking carries the included base policy (free) on the checkout extras step, or the customer upgrades to a paid tier.
  • A paid booking needs its insurance policy emitted with Intermundial.
  • Admins manage the catalogue of sellable policies per market, including which one is the included base.

Do NOT call the emission path (PostInsurance) directly: it is irreversible and must only run via ContractInsuranceJob after payment.

Required env vars (config/intermundial.php):

Terminal window
INTERMUNDIAL_ENVIRONMENT=sandbox # sandbox | production
INTERMUNDIAL_BASE_URL= # gateway; falls back to env default
INTERMUNDIAL_USERNAME=
INTERMUNDIAL_PASSWORD=
INTERMUNDIAL_API_KEY=

Optional overrides: timeouts/retries per environment, quote/contract tuning, logging, and cache TTLs. See config/intermundial.php for the full list. Notable optional keys (all commented in .env.example):

Env var Config key Default Purpose
INTERMUNDIAL_COUNTRY_CACHE_TTL intermundial.country_catalog.cache_ttl 86400 (24h) Country catalogue TTL
INTERMUNDIAL_POLICY_CACHE_TTL intermundial.policy_catalog.cache_ttl 86400 (24h) GetPolicy (policy details) TTL
INTERMUNDIAL_INCLUDED_COST_PER_PAX_FALLBACK intermundial.included_cost_per_pax_fallback 9.97 Per-pax fallback cost for the included base policy when its live quote is unavailable, so offer generation never blocks on the insurance API. Consumed by IncludedInsuranceCostService (which quotes at a REFERENCE_PAX of 2).

Sandbox vs production policies: The real Horizonte production policies (Selected ESB390 — the included base, Exclusive ESB391, Grand ESB392) live in production and are managed per market from the admin panel. SupplierInsuranceSeeder is environment-aware: it seeds the real Horizonte products (Selected flagged is_included) when pointed at production, and live UAT test products otherwise, so the policies endpoint returns resolvable data in either environment.

flowchart TD
    subgraph Checkout [Checkout — pre-payment]
        FE[InsuranceSelector React]
        FE -->|GET policies| POL[GET /checkout/insurance/policies]
        FE -->|live quote| QUO[POST /checkout/insurance/quote]
        FE -->|persist selection| SEL[PUT /checkout/insurance-selection]
        SEL --> SESS[(checkout session<br/>insurance_selection)]
    end

    subgraph Finalization [On successful payment]
        FIN[BookingFinalizationService] --> UPS[BookingUpsell type=Insurance]
        FIN --> CON[InsuranceContract status=pending]
        FIN -->|after commit| JOB[ContractInsuranceJob]
    end

    subgraph Emission [Out-of-band, irreversible]
        JOB -->|PostInsurance| IM[(Intermundial API)]
        JOB -->|success| ISS[InsuranceContract status=issued]
        JOB -->|failure| FAIL[InsuranceContract status=failed + Sentry]
    end

    SESS --> FIN

backend/app/Services/Insurance/Intermundial/ wraps the Nexus API:

Service Responsibility
IntermundialAuthenticationService Token login + cache (8h, with buffer)
IntermundialClientService HTTP client, env routing, retries; invalidates the cached token and retries once on 401 or 403 (#2192)
IntermundialQuoteService GetPolicy + PostPricing (live quote)
IntermundialContractService PostInsurance (irreversible emission) + certificate download
IntermundialCountryCatalogService Country catalogue (see below)

IntermundialCountryCatalogService resolves ISO codes to Intermundial’s proprietary idDyn, required by the contract for countryDestiny/countryOrigin. It fetches the undocumented GET policies/v5/country endpoint (returns countries with idDyn, isoCode2/3, and translations) and caches the result for the configurable TTL above. The catalogue is autodiscovered via the API — no external Intermundial file is needed.

Methods: all, findByIsoCode2, toContractCountry.

Source: backend/app/Services/Insurance/Intermundial/IntermundialCountryCatalogService.php

CheckoutInsuranceService bridges the SDK and the checkout:

  • getAvailablePolicies(Market) — reads supplier_insurances rows scoped to the market plus global ones (market_id IS NULL), ordered with the included base first, then enriches each with live GetPolicy data (is_included, age brackets, and the customer-facing coverage_info).
  • getResolvedQuote(array) / priceSelection(array, paxNum) — live PostPricing quote that also resolves the coverage params (destination + duration) server-side.
  • contractInsurance(array)PostInsurance; re-quotes to recover a valid basePrices.idDyn when one was not supplied.

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

Sellable policies live in the supplier_insurances table. The market_id column scopes a policy to a single market (or global when null), and the is_included flag marks the auto-applied base policy (at most one per market; the rest are paid upgrades). Managed from the Filament SupplierInsuranceResource (Suppliers group → “Insurance Policies”).

Source: backend/app/Filament/Resources/SupplierInsurances/SupplierInsuranceResource.php, backend/database/seeders/SupplierInsuranceSeeder.php

Issued contracts expose a “Download certificate” action on the Filament InsuranceContract view page (visible only when the contract is issued and has an Intermundial contract_id). It fetches the policy certificate live from Intermundial’s reports endpoint (GET reports/v5/insurance/{contractId}) in the compact “mini” format: key policy info plus QR links to the full documentation (general conditions + IPID) on the last page, which is why those documents are excluded from the PDF itself (mini=true, exc_generalConditions=true, exc_ipid=true). The certificate also carries the traveler-facing Intermundial App block (eSIM redemption).

The endpoint responds with a JSON envelope ({fileName, base64File, …}), parsed by CertificateResponse, which decodes the PDF and validates its signature. Nothing is stored — every download is fetched live.

Source: backend/app/Services/Insurance/Intermundial/IntermundialContractService.php (downloadCertificate), backend/app/Filament/Resources/InsuranceContracts/Pages/ViewInsuranceContract.php

All endpoints are under /api/{market}/{lang}/checkout. The quote endpoint runs behind the stateful.api (session) middleware and is rate-limited.

Lists sellable policies for the market (market-scoped + global), enriched with live Intermundial data — including is_included and the customer-facing coverage_info (headline bullets + scope description). The included base is listed first. Returns 408 on timeout, 502 on upstream error.

Live price quote. Rate limit: 20/min. Request: see GetInsuranceQuoteRequest. Returns retail_price (whole-party total, already factors in pax_num), formatted_price, coverage_extensions, and base_prices_id_dyn. For paid upgrades the returned retail_price is the customer price — the offer’s margin (read from the checkout session) applied on top of the Intermundial retail, then marketing-rounded per pax — so the selector card shows exactly what selecting the policy will charge. The included base (resolved with the same market scoping as the policies listing) keeps its raw quote.

Persists (or clears, when insurance is null) the chosen policy in the checkout session and recalculates the total. Request: see UpdateInsuranceSelectionRequest. The price is re-quoted server-side on every change (the client-sent price is discarded), and is_included is derived from the chosen policy — the included base is forced to 0 for the customer while keeping its real quoted price for the emission. The session response never exposes the included policy’s amount: its retail_price comes back as null (the price is bundled into the package, an internal cost only).

Source: backend/app/Http/Controllers/Api/InsuranceController.php, backend/app/Http/Controllers/Api/CheckoutController.php (updateInsuranceSelection)

The included base policy is free to the customer as an extra: it adds 0 to extras_price/total_price, even though it carries a real retail_price. Its cost is bundled into the offer’s package price as a dedicated component (offers.insurance_base_price, see Offers — Included Insurance Component), so the customer pays it inside the package without it ever being broken out (the emitted contract still records the real amounts). Only a paid upgrade adds to the total, at its customer price: the raw Intermundial whole-party retail with the offer’s margin applied on top, rounded per pax with the same marketing rounding offer prices use (CheckoutInsuranceService::clientPriceFor), never multiplied by the traveler count. The selection stores that customer price as retail_price and keeps the raw Intermundial retail alongside as cost_retail_price — an internal cost basis that CheckoutSessionResource strips before the payload reaches the client. The is_included flag is derived server-side from the catalogue, never trusted from the client. The BookingUpsell (type Insurance) stores the customer price (0 for the base, the margined price for an upgrade) as both unit_price and total_price with quantity = 1, plus the raw quoted whole-party retail as cost_price — shown as “Internal Cost” on the Filament booking view, so the breakdown exposes the real margin per upgrade (and the cost Volāre absorbs for the included base).

When a live flight search reprices the offer during checkout, the base_price recompute preserves the included-insurance component (base_price = flight + land + insurance), so the bundled base-policy cost is never dropped on reprice (#2189; see Dynamic Flight Cache — Economy Flight Cache Update).

Source: backend/app/Services/Checkout/CheckoutSessionService.php (calculateInsuranceExtrasPrice), backend/app/Services/Booking/BookingFinalizationService.php

The real policy is never emitted during checkout. On successful payment, BookingFinalizationService::finalizeFromCheckoutSession() first backfills the included base when the session carries no insurance selection but the market has one — the frontend auto-apply is async, so a fast customer can reach payment without it. The base is resolved with a live quote outside the transaction and degrades gracefully on failure, guaranteeing the base always emits without ever adding to the customer total. It then:

  1. Creates the BookingUpsell (type Insurance).
  2. Creates a pending InsuranceContract, capturing every parameter the job needs (the checkout session is gone by the time the job runs) plus a product_name snapshot, so the admin keeps showing the policy name even if the supplier_insurances row is later deleted or replaced.
  3. After the DB transaction commits, dispatches ContractInsuranceJob.

ContractInsuranceJob (queued, $tries = 3, progressive backoff):

  • Idempotent by the unique booking_id on insurance_contracts; returns immediately if the contract is already issued (never re-emits an irreversible policy).
  • Builds insured_list from the booking’s passengers (first passenger is the main insured).
  • Resolves destination country from the product (offer → productByMarket → getDestinationCountryIsoCode()) and origin from the main passenger’s residence country, falling back to the booking market’s first country code — both mapped to Intermundial idDyn via the country catalogue.
  • On success → contract issued with the emitted policy details.
  • On Intermundial failure → contract failed + reported to Sentry. The already-paid booking is never rolled back.

Every insurance call needs the trip’s destination as an ISO-3166-1 alpha-2 code: it identifies the country to Intermundial and selects the price zone the premium is quoted against, so a wrong or missing value is not cosmetic — it either overprices the policy or makes it impossible to issue.

ProductByMarket::getDestinationCountryIsoCode() is the single resolver:

destination = products_by_market.destination_country_code
?? first linked CMS country with a non-empty iso_code
?? null (the caller fails loudly)

The explicit column is set per product in Arkana and is the source of truth. The CMS association stays as a fallback for catalog products that predate the column, skipping countries whose iso_code was never filled. Bespoke quotes are never linked to a CMS country, so for them the column is the only source, and a product with no resolvable destination cannot be activated.

All four insurance entry points read that method, which is what keeps the price quoted, the coverage included and the policy emitted describing the same trip:

Entry point What it does
InsuranceController::quote (via the checkout options resources’ country_code) Prices the customer’s selection against the real zone
IncludedInsuranceCostService Costs the included base policy when generating offers
BookingFinalizationService::ensureIncludedInsurance Backfills the included base at checkout finalization
ContractInsuranceJob::resolveDestinationCountry Emits the policy

getPrimaryCountryIsoCode() still exists and is unrelated: it is the marketing country used by GA4.

Source: backend/app/Models/ProductByMarket.php (getDestinationCountryIsoCode)

Source: backend/app/Jobs/ContractInsuranceJob.php, backend/app/Models/InsuranceContract.php, backend/app/Enums/InsuranceContractStatus.php

Intermundial is never told a trip was cancelled — it has no way of knowing — so a policy nobody annuls stays live and stays billable. Cancelling a booking therefore has to annul its insurance, and by agreement with the broker the two product families are treated differently.

When a booking’s status changes to Cancelled, the model hook that already releases allotment also dispatches CancelInsuranceJob (queued, $tries = 3, same backoff as emission). Queued rather than inline: an operator cancelling a booking must not wait on the insurer’s API, nor have the cancellation fail with it.

InsuranceCancellationService::cancelForBooking() then decides:

Case Outcome
Contract not issued, or no contract_id Nothing — no policy exists at the insurer
Included policy (supplier_insurances.is_included) Always annulled via DELETE insurances/v5/insurance/{id}
Optional policy inside its published window Annulled
Optional policy outside it, or window unreadable Left alone, reason written to cancellation_note
Insurer answers 4xx Left alone, its own wording written to cancellation_note, not retried

The window is not hardcoded. Each policy publishes it in product.priceList.conditionsList as two entries — fecha_anulacion + campo_referencia naming the date it counts from, and fecha_anulacion + valor giving the days — read by PolicyResponse::cancellationWindowDays() off the already-cached policy payload. The figure genuinely varies: sandbox policy 19221 publishes two days where Intermundial’s own example shows three. A window counted from anything other than fecha_contratacion returns null, because that reference cannot be resolved and guessing would annul a policy out of window.

Annulling cannot be undone — recovering means emitting a fresh policy at today’s price — so the job is dispatched after the transaction commits (the status change runs inside one, and the queue connections use after_commit=false), and the service independently refuses any booking that is not actually cancelled. A rolled-back cancellation, or a job replayed by hand, must never destroy a live trip’s insurance.

Intermundial enforces its own window too, so the included policy’s “always attempt” rule can end in a refusal: 400 Bad Request with, verbatim, “El seguro solo puede ser cancelado ‘2’ días después de contratarse.” A 4xx is a decision about that policy and will be the same on every retry, so it is recorded immediately with the insurer’s own wording rather than retried — its wording is the part an operator can act on, where “failed after 3 attempts” six minutes later is not. 5xx, timeouts and transport errors still retry.

A policy that could not be annulled keeps status issued, since it is still live at the insurer however cancelled the trip is; saying otherwise would report a cancellation that never happened. What it gains is a cancellation_note, shown on the contract in Arkana and filterable there as Needs manual cancellation — the queue of cases the broker handles by hand. Failing to reach the insurer is treated as an unreadable window, never as permission, and the job’s failed() writes the same note once the retries are spent.

Date modifications (apidoc.intermundial.com/modificarseguro) are not implemented.

Source: backend/app/Jobs/CancelInsuranceJob.php, backend/app/Services/Insurance/InsuranceCancellationService.php, backend/app/Services/Insurance/Intermundial/IntermundialContractService.php

  • API client: frontend/src/features/checkout/api/insuranceApi.ts (fetchInsurancePolicies, fetchInsuranceQuote, updateInsuranceSelection).
  • InsuranceSelector (React) renders on the checkout extras step (Step 4, the transfers page). The included base shows an “Incluido” badge (auto-applied, not removable); the other tiers show a paid “Mejorar” action that replaces the base and reverts to it on removal. Each card’s coverage_info bullets sit behind a collapsible chevron (shared ProductCard pattern). The active policy is summarised on the summary/payment step (Step 7).
  • No billing address is collected at checkout. Intermundial’s emission only needs the policy holder’s country, which is derived from the main traveler’s residence country captured on the travelers step (Step 6).

Source: frontend/src/features/checkout/components/InsuranceSelector.tsx, frontend/src/features/checkout/components/SummaryPaymentPage.tsx

  • Each market has at most one included base policy, applied for free to every booking; the rest are paid upgrades. Every paid booking emits one policy.
  • The included base is free to the customer but has a real cost Volāre bears (recorded on the emitted contract).
  • No billing address is collected at checkout; emission needs only the policy holder’s country, derived from the main traveler’s residence country (Step 6).
  • Quote/contract require 1–20 passengers (pax_num).
  • A booking has at most one insurance contract (unique booking_id) to guarantee emission idempotency.
  • Emission is irreversible and only runs post-payment via the job.
  • A policy with no coverage params cannot be quoted and is surfaced as unavailable.