Embed HTTP API
This is the request/response contract for the two HTTP endpoints behind the embedded widget: POST /api/embed/quote (create a quote and get instant eligibility) and GET /api/embed/options (poll for the priced options). It covers authentication, the per-key rate limit, every status code and what triggers it, and the CORS rules. It’s for operator tech teams and AI-agent builders debugging the embed.
You usually don’t call these directly. In a Tier-1 or Tier-2 embed the loader’s iframe makes these calls for you — you mount the widget and listen for events. This page is the contract for debugging what the iframe sends, and the basis for any future server-to-server flow. For the loader, see /reference/loader/; for the post-sale webhook and the event payloads your page receives, see those references; for failure shapes in depth see errors and status codes.
The embed endpoints are Convex HTTP actions, served from the deployment’s .convex.site host (production: https://helpful-anteater-383.convex.site). Both routes live under /api/embed/.
Authentication
Section titled “Authentication”Every embed request authenticates with a publishable key (pk_op_…).
- Header (primary):
X-EI-Publishable-Key: pk_op_<48 hex chars> - GET query fallback:
?pk=pk_op_…— accepted onGET /api/embed/options. The header is read first (trimmed), then?pk=.
The publishable key is public by design — like a Stripe pk_. The security boundary is the per-operator Origin allowlist plus the per-key rate limit, not key secrecy. The key is stored in plaintext and resolved by index.
Credentials are off. The publishable key is the auth, not cookies — no Access-Control-Allow-Credentials header is ever sent (this also dodges Safari ITP). Don’t send cookies; they’re ignored.
A presented key resolves to its operator only when the key is live and the operator is enabled. A key that is unknown, revoked, attached to an inactive operator, or attached to an operator with the embed disabled resolves to nothing and the request returns 401.
Secret keys (
sk_op_…) are not consumable yet. [needs-product-work] A secret key can be minted and is resolvable internally, but no HTTP route consumes one today — neither embed endpoint below accepts it, and there is no server-to-server endpoint that does. The secret-key server API is forward guidance only; see /guides/server-to-server/. Until then, authenticate every call with the publishable key.
Rate limit
Section titled “Rate limit”Each publishable key is limited to 60 requests per minute, counted in a fixed one-minute window per key.
- When the limit is exceeded, the gate returns
429with aRetry-After: <seconds>header — the whole-second count until the current window resets. - The
429response does carry CORS headers (unlike401/403), so your client can read theRetry-Aftervalue and back off. - The window is fixed and resets lazily; after it resets, the next request starts a fresh count.
Respect Retry-After and back off; do not hammer the key.
The embed routes set CORS explicitly and never use a wildcard.
- Never reflects
*. Only the single matched Origin from the operator’s allowlist is echoed back inAccess-Control-Allow-Origin. Vary: Originis always set, so a shared cache never serves one origin’s headers to another.- Allowed methods:
GET, POST, OPTIONS. Allowed request headers:Content-Type, X-EI-Publishable-Key. - Credentials are off — no
Access-Control-Allow-Credentials. - Failure responses for
401and403carry no CORS headers. The browser blocks the body from the page even when a body string is present — a non-allowlisted origin can never read the response. (429is the exception: it includes CORS headers plusRetry-After.)
Preflight
Section titled “Preflight”Both routes answer the OPTIONS preflight. The preflight reflects the requested Origin without requiring the publishable key (browsers don’t send custom headers on preflight) and returns 204 with the CORS headers above plus Access-Control-Max-Age: 86400. A preflight with no Origin header returns 403. This is safe because the real POST/GET re-runs the full gate: an origin that passes preflight but isn’t allowlisted still gets a bare 403 with no CORS headers on the data-carrying request, so it can’t read any body.
Status codes
Section titled “Status codes”Both endpoints run the same gate — publishable-key auth, then Origin allowlist, then rate limit — before any route logic. The shared gate codes:
| Status | Trigger | Body | CORS headers |
|---|---|---|---|
401 | No publishable key presented. | Unauthorized | No |
401 | Key unknown or revoked, or the operator is inactive or has the embed disabled. | Unauthorized | No |
403 | Origin header missing, or the Origin isn’t in the operator’s allowlist (cross-operator / not allowlisted). | Forbidden | No |
429 | Rate limit exceeded for the key. | Too Many Requests | Yes, plus Retry-After: <seconds> |
200 | Gate passes. | Route-specific JSON. | Yes |
Per-route 400 and 403 cases (after the gate passes) are documented with each endpoint below. For the failure reference in full, see errors and status codes.
POST /api/embed/quote
Section titled “POST /api/embed/quote”Create a quote and synchronously generate its estimates and instant-quote eligibility.
Request body (JSON)
Section titled “Request body (JSON)”| Field | Type | Required | Notes |
|---|---|---|---|
destination | string | Yes | Primary destination. |
tripCost | number | Yes | Total trip cost in whole dollars (not per-traveler, not cents). |
travelers | number | Yes | Number of travelers. |
residence | string | Yes | Traveler residence (country code). |
email | string | Yes | Traveler email. |
startDate | string (YYYY-MM-DD) | No | Trip departure date. |
endDate | string (YYYY-MM-DD) | No | Trip return date. |
durationDays | number | No | If missing or ≤ 0 but startDate + endDate are present, computed as whole days between them; otherwise defaults to 0. |
currency | string | No | Trip-cost currency. |
otherCountries | string[] | No | Additional destinations. |
travelerAges | number[] | No | One age per traveler. If omitted, defaults to a zero-filled array sized to travelers. |
travelerDobs | string[] | No | Dates of birth, resolved to age-at-departure server-side. |
state | string | No | Residence state/region. |
name | string | No | Defaults to "Embedded quote" if blank. |
phone | string | No | Traveler phone. |
Any missing or wrong-typed required field returns 400 with { "error": "Missing or invalid required fields" }. The quote is attributed to the operator that owns the presented publishable key.
Response 200 (JSON)
Section titled “Response 200 (JSON)”{ "quoteId": "<quote id>", "quoteNumber": "EXP-XXXX", "instantQuoteEligible": true}| Field | Type | Notes |
|---|---|---|
quoteId | string | The created quote’s id. Capture it to poll options and to link the quote to your booking. |
quoteNumber | string | Human-readable quote number (e.g. EXP-XXXX). |
instantQuoteEligible | boolean | Whether instant priced options are expected to be available. |
Errors
Section titled “Errors”| Status | Trigger | Body |
|---|---|---|
400 | Body missing/unparseable, or a required field missing/wrong type. | { "error": "Missing or invalid required fields" } |
400 | The quote could not be created downstream. | { "error": "<reason>" } (the underlying error message, or "Quote could not be created"). |
(The shared 401/403/429 gate codes apply first — see the table above.)
GET /api/embed/options?quoteId=…
Section titled “GET /api/embed/options?quoteId=…”Fetch the instant priced options for a quote. Authenticate with the header or the ?pk= fallback. Pass the quoteId returned by POST /api/embed/quote as a query param.
Pending vs ready
Section titled “Pending vs ready”The endpoint returns one of two 200 shapes (both with CORS headers):
Pending — eligible but options are still generating, or not yet eligible. Poll; this is not an error:
{ "options": [], "pending": true }Ready — the priced options payload is returned directly. (Its shape is owned by the options query, not by this endpoint.)
Errors
Section titled “Errors”| Status | Trigger | Body | CORS headers |
|---|---|---|---|
400 | Missing quoteId query param. | { "error": "quoteId required" } | Yes |
403 | The quote belongs to a different operator, or the quoteId is unknown/malformed. | Forbidden | No |
The 403 is deliberately indistinguishable from a foreign id — there is no existence oracle. A malformed id is normalized internally to the same forbidden result. (The shared gate codes apply first.)
Next steps
Section titled “Next steps”- /reference/loader/ — the
window.ExpeditionInsureloader the iframe runs through. - /reference/events/ — the
postMessageevents your page receives. - /reference/webhooks/ — the authoritative post-sale
policy.issuedwebhook. - /reference/errors/ — every failure shape, body, and CORS detail.