Skip to content

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

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 on GET /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.

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 429 with a Retry-After: <seconds> header — the whole-second count until the current window resets.
  • The 429 response does carry CORS headers (unlike 401/403), so your client can read the Retry-After value 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 in Access-Control-Allow-Origin.
  • Vary: Origin is 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 401 and 403 carry 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. (429 is the exception: it includes CORS headers plus Retry-After.)

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.

Both endpoints run the same gate — publishable-key auth, then Origin allowlist, then rate limit — before any route logic. The shared gate codes:

StatusTriggerBodyCORS headers
401No publishable key presented.UnauthorizedNo
401Key unknown or revoked, or the operator is inactive or has the embed disabled.UnauthorizedNo
403Origin header missing, or the Origin isn’t in the operator’s allowlist (cross-operator / not allowlisted).ForbiddenNo
429Rate limit exceeded for the key.Too Many RequestsYes, plus Retry-After: <seconds>
200Gate 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.

Create a quote and synchronously generate its estimates and instant-quote eligibility.

FieldTypeRequiredNotes
destinationstringYesPrimary destination.
tripCostnumberYesTotal trip cost in whole dollars (not per-traveler, not cents).
travelersnumberYesNumber of travelers.
residencestringYesTraveler residence (country code).
emailstringYesTraveler email.
startDatestring (YYYY-MM-DD)NoTrip departure date.
endDatestring (YYYY-MM-DD)NoTrip return date.
durationDaysnumberNoIf missing or ≤ 0 but startDate + endDate are present, computed as whole days between them; otherwise defaults to 0.
currencystringNoTrip-cost currency.
otherCountriesstring[]NoAdditional destinations.
travelerAgesnumber[]NoOne age per traveler. If omitted, defaults to a zero-filled array sized to travelers.
travelerDobsstring[]NoDates of birth, resolved to age-at-departure server-side.
statestringNoResidence state/region.
namestringNoDefaults to "Embedded quote" if blank.
phonestringNoTraveler 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.

{
"quoteId": "<quote id>",
"quoteNumber": "EXP-XXXX",
"instantQuoteEligible": true
}
FieldTypeNotes
quoteIdstringThe created quote’s id. Capture it to poll options and to link the quote to your booking.
quoteNumberstringHuman-readable quote number (e.g. EXP-XXXX).
instantQuoteEligiblebooleanWhether instant priced options are expected to be available.
StatusTriggerBody
400Body missing/unparseable, or a required field missing/wrong type.{ "error": "Missing or invalid required fields" }
400The 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.)

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.

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

StatusTriggerBodyCORS headers
400Missing quoteId query param.{ "error": "quoteId required" }Yes
403The quote belongs to a different operator, or the quoteId is unknown/malformed.ForbiddenNo

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