Testing and sandbox
How to test your Expedition Insure embed integration before you go live: get a test key, allowlist a test origin, prove the fail-closed security model works, and simulate the error, rate-limit, and polling paths your code must handle. For the integration mechanics this builds on, see the embed integration guide, the event reference, and the webhook reference.
Everything here runs against the same production API your live integration uses — there is no separate sandbox host. You isolate testing by using a test publishable key allowlisted to a non-production origin, so test traffic never touches your live page.
1. Get a test pk_op_ and allowlist a test origin
Section titled “1. Get a test pk_op_ and allowlist a test origin”The publishable key (pk_op_…) and the Origin allowlist are the two halves of the embed
trust model. The key is public by design (it ships in your page HTML, like a Stripe
pk_); the real boundary is the per-operator Origin allowlist enforced server-side. To
test safely, point a dedicated key at a dedicated origin.
- Ask us for a test publishable key scoped to your account. You can also generate a separate key labeled for testing — each key is independent and individually revocable.
- Send us the exact test/staging origin you’ll embed from — scheme, host, and
optional port only, no path or query (e.g.
https://sandbox.youroperator.comorhttp://localhost:3000).localhostand127.0.0.1may usehttp; everything else must behttps. You can allowlist up to 20 origins. - We add that origin to your allowlist. The same origin set drives both the API CORS
check and the iframe’s
frame-ancestors— so once it’s on the list, the embed both answers your requests and renders in your frame.
One origin = scheme + host + port.
https://sandbox.youroperator.comandhttps://www.youroperator.comare different origins. Allowlist every origin you load from, including staging and local dev.
Mount the widget exactly as in production, passing your test key:
<div id="quote"></div>
<script src="https://expedition.insure/widget.js"></script><script> ExpeditionInsure.mount("#quote", { pk: "pk_op_...", // your test publishable key destination: "antarctica", startDate: "2026-12-01", endDate: "2026-12-14", ages: [45, 47], residence: "US", tripCost: 18000, // total trip cost, whole dollars travelers: 2, });
ExpeditionInsure.on("quote.ready", (e) => console.log("ready", e)); ExpeditionInsure.on("quote.selected", (e) => console.log("selected", e)); ExpeditionInsure.on("quote.error", (e) => console.warn("error", e));</script>2. Negative test — non-allowlisted origin must fail closed
Section titled “2. Negative test — non-allowlisted origin must fail closed”The single most important test is the one that should not work. Load the same page from an origin that is not on your allowlist and confirm you get nothing.
What you should observe:
- The iframe stays blank and renders no content. Every
/embed/*response carries aContent-Security-Policy: frame-ancestors <your allowlisted origins>header resolved from yourpk. When the framing origin isn’t on the list, it resolves toframe-ancestors 'none'and the browser refuses to render the frame. Your console shows aframe-ancestorsCSP violation. - The API itself returns nothing usable. A data request from a non-allowlisted
Originreturns a bare403 Forbiddenwith no CORS headers, so the browser blocks the body from your page even though one is sent. There is no readable payload.
allowlisted origin → iframe renders, events fire, API returns JSONnon-allowlisted → blank frame (frame-ancestors 'none') + bare 403, no CORSA blank frame here is the expected pass. Fail-closed framing is the proof the security model works. A copied key on the wrong origin can read nothing. Make this a permanent check in your test plan — if a non-allowlisted origin ever renders content, tell us immediately.
3. Simulate quote.error
Section titled “3. Simulate quote.error”The widget emits quote.error whenever the quote or options request fails. Drive it on
purpose so your UI handles the unhappy path instead of hanging:
- Bad input. Mount with a missing or malformed required field.
POST /api/embed/quoterequiresdestination(string),tripCost(number),travelers(number),residence(string), andemail(string); a missing or wrong-typed field returns400 { "error": "Missing or invalid required fields" }, and the widget surfaces aquote.error. - Bad key or origin. A revoked or unknown
pk, or a disabled operator, returns401(the embed treats this asquote.error). A request whoseOriginisn’t allowlisted returns a bare403. Both surface as an error event, not a silent stall.
ExpeditionInsure.on("quote.error", (e) => { console.warn("quote.error", e.code, e.message); showRetryUI(); // your fallback — never leave the user on a spinner});The payload is { code?: string, message?: string } — both optional. Treat quote.error
as a terminal signal for that attempt: show a fallback and offer a human at
help@expedition.insure.
4. Simulate 429 and Retry-After
Section titled “4. Simulate 429 and Retry-After”The API rate-limits 60 requests per minute per publishable key (a fixed window). When
you exceed it, the gate returns 429 Too Many Requests with a Retry-After header (in
seconds) telling you how long until the window resets. Unlike 401/403, the 429
response does carry CORS headers, so the body and header are readable from your page.
To exercise it, loop quote/options calls past 60 in a single minute on your test key and confirm your code backs off:
requests 1–60 within the minute → 200request 61 within the minute → 429 + Retry-After: <seconds>after the window resets → 200async function callEmbedApi(url, init) { const res = await fetch(url, init); if (res.status === 429) { const wait = Number(res.headers.get("Retry-After") ?? 1); await new Promise((r) => setTimeout(r, wait * 1000)); return callEmbedApi(url, init); // retry after the window resets } return res;}Respect
Retry-After; don’t hammer the poll. The most common way to hit429is a tight options-polling loop. Back off on thependingshape (§5) and you’ll rarely see a rate-limit response at all.
5. Simulate pending-options polling
Section titled “5. Simulate pending-options polling”Quote creation is synchronous, but instant options are generated just after. While they’re
still being produced, GET /api/embed/options?quoteId=… returns a pending shape at
200 with CORS headers:
{ "options": [], "pending": true }This is not an error — it’s the signal to poll again. Once options are ready, the endpoint returns the options payload directly. Test that your code:
- Treats
{ "options": [], "pending": true }as “keep waiting,” not “no plans.” - Polls on a sensible interval (well within 60 requests/minute) until
pendingclears. - Surfaces the ready options when they arrive.
POST /api/embed/quote → { quoteId, quoteNumber, instantQuoteEligible }GET /api/embed/options?quoteId=… ⤵ still generating → 200 { "options": [], "pending": true } (poll) ready → 200 <options payload> foreign / unknown / malformed id → 403 (no CORS, no body)A 403 here is deliberate and uniform — a quote owned by a different operator, or an
unknown or malformed quoteId, all return the same bare 403 so there’s no way to probe
which quotes exist. Pass ages when you create the quote so estimates are quotable and
options resolve.
6. Tier-3 test mode, test cards, and payment.succeeded
Section titled “6. Tier-3 test mode, test cards, and payment.succeeded”For Tier-3 embedded checkout, the insurance charge is captured inside our iframe (we
are the Merchant of Record on a separate PaymentIntent), and the iframe emits
payment.succeeded / payment.failed on the result.
Get a test key
Section titled “Get a test key”In the partner portal, open API keys and click Generate test key.
You get a pk_op_test_… publishable key. Drop it into the same embed snippet in place of
your live pk_op_… key — nothing else changes. A test key drives the embedded checkout in
Stripe test mode, so the charge is never real.
Stripe test cards
Section titled “Stripe test cards”Use any of Stripe’s standard test cards in the in-iframe payment form. Any future expiry date, any 3-digit CVC, and any postal code are accepted.
| Card number | Outcome | You observe |
|---|---|---|
4242 4242 4242 4242 | Succeeds immediately | payment.succeeded |
4000 0025 0000 3155 | Requires 3-D Secure authentication, then succeeds | payment.succeeded (after the auth step) |
4000 0000 0000 0002 | Generic decline | payment.failed |
4000 0000 0000 9995 | Decline — insufficient funds | payment.failed |
This is the full Stripe testing card list — any card there behaves identically inside our iframe.
Isolation and what test mode does not do
Section titled “Isolation and what test mode does not do”- No real money, ever. A
pk_op_test_…key settles only against Stripe test mode; a livepk_op_…key can never run in test mode. The two are bound at the key, not toggled at runtime — a test checkout cannot settle real money, and a live key cannot run a test. - Front-end contract only. Test mode exercises the in-iframe checkout and the
payment.succeeded/payment.failedsignals. It does not drive server-side fulfillment, policy issuance, or yourpolicy.issuedwebhook — those require a real (live) sale with manual carrier fulfillment. Test purchases are flagged internally and never produce a policy or a commission.
The rules that matter to your code are the same in test and live:
payment.succeededcarries{ quoteId?, planId?, premiumCents?, currency? }. ReadpremiumCents— the integer minor-units field; divide by 100 for display.payment.succeededis a UX-only signal and can be missed. It tells your page the in-iframe insurance charge confirmed so you can sequence your own (separate) trip-charge step. It is not the authoritative post-sale signal. The authoritative confirmation is the server-sidepolicy.issuedwebhook — reconcile against that, never againstpayment.succeeded. See §7 and the webhook reference.
ExpeditionInsure.on("payment.succeeded", (e) => { // UX nudge only — advance your own trip-charge step. // Do NOT mark the order fulfilled here; wait for policy.issued. advanceTripCheckout(e.quoteId);});7. Local webhook testing — sign, verify, and replay
Section titled “7. Local webhook testing — sign, verify, and replay”The policy.issued webhook is the authoritative post-sale signal. It fires server-side
when an operator-attributed payment produces a commission, independent of whether the
browser ever saw payment.succeeded. Test your receiver against the exact signing and
delivery contract.
Verify the signature
Section titled “Verify the signature”Each delivery sends an X-EI-Signature header using the Stripe signing scheme:
X-EI-Signature: t=<unix-seconds>,v1=<hex-hmac>tis a unix timestamp in seconds (not milliseconds).v1isHMAC-SHA256(webhookSecret, "<t>.<rawBody>")as a lowercase hex digest, whererawBodyis the exact bytes of the request body.
Verify by recomputing the HMAC over "<t>.<rawBody>" with your shared webhook secret and
comparing in constant time. Reject any t outside a ±5 minute tolerance window to
defend against replay and clock skew — that window is enforced on your side, not ours.
import crypto from "node:crypto";
function verifyWebhook(rawBody, header, secret) { const parts = Object.fromEntries( header.split(",").map((kv) => kv.split("=")), ); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) { return false; // outside ±5 min → reject (replay / skew) } const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 ?? ""); return a.length === b.length && crypto.timingSafeEqual(a, b);}The body shape is:
{ "event": "policy.issued", "eventId": "<stable-uuid>", "policyId": "<policy-number-or-null>", "operatorId": "<operator-id>", "premium": 420, "commission": 42, "premiumCents": 42000, "commissionCents": 4200, "issuedAt": 1764547200000, "sentAt": 1764547205000}premiumCents / commissionCents are integer minor units (cents) — divide by 100 for
display. Note the unit split: the signature t is in seconds, while issuedAt /
sentAt in the body are epoch milliseconds.
Other headers on every delivery: Content-Type: application/json,
X-EI-Event: policy.issued, and User-Agent: ExpeditionInsure-Webhook/1.
Test delivery and replay locally
Section titled “Test delivery and replay locally”Delivery is at-least-once. The same event may arrive more than once — on a failed
delivery we retry up to 2 times (3 attempts total) with a 30-second then 120-second
backoff, and a 10-second per-attempt timeout. Every attempt carries the same eventId
but a fresh sentAt and signature.
Your receiver must therefore:
- Dedupe on
eventId. Process eacheventIdexactly once; treat repeat deliveries of the sameeventIdas already-handled and still return2xx. - Return
2xxquickly. Any non-2xx response, or a timeout past 10 seconds, makes us retry. Acknowledge fast, then process out of band.
To test locally:
- Expose your local endpoint over a public HTTPS tunnel, then have us point a test delivery at it (the webhook URL is configured on our side).
- Replay test: capture a real delivery’s raw body,
t, andv1, then re-POST it to your endpoint. Within the ±5 min window your verifier should accept it; reusing the sameeventIdshould be a no-op (deduped); replaying after the window should be rejected on the timestamp check. - Signature-failure test: flip one byte of the body or the secret and confirm your verifier rejects it.
Reconcile against
policy.issued, notpayment.succeeded. The browser event is a UX convenience that can be missed; the signed webhook is the source of truth for policy issuance and commission. There is currently [needs-product-work] no cancellation/refund or commission-void webhook —policy.issuedis the only event, so don’t build your reconciliation around a void notification that isn’t sent yet.