Skip to content

Embed integration guide

This guide is for developers integrating ExpeditionInsure into an operator’s website. The ExpeditionInsure widget embeds a travel-insurance quote directly in your pages, so your travelers can get a quote — and buy a policy — without leaving your site.

Below you’ll find everything you need to ship it: adding the script and mounting the widget, the configuration you pass in, the events it sends back to your page, the keys and origins we exchange to authorize your site, the security model, and a checklist for testing it in your sandbox.

Add one script tag, then call mount(target, config):

<div id="quote"></div>
<script src="https://expedition.insure/widget.js"></script>
<script>
ExpeditionInsure.mount("#quote", {
pk: "pk_op_...", // your publishable key (we issue this)
destination: "antarctica",
startDate: "2026-12-01",
endDate: "2026-12-14",
ages: [45, 47],
residence: "US",
tripCost: 18000, // whole dollars
travelers: 2,
});
ExpeditionInsure.on("quote.selected", (e) => console.log(e));
</script>

widget.js is a tiny, React-free loader (apps/embed-widget/src/loader.ts). On mount() it injects a sandboxed <iframe> into your target element pointing at https://expedition.insure/embed/quote?<params>. The iframe content (the quote form + options) is served from our origin; your page never handles insurance data directly.

  • target — a CSS selector string ("#quote") or an HTMLElement. If it matches nothing, mount() throws.
  • config — see the params table below. pk is required; everything else is optional and pre-fills the form. Missing pk throws.
  • mount() returns the created HTMLIFrameElement (handy for styling the wrapper).
  • The iframe auto-sizes to its content (no inner scrollbars) via an ei:resize relay, so you don’t set a height.

The global is window.ExpeditionInsure with { mount, on, embedOrigin }. embedOrigin exposes the origin this build trusts (useful when debugging).

All params except pk are optional. Arrays are serialised to CSV in the iframe URL.

ParamTypeExampleNotes
pkstring (required)"pk_op_a1b2c3..."Your publishable key. Public by design — see §4.
destinationstring"antarctica"Primary destination slug/name.
startDatestring (YYYY-MM-DD)"2026-12-01"Trip departure date.
endDatestring (YYYY-MM-DD)"2026-12-14"Trip return date. Duration is derived from startDateendDate.
agesnumber[] or CSV string[45, 47] / "45,47"One age per traveler. Strongly recommended — without ages the estimate quality drops.
residencestring"US"Traveler residence (country code). Drives carrier eligibility.
tripCostnumber or string18000Total trip cost in whole dollars (not per-traveler, not cents).
travelersnumber or string2Number of travelers.
refstring"booking-9281"Your attribution/reference tag, echoed back to us for reconciliation.
logoUrlstring (URL)"https://cdn.you/logo.svg"Co-brand logo shown in the widget header.
accentColorstring (CSS color)"#00B4A0"Accent color for buttons/links inside the widget.

Empty or undefined values are dropped from the iframe URL. A host=<your-origin> param is added automatically by the loader (it’s advisory — the real trust boundary is the server-side allowlist in §4).

Subscribe with ExpeditionInsure.on(eventName, handler). It returns an unsubscribe function. Unknown event names throw. Handlers run isolated — a throw in your handler is caught and logged, never breaking the bridge.

The widget bridges a namespaced ei:* postMessage channel, origin-locked in both directions (inbound only from our embed origin + the exact iframe; outbound only to the iframe with our origin as targetOrigin, never "*"). The ei: prefix is stripped before your handler sees it.

EventPayload shapeFires when
quote.ready{ quoteId?: string, plansCount?: number }The quote form first renders / options are ready.
quote.selected{ planId: string, premiumCents: number, currency?: string }A traveler clicks a plan card. Read premiumCents — the premium in minor units (e.g. cents).
quote.error{ code?: string, message?: string }The quote/options request failed.
payment.succeeded{ quoteId?: string, planId?: string, premiumCents?: number, currency?: string }The insurance PaymentIntent confirmed inside the iframe. Read premiumCents (minor units).
payment.failed{ code?: string, message?: string }The insurance payment failed.
ExpeditionInsure.on("quote.ready", (e) => console.log("ready", e.quoteId, e.plansCount));
ExpeditionInsure.on("quote.selected", (e) => console.log("picked", e.planId, e.premiumCents, e.currency));
ExpeditionInsure.on("quote.error", (e) => console.warn("error", e.code, e.message));
const off = ExpeditionInsure.on("payment.succeeded", (e) => {
// Insurance is paid (separate rail). Now advance/gate YOUR trip-charge checkout.
startTripCheckout(e.quoteId);
});
// off(); // unsubscribe later

Read premiumCents (integer minor units). Both quote.selected and payment.succeeded include premiumCents — the premium in the smallest currency unit (e.g. cents). Divide by 100 when you display it. It is the precise value to store and reconcile against.

Separate payment rails. payment.succeeded / payment.failed report only the insurance charge, which is captured inside our iframe. Your trip charge is a separate rail — use these events to sequence or gate your own checkout step; never treat insurance payment as your trip payment.

