Skip to content

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.

  1. 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.
  2. 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.com or http://localhost:3000). localhost and 127.0.0.1 may use http; everything else must be https. You can allowlist up to 20 origins.
  3. 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.com and https://www.youroperator.com are 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 a Content-Security-Policy: frame-ancestors <your allowlisted origins> header resolved from your pk. When the framing origin isn’t on the list, it resolves to frame-ancestors 'none' and the browser refuses to render the frame. Your console shows a frame-ancestors CSP violation.
  • The API itself returns nothing usable. A data request from a non-allowlisted Origin returns a bare 403 Forbidden with 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 JSON
non-allowlisted → blank frame (frame-ancestors 'none') + bare 403, no CORS

A 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.

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/quote requires destination (string), tripCost (number), travelers (number), residence (string), and email (string); a missing or wrong-typed field returns 400 { "error": "Missing or invalid required fields" }, and the widget surfaces a quote.error.
  • Bad key or origin. A revoked or unknown pk, or a disabled operator, returns 401 (the embed treats this as quote.error). A request whose Origin isn’t allowlisted returns a bare 403. 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.

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 → 200
request 61 within the minute → 429 + Retry-After: <seconds>
after the window resets → 200
async 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 hit 429 is a tight options-polling loop. Back off on the pending shape (§5) and you’ll rarely see a rate-limit response at all.

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:

  1. Treats { "options": [], "pending": true } as “keep waiting,” not “no plans.”
  2. Polls on a sensible interval (well within 60 requests/minute) until pending clears.
  3. 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.

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.

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 numberOutcomeYou observe
4242 4242 4242 4242Succeeds immediatelypayment.succeeded
4000 0025 0000 3155Requires 3-D Secure authentication, then succeedspayment.succeeded (after the auth step)
4000 0000 0000 0002Generic declinepayment.failed
4000 0000 0000 9995Decline — insufficient fundspayment.failed

This is the full Stripe testing card list — any card there behaves identically inside our iframe.

  • No real money, ever. A pk_op_test_… key settles only against Stripe test mode; a live pk_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.failed signals. It does not drive server-side fulfillment, policy issuance, or your policy.issued webhook — 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.succeeded carries { quoteId?, planId?, premiumCents?, currency? }. Read premiumCents — the integer minor-units field; divide by 100 for display.
  • payment.succeeded is 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-side policy.issued webhook — reconcile against that, never against payment.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.

Each delivery sends an X-EI-Signature header using the Stripe signing scheme:

X-EI-Signature: t=<unix-seconds>,v1=<hex-hmac>
  • t is a unix timestamp in seconds (not milliseconds).
  • v1 is HMAC-SHA256(webhookSecret, "<t>.<rawBody>") as a lowercase hex digest, where rawBody is 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.

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:

  1. Dedupe on eventId. Process each eventId exactly once; treat repeat deliveries of the same eventId as already-handled and still return 2xx.
  2. Return 2xx quickly. 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, and v1, then re-POST it to your endpoint. Within the ±5 min window your verifier should accept it; reusing the same eventId should 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, not payment.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.issued is the only event, so don’t build your reconciliation around a void notification that isn’t sent yet.