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.
1. One-line include + mount
Section titled “1. One-line include + mount”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 anHTMLElement. If it matches nothing,mount()throws.config— see the params table below.pkis required; everything else is optional and pre-fills the form. Missingpkthrows.mount()returns the createdHTMLIFrameElement(handy for styling the wrapper).- The iframe auto-sizes to its content (no inner scrollbars) via an
ei:resizerelay, 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).
2. Config params (full reference)
Section titled “2. Config params (full reference)”All params except pk are optional. Arrays are serialised to CSV in the iframe URL.
| Param | Type | Example | Notes |
|---|---|---|---|
pk | string (required) | "pk_op_a1b2c3..." | Your publishable key. Public by design — see §4. |
destination | string | "antarctica" | Primary destination slug/name. |
startDate | string (YYYY-MM-DD) | "2026-12-01" | Trip departure date. |
endDate | string (YYYY-MM-DD) | "2026-12-14" | Trip return date. Duration is derived from startDate→endDate. |
ages | number[] or CSV string | [45, 47] / "45,47" | One age per traveler. Strongly recommended — without ages the estimate quality drops. |
residence | string | "US" | Traveler residence (country code). Drives carrier eligibility. |
tripCost | number or string | 18000 | Total trip cost in whole dollars (not per-traveler, not cents). |
travelers | number or string | 2 | Number of travelers. |
ref | string | "booking-9281" | Your attribution/reference tag, echoed back to us for reconciliation. |
logoUrl | string (URL) | "https://cdn.you/logo.svg" | Co-brand logo shown in the widget header. |
accentColor | string (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).
3. Event reference (.on())
Section titled “3. Event reference (.on())”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.
| Event | Payload shape | Fires 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 laterRead
premiumCents(integer minor units). Bothquote.selectedandpayment.succeededincludepremiumCents— 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.failedreport 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.
4. What we exchange — keys vs. origins
Section titled “4. What we exchange — keys vs. origins”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:
- Per-operator Origin allowlist — the API rejects any request whose
Originisn’t on your list (a bare403, no CORS headers, so the browser blocks the body), and the iframe only renders when framed by an allowlisted origin (fail-closedframe-ancestors, see §6 + §7).- Rate limit — 60 requests / minute / key (fixed window; a
429withRetry-Afterwhen 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.
5. The embed API (what the iframe calls)
Section titled “5. The embed API (what the iframe calls)”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).
6. CSP + iframe sandbox requirements
Section titled “6. CSP + iframe sandbox requirements”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-validatedei:navigatefallback (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).
7. Sandbox-testing checklist
Section titled “7. Sandbox-testing checklist”Before go-live, verify on a non-production allowlisted origin:
- We’ve added your test/staging origin to the
pk_op_…allowlist. -
widget.jsloads (no CSPscript-srcviolation in the console). - The iframe renders (no
frame-ancestorsviolation — see §8 if blank). -
quote.readyfires with aquoteIdandplansCount > 0. - Plan cards render; clicking one fires
quote.selectedwithpremiumCents(integer minor units) and acurrency. - 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.failedfire 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.
-
refround-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)”| Symptom | Likely cause | Fix |
|---|---|---|
widget.js doesn’t load / ExpeditionInsure is undefined | CSP 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 Requests | You 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/small | Reading 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.