Skip to content

Embed the widget on your site

Paste one snippet and your site shows real availability, then walks a guest through a quote, a hold, and payment — no server code required on your side. Payment happens on a Stripe-hosted checkout page, not inside the widget itself; the booking is confirmed automatically once Stripe tells us the card payment succeeded.

Only want to show availability, with no booking flow at all? A separate, smaller embed does exactly that — see Embed the calendar.

Want to see it running before you paste anything? The live widget demo embeds this exact snippet against a real resource — pick dates, get a quote, create a hold, and pay with a real Stripe-hosted checkout page.

<script async src="https://docs.booking-engine.davidvalentino.com/widget/v1.js"></script>
<div
data-booking-widget
data-key="pk_live_..."
data-resource="<RESOURCE_ID>"
data-api-base="https://<your-api-host>"
></div>

This is the one script URL for the booking widget everywhere in these docs. The dashboard’s widget configurator (under Keys) fills in the script URL and data-api-base for you.

v1.js finds every [data-booking-widget] element on the page and mounts one widget into each, inside a Shadow DOM. The host page’s CSS can’t leak in, and the widget’s CSS can’t leak out. This works the same inside WordPress, Wix, or hand-rolled HTML.

Not using plain HTML? Use the JavaScript API in React, Next.js, Vue, Nuxt, Svelte, or WordPress.

Every option is a data-* attribute on the element. You can also pass the same option to BookingWidget.mount(el, { ... }). A value passed to mount wins over the attribute.

Attributemount optionRequiredWhat it does
data-keykeyYesYour pk_live_… or pk_test_… key. A missing or wrong key shows “Booking widget misconfigured — see console” to whoever builds the page, never to a guest.
data-resourceresourceYesOne resource’s ID. all is not built yet. Embed one widget per resource.
data-api-baseapiBaseYes in practiceThe API URL the widget calls, for example https://<your-api-host>. If you leave it out, the widget calls the origin of its own script. That is the docs site, not the API, so every request fails.
data-rate-plan-idratePlanIdSometimesNeeded if the resource has a room type and more than one rate plan. There is no rate-plan picker yet.
data-modemodeNolodging (default) or rental. Any other value is rejected. Leave it out for hotel stays and for hourly spaces: the widget reads the resource and picks the right steps. Rental is not available yet: the widget starts in rental mode, but every quote fails with 503 rental_unavailable.
data-langlangNoen (default) or id.
data-accentaccentNoA 6-digit hex color, for example #2563eb.
data-radiusradiusNoPixels, clamped to 2–16.
data-fontfontNosystem, serif, or mono.
data-densitydensityNocomfortable (default) or compact.
data-fallback-contactfallbackContactNoA tel:, mailto:, or WhatsApp link shown if the widget can’t reach the API.
data-on-funnel-eventonEventNoGet funnel events in your own analytics. See Funnel events.

The script adds window.BookingWidget with two functions:

BookingWidget.mount(el: HTMLElement, options?: { key, resource, apiBase, ... }): { destroy(): void }
BookingWidget.scan(root?: ParentNode): void
  • mount puts a widget inside el and returns a handle. Call handle.destroy() to remove it. destroy stops the hold countdown and other listeners, sends the abandoned funnel event if the guest had not finished, and removes the widget. You can then mount again in the same el.
  • Mounting an element that already has a widget does nothing. It never throws.
  • scan mounts every [data-booking-widget] element under root. The script runs it once when it loads. Call it again after you add markup to the page.
  • If one element has a bad config, the error goes to the console and the other elements still mount.

Don’t put data-booking-widget on an element you pass to mount. The auto-scan would pick it up too.

mount is not available until the script has loaded. In a framework, wait for the script’s load event first. This helper does that, and the tabs below use it:

