Shopify and server-rendered HTML
This page shows how to embed the Expedition Insure quote widget on a Shopify storefront or any server-rendered HTML page using a plain <script> include and a mount target. It’s for teams whose pages are rendered by a backend or a theme engine (Liquid, ERB, PHP, Razor) rather than a JavaScript framework.
The widget loads an iframe served from expedition.insure, so it works the same way regardless of how your page is built. If you use a framework, see the React guide or the Vue guide instead. For the full embed contract, the integration guide and the events reference are the source of truth.
What you need
Section titled “What you need”- A publishable key (
pk_op_...). It’s public by design, like a Stripepk_key — safe to ship in page source. The security boundary is your per-operator origin allowlist plus a per-key rate limit, not key secrecy. - Your storefront origin added to your operator origin allowlist. The widget’s iframe only posts events back to an allowlisted origin, so events stay silent until your origin is allowed.
1. Add the script and a mount target
Section titled “1. Add the script and a mount target”Drop two things into your page: an empty target element, and the loader script. Then call mount() once the script has loaded.
<!-- 1. A target element the widget mounts into --><div id="expedition-insure-quote"></div>
<!-- 2. The loader, served from expedition.insure --><script src="https://expedition.insure/widget.js"></script>
<!-- 3. Mount once the loader has defined window.ExpeditionInsure --><script> window.ExpeditionInsure.mount("#expedition-insure-quote", { pk: "pk_op_...", destination: "antarctica", startDate: "2026-12-01", endDate: "2026-12-14", ages: [45, 47], residence: "US", tripCost: 18000, travelers: 2, });</script>mount(target, config) resolves target (a CSS selector string or an HTMLElement), creates a sandboxed iframe pointed at https://expedition.insure/embed/quote, appends it into the target, and returns the HTMLIFrameElement. The iframe starts at 520px tall and then auto-resizes to its content — there are no inner scrollbars.
Order matters. The third
<script>must run afterwidget.jshas executed, because it readswindow.ExpeditionInsure. With a plain synchronous<script src="...widget.js">(as above), the loader runs first and the global is ready. If you mark the loaderasyncordefer, guard the mount call — see loading after async or deferred scripts.
mount() throws if
Section titled “mount() throws if”| Condition | Error message |
|---|---|
| Target selector matches nothing, or the element is missing | [ExpeditionInsure] mount target not found: <target> |
pk is missing, not a string, or empty | [ExpeditionInsure] mount requires a `pk` (publishable key). |
config parameters
Section titled “config parameters”All parameters are optional except pk. They pre-fill the quote form inside the iframe.
| Param | Type | Example | Notes |
|---|---|---|---|
pk | string (required) | "pk_op_..." | Your publishable key. |
destination | string | "antarctica" | Trip destination slug. |
startDate | string | "2026-12-01" | ISO date. |
endDate | string | "2026-12-14" | ISO date. |
ages | number[] or string | [45, 47] | Array is serialized to CSV. |
residence | string | "US" | Residence country code. |
tripCost | number or string | 18000 | Total trip cost (whole dollars), not per-traveler. |
travelers | number or string | 2 | Traveler count. |
ref | string | — | Your referral or tracking ref. |
logoUrl | string | — | Your logo for co-branding. |
accentColor | string | — | Brand accent color for the widget. |
The loader also appends your page origin as a host parameter automatically — you don’t set it. It’s advisory only and used as the iframe’s postMessage target; the real trust boundary is the server-side pk_op_ allowlist.
2. Listen for events
Section titled “2. Listen for events”Subscribe with window.ExpeditionInsure.on(eventName, handler). It returns an unsubscribe function. A handler that throws is caught and logged — it can’t break the widget or stop other handlers.
<script> const off = window.ExpeditionInsure.on("quote.selected", (e) => { // premiumCents is canonical — integer minor units (cents). console.log("plan", e.planId, "premium (cents)", e.premiumCents); });
// Call off() to stop listening.</script>The valid event names are quote.ready, quote.selected, quote.error, payment.succeeded, and payment.failed. Passing any other name throws. See the events reference for full payloads.
Read
premiumCents(integer minor units) — divide by 100 for display.
payment.succeededis a UX signal only. It tells you the buyer reached the success screen, and it can be missed (a dropped connection, a closed tab). The authoritative post-sale signal is thepolicy.issuedwebhook, delivered server-to-server, signed, and retried. Reconcile sales from the webhook, never frompayment.succeeded.
3. Shopify theme placement
Section titled “3. Shopify theme placement”On Shopify, the embed is just markup and script — place it where the theme renders HTML.
Theme section or template (Online Store 2.0)
Section titled “Theme section or template (Online Store 2.0)”Add the markup to a section file (sections/*.liquid) or a template (templates/*.liquid), or a custom block. Liquid is server-rendered, so the snippet ships in the page HTML exactly as written:
{%- comment -%} sections/insurance-widget.liquid {%- endcomment -%}<div id="expedition-insure-quote"></div><script src="https://expedition.insure/widget.js"></script><script> window.ExpeditionInsure.mount("#expedition-insure-quote", { pk: "pk_op_...", destination: "antarctica", });</script>Pre-fill from Liquid objects
Section titled “Pre-fill from Liquid objects”Because Liquid renders before the browser runs, you can inject trip data from the product, cart, or line-item properties straight into the config. Quote literal Liquid output into the JavaScript values:
<div id="expedition-insure-quote"></div><script src="https://expedition.insure/widget.js"></script><script> window.ExpeditionInsure.mount("#expedition-insure-quote", { pk: "pk_op_...", destination: {{ product.metafields.custom.destination | json }}, tripCost: {{ cart.total_price | divided_by: 100 }}, travelers: {{ cart.item_count }}, });</script>Mind Shopify money units. Shopify Liquid money fields (
cart.total_price, line-item prices) are in cents. The widget’stripCostis whole dollars, so divide by 100 as shown. Always pass strings/numbers through| jsonwhen injecting into JavaScript to avoid breaking the script on quotes or special characters.
App embed block vs. inline
Section titled “App embed block vs. inline”For a checkout-adjacent placement you control centrally, a theme app embed block or a single included snippet ({% render 'insurance-widget' %}) keeps one copy of the markup. For a one-off product page, inline the snippet directly in the template. Either way the contract is identical — one target <div>, one widget.js include, one mount() call.
Cross-cutting concerns
Section titled “Cross-cutting concerns”Loading after async or deferred scripts
Section titled “Loading after async or deferred scripts”window.ExpeditionInsure only exists after widget.js runs. If you load the loader with async or defer, your mount() call may run first. Two safe patterns:
Listen for the script’s load event:
<script src="https://expedition.insure/widget.js" onload="window.ExpeditionInsure.mount('#expedition-insure-quote', { pk: 'pk_op_...' })"></script>Or poll briefly for the global before mounting:
<script src="https://expedition.insure/widget.js" async></script><script> (function mountWhenReady() { if (window.ExpeditionInsure) { window.ExpeditionInsure.mount("#expedition-insure-quote", { pk: "pk_op_..." }); } else { setTimeout(mountWhenReady, 50); } })();</script>Single-page apps and theme route changes
Section titled “Single-page apps and theme route changes”Some storefronts (headless Shopify, themes with client-side section rendering, or any SPA-like navigation) swap page content without a full reload. The loader installs a single shared message listener once and reuses it across mounts, so the script include itself survives route changes. But the target <div> and the iframe inside it are torn out of the DOM when the view is replaced.
After each navigation that re-renders the container:
- Ensure the target element exists in the new view.
- Call
mount()again against it.
<script> function mountInsuranceWidget() { const target = document.querySelector("#expedition-insure-quote"); if (target && window.ExpeditionInsure) { window.ExpeditionInsure.mount(target, { pk: "pk_op_..." }); } }
// Call mountInsuranceWidget() in your router's "route changed" hook.</script>Updating trip data after mount
Section titled “Updating trip data after mount”There is no update() method and no unmount() method today. The widget global exposes only mount, on, and embedOrigin. To change config — for example, when the buyer edits trip dates, traveler count, or trip cost on your page after the widget has rendered — re-mount:
- Clear the target element’s contents (remove the old iframe).
- Call
mount()again with the new config.
<script> function remountWithConfig(config) { const target = document.querySelector("#expedition-insure-quote"); if (!target || !window.ExpeditionInsure) return; target.innerHTML = ""; // remove the previous iframe window.ExpeditionInsure.mount(target, { pk: "pk_op_...", ...config }); }
// e.g. remountWithConfig({ tripCost: 24000, travelers: 3 });</script>Clearing the target before re-mounting matters: each mount() call appends a new iframe and tracks it, and it does not remove a prior iframe for you. Emptying the container yourself keeps a single widget on screen.
Next steps
Section titled “Next steps”- Integration guide — the end-to-end embed contract.
- Events reference — full payloads for every
on()event. - Webhooks reference — the signed
policy.issuedwebhook, your authoritative post-sale signal.