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.
1. Two rails, two Stripe accounts
Section titled “1. Two rails, two Stripe accounts”There are two separate payments in an embedded booking, and they never touch each other:
| Rail | Whose Stripe | What it charges | Who is Merchant of Record |
|---|---|---|---|
| Trip charge | Yours | The expedition / booking cost | You |
| Insurance charge | Ours | The travel insurance premium | Expedition 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.
2. Recommended sequence
Section titled “2. Recommended sequence”Order the steps so the traveler has a confirmed trip before they pay for insurance:
1. Quote traveler enters trip details → POST /api/embed/quote2. Plan select traveler picks a plan in the embed → quote.selected event3. Your checkout traveler pays the trip cost on YOUR Stripe rail4. Insurance traveler pays the premium on OUR Stripe rail (MoR) → policy.issued webhook fires server-sideWhy this order:
- Quote first so the premium is known before the traveler commits. The quote call returns a
quoteIdand aninstantQuoteEligibleflag; pollGET /api/embed/optionsfor ready plans. - Plan select emits a
quote.selectedevent carryingplanId,premiumCents(integer minor units), andcurrency. UsepremiumCentsto 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.
premiumCentsis an integer in minor units (cents). Always readpremiumCentsand divide by 100 for display.
3. Gate off the webhook, not the event
Section titled “3. Gate off the webhook, not the event”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 TRUTHThe 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.
| Signal | Channel | Trustworthy for | Can be missed? |
|---|---|---|---|
payment.succeeded | ei:* postMessage (browser) | Updating your UI | Yes |
policy.issued | Signed HTTP webhook (server) | Fulfillment, ledger, commission | No (retried, deduped on eventId) |
Dedupe on
eventId. The webhook is at-least-once, so the sameeventIdmay arrive more than once with a different signature timestamp. Treat the firsteventIdyou 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.
4. Orphan handling
Section titled “4. Orphan handling”Because the two rails are independent, two mismatches are possible. Handle both explicitly.
Insurance paid, trip abandoned
Section titled “Insurance paid, trip abandoned”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.cancelledorpolicy.refundedwebhook, so we will not notify you of a void automatically. [needs-product-work]
Trip paid, insurance skipped
Section titled “Trip paid, insurance skipped”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.issuedfor 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.selectedevent alone — selecting a plan is not paying for it.
5. Tier-2 vs Tier-3 sequencing
Section titled “5. Tier-2 vs Tier-3 sequencing”The two embed tiers differ in where the insurance payment happens, which changes how step 4 plays out.
Tier-2 — handoff to hosted checkout
Section titled “Tier-2 — handoff to hosted checkout”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.failedfor this path — those are Tier-3 only. - Fulfillment signal: the
policy.issuedwebhook. 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.
Tier-3 — checkout inside the iframe
Section titled “Tier-3 — checkout inside the iframe”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.issuedwebhook. The in-iframepayment.succeededis 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/quotecreates a new quote. There is no client-supplied idempotency key, so two mounts with the same trip details produce twoquoteIds. Track the latestquoteIdyour embed reports viaquote.readyand treat earlier ones as superseded. - Drive options off the live
quoteId. Always pollGET /api/embed/options?quoteId=…with the most recentquoteId, not a cached one. An options read scoped to a foreign or stale id returns403with no body (no existence oracle). - Reconcile on
policy.issued, keyed byeventIdandpolicyId— not byquoteId. A re-mount may change thequoteId, but a paid policy resolves to exactly onepolicy.issuedevent you dedupe oneventId. - 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.