Widget Quickstart
This Quickstart builds a Spot widget integration from scratch. It starts with a plain partner checkout app that knows nothing about Spot, then adds the widget one concept at a time until the full flow (retrieve quote, accept/decline, and webhook) works end to end.
At a glance
The Spot widget flow has three main steps, sketched briefly here before the detailed walkthrough that follows.
1. Retrieving Quotes from Spot
Terminology: Quote
A Quote represents the calculated price of coverage the customer can expect to pay and what coverage they will receive upon accepting the quote. Quote details are displayed in the widget, where customers then choose whether to accept or decline coverage.
To start, calls to the Spot API will retrieve a quote to present to customers as part of your checkout flow. API calls to retrieve a quote only require your partnerId (no secret values) for authentication, and so can be handled entirely on the frontend as part of adding the widget to your app's UI. Reference: POST /api/v1/quote.
We'll dive more into these calls and how to handle them in Step 1 of the walkthrough below.
2. Sending the Customer's Accept or Decline Selection to Spot
Once customers have selected whether they wish to accept or decline Spot coverage and complete your checkout flow, your app makes a call to one of two Spot API endpoints, depending on the customer's final submitted selection:
Accepting the quote creates an enrollment, while declining marks the quote as not accepted on Spot's side.
Terminology: Enrollment
When a customer accepts coverage and your app reports that to Spot, Spot creates an Enrollment from the quote: the record of the coverage the customer purchased. Post-checkout activity (claims, status changes, and the webhooks covered below) acts on the enrollment, not the quote.
The accept and decline endpoints both require your app to fetch a valid OAuth token from Spot for authorization, and so will need to go through your backend rather than be executed directly from the frontend (since OAuth tokens are unsafe to expose to the frontend).
We'll dive more into how to fetch the token and make the API calls in Steps 2 and 3 of the walkthrough below.
3. Listening for Status Updates via Webhook
If the customer accepts coverage and then later decides to file a claim, Spot will emit a POST request webhook indicating such.
We'll go through an example of this in Step 4 of the walkthrough below.
How this guide works
The companion example repository is lifebyspot/spot-widget-example. Each step below corresponds to a single commit on the main branch, tagged so you can check out the repo (or simply view the changes in your browser) at any point in the guide:
| Tag | Step | Browse |
|---|---|---|
quickstart-step-0 | The starting checkout app (no Spot) | view |
quickstart-step-1 | Render the widget and retrieve a quote | view |
quickstart-step-2 | Read the customer's selection at checkout | view |
quickstart-step-3 | Accept / decline through your backend | view |
quickstart-step-4 | Receive and verify webhooks | view |
quickstart-step-5 | Optional demo / observability tooling | view |
To follow along locally:
git clone https://github.com/lifebyspot/spot-widget-example.git
cd spot-widget-example
git checkout quickstart-step-0 # or any step belowReading the code samples
Files added at a step are shown in full. Files that changed are shown as a diff: lines beginning with
-are removed and lines beginning with+are added. Descriptions of the changes are also included to describe the "what" and "why" of the updated code.
Building the integration step-by-step
The rest of the guide adds the widget to the sample app step by step, showing each step's code changes.
Step 0: The Starting App
Tag: quickstart-step-0
Before adding anything Spot-specific, we start from a small but complete partner checkout. There is nothing here you don't already have in your own app; it just gives us a realistic place to add the widget.
The app (apps/web-react) is a React 18 + Vite + TypeScript project with:
src/types.ts: the partner's own domain types: aQuoteItem(one bookable item in the cart) and aPurchaser(who is buying), plusbuildDefaultQuoteRequest()for a sensible starting booking.src/components/QuoteForm.tsx: an editable view of the booking details.src/components/PurchaserForm.tsx: the buyer's name and email.src/App.tsx: lays out the two forms and a "Proceed to checkout" button that, for now, simply marks the order as placed.
There is also a minimal backend (apps/server): an Express server with CORS and JSON body parsing and no routes yet. We'll add its endpoints starting in Step 3.
Run it:
nvm use 20
pnpm install
pnpm dev # frontend on http://localhost:5180You should see the booking and purchaser forms and be able to "place an order." That's the canvas starting app, with no Spot functionality implemented. Our first addition to this app will be to display the Spot widget and retrieve a quote.
Step 1: Render the Widget and Retrieve a Quote
Tag: quickstart-step-1
Now we add the Spot widget. This covers the first part of the flow: a quote is requested directly from Spot in the browser, using only the public partnerId. No secrets and no backend are involved yet.
Install the packages
Add the widget packages to apps/web-react:
pnpm --filter web-react add @getspot/spot-widget @getspot/spot-widget-reactPoint the widget at Sandbox
src/config.ts builds an apiConfig from VITE_SPOT_* env vars, defaulting to the Sandbox environment. Create apps/web-react/.env.local and set your partner id:
VITE_SPOT_ENV=sandbox
VITE_SPOT_PARTNER_ID=<your-sandbox-partner-id>apps/web-react/src/config.ts (new)
import type { ApiConfig } from "@getspot/spot-widget";
// Point the widget at an environment and partner. Set these in .env.local; the
// widget resolves `environment` to the right Spot API base URL on its own.
// Setting VITE_SPOT_CUSTOM_ENDPOINT overrides that resolution entirely; leave it
// unset for Sandbox and production.
export const apiConfig: ApiConfig = {
environment: (import.meta.env.VITE_SPOT_ENV ?? "sandbox") as ApiConfig["environment"],
partnerId: import.meta.env.VITE_SPOT_PARTNER_ID ?? "",
...(import.meta.env.VITE_SPOT_CUSTOM_ENDPOINT
? { customEndpoint: import.meta.env.VITE_SPOT_CUSTOM_ENDPOINT }
: {}),
};Getting Sandbox credentials
Spot provisions your partner id (and, in Step 3, your client id and secret) per environment; there is no self-serve signup, so request Sandbox credentials from your Spot contact.
Describe the booking to quote
src/defaultQuote.ts exports buildDefaultQuoteRequest(), which returns a QuoteItem. This is the same data your booking form already edits. Step 0 defined that type locally; the only change here is importing it from @getspot/spot-widget instead, because the shape a partner's cart already has is the shape the widget wants.
apps/web-react/src/defaultQuote.ts (new)
import type { QuoteItem } from "@getspot/spot-widget";
function isoDaysFromNow(days: number): string {
const millisecondsPerDay = 24 * 60 * 60 * 1000;
return new Date(Date.now() + days * millisecondsPerDay).toISOString();
}
/**
* A starting quote request so the widget renders a real quote out of the box.
* Offer matching is driven mainly by productId together with productType and
* productDuration, so replace these defaults with the values provisioned for
* your partner account, or tweak them at runtime in the booking form. A real
* integration builds this object from its own cart data.
*/
export function buildDefaultQuoteRequest(): QuoteItem {
return {
productPrice: 500,
productType: "Pass",
productDuration: "Seasonal",
productId: "example-pass-001",
productName: "Season Pass",
cartId: "example-cart",
cartName: "Sample Cart",
eventType: "Travel Experience",
currencyCode: "USD",
// A short window keeps the request under offers that cap coverage duration.
startDate: isoDaysFromNow(7),
endDate: isoDaysFromNow(9),
hostCountry: "US",
hostCountryState: "TX",
destinations: ["US"],
isPartialPayment: false,
};
}Render it
src/components/WidgetPanel.tsx wraps <ReactSpotWidget>, passing apiConfig, quoteRequestData, and the widget's callbacks:
apps/web-react/src/components/WidgetPanel.tsx (new)
import type { RefObject } from "react";
import ReactSpotWidget, {
type ReactSpotWidgetRef,
} from "@getspot/spot-widget-react";
import type { ApiConfig, Quote, QuoteItem, SelectionData } from "@getspot/spot-widget";
interface WidgetPanelProps {
widgetRef: RefObject<ReactSpotWidgetRef>;
apiConfig: ApiConfig;
quoteRequestData: QuoteItem;
onQuoteRetrieved: (quote: Quote) => void;
onOptIn: (data: SelectionData) => void;
onOptOut: (data: SelectionData) => void;
onError: (error: { message: string; status?: number }) => void;
onNoMatchingQuote: (data: { status: string; data: unknown }) => void;
}
/**
* Hosts the Spot widget. Its only job here is to request and render a quote;
* capturing the customer's selection at checkout comes in the next step.
*/
export function WidgetPanel({
widgetRef,
apiConfig,
quoteRequestData,
onQuoteRetrieved,
onOptIn,
onOptOut,
onError,
onNoMatchingQuote,
}: WidgetPanelProps) {
return (
<section className="panel">
<div className="panel__header">
<h2>Widget</h2>
</div>
<div className="widget-host">
<ReactSpotWidget
ref={widgetRef}
apiConfig={apiConfig}
quoteRequestData={quoteRequestData}
showTable={false}
onQuoteRetrieved={onQuoteRetrieved}
onOptIn={onOptIn}
onOptOut={onOptOut}
onError={onError}
onNoMatchingQuote={onNoMatchingQuote}
/>
</div>
</section>
);
}View apps/web-react/src/components/WidgetPanel.tsx on GitHub
onQuoteRetrieved: a quote came back and rendered.onOptIn/onOptOut: the customer picked yes / no. This is just a selection held in the widget; nothing is bound yet.onError/onNoMatchingQuote: fire on a failed quote request, or when no offer matches the booking (NO_MATCHING_QUOTE).
Wire it up
src/App.tsx holds the initial quote request stable in a ref (so editing the form never remounts the widget and loses the selection) and keeps a widgetRef for imperative calls. Applying booking changes calls widgetRef.current.updateQuote(draft) to re-quote in place.
apps/web-react/src/App.tsx (changed)
@@ -1,19 +1,32 @@
-import { useState } from "react";
-import { buildDefaultQuoteRequest, type QuoteItem, type Purchaser } from "./types";
+import { useRef, useState } from "react";
+import type { ReactSpotWidgetRef } from "@getspot/spot-widget-react";
+import type { QuoteItem } from "@getspot/spot-widget";
+import { apiConfig } from "./config";
+import { buildDefaultQuoteRequest } from "./defaultQuote";
import { QuoteForm } from "./components/QuoteForm";
+import { WidgetPanel } from "./components/WidgetPanel";
import { PurchaserForm } from "./components/PurchaserForm";
+import type { Purchaser } from "./types";
export function App() {
- const [booking, setBooking] = useState<QuoteItem>(buildDefaultQuoteRequest);
+ // Hold the initial quote request stable so editing the form never remounts
+ // the widget (which would lose the selection); re-quote via updateQuote().
+ const initialQuoteRef = useRef<QuoteItem>(buildDefaultQuoteRequest());
+ const widgetRef = useRef<ReactSpotWidgetRef>(null);
+
+ const [draft, setDraft] = useState<QuoteItem>(initialQuoteRef.current);
+ const [applied, setApplied] = useState<QuoteItem>(initialQuoteRef.current);
const [purchaser, setPurchaser] = useState<Purchaser>({
firstName: "Test",
lastName: "Purchaser",
email: "[email protected]",
});
- const [placed, setPlaced] = useState(false);
- function handleCheckout() {
- setPlaced(true);
+ const dirty = JSON.stringify(draft) !== JSON.stringify(applied);
+
+ async function handleApply() {
+ setApplied(draft);
+ await widgetRef.current?.updateQuote(draft);
}
return (
@@ -22,38 +35,32 @@ export function App() {
<div>
<h1>Spot widget example</h1>
<p className="muted">
- A sample partner checkout. This is the starting point, before the
- Spot widget is added.
+ A sample partner integration consuming{" "}
+ <code>@getspot/spot-widget-react</code>.
</p>
</div>
+ <div className="badges">
+ <span className="badge">env: {apiConfig.environment}</span>
+ </div>
</header>
<main className="app__grid">
<div className="app__column">
- <QuoteForm value={booking} onChange={setBooking} />
+ <QuoteForm value={draft} onChange={setDraft} onApply={handleApply} dirty={dirty} />
<PurchaserForm value={purchaser} onChange={setPurchaser} />
</div>
<div className="app__column">
- <section className="panel">
- <div className="panel__header">
- <h2>Checkout</h2>
- </div>
- <p className="muted">
- Review the booking and place the order. The Spot widget will slot
- in here in the next step of the guide.
- </p>
- <button type="button" className="button button--primary" onClick={handleCheckout}>
- Proceed to checkout
- </button>
- {placed && (
- <p className="notice notice--ok">
- Order placed for {purchaser.firstName} {purchaser.lastName}:{" "}
- {booking.productName} ({booking.currencyCode}{" "}
- {booking.productPrice}).
- </p>
- )}
- </section>
+ <WidgetPanel
+ widgetRef={widgetRef}
+ apiConfig={apiConfig}
+ quoteRequestData={initialQuoteRef.current}
+ onQuoteRetrieved={() => {}}
+ onOptIn={() => {}}
+ onOptOut={() => {}}
+ onError={() => {}}
+ onNoMatchingQuote={() => {}}
+ />
</div>
</main>
</div>Re-quote when the booking changes
QuoteForm owns the trigger. App.tsx tracks whether the edited draft differs from the last applied booking (dirty) and passes that, plus handleApply, to the form. The form renders an Apply button that calls handleApply, so a fresh quote is fetched only when the customer commits their edits, not on every keystroke.
apps/web-react/src/components/QuoteForm.tsx (changed)
@@ -1,8 +1,10 @@
-import type { QuoteItem } from "../types";
+import type { QuoteItem } from "@getspot/spot-widget";
interface QuoteFormProps {
value: QuoteItem;
onChange: (next: QuoteItem) => void;
+ onApply: () => void;
+ dirty: boolean;
}
const PRODUCT_TYPES: QuoteItem["productType"][] = ["Trip", "Pass", "Registration"];
@@ -23,7 +25,7 @@ function fromDateInput(value: string): string {
}
/** Editable view of the fields a partner most commonly varies per booking. */
-export function QuoteForm({ value, onChange }: QuoteFormProps) {
+export function QuoteForm({ value, onChange, onApply, dirty }: QuoteFormProps) {
function update<Key extends keyof QuoteItem>(key: Key, next: QuoteItem[Key]) {
onChange({ ...value, [key]: next });
}
@@ -181,6 +183,15 @@ export function QuoteForm({ value, onChange }: QuoteFormProps) {
/>
</div>
</div>
+
+ <button
+ type="button"
+ className="button button--primary"
+ onClick={onApply}
+ disabled={!dirty}
+ >
+ {dirty ? "Apply changes and re-quote" : "Quote is up to date"}
+ </button>
</section>
);
}Run pnpm dev. The widget requests a quote from Sandbox and renders the offer. Picking yes or no does nothing yet; we read that selection at checkout in the next step.
Describing the product you are quoting
Spot returns a quote only when the product you describe matches an offer configured for your partner account. Offers are set up by Spot per environment, and matching is driven mainly by productId together with productType and productDuration. Until they line up, the API answers NO_MATCHING_QUOTE and the widget renders nothing.
The sample hard-codes a starting request in src/defaultQuote.ts. None of this is Spot configuration: in a real integration every field comes from your own cart or product record, and buildDefaultQuoteRequest() is the seam where you would read it from instead. To experiment, edit the defaults there or change the values at runtime in the booking form.
| Field | What it is | Values |
|---|---|---|
productId | Identifies the product being sold. Free-form, but it must map to values configured with Spot; a product category or SKU-family field in your system is usually the right level of detail. | Ask your Spot contact which ids exist in Sandbox. |
productType | The kind of thing being sold. | Pass, Trip, Registration |
productDuration | How the coverage period is shaped. | Daily, Seasonal, Trip, Event |
eventType | Free-text label for the vertical, used by Spot for conversion reporting. | Any string, e.g. Concert, Travel Experience |
productPrice | The price of your product — Spot returns the coverage price separately as spotPrice, and only spotPrice is added to the cart total. Include taxes and fees; for deposits, send only the amount paid today with isPartialPayment: true (Integration Scenarios). | A number |
startDate, endDate | The coverage window, in the event's local timezone. The sample builds a short window (7 days out, 2 days long) because some offers cap coverage duration; your integration sends the booking's real dates. | ISO8601 date-times |
Getting a quote back in Sandbox
Ask your Spot contact for the
productId(and matchingproductType/productDuration) configured for your account — these values are defined together during onboarding, not guessed at. Set them as the defaults insrc/defaultQuote.ts. The shippedexample-pass-001is a placeholder, so until you replace it every request returnsNO_MATCHING_QUOTE.
Full field list: POST /api/v1/quote reference. How these values shape pricing and benefits: Quote Routing.
Full change for this step: git diff quickstart-step-0 quickstart-step-1.
Step 2: Read the Customer's Selection at Checkout
Tag: quickstart-step-2
The widget captures a yes/no selection, but it never contacts your server to create an enrollment; that is deliberately your app's job. This step wires your own "Proceed to checkout" button to read the selection. It's still entirely client-side; no backend yet.
In src/components/WidgetPanel.tsx, the panel gains a type="submit" Proceed to checkout button inside the checkout form. In src/App.tsx, the form's handleSubmit then does two things through the widget ref:
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
// Widget shows its own inline error if nothing is picked.
if (!widgetRef.current?.validateSelection()) return;
// getSelection() returns { status, quoteId, ... }.
await handleCheckout(widgetRef.current?.getSelection() ?? null);
}src/App.tsx receives that selection in its own handleCheckout and, for now, just displays it. The key field is selection.status (QUOTE_ACCEPTED vs QUOTE_DECLINED) together with selection.quoteId; those are exactly what the backend needs in the next step.
apps/web-react/src/App.tsx (changed)
@@ -1,6 +1,6 @@
import { useRef, useState } from "react";
import type { ReactSpotWidgetRef } from "@getspot/spot-widget-react";
-import type { QuoteItem } from "@getspot/spot-widget";
+import type { QuoteItem, SelectionData } from "@getspot/spot-widget";
import { apiConfig } from "./config";
import { buildDefaultQuoteRequest } from "./defaultQuote";
import { QuoteForm } from "./components/QuoteForm";
@@ -21,6 +21,7 @@ export function App() {
lastName: "Purchaser",
email: "[email protected]",
});
+ const [checkoutIntent, setCheckoutIntent] = useState<SelectionData | null>(null);
const dirty = JSON.stringify(draft) !== JSON.stringify(applied);
@@ -29,6 +30,21 @@ export function App() {
await widgetRef.current?.updateQuote(draft);
}
+ /**
+ * Real checkouts are forms, so the widget is mounted inside one and the
+ * checkout button is a submit control. Enter in any field submits too,
+ * which is why every other button sets type="button".
+ */
+ async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
+ event.preventDefault();
+ if (!widgetRef.current?.validateSelection()) return;
+ await handleCheckout(widgetRef.current?.getSelection() ?? null);
+ }
+
+ function handleCheckout(selection: SelectionData | null) {
+ setCheckoutIntent(selection);
+ }
+
return (
<div className="app">
<header className="app__header">
@@ -44,24 +60,39 @@ export function App() {
</div>
</header>
- <main className="app__grid">
- <div className="app__column">
- <QuoteForm value={draft} onChange={setDraft} onApply={handleApply} dirty={dirty} />
- <PurchaserForm value={purchaser} onChange={setPurchaser} />
- </div>
+ <main>
+ <form className="app__grid" onSubmit={handleSubmit}>
+ <div className="app__column">
+ <QuoteForm value={draft} onChange={setDraft} onApply={handleApply} dirty={dirty} />
+ <PurchaserForm value={purchaser} onChange={setPurchaser} />
+ </div>
- <div className="app__column">
- <WidgetPanel
- widgetRef={widgetRef}
- apiConfig={apiConfig}
- quoteRequestData={initialQuoteRef.current}
- onQuoteRetrieved={() => {}}
- onOptIn={() => {}}
- onOptOut={() => {}}
- onError={() => {}}
- onNoMatchingQuote={() => {}}
- />
- </div>
+ <div className="app__column">
+ <WidgetPanel
+ widgetRef={widgetRef}
+ apiConfig={apiConfig}
+ quoteRequestData={initialQuoteRef.current}
+ onQuoteRetrieved={() => {}}
+ onOptIn={() => {}}
+ onOptOut={() => {}}
+ onError={() => {}}
+ onNoMatchingQuote={() => {}}
+ />
+
+ {checkoutIntent && (
+ <section className="panel panel--seam">
+ <div className="panel__header">
+ <h2>Selection captured</h2>
+ </div>
+ <p className="muted">
+ status <code>{checkoutIntent.status}</code>, quote{" "}
+ <code>{checkoutIntent.quoteId ?? "(none)"}</code>. Next we send
+ this to the backend.
+ </p>
+ </section>
+ )}
+ </div>
+ </form>
</main>
</div>
);Run pnpm dev, pick an option, and click Proceed to checkout: the captured selection appears. Next we send it to a backend.
Beyond the example: carrying the quoteId to checkout
quoteId to checkoutThis example keeps things simple: the widget stays mounted, so at checkout it reads the quoteId straight from getSelection(). Real checkouts often submit the order on a different screen, step, or request, where the widget may no longer be mounted. In such cases, you should capture the quoteId when the quote is retrieved and carry it through to the point where you call your backend.
Two rules always apply, regardless of where you store it:
Quotes expire — check before you submit.
Every quote carries an
expiresAttimestamp, and Spot rejects expired quotes. Check it before calling accept or decline; if it has passed, re-quote withupdateQuote()and submit the new id. Step 3 shows the sample doing exactly this.Two implementation notes:
expiresAtarrives as an ISO8601 string (compare withnew Date(expiresAt).getTime()), and the widget'sQuotetype does not declare the field yet, so Step 3 adds a one-line type extension — the value is present at runtime.
Re-quoting replaces the id.
Every
updateQuote()returns a new quote with a new id. Always submit the latest one, never aquoteIdfrom a booking the customer has since edited. The sample enforces this by refusing checkout while the booking has unapplied edits.
The id arrives in onQuoteRetrieved as quote.id:
// pattern (not in the sample): capture the id as soon as the quote is retrieved
onQuoteRetrieved={(quote) => {
saveQuoteId(quote.id); // persist wherever checkout can read it later
}}Where to keep it depends on your checkout architecture:
-
Single-page apps. The widget and your checkout share one long-lived page. Hold the latest
quote.id(and the choice fromonOptIn/onOptOut) in app state (React state, context, or a store like Redux/Zustand). At submit, either readgetSelection()as the sample does, or use the value you stashed; the stash helps when the submit handler is far from the widget component. -
Multi-step / wizard flows. The widget lives on an early step and the order is submitted several steps later, after navigation that may unmount it. Persist the
quoteIdand selection somewhere that survives those transitions: a store that outlives the route, URL/query state,sessionStorage, or your own server-side cart/session for the checkout. Refresh the stored id whenever the customer goes back and edits the booking (each edit re-quotes). -
Traditional form-based checkout. A server-rendered form POST has no persistent JS state across the submit. Write the
quoteIdand selection into hidden inputs, kept in sync from the widget callbacks, so they post with the rest of the form to your backend:<!-- pattern (not in the sample) --> <input type="hidden" name="spotQuoteId" value="" /> <input type="hidden" name="spotSelection" value="" />onQuoteRetrieved = (quote) => { form.spotQuoteId.value = quote.id; }; onOptIn = () => { form.spotSelection.value = "accept"; }; onOptOut = () => { form.spotSelection.value = "decline"; }; -
Batch quotes. Quoting a cart of several items at once (a
BatchQuoteRequest) changes the shape, not the principle. The response comes back asquotes[](statusQUOTES_AVAILABLE), and the selection carriesbatchQuoteDetails: one{ quoteId, cartItemId, productPrice }per item. Store and expiry-check each id (keyed bycartItemId) the same way, and at checkout send every acceptedquoteIdin theitems[]of one batch-accept call (and any declined ids in one batch-decline call); each accepted item still produces its own enrollment. Reference:POST /api/v1/quote/batch, accept and decline.
However you carry it, the destination is the same as Step 3: your backend receives the quoteId and the accept/decline choice, then makes the authenticated call to Spot.
Full change for this step: git diff quickstart-step-1 quickstart-step-2.
Step 3: Accept / Decline Through Your Backend
Tag: quickstart-step-3
Why are backend changes necessary for these API calls?
Accepting or declining requires an OAuth token, obtained with your client secret, which must never reach the browser. Unlike the quote request, these calls therefore go through your backend.
This step accepts (enrolls) or declines the coverage. The backend holds the OAuth client secret, exchanges it for a token, and calls one of these Spot API endpoints:
POST /api/v1/quote/{id}/acceptPOST /api/v1/quote/{id}/decline
Note: Implementations must handle both the
acceptanddeclinecases. Even though thedeclinepath seems like a no-op at first blush, Spot needs to accurately track when quotes are declined.
Backend (apps/server)
apps/server)src/spot/spotClient.ts fetches a client-credentials token (cached in memory, refreshed before expiry) and exposes acceptQuote() / declineQuote(). The client secret never leaves the server. Reference: POST /api/oauth/token:
apps/server/src/spot/spotClient.ts (new)
import { config } from "../config.js";
interface CachedToken {
accessToken: string;
expiresAt: number;
}
let cachedToken: CachedToken | null = null;
// Refresh a bit before the real expiry so an in-flight request never uses a
// token that expires mid-call. The token lives 24h, so this margin is generous.
const EXPIRY_MARGIN_MS = 60_000;
/**
* Client-credentials token, cached in memory. This is the whole reason a
* partner needs a backend: the client secret is required to obtain this token
* and must never reach the browser.
*/
async function getAccessToken(): Promise<string> {
const now = Date.now();
if (cachedToken && cachedToken.expiresAt - EXPIRY_MARGIN_MS > now) {
return cachedToken.accessToken;
}
const response = await fetch(`${config.spotApiBase}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Token request failed (${response.status}): ${body}`);
}
const data = (await response.json()) as {
access_token: string;
expires_in: number;
};
cachedToken = {
accessToken: data.access_token,
expiresAt: now + data.expires_in * 1000,
};
return cachedToken.accessToken;
}
export interface SpotCallResult {
status: number;
body: unknown;
}
async function authedPost(path: string, payload: unknown): Promise<SpotCallResult> {
const token = await getAccessToken();
const response = await fetch(`${config.spotApiBase}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-Spot-Partner-Id": config.partnerId,
},
body: JSON.stringify(payload),
});
const text = await response.text();
let body: unknown = text;
try {
body = text ? JSON.parse(text) : null;
} catch {
// leave body as raw text if it is not JSON
}
return { status: response.status, body };
}
export function acceptQuote(quoteId: string, payload: unknown): Promise<SpotCallResult> {
return authedPost(`/api/v1/quote/${encodeURIComponent(quoteId)}/accept`, payload);
}
// Spot's decline endpoint takes no request body.
export function declineQuote(quoteId: string): Promise<SpotCallResult> {
return authedPost(`/api/v1/quote/${encodeURIComponent(quoteId)}/decline`, {});
}src/config.ts reads the required credentials from apps/server/.env (see .env.example); the secret stays here and never reaches the browser:
apps/server/src/config.ts (changed)
@@ -1,6 +1,20 @@
import "dotenv/config";
+function required(name: string): string {
+ const value = process.env[name];
+ if (!value) {
+ throw new Error(
+ `Missing required env var ${name}. Copy apps/server/.env.example to apps/server/.env and fill it in.`,
+ );
+ }
+ return value;
+}
+
export const config = {
+ spotApiBase: process.env.SPOT_API_BASE ?? "https://api.sandbox.getspot.com",
+ partnerId: required("SPOT_PARTNER_ID"),
+ clientId: required("SPOT_CLIENT_ID"),
+ clientSecret: required("SPOT_CLIENT_SECRET"),
port: Number(process.env.PORT ?? 8787),
// Both sample frontends may call the backend: React (5180) and vanilla (5181).
frontendOrigins: (src/index.ts adds POST /accept and POST /decline. /accept validates the body and requires a transactionId; /decline needs only the quoteId, since Spot's decline endpoint takes no request body. a forward() helper mirrors Spot's status and body back:
The idempotency key has to be stable.
Idempotency is enforced by
transactionItemId, nottransactionId. Accept-quote requests reusing the sametransactionItemIdfor the same offer are rejected as duplicates rather than creating additional coverages, so a retry cannot double-charge.transactionIdidentifies the purchase as a whole and defaults to the enrollment request id if you omit it.The protection only works while the value is stable. Sending a freshly generated
transactionItemIdon every attempt makes each retry look like a new purchase and removes it entirely. If you omittransactionItemId, Spot derives a stable fallback from the cart context (partnerId_cartId_cartItemId, orpartnerId_cartIdwhen there is nocartItemId), so repeated requests for the same cart item still resolve to one enrollment. Either send your own stable per-line id or leave it out; a random value per request is the one thing to avoid.This sample requires a
transactionIdfrom the caller and derives the item key from it, which keeps both stable across retries.transactionItemIdis per cart line, so a multi-item cart needs one for each.
apps/server/src/index.ts (changed)
@@ -1,11 +1,80 @@
import cors from "cors";
import express from "express";
import { config } from "./config.js";
+import { acceptQuote, declineQuote, type SpotCallResult } from "./spot/spotClient.js";
const app = express();
app.use(cors({ origin: config.frontendOrigins }));
app.use(express.json());
+// ===========================================================================
+// The actual Spot integration. This is the code a partner writes.
+// ===========================================================================
+
+/**
+ * Accept a quote. This is what actually creates coverage, and it is why a
+ * backend exists: it attaches the OAuth bearer token the browser cannot hold.
+ */
+app.post("/accept", async (req, res) => {
+ const { quoteId, productPrice, purchaser, transactionId, transactionItemId } =
+ req.body ?? {};
+
+ if (!quoteId || typeof productPrice !== "number" || !purchaser) {
+ res.status(400).json({
+ error: "quoteId, productPrice (number), and purchaser are required",
+ });
+ return;
+ }
+
+ // Spot dedupes accept requests that reuse the same transactionItemId, so a
+ // retry cannot double-charge. The key below is derived from transactionId,
+ // so require a stable one rather than minting a fresh UUID per request.
+ if (!transactionId) {
+ res.status(400).json({
+ error: "transactionId is required and must be stable across retries of the same order",
+ });
+ return;
+ }
+ const payload = {
+ productPrice,
+ purchaser,
+ transactionId,
+ // One key per cart line; this sample has a single item.
+ transactionItemId: transactionItemId ?? `${transactionId}-1`,
+ };
+
+ await forward(res, () => acceptQuote(quoteId, payload));
+});
+
+/**
+ * Decline a quote. Always report declines as well as accepts so Spot keeps a
+ * complete, accurate record of every coverage decision.
+ */
+app.post("/decline", async (req, res) => {
+ const { quoteId } = req.body ?? {};
+ if (!quoteId) {
+ res.status(400).json({ error: "quoteId is required" });
+ return;
+ }
+ await forward(res, () => declineQuote(quoteId));
+});
+
+/** Run a Spot call and mirror its status and body back to the caller. */
+async function forward(
+ res: express.Response,
+ call: () => Promise<SpotCallResult>,
+): Promise<void> {
+ try {
+ const result = await call();
+ res.status(result.status).json(result.body);
+ } catch (error) {
+ // Network or token-exchange failure, not a Spot API rejection.
+ res.status(502).json({ error: (error as Error).message });
+ }
+}
+
app.listen(config.port, () => {
- console.log(`Spot example backend listening on http://localhost:${config.port}`);
+ console.log(
+ `Spot example backend listening on http://localhost:${config.port} -> ${config.spotApiBase}`,
+ );
});Frontend (apps/web-react)
apps/web-react)src/bff.ts is the browser's client for your backend (BFF = backend for frontend). The Purchaser type moves here (it's sent with the accept call), so src/types.ts goes away:
apps/web-react/src/bff.ts (new)
// BFF = "backend for frontend": a small backend that exists to serve this
// frontend. The browser cannot call Spot's accept/decline endpoints directly
// (they need an OAuth secret that must not ship to the browser), so it calls
// our own backend, which holds the secret and forwards to Spot. This module is
// the browser's client for that backend.
import { backendUrl } from "./config";
/** Buyer details collected at checkout and sent to the backend for the accept call. */
export interface Purchaser {
firstName: string;
lastName: string;
email: string;
}
/** Normalized result of a backend call: whether it succeeded, the HTTP status
* (0 means the request never reached the backend), and the parsed body. */
export interface CheckoutResult {
ok: boolean;
status: number;
body: unknown;
}
async function post(path: string, payload: unknown): Promise<CheckoutResult> {
const response = await fetch(`${backendUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
let body: unknown = null;
try {
body = await response.json();
} catch {
// no-op: leave body null if the response was not JSON
}
return { ok: response.ok, status: response.status, body };
}
/**
* Ask the backend to accept (bind) the quote. Creates coverage.
* transactionId must stay stable across retries; the server derives the
* idempotency key (transactionItemId) from it.
*/
export function acceptQuote(
quoteId: string,
productPrice: number,
purchaser: Purchaser,
transactionId: string,
): Promise<CheckoutResult> {
return post("/accept", { quoteId, productPrice, purchaser, transactionId });
}
/** Ask the backend to decline the quote (conversion tracking). */
export function declineQuote(quoteId: string): Promise<CheckoutResult> {
return post("/decline", { quoteId });
}src/config.ts adds backendUrl (import.meta.env.VITE_BFF_URL ?? "http://localhost:8787").
src/App.tsx now has handleCheckout call the BFF and render the response (enrollment id on accept; a "is the backend running?" hint if the request never lands). It also guards the submit path against an expired quote: expiresAt is captured in onQuoteRetrieved and stored in a ref, and if it has passed by the time the customer submits, handleCheckout calls updateQuote() to re-quote and asks them to confirm again rather than sending an id Spot would reject:
apps/web-react/src/App.tsx (changed)
@@ -1,12 +1,25 @@
import { useRef, useState } from "react";
import type { ReactSpotWidgetRef } from "@getspot/spot-widget-react";
-import type { QuoteItem, SelectionData } from "@getspot/spot-widget";
+import type { Quote, QuoteItem, SelectionData } from "@getspot/spot-widget";
import { apiConfig } from "./config";
import { buildDefaultQuoteRequest } from "./defaultQuote";
import { QuoteForm } from "./components/QuoteForm";
import { WidgetPanel } from "./components/WidgetPanel";
import { PurchaserForm } from "./components/PurchaserForm";
-import type { Purchaser } from "./types";
+import { acceptQuote, declineQuote, type CheckoutResult, type Purchaser } from "./bff";
+
+/**
+ * The API returns `expiresAt` and the widget passes the quote through untouched,
+ * but the published `Quote` type omits it. ISO8601 string, not a Date.
+ */
+type QuoteWithExpiry = Quote & { expiresAt?: string };
+
+/** True only when the quote is known to have expired; unknown means proceed. */
+function isExpired(expiresAt: string | null): boolean {
+ if (!expiresAt) return false;
+ const at = new Date(expiresAt).getTime();
+ return Number.isFinite(at) && at <= Date.now();
+}
export function App() {
// Hold the initial quote request stable so editing the form never remounts
@@ -21,7 +34,17 @@ export function App() {
lastName: "Purchaser",
email: "[email protected]",
});
- const [checkoutIntent, setCheckoutIntent] = useState<SelectionData | null>(null);
+ const [checkoutResult, setCheckoutResult] = useState<CheckoutResult | null>(null);
+
+ // Only the submit handler reads this, so a ref rather than state.
+ const quoteExpiryRef = useRef<string | null>(null);
+
+ /**
+ * Identifies this order. The server derives the real idempotency key
+ * (transactionItemId) from it, so it must stay stable across retries. Real
+ * integrations should send their own order id.
+ */
+ const transactionIdRef = useRef<string>(crypto.randomUUID());
const dirty = JSON.stringify(draft) !== JSON.stringify(applied);
@@ -41,8 +64,54 @@ export function App() {
await handleCheckout(widgetRef.current?.getSelection() ?? null);
}
- function handleCheckout(selection: SelectionData | null) {
- setCheckoutIntent(selection);
+ async function handleCheckout(selection: SelectionData | null) {
+ setCheckoutResult(null);
+ if (!selection?.quoteId) return;
+
+ // The quote describes `applied`; unapplied edits in `draft` were never
+ // quoted, so re-quote before submitting.
+ if (dirty) {
+ setCheckoutResult({
+ ok: false,
+ status: 0,
+ body: {
+ error:
+ "The booking changed since it was quoted. Apply the changes to re-quote, then confirm.",
+ },
+ });
+ return;
+ }
+
+ // Re-quote rather than submit an id Spot will reject. updateQuote() fires
+ // onQuoteRetrieved again, refreshing the stored expiry.
+ if (isExpired(quoteExpiryRef.current)) {
+ const requoted = await widgetRef.current?.updateQuote(applied);
+ setCheckoutResult({
+ ok: false,
+ status: 0,
+ body: {
+ error: requoted
+ ? "That quote had expired, so it was refreshed. Review the new quote and confirm again."
+ : "That quote had expired and could not be refreshed. Please try again.",
+ },
+ });
+ return;
+ }
+
+ try {
+ const result =
+ selection.status === "QUOTE_ACCEPTED"
+ ? await acceptQuote(
+ selection.quoteId,
+ applied.productPrice,
+ purchaser,
+ transactionIdRef.current,
+ )
+ : await declineQuote(selection.quoteId);
+ setCheckoutResult(result);
+ } catch (error) {
+ setCheckoutResult({ ok: false, status: 0, body: { error: (error as Error).message } });
+ }
}
return (
@@ -72,23 +141,26 @@ export function App() {
widgetRef={widgetRef}
apiConfig={apiConfig}
quoteRequestData={initialQuoteRef.current}
- onQuoteRetrieved={() => {}}
+ onQuoteRetrieved={(quote) => {
+ quoteExpiryRef.current = (quote as QuoteWithExpiry).expiresAt ?? null;
+ }}
onOptIn={() => {}}
onOptOut={() => {}}
onError={() => {}}
onNoMatchingQuote={() => {}}
/>
- {checkoutIntent && (
+ {checkoutResult && (
<section className="panel panel--seam">
<div className="panel__header">
- <h2>Selection captured</h2>
+ <h2>Backend response</h2>
</div>
- <p className="muted">
- status <code>{checkoutIntent.status}</code>, quote{" "}
- <code>{checkoutIntent.quoteId ?? "(none)"}</code>. Next we send
- this to the backend.
+ <p className={checkoutResult.ok ? "notice notice--ok" : "notice notice--error"}>
+ {checkoutResult.status === 0
+ ? "Could not reach the backend. Is it running?"
+ : `HTTP ${checkoutResult.status}${checkoutResult.ok ? " (success)" : ""}`}
</p>
+ <pre className="log__detail">{JSON.stringify(checkoutResult.body, null, 2)}</pre>
</section>
)}
</div>Note: It is important that these endpoints not be called until the customer fully completes and submits your checkout flow. A common mistake in first-pass implementations is to call Spot's API as soon as the widget fires
onOptIn/onOptOutcallbacks; however, if the customer then abandons checkout, you will have created an enrollment for coverage they never bought.
Configure apps/server/.env (copy from .env.example), then run both:
pnpm dev:all # backend on :8787, frontend on :5180Pick yes or no and "Proceed to checkout": accept returns an enrollmentId, decline returns a declined status.
Full change for this step: git diff quickstart-step-2 quickstart-step-3.
Step 4: Receive and Verify Webhooks
Tag: quickstart-step-4
After a customer accepts coverage, Spot posts lifecycle and claim events to a partner endpoint, signed with an HMAC. This step adds the receiver.
Backend (apps/server)
apps/server)src/spot/webhookSignature.ts recomputes the signature as Spot produces it: a hex HMAC-SHA256 over the raw request body, keyed by partnerId:hmacSecret. Verifying over the raw bytes matters, since re-serializing the parsed JSON could reorder keys and break the comparison:
apps/server/src/spot/webhookSignature.ts (new)
import { createHmac, timingSafeEqual } from "node:crypto";
import { config } from "../config.js";
/**
* Verify Spot's X-Spot-Signature exactly as the platform produces it:
* hex HMAC-SHA256 over the RAW request body bytes, keyed by
* `${partnerId}:${hmacSecret}`. The raw bytes matter: re-serializing the parsed
* JSON could reorder keys or reformat dates and break the comparison.
*/
export function verifySignature(
rawBody: Buffer,
signatureHeader: string | undefined,
): boolean {
if (!signatureHeader) {
return false;
}
const key = `${config.partnerId}:${config.webhookHmacSecret}`;
const expectedHex = createHmac("sha256", key).update(rawBody).digest("hex");
const expected = Buffer.from(expectedHex, "hex");
const provided = Buffer.from(signatureHeader, "hex");
if (expected.length === 0 || expected.length !== provided.length) {
return false;
}
return timingSafeEqual(expected, provided);
}src/config.ts adds webhookHmacSecret, a separate secret from the OAuth client secret.
src/index.ts has the express.json parser stash the raw body, and a new POST /webhooks route verifies the X-Spot-Signature header, returning 200 on success and 401 on a bad or missing signature (so a real sender retries):
apps/server/src/index.ts (changed)
@@ -2,10 +2,23 @@ import cors from "cors";
import express from "express";
import { config } from "./config.js";
import { acceptQuote, declineQuote, type SpotCallResult } from "./spot/spotClient.js";
+import { verifySignature } from "./spot/webhookSignature.js";
+
+interface RawBodyRequest extends express.Request {
+ rawBody?: Buffer;
+}
const app = express();
app.use(cors({ origin: config.frontendOrigins }));
-app.use(express.json());
+// Keep the raw body alongside the parsed JSON so the webhook route can verify
+// the HMAC over exactly the bytes that were received.
+app.use(
+ express.json({
+ verify: (req, _res, buffer) => {
+ (req as RawBodyRequest).rawBody = buffer;
+ },
+ }),
+);
// ===========================================================================
// The actual Spot integration. This is the code a partner writes.
@@ -59,6 +72,22 @@ app.post("/decline", async (req, res) => {
await forward(res, () => declineQuote(quoteId));
});
+/**
+ * Receive an outbound Spot webhook: verify the signature over the raw body and
+ * respond (401 on a bad signature so a real sender retries).
+ */
+app.post("/webhooks", (req, res) => {
+ const rawBody = (req as RawBodyRequest).rawBody ?? Buffer.alloc(0);
+ const signature = req.header("X-Spot-Signature") ?? null;
+ const verified = verifySignature(rawBody, signature ?? undefined);
+
+ if (!verified) {
+ res.status(401).json({ error: "invalid or missing X-Spot-Signature" });
+ return;
+ }
+ res.status(200).json({ received: true });
+});
+
/** Run a Spot call and mirror its status and body back to the caller. */
async function forward(
res: express.Response,To receive real Spot webhooks (extra steps, by design):
- In
apps/server/.env, setSPOT_WEBHOOK_HMAC_SECRETto your realhmacSecret. This is a per-partner secret Spot issues separately from your OAuth credentials; ask your Spot contact for it. - Expose the backend over public HTTPS (e.g. an ngrok/cloudflared tunnel to
:8787). Spot rejectshttp/localhost receiver URLs. - Register the public URL with Spot (partner-wide via
POST /api/v2/enrollments/webhooks, or per enrollment viawebhookUrlOverride). Spot allows one configuration per partner: re-registering returns409, so delete the existing configuration (DELETE /api/v2/enrollments/webhooks/{id}) when your tunnel URL changes. - For an accepted quote Spot only delivers
ClaimReceived, so you must trigger a claim to observe a real delivery.
Setting up a public tunnel just to see the verify path work is a lot of ceremony; the next (optional) step adds a local simulator so you don't have to.
Full change for this step: git diff quickstart-step-3 quickstart-step-4.
Step 5: Optional Demo / Observability Tooling
Tag: quickstart-step-5
Everything up to here is a complete integration. This final step adds tooling that is not part of a real integration but makes the sample easy to explore and demo locally. Each file below carries a SAMPLE-APP ONLY comment.
Backend (apps/server)
apps/server)src/demo/webhookStore.ts: an in-memory buffer of received webhooks.src/demo/simulation.ts: builds a representative webhook payload and signs it the way Spot would.src/demo/routes.ts:registerDemoRoutes()mountsGET /health,GET/DELETE /webhooks/events, andPOST /dev/simulate-webhook(which self-signs an event and delivers it to the real/webhooksroute).src/index.ts: records each received webhook into the buffer and callsregisterDemoRoutes(app)behind a clear banner.
Frontend (apps/web-react)
apps/web-react)src/demo/EventLog.tsx: lists the widget callbacks as they fire.src/demo/WebhookPanel.tsx+src/demo/webhookApi.ts: polls the backend's webhook buffer and offers Simulate / Clear controls.src/config.ts+src/defaultQuote.ts: add mock mode: withVITE_SPOT_USE_MOCK=true,buildMockQuoteResponse()renders a complete quote without calling the API (handy when a local env has no matching offer).src/App.tsx+WidgetPanel.tsx: wire the two panels and the mock props in.
Try the whole thing without any Spot setup. The backend still refuses to start without its env file, so copy apps/server/.env.example to apps/server/.env first; the placeholder values are fine in mock mode:
VITE_SPOT_USE_MOCK=true pnpm dev:allOpen http://localhost:5180, watch the Widget events panel as you interact, and click Simulate delivery in the Webhooks panel to send a correctly signed event through the verify-and-display path.
That's the finished app: the full quote, accept/decline, and webhook flow, working end to end.
Updated 2 days ago
