Skip to content

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.

  • A publishable key (pk_op_...). It’s public by design, like a Stripe pk_ 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.

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 after widget.js has executed, because it reads window.ExpeditionInsure. With a plain synchronous <script src="...widget.js"> (as above), the loader runs first and the global is ready. If you mark the loader async or defer, guard the mount call — see loading after async or deferred scripts.

ConditionError 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).

All parameters are optional except pk. They pre-fill the quote form inside the iframe.

ParamTypeExampleNotes
pkstring (required)"pk_op_..."Your publishable key.
destinationstring"antarctica"Trip destination slug.
startDatestring"2026-12-01"ISO date.
endDatestring"2026-12-14"ISO date.
agesnumber[] or string[45, 47]Array is serialized to CSV.
residencestring"US"Residence country code.
tripCostnumber or string18000Total trip cost (whole dollars), not per-traveler.
travelersnumber or string2Traveler count.
refstringYour referral or tracking ref.
logoUrlstringYour logo for co-branding.
accentColorstringBrand 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.

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.succeeded is 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 the policy.issued webhook, delivered server-to-server, signed, and retried. Reconcile sales from the webhook, never from payment.succeeded.

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>

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’s tripCost is whole dollars, so divide by 100 as shown. Always pass strings/numbers through | json when injecting into JavaScript to avoid breaking the script on quotes or special characters.

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.

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>

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:

  1. Ensure the target element exists in the new view.
  2. 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>

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:

  1. Clear the target element’s contents (remove the old iframe).
  2. 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.