Skip to content

Checkout sequencing and the separate insurance rail

This guide explains how to sequence two independent payments when you embed Expedition Insure: the charge for your trip (on your Stripe account) and the charge for travel insurance (on our Stripe account, where we are the Merchant of Record). It also explains which signal to trust for fulfillment — the short version is that the policy.issued webhook is authoritative and payment.succeeded is not.

It’s for partner and operator engineering teams wiring embedded insurance into an existing booking flow. Before you start, skim the events reference for the ei:* postMessage events, the webhooks reference for the signed policy.issued payload, and the embed API reference for the quote and options endpoints.

There are two separate payments in an embedded booking, and they never touch each other:

RailWhose StripeWhat it chargesWho is Merchant of Record
Trip chargeYoursThe expedition / booking costYou
Insurance chargeOursThe travel insurance premiumExpedition Insure

When a traveler buys insurance through the embed, the premium is collected on our Stripe as a separate PaymentIntent. We are the Merchant of Record (MoR) for the insurance sale, we issue the policy, and we track the commission you’re owed in our ledger. Your trip charge stays entirely on your own rail — we never see it, and it never blocks or gates the insurance charge.

This separation is deliberate. It keeps insurance compliance, refunds, and chargebacks on our side, and keeps your booking economics on yours.

MoR. Because we are the Merchant of Record for insurance, the insurance line item appears on the traveler’s statement under Expedition Insure, not your brand. Set that expectation in your checkout copy.

Order the steps so the traveler has a confirmed trip before they pay for insurance:

1. Quote traveler enters trip details → POST /api/embed/quote
2. Plan select traveler picks a plan in the embed → quote.selected event
3. Your checkout traveler pays the trip cost on YOUR Stripe rail
4. Insurance traveler pays the premium on OUR Stripe rail (MoR)
→ policy.issued webhook fires server-side

Why this order:

  • Quote first so the premium is known before the traveler commits. The quote call returns a quoteId and an instantQuoteEligible flag; poll GET /api/embed/options for ready plans.
  • Plan select emits a quote.selected event carrying planId, premiumCents (integer minor units), and currency. Use premiumCents to show the price and to reconcile later.
  • Your checkout before insurance so a failed trip payment doesn’t leave a paid-for policy attached to a trip that never happened. Insurance is the last money to move.

You can reorder if your funnel demands it, but you then own the orphan cases in section 4.

Premium field. premiumCents is an integer in minor units (cents). Always read premiumCents and divide by 100 for display.

The embed emits a payment.succeeded event (and a payment.failed event) over the ei:* postMessage channel when the in-iframe insurance checkout finishes. This event is a UX signal only. It is convenient for flipping your UI to a success state, but it can be missed:

  • the traveler closes the popup or tab before the redirect completes,
  • a 3-D Secure redirect loses the parent frame,
  • a network blip drops the message,
  • a stricter outer sandbox blocks the post.

Because the event can be lost, never use payment.succeeded to fulfill, record a sale, or pay yourself a commission. The authoritative signal is the policy.issued webhook, delivered server-to-server:

Traveler pays insurance (our Stripe)
→ Stripe payment_intent.succeeded (our backend)
→ policy issued + commission recorded
→ POST policy.issued → your webhook endpoint ← SOURCE OF TRUTH

The policy.issued webhook carries eventId, policyId, operatorId, premiumCents, commissionCents (both integer minor units), and timestamps. It is signed with X-EI-Signature (HMAC-SHA256, Stripe scheme) and delivered at-least-once — see the webhooks reference for the full payload, signature verification, and retry semantics.

SignalChannelTrustworthy forCan be missed?
payment.succeededei:* postMessage (browser)Updating your UIYes
policy.issuedSigned HTTP webhook (server)Fulfillment, ledger, commissionNo (retried, deduped on eventId)

Dedupe on eventId. The webhook is at-least-once, so the same eventId may arrive more than once with a different signature timestamp. Treat the first eventId you see as final and ignore repeats.