What you give us: the exact origins your widget will run on (e.g. https://www.youroperator.com, plus any staging/sandbox origin). These become your per-operator Origin allowlist. Origins are scheme + host (+ optional port) only — no path, query, or fragment.

What we give you: a publishable key pk_op_…. You pass it as config.pk.

Accepted-risk note — pk_op_ is public by design. The publishable key is meant to ship in your page’s HTML; it is not a secret. The trust boundary is not key secrecy. It is enforced server-side by:

  1. Per-operator Origin allowlist — the API rejects any request whose Origin isn’t on your list (a bare 403, no CORS headers, so the browser blocks the body), and the iframe only renders when framed by an allowlisted origin (fail-closed frame-ancestors, see §6 + §7).
  2. Rate limit — 60 requests / minute / key (fixed window; a 429 with Retry-After when exceeded).

So a copied key is useless from a non-allowlisted origin. Tell us promptly if your set of origins changes so we can update the allowlist.

You don’t call these directly — the iframe does — but knowing the contract helps when debugging. Both endpoints are authed by the publishable key (X-EI-Publishable-Key header, or ?pk= fallback on GET) and gated by allowlist + rate limit:

  • POST /api/embed/quote — creates the quote and synchronously generates estimates. Returns { quoteId, quoteNumber, instantQuoteEligible }. Required body fields: destination (string), tripCost (number, whole dollars), travelers (number), residence (string), email (string). Missing/invalid → 400.
  • GET /api/embed/options?quoteId=… — fetches instant options. While estimates are still generating it returns { options: [], pending: true } (poll, don’t treat as an error).

CORS never reflects *: only your matched allowlisted origin is echoed, with Vary: Origin, credentials off (the pk is the auth, not cookies).

On your page (Content-Security-Policy). If you run a CSP, allow our origin to be a framed child and a script source:

script-src 'self' https://expedition.insure;
frame-src https://expedition.insure;

(child-src if you target older browsers.) You do not need to relax connect-src — the API calls happen from inside our iframe, on our origin.

The sandbox we set on the iframe (you don’t set this — listed so your CSP/security review knows what to expect):

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation"
  • allow-scripts + allow-same-origin — required for the quote app and Stripe.js. Safe because the framed document is our origin, not yours.
  • allow-forms — the quote/checkout forms.
  • allow-popups — provider/hosted flows that open a tab.
  • allow-top-navigation-by-user-activation — lets the Buy→hosted-checkout handoff navigate the top window only on a user click (the Buy button). The iframe can never silently redirect your page. If your own outer sandbox strips this, the loader covers it via an origin-validated ei:navigate fallback (it only ever navigates to a URL on our embed origin).

Our side (frame-ancestors). Every /embed/* response carries a Content-Security-Policy: frame-ancestors <your allowlisted origins> header, resolved from your pk. If your origin isn’t on the list it resolves to frame-ancestors 'none' and the iframe refuses to render — fail-closed (see troubleshooting §8).

Before go-live, verify on a non-production allowlisted origin:

  • We’ve added your test/staging origin to the pk_op_… allowlist.
  • widget.js loads (no CSP script-src violation in the console).
  • The iframe renders (no frame-ancestors violation — see §8 if blank).
  • quote.ready fires with a quoteId and plansCount > 0.
  • Plan cards render; clicking one fires quote.selected with premiumCents (integer minor units) and a currency.
  • The iframe auto-heights with no inner scrollbar as content changes.
  • An intentionally bad input surfaces quote.error (not a silent hang).
  • (If using embedded checkout) payment.succeeded / payment.failed fire and your own trip-charge step sequences correctly off them.
  • Loading the same page from a non-allowlisted origin → iframe stays blank (proves fail-closed). This is the expected negative result.
  • ref round-trips so we can reconcile attribution.

We can provide a worked reference host page that mounts the widget and logs every event — ask us and we’ll share it alongside a sandbox key and an allowlisted origin.

8. Troubleshooting (the six failure modes)

Section titled “8. Troubleshooting (the six failure modes)”
SymptomLikely causeFix
widget.js doesn’t load / ExpeditionInsure is undefinedCSP script-src blocks our origin, or the script tag is wrong/missing.Add https://expedition.insure to script-src; confirm the <script src> URL; check the console for the CSP violation.
iframe won’t render (blank box)Your origin is not on the allowlist → the embed origin returns fail-closed frame-ancestors 'none'.Send us the exact origin (scheme+host+port) you’re loading from; we add it to the pk_op_… allowlist. Check the console for a frame-ancestors CSP violation to confirm. Also allow our origin in your own frame-src.
quote.error fires immediately (or 401/403 in network)Wrong/revoked pk, operator disabled, or request Origin not allowlisted.Verify the pk_op_… value; confirm the page origin is allowlisted; a 401 = bad key/disabled operator, a bare 403 = origin not allowed.
429 Too Many RequestsYou exceeded 60 requests / minute / key.Back off per the Retry-After header; don’t hammer the options poll — respect the pending shape.
Options never arrive (quote.ready ok, no plans)Estimates still generating — the options endpoint is returning { options: [], pending: true }.Poll /api/embed/options until pending clears; ensure you passed ages so estimates are quotable.
premium looks 100× too big/smallReading premiumCents (minor units) as if it were whole dollars; or treating tripCost as per-traveler.Read premiumCents (integer minor units) and divide by 100 for display; tripCost is the total trip cost in whole dollars.

If a value still looks wrong, reproduce it in test-host.html (it logs raw event payloads) and send us the quoteId + the logged events.