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. Register, generate a test key and allowlist your page

Section titled “1. Register, generate a test key and allowlist your page”
  1. Register your partner account, then open the partner dashboard.
  2. Open Test checkout and select Generate test key. Use the resulting pk_op_test_… key. A live pk_op_… key with a label such as “testing” is still a live key; changing its label does not change its payment mode.
  3. Try the built-in Test checkout first. It works before live embedding is approved.
  4. Under Allowed origins, add the exact origin of your own private test page: scheme, host and optional port, without a path. For example, https://sandbox.youroperator.com or http://localhost:4321. Localhost and 127.0.0.1 may use HTTP; other origins require HTTPS.

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_test_REPLACE_WITH_YOUR_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 a third-party origin that is not on your allowlist and confirm framing is blocked. Our first-party dashboard origins are deliberately allowed for test keys.

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 is not on the list, the browser refuses to render the frame. An empty resolved allowlist uses frame-ancestors 'none'. 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 → blocked frame + 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 API input. Send a direct request with a missing or malformed required field. The widget validates its form before submitting, so invalid form input may simply keep submission disabled. 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" }. A failed request made by the widget surfaces quote.error.
  • Bad key or origin. A revoked or unknown pk, or an inactive operator, returns 401. A live key also requires live embedding approval; a test key does not. A request whose Origin isn’t allowlisted returns a bare 403. A request failure inside a rendered widget surfaces an error event. If CSP blocks the iframe from loading, its JavaScript cannot emit an event; inspect the browser console.
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 dashboard, open Test checkout and select 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 synthetic traveler information and an email address you control. Submit the quote, compare the returned plans, select one, save the primary traveler’s details, and enter a test card below. Complete the terms step and payment. The final screen says Test purchase complete and the parent receives payment.succeeded. Use the card payment tab for this acceptance test. Methods that require an external redirect can leave the partner page and its event listener.

Demo quote emails are labelled Demo. Any email link to ordinary hosted checkout cannot take a live payment for a test-key quote; complete the rehearsal inside the partner’s test widget. Test quotes appear in admin only when Show test data is on. No policy is issued and no commission is earned.

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

Send us your test page and results for review. We confirm products, permitted markets, disclosures, consent and commercial terms before enabling live embedded checkout. Then replace the test key with the approved live pk_op_… key and confirm your live origin allowlist. Keep the same widget loader and API hostname. Never switch the key on a page still used for test cards or synthetic customer data.