React
This guide shows how to mount the Expedition Insure quote embed inside a React component, subscribe to its events, and handle React-specific lifecycle pitfalls (StrictMode double-mounting and changing trip data). It’s for engineers integrating the widget.js loader into a React or Next.js app.
The embed loads as a sandboxed iframe served from expedition.insure; the parent-page loader exposes a small global, window.ExpeditionInsure, with exactly three members: mount, on, and embedOrigin. For the full loader contract see the integration guide, for the event payloads see events reference, and for the underlying HTTP surface see the embed API.
1. Load the script once
Section titled “1. Load the script once”Add the loader script to your page (or inject it once at app startup). It resolves its trust origin from its own src, so a single tag works everywhere:
<script src="https://expedition.insure/embed/widget.js" async></script>After it loads, window.ExpeditionInsure is available with:
| Member | Signature | Notes |
|---|---|---|
mount | mount(target, config) => HTMLIFrameElement | Creates and appends the iframe; returns it. |
on | on(eventName, handler) => () => void | Subscribes a handler; returns an unsubscribe function. |
embedOrigin | string | The embed origin this build trusts (read-only, for debugging). |
No
unmount()and noupdate()exist today. The global has onlymount,on, andembedOrigin. There’s no API to tear down an iframe or push new config into a mounted one. To change config, remount (see section 4).
2. Mount inside useEffect with cleanup
Section titled “2. Mount inside useEffect with cleanup”Mount in a useEffect keyed to a ref on the container element. Because there’s no unmount(), your cleanup function clears the container’s DOM yourself (el.innerHTML = ""), which removes the iframe the loader appended.
import { useEffect, useRef } from "react";
export function InsuranceQuote() { const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { const el = containerRef.current; if (!el || !window.ExpeditionInsure) return;
// mount() appends an <iframe> into `el` and returns it. window.ExpeditionInsure.mount(el, { pk: "pk_op_...", destination: "antarctica", startDate: "2026-12-01", endDate: "2026-12-14", ages: [45, 47], residence: "US", tripCost: 18000, travelers: 2, });
// Cleanup: there is no unmount(), so clear the container. // This removes the appended iframe and prevents duplicates. return () => { el.innerHTML = ""; }; }, []);
return <div ref={containerRef} />;}mount(target, config) throws if the target element can’t be resolved, or if config.pk is missing or not a string. Pass the HTMLElement directly (as above) rather than a selector string, so you don’t depend on the element being in the document at a fixed selector.
The iframe is appended with a 520px first-paint height; the loader then auto-resizes it to the real content height over the ei:resize channel, so you don’t manage height yourself.
The
pkis your publishable operator key (pk_op_...). It’s public by design, like a Stripepk_. The real trust boundary is your server-side origin allowlist and per-key rate limit, not key secrecy — so shipping it in client JavaScript is expected.
3. StrictMode double-mounting
Section titled “3. StrictMode double-mounting”In development, React StrictMode intentionally runs effects twice (mount, unmount, mount) to surface missing cleanup. Without cleanup, that would append two iframes into your container.
The el.innerHTML = "" cleanup in section 2 handles this: StrictMode’s first cleanup clears the container before the second mount runs, so you end up with exactly one iframe. Keep the cleanup even if you never enable StrictMode — it’s also what makes the remount workaround below correct.
Note on the loader side: mount() installs exactly one shared message listener for all instances (the install is idempotent), so re-mounting never stacks duplicate listeners. It does, however, leave prior iframes in place unless you clear them — which is exactly what the cleanup does.
4. Changing trip data: the remount workaround
Section titled “4. Changing trip data: the remount workaround”There is no update() on the embed today. A mounted iframe won’t pick up new trip data (a different destination, tripCost, dates, or ages) on its own.
To reflect changed trip data, remount: clear the container and call mount() again with the new config. Key the effect to the config values so React re-runs it whenever they change. Serialize the config to a stable string for the dependency array so object identity doesn’t force needless remounts.
import { useEffect, useMemo, useRef } from "react";
type QuoteConfig = { pk: string; destination: string; startDate: string; endDate: string; ages: number[]; residence: string; tripCost: number; travelers: number;};
export function InsuranceQuote({ config }: { config: QuoteConfig }) { const containerRef = useRef<HTMLDivElement>(null);
// Stable key so the effect only re-runs when values actually change. const configKey = useMemo(() => JSON.stringify(config), [config]);
useEffect(() => { const el = containerRef.current; if (!el || !window.ExpeditionInsure) return;
el.innerHTML = ""; // clear any prior iframe before remounting window.ExpeditionInsure.mount(el, config);
return () => { el.innerHTML = ""; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [configKey]);
return <div ref={containerRef} />;}This is the supported pattern until an in-place update() ships. [needs-product-work]
5. Subscribe and unsubscribe to events
Section titled “5. Subscribe and unsubscribe to events”window.ExpeditionInsure.on(eventName, handler) returns an unsubscribe function. Register listeners in their own useEffect and call the returned function in cleanup so handlers don’t leak across renders.
The valid event names are:
| Event | When it fires | Payload |
|---|---|---|
quote.ready | Quote created, plans loaded | { quoteId, plansCount } |
quote.selected | Traveler picks a plan card | { planId, premiumCents, premium, currency } |
quote.error | Quote generation failed | { code, message } |
payment.succeeded | In-iframe checkout completed (UX signal only) | { quoteId, planId, premiumCents, premium, currency } |
payment.failed | In-iframe checkout failed | { code, message } |
on() throws if you pass any other event name. Handlers are isolated — a throw in one of your handlers is caught and logged by the loader and can’t break the bridge or stop your other listeners.
import { useEffect } from "react";
export function useQuoteEvents() { useEffect(() => { if (!window.ExpeditionInsure) return; const { on } = window.ExpeditionInsure;
const offSelected = on("quote.selected", (payload) => { const p = payload as { planId: string; premiumCents: number; currency?: string; }; // premiumCents is integer minor units (cents); divide by 100 for display. console.log("selected", p.planId, p.premiumCents, p.currency); });
const offError = on("quote.error", (payload) => { const p = payload as { code?: string; message?: string }; console.warn("quote error", p.code, p.message); });
return () => { offSelected(); offError(); }; }, []);}Read
premiumCents— an integer in minor units (cents); divide by 100 yourself when you need dollars.
payment.succeededis a UX signal only — it can be missed (a closed tab, a dropped message). Never treat it as proof a policy was issued or a commission earned. The authoritative post-sale signal is the server-sidepolicy.issuedwebhook, delivered at-least-once and signed; reconcile against that. See webhooks.
payment.succeeded and payment.failed fire only in the in-iframe checkout flow. quote.ready, quote.selected, and quote.error fire in the base quote flow.
6. A note on TypeScript types
Section titled “6. A note on TypeScript types”The loader doesn’t ship a typed package, so declare the global yourself. Event payloads arrive as unknown — narrow them in your handlers (as shown above). Add a .d.ts to your project:
type EIEventName = | "quote.ready" | "quote.selected" | "quote.error" | "payment.succeeded" | "payment.failed";
interface EIMountConfig { pk: string; // required publishable key, "pk_op_..." destination?: string; startDate?: string; // ISO date endDate?: string; // ISO date ages?: number[] | string; residence?: string; tripCost?: number | string; travelers?: number | string; ref?: string; logoUrl?: string; accentColor?: string;}
interface ExpeditionInsureGlobal { mount(target: string | HTMLElement, config: EIMountConfig): HTMLIFrameElement; on(eventName: EIEventName, handler: (payload: unknown) => void): () => void; readonly embedOrigin: string;}
interface Window { ExpeditionInsure?: ExpeditionInsureGlobal;}pk is the only required config field; everything else is optional. Type handler payloads as unknown and cast per-event (the loader doesn’t type them for you), keeping premiumCents as the field you act on.
Full component example
Section titled “Full component example”A self-contained component that mounts, remounts on config change, and wires up events with proper cleanup:
import { useEffect, useMemo, useRef } from "react";
type QuoteConfig = { pk: string; destination: string; startDate: string; endDate: string; ages: number[]; residence: string; tripCost: number; travelers: number;};
export function InsuranceQuote({ config }: { config: QuoteConfig }) { const containerRef = useRef<HTMLDivElement>(null); const configKey = useMemo(() => JSON.stringify(config), [config]);
// Mount / remount when trip data changes. useEffect(() => { const el = containerRef.current; if (!el || !window.ExpeditionInsure) return;
el.innerHTML = ""; // clear prior iframe (StrictMode + remount safe) window.ExpeditionInsure.mount(el, config);
return () => { el.innerHTML = ""; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [configKey]);
// Subscribe to events; unsubscribe on unmount. useEffect(() => { if (!window.ExpeditionInsure) return; const { on } = window.ExpeditionInsure;
const offReady = on("quote.ready", (payload) => { const p = payload as { quoteId?: string; plansCount?: number }; console.log("ready", p.quoteId, p.plansCount); });
const offSelected = on("quote.selected", (payload) => { const p = payload as { planId: string; premiumCents: number; currency?: string }; // premiumCents is canonical (integer minor units). console.log("selected", p.planId, p.premiumCents, p.currency); });
const offError = on("quote.error", (payload) => { const p = payload as { code?: string; message?: string }; console.warn("error", p.code, p.message); });
return () => { offReady(); offSelected(); offError(); }; }, []);
return <div ref={containerRef} />;}For other frameworks, see the Vue and Next.js recipes. For the complete event catalog and payload shapes, see events reference.