TravelBot SDK
A lightweight, dependency-free JavaScript embed that gives any partner site a chat + checkout widget in one script tag.
Overview
sdk.js is a single vanilla JS file a partner drops into their page.
It injects two isolated iframes — a persistent chat widget and an on-demand checkout modal,
each built in React — and brokers all communication between them with window.postMessage.
The partner never writes iframe markup, manages z-index, or touches DOM overlay logic; the SDK owns all of it.
Deployment model — what ships where
Two different audiences receive two completely different things. Mixing them up is the most common point of confusion in this repo.
What a partner (e.g. Upgrade) receives
Only the <script src="sdk.js"> tag and the four-line init() call from the Quick Start below. Nothing else — no source code, no React, no build step on their end.
What Odynn/the agency must host
The chat/ and checkout/ React apps, built and deployed to a real public domain (e.g. widget.odynn.com) — partners never see or receive this code. sdk.js itself also needs to be hosted somewhere (ideally a CDN) so partners can point a <script src> at it.
Source vs. build output — read this before editing
Each of chat/ and checkout/ contains two different index.html files — easy to confuse:
| File | What it is |
|---|---|
chat/index.html | Vite's source template — just a <div id="root"> and a script tag. Not the real app. |
chat/src/*.jsx | The actual React app — this is what you edit. |
chat/dist/index.html | Compiled output of npm run build — the real, working, servable app. This is what sdk.js's CHAT_URL points to. |
chat/src/App.jsx does nothing visible until you run npm run build inside chat/ — the static server serves dist/, not src/.Current state of this repo (local/POC only)
CHAT_URL/CHECKOUT_URL in sdk.js are currently just relative paths (chat/dist/index.html) that only resolve because everything is served from one local static server. Sharing sdk.js alone, as-is, will not work anywhere else — those two constants need to become real hosted URLs once chat/dist and checkout/dist are deployed somewhere public.
Quick start
This is the entire integration surface a partner ever needs to write.
<!-- 1. Include the SDK --> <script src="sdk/sdk.js"></script> <!-- 2. Initialize it --> <script> TravelBot.init({ partnerId: "partner_123", userJWT: "SIGNED_JWT_TOKEN", theme: { primaryColor: "#2563eb" } }); </script>
That's it — a chat bubble appears immediately. No iframe URLs to configure: the SDK owns where its own chat and checkout apps live.
Architecture
The two-iframe pattern: one persistent, one ephemeral, brokered by the SDK.
persistent, injected on load
ephemeral, spawned on demand
Chat (Iframe A)
Injected once, on load, and stays alive for the whole session. Built in React (Vite), served as a static build. Streams live replies from Odynn's agent backend — see Live backend integration below — and renders structured flight offers inline.
Checkout (Iframe B)
Only exists while the user is paying. Chat sends OPEN_CHECKOUT → the SDK builds a darkened overlay + centered modal + iframe → on success or cancel, the SDK completely removes it from the DOM and focus returns to chat. Also built in React (Vite). Renders one of two real card-collection paths depending on a feature flag (see Security model), plus a real Uplift Flex Pay (Buy Now Pay Later) integration — both tested end-to-end against live Duffel bookings, not mocked.
Live backend integration
The chat iframe streams real replies from Odynn's live agent API — not a mock.
| Endpoint | Purpose |
|---|---|
POST /chat/start/stream | Opens a new conversation thread — { thread_id, user_id } |
POST /chat/continue/stream | Sends a message on an existing thread — { thread_id, user_id, message } |
Both return Server-Sent Events: {type: "message"|"tool_call"|"tool_data"|"end", content, ...}. message events stream the reply token-by-token; tool_call events surface a transient status (e.g. "Searching for flights…"); tool_data carries structured payloads — real Duffel flight offers (tool_name: "search_flights") or payment-session data (tool_name: "initiate_payment"); end closes the stream. thread_id is generated once per page load, so the agent keeps conversation memory across turns without the frontend re-sending history.
A separate POST /booking/direct call — { offer_id, card_id, passengers, user_id, thread_id } — actually creates the booking once payment succeeds. This is currently the live agent backend's own endpoint (Anuj's), not yet routed through EngiNeo's Governance Layer.
Config reference
TravelBot.init(config) accepts:
| Key | Type | Required | Description |
|---|---|---|---|
partnerId | string | Yes | Identifies the embedding partner; passed through to both iframes as a query param. |
userJWT | string | Yes | Signed token identifying the end user. Never placed in a URL — passed only via postMessage. |
tokenExpiresIn | number | No | Seconds until userJWT expires. Lets the chat iframe schedule its own refresh ahead of expiry instead of waiting for a request to fail first. |
onTokenRefreshNeeded | function | No | Called when the current token is about to expire (or a request just hit a 401). The partner should fetch a new token from their own backend, then call TravelBot.updateToken(token, expiresIn). |
threadId | string | No | Resumes a specific prior conversation instead of starting a new one. |
theme.primaryColor | string | No | Brand color for the bubble, chat header, and buttons. Defaults to #2563eb. |
theme.backgroundColor | string | No | Widget background. Defaults to #ffffff. |
debug | boolean | No | Enables verbose [TravelBot] console logging. Off by default. |
sdk.js per the whitelabel security model.Public methods
| Method | Description |
|---|---|
TravelBot.init(config) | Initializes the widget. Safe to call again — automatically tears down any previous instance first. |
TravelBot.open() | Expands the chat panel. |
TravelBot.close() | Collapses the chat panel. |
TravelBot.destroy() | Removes all DOM, listeners, and timers. Resets the widget to an uninitialized state. |
TravelBot.updateToken(token, expiresIn) | Call once the partner's backend has minted a fresh session token — typically from onTokenRefreshNeeded, but safe to call proactively at any time. Re-sends auth to whichever iframes are currently mounted. |
Message protocol
Every message is { type, data? }, sent via postMessage, and origin-checked on both ends.
| Type | Direction | Trigger |
|---|---|---|
READY | Iframe → SDK | Sent the moment an iframe's script loads. Starts the auth handshake. |
INIT_AUTH | SDK → Iframe | Reply to READY; delivers the JWT. Retried up to 3× if unacknowledged. |
OPEN_CHECKOUT | Chat → SDK | User selects something to book. SDK spawns the checkout modal. |
CLOSE_CHECKOUT | Checkout → SDK | User cancels. SDK tears the modal down. |
PAYMENT_SUCCESS | Checkout → SDK → Chat | Payment completes. SDK destroys checkout and forwards confirmation to chat. |
CLOSE_CHAT | Chat → SDK | Collapses the chat panel programmatically. |
REQUEST_TOKEN_REFRESH | Chat/Checkout → SDK | Sent when the current token is nearing expiry, or a request just hit a 401. SDK invokes the partner's onTokenRefreshNeeded callback; the SDK cannot mint a token itself. |
Security model
Origin validation
The SDK only accepts messages whose event.origin matches its own chat or checkout URL. Anything else is silently dropped before its contents are even read. Each iframe independently checks event.source === window.parent on its side.
Stateless JWT, not cookies
Third-party cookies can't be relied on inside iframes. Instead, the partner's JWT travels only over postMessage — never in a URL, never in localStorage — and is expected to ride on every backend request as Authorization: Bearer <jwt>.
Card data handling — two phases, one feature flag
Which of these is active is controlled by a single setting (VITE_PAYMENT_PROVIDER, default duffel) — flipping it is the entire migration, nothing else needs to change.
Phase 1 — current
Card entry uses Duffel's own hosted card form (@duffel/components). The card never reaches our own code at all — Duffel's widget tokenizes it directly and hands back a card_id. Uplift's Flex Pay virtual card is likewise passed through in plain form at this phase (no client-side encryption yet), matching the same phasing decision.
Phase 2 — October, built and ready behind the flag
A full Evervault-based card form (@evervault/react) tokenizes card details client-side before anything leaves the browser; Uplift's Flex Pay VCC gets the same client-side encryption treatment. Both are already built and tested — switching VITE_PAYMENT_PROVIDER to evervault activates this path with no other code changes.
Build status
| Piece | Status |
|---|---|
| SDK, two-iframe architecture, JWT handshake | done |
| Chat + Iframe A, in React | done |
| Live chat replies (Odynn agent API, streamed) | done |
| Proactive + reactive JWT refresh | done |
| 429 rate-limit handling (auto-retry) | done |
| Flight offer cards | done real Duffel offers, not mocked |
| Card payment — Phase 1 (Duffel-hosted form) | done tested with confirmed live bookings |
| Uplift / Flex Pay (BNPL) | done tested with a confirmed live booking |
| Card + Flex Pay payment — Phase 2 (Evervault) | built, on hold tested working; intentionally not active until October per agreed phasing |
| Routing through EngiNeo's Governance Layer | not started still calling the agent backend directly |
| Production iframe hosting domain | not assigned |
| Partner developer portal | not started |