loadBookingWidget.ts
const SRC = "https://docs.booking-engine.davidvalentino.com/widget/v1.js";
export interface BookingWidgetApi {
mount(el: HTMLElement, options: Record<string, unknown>): { destroy(): void };
scan(root?: ParentNode): void;
}
export function loadBookingWidget(): Promise<BookingWidgetApi> {
const w = window as unknown as { BookingWidget?: BookingWidgetApi };
if (w.BookingWidget) return Promise.resolve(w.BookingWidget);
return new Promise((resolve, reject) => {
let script = document.querySelector<HTMLScriptElement>(`script[src="${SRC}"]`);
if (!script) {
script = document.createElement("script");
script.src = SRC;
script.async = true;
document.head.appendChild(script);
}
script.addEventListener("load", () =>
w.BookingWidget ? resolve(w.BookingWidget) : reject(new Error("BookingWidget missing")),
);
script.addEventListener("error", () => reject(new Error("Could not load the widget script")));
});
}

Use a publishable key (pk_). It is safe in a browser.

Simplest: the data attributes, with the auto-scan.

<script async src="https://docs.booking-engine.davidvalentino.com/widget/v1.js"></script>
<div
data-booking-widget
data-key="pk_live_..."
data-resource="<RESOURCE_ID>"
data-api-base="https://<your-api-host>"
></div>

Or from JavaScript, when you want to start and stop it yourself:

<div id="booking"></div>
<script src="https://docs.booking-engine.davidvalentino.com/widget/v1.js"></script>
<script>
const handle = BookingWidget.mount(document.getElementById("booking"), {
key: "pk_live_...",
resource: "<RESOURCE_ID>",
apiBase: "https://<your-api-host>",
});
// Later: handle.destroy();
</script>

Call the API from your server (sk_ — never in a browser)

Section titled “Call the API from your server (sk_ — never in a browser)”

The widget only shows availability, a quote, and a hold, and then sends the guest to Stripe to pay. Reading guest data, confirming a booking as paid offline, and refunds need your server and a secret key (sk_). An sk_ key can read guest data and move money, so it must never appear in a page, a mobile app, or a NEXT_PUBLIC_ variable. See the Quickstart for the full server flow with curl, Node, PHP, Python, and TypeScript.

The widget sends these events: availability_viewed, quote_created, hold_created, checkout_started, checkout_returned, abandoned, and error. Each event carries ids only, never guest data. There are three ways to get them.

// 1. A DOM event on the element (bubbles)
el.addEventListener("booking-widget:funnel", (e) => console.log(e.detail));
// 2. A global function named in the attribute: data-on-funnel-event="onBookingFunnel"
window.onBookingFunnel = (e) => console.log(e);
// 3. A callback in mount()
BookingWidget.mount(el, { key, resource, apiBase, onEvent: (e) => console.log(e) });

checkout_returned with outcome: "success" means Stripe sent the guest back after payment. The booking is confirmed a moment later, when Stripe’s webhook reaches us.

The widget first reads the resource (GET /v1/resources/{id}). If the resource is an hourly space, it shows the hourly flow below. Otherwise it shows this nights flow.

  1. Dates. The guest sees a 7-day availability strip, picks a check-in date, and sets a length of stay.
  2. Quote. The widget prices the stay (POST /v1/quotes) and shows a per-night breakdown and total.
  3. Hold. “Continue” turns the quote into a 15-minute hold (POST /v1/holds). A countdown shows how long the guest has left.
  4. Pay. “Pay now” creates a Stripe-hosted Checkout Session for that hold and sends the guest’s browser there — a real page navigation, away from your site, to a page Stripe serves and secures. Nobody’s card details pass through your site or through us.
  5. Return and confirm. Stripe sends the guest back to the same page they started on, either after payment or after cancelling. Confirmation itself happens in the background: once Stripe tells us the payment succeeded, the reservation is marked confirmed automatically. The widget’s own “Payment received” screen means the charge went through and confirmation is on its way — not a claim the widget itself has any way to verify further.

If a guest closes the tab or cancels on Stripe’s page, nothing is charged and the hold stays in place for a few minutes so they can try again.