Practical pattern: show the success UI optimistically on payment.succeeded, but only mark the booking insured, reconcile revenue, and trust the commission once you’ve received and verified a policy.issued webhook for that traveler.

Because the two rails are independent, two mismatches are possible. Handle both explicitly.

The traveler paid for insurance but never completed (or later canceled) the trip booking on your rail. The policy is real — we issued it and you’ll receive a policy.issued webhook — so:

  • Don’t silently drop the policy. It exists and the traveler is covered.
  • Reconcile against your own booking records to detect the mismatch.
  • For a refund or cancellation, contact us at help@expedition.insure. There is currently no policy.cancelled or policy.refunded webhook, so we will not notify you of a void automatically. [needs-product-work]

The traveler completed your checkout but closed the embed before paying for insurance. You’ll see no quote.selected/payment.succeeded, and crucially no policy.issued webhook — so there’s nothing to reconcile on our side. Recommended:

  • Treat “no policy.issued for this booking” as “uninsured.”
  • Offer insurance again post-booking (a confirmation email or order page can re-mount the embed with the same trip details).
  • Don’t assume coverage from a quote.selected event alone — selecting a plan is not paying for it.

The two embed tiers differ in where the insurance payment happens, which changes how step 4 plays out.

The embed shows quote and plans inline. When the traveler chooses to buy, the embed hands off the top window to our hosted /options checkout flow. The handoff is driven by a user-activated top-navigation (the Buy click); if the operator’s outer sandbox strips top-navigation, the embed falls back to an ei:navigate postMessage that the loader performs.

  • Insurance is paid on our hosted page, not inside your frame.
  • You won’t receive an in-frame payment.succeeded/payment.failed for this path — those are Tier-3 only.
  • Fulfillment signal: the policy.issued webhook. This is the only reliable signal in Tier-2, which makes wiring the webhook mandatory.
  • Emit your own analytics on quote.selected (fired before the handoff) if you want to track intent.

The embed renders an embedded checkout (/embed/checkout) that creates the insurance-only PaymentIntent on our Stripe and confirms it inline with Stripe.js. On completion, the iframe lands on /embed/confirmed, which emits ei:payment.succeeded (or ei:payment.failed).

  • Insurance is paid without leaving your frame.
  • You do receive payment.succeeded/payment.failed — but, as in section 3, only as a UX signal.
  • Fulfillment signal: still the policy.issued webhook. The in-iframe payment.succeeded is a convenience; policy issuance and the commission write happen server-side off our Stripe webhook regardless of whether the browser event arrives.

The takeaway is the same for both tiers: fulfill on policy.issued. Tier-3 simply gives you an extra (best-effort) browser event for snappier UI.

6. Quote idempotency and dedup on re-mount

Section titled “6. Quote idempotency and dedup on re-mount”

The embed has no unmount() or update() method today. To change a mounted quote’s config, you re-mount — call mount() again into a cleared target. Re-mounting is common (route changes, config edits, React re-renders), so design for repeat quote creation:

  • Each POST /api/embed/quote creates a new quote. There is no client-supplied idempotency key, so two mounts with the same trip details produce two quoteIds. Track the latest quoteId your embed reports via quote.ready and treat earlier ones as superseded.
  • Drive options off the live quoteId. Always poll GET /api/embed/options?quoteId=… with the most recent quoteId, not a cached one. An options read scoped to a foreign or stale id returns 403 with no body (no existence oracle).
  • Reconcile on policy.issued, keyed by eventId and policyId — not by quoteId. A re-mount may change the quoteId, but a paid policy resolves to exactly one policy.issued event you dedupe on eventId.
  • Mind the rate limit. Quote creation is capped at 60 requests per minute per publishable key. Avoid re-mounting in a tight loop; debounce config-driven remounts so a flurry of re-renders doesn’t burn the budget and return 429.

One source of truth, end to end. UI reacts to browser events; money and fulfillment react to policy.issued. Keep that boundary and the orphan and re-mount cases stay manageable.