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.
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, 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 laterRead
premiumCents(integer minor units).premiumCentsis the canonical premium field on bothquote.selectedandpayment.succeeded. The whole-dollarpremiumfield is deprecated (kept for back-compat, derived asMath.round(premiumCents / 100)) and will be removed — migrate topremiumCentsand divide by 100 yourself for display.
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 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.succeededevents. - Host-checkout isolation — sandboxed cross-origin iframe + a locked-down
postMessagechannel; a failed or erroring embed cannot block or navigate your page. Explicitly tested. - Security — see the fixes below.
Security fixes shipped in this pass
Section titled “Security fixes shipped in this pass”- F1 —
/api/embed/optionsis 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 thev.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 (atry/catcharoundrunQuerydoes not catch Convex’sArgumentValidationError). Verified live in production: allowlisted origin + malformed id → 403 (confirmed it’s the post-gate path, not a gate block, via the empty-id400+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.
Test surface (in-repo)
Section titled “Test surface (in-repo)”- 26 Convex unit tests — operator gate, origin-allowlist resolver, F1 authz (incl. a malformed-id regression).
- Playwright
embedproject (22/24 green) — drives the realwidget.jsloader 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.
How to test in your sandbox
Section titled “How to test in your sandbox”- Send us the origin(s) you’ll embed from (e.g.
https://sandbox.yourdomain.com). - We provision a publishable key (
pk_op_…) allowlisted to those origins. - Add the
widget.js<script>tag, callExpeditionInsure.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.