Skip to content

Embed integration guide

Operator tech-team facing. How to drop the ExpeditionInsure quote widget into your site, what events it emits, what we exchange with you, and how to get it rendering.

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, premium: number, currency?: string }A traveler clicks a plan card. premiumCents (integer minor units) is canonical; premium (whole dollars) is deprecated.
quote.error{ code?: string, message?: string }The quote/options request failed.
payment.succeeded{ quoteId?: string, planId?: string, premiumCents?: number, premium?: number, currency?: string }The insurance PaymentIntent confirmed inside the iframe. premiumCents is canonical; premium is deprecated.
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). premiumCents is the canonical premium field on both quote.selected and payment.succeeded. The whole-dollar premium field is deprecated (kept for back-compat, derived as Math.round(premiumCents / 100)) and will be removed — migrate to premiumCents and divide by 100 yourself for display.

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 the deprecated premium (whole dollars) and treating it as cents, or vice-versa; or tripCost as per-traveler.Read canonical 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.

9. Test & evaluation regime + sandbox readiness (shipped June 2026)

Section titled “9. Test & evaluation regime + sandbox readiness (shipped June 2026)”

A regimented automated test + evaluation suite specific to the embed shipped to production on 2026-06-11 (PR #1213, Linear EXP-1110). It exists so embed-specific failure modes are caught here before your technical team hits them in your own sandbox. Coverage maps directly to the six concerns operators raise:

  • Documentation — this guide (script include, mount() params, .on() event reference, allowlist/key provisioning, CSP + sandbox requirements, the sandbox-testing checklist).
  • Responsive (mobile/tablet/desktop) — auto-height iframe resizing (no inner scrollbars) and a plan-card grid that reflows 1→2→3 columns; tested across phone/tablet/desktop viewports.
  • Correct data pulling in — automated checks confirm the trip data you pass (destination, dates, ages, trip cost, currency, residence) round-trips into the rendered quote, and that premiums always display in whole dollars.
  • Order capture / attribution — verified that an embed-created quote is captured and attributed to the correct operator, and that your page receives the quote.selected / payment.succeeded events.
  • Host-checkout isolation — sandboxed cross-origin iframe + a locked-down postMessage channel; a failed or erroring embed cannot block or navigate your page. Explicitly tested.
  • Security — see the fixes below.
  • F1/api/embed/options is now strictly operator-scoped, and a malformed or foreign quote reference returns a uniform 403 (no CORS, no body) instead of a distinguishable 500. Previously a malformed id hit the v.id() argument validator and escaped the HTTP action as a 500 — an existence/validity oracle. Fixed by accepting the id as a raw string and normalizing it (ctx.db.normalizeId) inside the query (a try/catch around runQuery does not catch Convex’s ArgumentValidationError). Verified live in production: allowlisted origin + malformed id → 403 (confirmed it’s the post-gate path, not a gate block, via the empty-id 400+CORS contrast).
  • F2 — the widget no longer falls back to posting events to a wildcard target; quote/payment payloads can never broadcast to an unintended parent frame.
  • F3 — operator API keys now record a last-used timestamp for auditability.
  • 26 Convex unit tests — operator gate, origin-allowlist resolver, F1 authz (incl. a malformed-id regression).
  • Playwright embed project (22/24 green) — drives the real widget.js loader against a deployed env: loader injection, data integrity, order capture, responsive, host isolation, and a security suite (401/403/429 + F1/F2). Two specs are tracked as a follow-up (Linear EXP-1132) and are test-quality, not product, issues.
  • scripts/embed/probe.mjs — a read-only adversarial probe (forged origin, unknown/revoked key, cross-operator id, rate-limit ceiling, secret-leak scan) runnable against any environment.
  1. Send us the origin(s) you’ll embed from (e.g. https://sandbox.yourdomain.com).
  2. We provision a publishable key (pk_op_…) allowlisted to those origins.
  3. Add the widget.js <script> tag, call ExpeditionInsure.mount() with your trip details, and subscribe to the events. An unapproved origin gets a blank, fail-closed frame — never data.

The step-by-step checklist is in §7 above.