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).
When to Use
Section titled “When to Use”- 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.
Configuration
Section titled “Configuration”Required env vars (config/intermundial.php):
INTERMUNDIAL_ENVIRONMENT=sandbox # sandbox | productionINTERMUNDIAL_BASE_URL= # gateway; falls back to env defaultINTERMUNDIAL_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). |
The insurer’s 24h assistance numbers shown to travellers are config, not
policy data: intermundial.assistance.phones and
intermundial.assistance.whatsapp, overridable via
INTERMUNDIAL_ASSISTANCE_PHONE_PRIMARY, INTERMUNDIAL_ASSISTANCE_PHONE_SECONDARY
and INTERMUNDIAL_ASSISTANCE_WHATSAPP (no .env.example entries — the
production numbers are the code defaults in config/intermundial.php). One
source feeds two surfaces — the pre-departure email and the trip page insurance
block — so they can never disagree.
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.
Architecture
Section titled “Architecture”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
Building Blocks
Section titled “Building Blocks”Intermundial SDK
Section titled “Intermundial SDK”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) |
Country catalogue
Section titled “Country catalogue”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
Checkout orchestration
Section titled “Checkout orchestration”CheckoutInsuranceService bridges the SDK and the checkout:
getAvailablePolicies(Market)— readssupplier_insurancesrows scoped to the market plus global ones (market_id IS NULL), ordered with the included base first, then enriches each with liveGetPolicydata (is_included, age brackets, and the customer-facingcoverage_info).getResolvedQuote(array)/priceSelection(array, paxNum)— livePostPricingquote that also resolves the coverage params (destination + duration) server-side.contractInsurance(array)—PostInsurance; re-quotes to recover a validbasePrices.idDynwhen one was not supplied.
Source: backend/app/Services/Checkout/CheckoutInsuranceService.php
Policy catalogue (admin)
Section titled “Policy catalogue (admin)”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
Certificate download (admin)
Section titled “Certificate download (admin)”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.
Certificate download (traveller)
Section titled “Certificate download (traveller)”The trip page offers the same certificate to the passenger, from the “Tu seguro de viaje” block. Unlike the admin action above, it serves the stored PDF rather than fetching live:
GET /api/trip/{booking}/insurance-certificateInsuranceCertificateService::resolveStoredPath() decides what to serve. It
refuses any policy that is not issued — pending, failed and cancelled have no
certificate — and otherwise returns the file recorded on
InsuranceContract::certificate_path. When that path is null or the file is
gone from disk, it fetches the certificate from Intermundial, stores it and
persists the path before serving. ContractInsuranceJob resolves the
certificate through the same service when it mails it after emission, so a
download that failed there is recovered the first time the traveller asks for
it, instead of needing a manual re-send from the backoffice.
Authorisation is the URL signature, not the passenger’s trip-access token.
Booking::generateCertificateUrl() mints an expiring signed link (15 minutes)
on every render of the trip page, for whoever already passed that page’s own
access check; the same link works in the admin preview, which carries no
passenger token. The PDF names and covers every passenger on the booking, so it
is streamed from the private disk (InsuranceContract::CERTIFICATE_DISK) and is
never reachable at a storage URL, which carries no authentication and would be
guessable from a contract id.
The link reaches the frontend as insurance.certificate_url on the booking
payload, emitted only for issued policies — see
Booking details. Failures answer with the same
JSON envelope as the rest of the trip endpoints: 404 certificate_unavailable
when there is nothing to serve, 503 certificate_fetch_failed when Intermundial
cannot be reached.
Source: backend/app/Services/Insurance/Intermundial/IntermundialContractService.php
(downloadCertificate), backend/app/Filament/Resources/InsuranceContracts/Pages/ViewInsuranceContract.php
Policy documents (traveller-facing)
Section titled “Policy documents (traveller-facing)”Each policy can hand the customer three PDFs — general conditions, the IPID (Insurance Product Information Document) and the cover summary — streamed through the app on stable per-policy public routes:
| Route | Document (column) | Served as |
|---|---|---|
GET /api/insurance-policies/{supplierInsurance}/conditions |
General conditions (conditions_document) |
condiciones-seguro.pdf |
GET /api/insurance-policies/{supplierInsurance}/ipid |
IPID (ipid_document) |
ipid-seguro.pdf |
GET /api/insurance-policies/{supplierInsurance}/price-summary |
Cover summary (price_summary_document) |
coberturas-seguro.pdf |
- The uploads are stored private (
SupplierInsuranceForm::documentUpload), so a raw disk URL would fail for the very traveller the link is shared with; these routes are the only way to reach the files. A missing or blank document returns 404. - The URLs are stable per policy: they always resolve to whichever file the policy currently holds, so a link mailed earlier keeps working after an admin replaces the PDF.
- Registered in
backend/routes/web.php(public,throttle:60,1). The path lives under/apibecause the production load balancer only forwards/api/*to the application on the public API host. Links are built through thePublicApiUrlhelper via theconditions_link_url/ipid_link_url/price_summary_link_urlaccessors onSupplierInsurance. - Surfaces: the trip page insurance block (
insurance.documentsin the Booking Details API) and the pre-departure email.
Source: backend/app/Http/Controllers/InsurancePolicyDocumentController.php,
backend/app/Models/SupplierInsurance.php, backend/routes/web.php
All endpoints are under /api/{market}/{lang}/checkout. The quote endpoint runs
behind the stateful.api (session) middleware and is rate-limited.
GET /checkout/insurance/policies
Section titled “GET /checkout/insurance/policies”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.
POST /checkout/insurance/quote
Section titled “POST /checkout/insurance/quote”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.
PUT /checkout/insurance-selection
Section titled “PUT /checkout/insurance-selection”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)
Pricing
Section titled “Pricing”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
Post-Payment Emission
Section titled “Post-Payment Emission”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:
- Creates the
BookingUpsell(typeInsurance). - Creates a
pendingInsuranceContract, capturing every parameter the job needs (the checkout session is gone by the time the job runs) plus aproduct_namesnapshot, so the admin keeps showing the policy name even if thesupplier_insurancesrow is later deleted or replaced. - After the DB transaction commits, dispatches
ContractInsuranceJob.
ContractInsuranceJob (queued, $tries = 3, progressive backoff):
- Idempotent by the unique
booking_idoninsurance_contracts; returns immediately if the contract is alreadyissued(never re-emits an irreversible policy). - Builds
insured_listfrom the booking’s passengers (first passenger is the main insured). - Sends the booking number (
booking.id) asbookingReference2, the agency reference Intermundial’s Safer panel shows as “Ref. Agencia”, so a policy can be matched to its booking and to Intermundial’s invoices.bookingReference1is not ours: Intermundial overwrites it with its own locator (N-…), verified against sandbox. Policies issued before this change keep an empty agency reference. - 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 IntermundialidDynvia the country catalogue. - On success → contract
issuedwith the emitted policy details. - On Intermundial failure → contract
failed+ reported to Sentry. The already-paid booking is never rolled back.
Destination country
Section titled “Destination country”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
Coverage dates
Section titled “Coverage dates”A policy runs from effect_date (the outbound departure day) to
unsuscribe_date, and unsuscribe_date must be the day the traveller lands
back home: the arrival date of the last segment of the selected return
flight, the same date the admin booking view shows. It is not the itinerary
end (Offer::getReturnDate(), departure_date + trip duration - 1) nor the
day the return flight departs (international_route.return_date). Overnight and
long-haul returns land a day later, and either of those dates would leave the
flight home uncovered.
CheckoutInsuranceService::coverageEndDateFor() resolves the date from the
session’s flight selection. It reads the last inbound segment’s arrivalDate,
falls back to international_route.return_date for selections without segments,
and returns null when there is no flight at all:
| Where | What it does |
|---|---|
TransferSelectionPage (frontend) |
Quotes with resolveTripEndDate(), the same arrival date, so the price shown matches the policy |
CheckoutSessionService::updateInsuranceSelection |
Overrides the client-sent unsuscribe_date with the flight’s before re-pricing, for upgrades and the included base alike |
BookingFinalizationService::alignInsuranceCoverageWithFlight |
Before the pending contract is written, re-quotes a selection whose end date no longer matches the booked flight (customer changed flights after choosing insurance, or an older session). Coverage params and cost are refreshed; the price charged to the customer is left as is. On a failed quote it keeps the selection and logs a warning |
BookingFinalizationService::ensureIncludedInsurance |
Backfills the included base with the same end date |
Land-only checkouts have no flight, so they keep the itinerary-based date the client sends. Emitted policies are irreversible: policies issued before this rule with a short end date have to be extended with Intermundial manually.
Source: backend/app/Services/Checkout/CheckoutInsuranceService.php
(coverageEndDateFor), backend/app/Services/Booking/BookingFinalizationService.php
Cancellation
Section titled “Cancellation”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
Frontend
Section titled “Frontend”- 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’scoverage_infobullets sit behind a collapsible chevron (sharedProductCardpattern). 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).
- The trip page shows the traveller their own policy in a dedicated
TripInsuranceblock (it replaced the old “Otros servicios” line item, which made the same policy appear twice): policy number, coverage dates, insured count, the insurer’s 24h numbers and the three policy documents above. While emission is still in flight (pending) the block says the policy is being issued instead of showing an empty number; afailedor cancelled emission is never surfaced. The block also expands when the trip page is printed (beforeprint), so the policy number and assistance numbers survive on paper. Data:insurancein the Booking Details API.
Source: frontend/src/features/checkout/components/InsuranceSelector.tsx,
frontend/src/features/checkout/components/SummaryPaymentPage.tsx,
frontend/src/features/trip/components/TripInsurance/TripInsurance.tsx
Business Rules
Section titled “Business Rules”- 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.
Related
Section titled “Related”- Checkout API — full checkout flow
- Payment Gateway System — deposit/balance split
- Bookings —
BookingUpsellmodel - Queue System — job processing