Use the same snippet. There is no extra option. The widget shows three stages: When, Review, and Pay.

  1. When. The guest picks how long, then a day, then a start time. Each time is shown in the space’s own time zone. The guest also enters how many people come. If the number is above the space’s capacity, the widget asks for a smaller group. A day with no times left is marked “Full” or “Closed” once the guest opens it. The widget calls GET /v1/resources/{id}/slots for one day at a time.
  2. Review. The widget prices the slot (POST /v1/quotes) and shows the price, for example “1 hour × IDR 150,000 / hour”. The guest then types a name, an email, and an optional phone or WhatsApp number. We send these as guest with the hold. Only the space’s operator sees them.
  3. Pay. “Continue — hold this time” makes the hold (POST /v1/holds) and shows a 15-minute timer. “Pay now” then sends the guest to the same Stripe-hosted checkout page. The booking is confirmed when Stripe tells us the payment succeeded. The guest then gets a “Booking confirmed” email.

If someone else takes the time first, the widget goes back to the time list with “That time was just taken. Pick another time.” and loads the times again. If your site sends too many requests, the widget shows “Too many requests — try again in N s” and waits. See Hourly spaces and services for the API behind these steps.

The widget only offers “Pay now” with Stripe. A hold it makes lasts 15 minutes. If a guest pays the operator in cash or by bank transfer instead, the operator must confirm the hold as paid offline within that time. See Holds and confirmation.

Four options, all as data-* attributes on the same element (or mount options). Anything outside these bounds falls back to a safe default — never a broken layout — and logs a console warning for you to see, never the guest.

AttributeValuesWhat happens if it’s invalid
data-accentA 6-digit hex color, e.g. #2563ebFalls back to the default accent
data-radiusA number (pixels), clamped to 2–16Out-of-range values are clamped, not rejected
data-fontsystem, serif, or monoFalls back to system
data-densitycomfortable or compactFalls back to comfortable

The text color drawn on top of your accent color is always computed automatically for readable contrast — there’s no attribute for it, and none is planned. You pick the accent; we make sure the text on it is legible.

These four options are a fixed, closed set. There’s no free-text font field and no way to change spacing, component shape, or the booking-flow order — that’s deliberate, not a current limitation.

Your dashboard’s widget configurator (under Keys) builds this exact snippet for you from a live preview — pick your key, resource, and theme there, and copy the result instead of hand-typing attributes.

The publishable key and the domain allowlist

Section titled “The publishable key and the domain allowlist”

Your operator contact issues pk_ keys with an allowlist of the domains they’re allowed to run on. If the widget’s request comes from a domain not on that list, the API rejects it, and the widget falls back to its outage message (below). Open the browser console: the widget logs the reason, for example origin_not_allowed. Check the allowlist first if the widget looks “down” only on one domain. An entry can be one site (https://shop.example.com) or every subdomain (https://*.example.com). See Allowed origins. The same allowlist check applies to the Stripe-hosted checkout handoff, so a guest is only ever redirected back to an approved domain.

Two different things can go wrong, and the widget treats them differently on purpose:

  • A bad snippet (missing/malformed key, data-resource="all", a missing resource id) is a mistake in your page, not a guest-facing outage. The element is replaced with plain, unstyled text pointing you at the browser console. This is for you, not your guests.
  • A working snippet that can’t reach the API (network failure, rejected key, rate limit) never renders an empty calendar that could look like “no availability” — that would be a silent loss of bookings with no signal anyone could act on. If every visible day fails, the whole strip is replaced with a “Booking temporarily unavailable” message, plus your contact details if you set data-fallback-contact to a tel:, mailto:, or WhatsApp link. If only some days fail, the ones that succeeded still show real data, and the failed ones show as “unknown” rather than joining either bucket.
  • A hold that expires mid-payment, or a checkout session that fails to create, sends the guest back to a step they can actually retry from — never an optimistic “you’re booked” ahead of a real, confirmed charge.
  • Quickstart — the same flow from your own server, using curl and TypeScript, if you’d rather build your own checkout instead of embedding ours.
  • Embed the calendar — the read-only, availability-only version of this embed, if you don’t want a booking flow at all.