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.
The snippet
Section titled “The snippet”<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.
All options
Section titled “All options”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.
| Attribute | mount option | Required | What it does |
|---|---|---|---|
data-key | key | Yes | Your 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-resource | resource | Yes | One resource’s ID. all is not built yet. Embed one widget per resource. |
data-api-base | apiBase | Yes in practice | The 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-id | ratePlanId | Sometimes | Needed if the resource has a room type and more than one rate plan. There is no rate-plan picker yet. |
data-mode | mode | No | lodging (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-lang | lang | No | en (default) or id. |
data-accent | accent | No | A 6-digit hex color, for example #2563eb. |
data-radius | radius | No | Pixels, clamped to 2–16. |
data-font | font | No | system, serif, or mono. |
data-density | density | No | comfortable (default) or compact. |
data-fallback-contact | fallbackContact | No | A tel:, mailto:, or WhatsApp link shown if the widget can’t reach the API. |
data-on-funnel-event | onEvent | No | Get funnel events in your own analytics. See Funnel events. |
Mount it from code
Section titled “Mount it from code”The script adds window.BookingWidget with two functions:
BookingWidget.mount(el: HTMLElement, options?: { key, resource, apiBase, ... }): { destroy(): void }BookingWidget.scan(root?: ParentNode): voidmountputs a widget insideeland returns a handle. Callhandle.destroy()to remove it.destroystops the hold countdown and other listeners, sends theabandonedfunnel event if the guest had not finished, and removes the widget. You can then mount again in the sameel.- Mounting an element that already has a widget does nothing. It never throws.
scanmounts every[data-booking-widget]element underroot. 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:
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"))); });}Embed on your website (pk_)
Section titled “Embed on your website (pk_)”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>import { useEffect, useRef } from "react";import { loadBookingWidget } from "./loadBookingWidget";
export function BookingWidget({ resource }: { resource: string }) { const ref = useRef<HTMLDivElement>(null);
useEffect(() => { let cancelled = false; let handle: { destroy(): void } | undefined; void loadBookingWidget().then((api) => { if (cancelled || !ref.current) return; handle = api.mount(ref.current, { key: "pk_live_...", resource, apiBase: "https://<your-api-host>", }); }); return () => { cancelled = true; handle?.destroy(); }; }, [resource]);
return <div ref={ref} />;}The cleanup makes this safe with React Strict Mode, which runs effects twice in development.
Use a Client Component (App Router). Do not rely on next/script for this.
It does not run the script again when the guest moves between pages with
client-side navigation, so the widget would not appear on the second visit.
"use client";
import { useEffect, useRef } from "react";import { loadBookingWidget } from "@/lib/loadBookingWidget";
export function BookingWidget({ resource }: { resource: string }) { const ref = useRef<HTMLDivElement>(null);
useEffect(() => { let cancelled = false; let handle: { destroy(): void } | undefined; void loadBookingWidget().then((api) => { if (cancelled || !ref.current) return; handle = api.mount(ref.current, { key: process.env.NEXT_PUBLIC_BOOKING_PK!, resource, apiBase: process.env.NEXT_PUBLIC_BOOKING_API_BASE!, }); }); return () => { cancelled = true; handle?.destroy(); }; }, [resource]);
return <div ref={ref} />;}import { BookingWidget } from "@/components/booking-widget";
export default function Page() { return <BookingWidget resource="<RESOURCE_ID>" />;}NEXT_PUBLIC_ is fine here because a pk_ key is public by design. Never
use that prefix for an sk_ key.
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from "vue";import { loadBookingWidget } from "./loadBookingWidget";
const props = defineProps<{ resource: string }>();const el = ref<HTMLElement | null>(null);let handle: { destroy(): void } | undefined;let unmounted = false;
onMounted(async () => { const api = await loadBookingWidget(); if (unmounted || !el.value) return; handle = api.mount(el.value, { key: "pk_live_...", resource: props.resource, apiBase: "https://<your-api-host>", });});
onBeforeUnmount(() => { unmounted = true; handle?.destroy();});</script>
<template> <div ref="el" /></template>Name the file with the .client.vue suffix. Nuxt then renders it only in
the browser, so the server render never touches window.
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from "vue";import { loadBookingWidget } from "~/utils/loadBookingWidget";
const props = defineProps<{ resource: string }>();const config = useRuntimeConfig();const el = ref<HTMLElement | null>(null);let handle: { destroy(): void } | undefined;let unmounted = false;
onMounted(async () => { const api = await loadBookingWidget(); if (unmounted || !el.value) return; handle = api.mount(el.value, { key: config.public.bookingPk as string, resource: props.resource, apiBase: config.public.bookingApiBase as string, });});
onBeforeUnmount(() => { unmounted = true; handle?.destroy();});</script>
<template> <div ref="el" /></template>Then use <BookingWidget resource="<RESOURCE_ID>" /> in any page.
Works in Svelte and SvelteKit. onMount runs only in the browser.
<script lang="ts"> import { onMount } from "svelte"; import { loadBookingWidget } from "$lib/loadBookingWidget";
export let resource: string; let el: HTMLDivElement;
onMount(() => { let cancelled = false; let handle: { destroy(): void } | undefined; void loadBookingWidget().then((api) => { if (cancelled) return; handle = api.mount(el, { key: "pk_live_...", resource, apiBase: "https://<your-api-host>", }); }); return () => { cancelled = true; handle?.destroy(); }; });</script>
<div bind:this={el}></div>Add one Custom HTML block (block editor) or a code-snippet plugin. Paste all three parts in the same block, in this order.
<div id="booking-widget"></div><script src="https://docs.booking-engine.davidvalentino.com/widget/v1.js"></script><script> BookingWidget.mount(document.getElementById("booking-widget"), { key: "pk_live_...", resource: "<RESOURCE_ID>", apiBase: "https://<your-api-host>", });</script>The first script has no async, so it finishes before the second one runs.
WordPress themes often set global font and color rules. The widget’s Shadow
DOM stops them from reaching inside, so it looks the same in any theme.
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.
Funnel events
Section titled “Funnel events”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 flow, step by step
Section titled “The flow, step by step”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.
- Dates. The guest sees a 7-day availability strip, picks a check-in date, and sets a length of stay.
- Quote. The widget prices the stay (
POST /v1/quotes) and shows a per-night breakdown and total. - Hold. “Continue” turns the quote into a 15-minute hold
(
POST /v1/holds). A countdown shows how long the guest has left. - 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.
- 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.
The flow for an hourly space
Section titled “The flow for an hourly space”Use the same snippet. There is no extra option. The widget shows three stages: When, Review, and Pay.
- 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 callsGET /v1/resources/{id}/slotsfor one day at a time. - 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 asguestwith the hold. Only the space’s operator sees them. - 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.
Theme it
Section titled “Theme it”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.
| Attribute | Values | What happens if it’s invalid |
|---|---|---|
data-accent | A 6-digit hex color, e.g. #2563eb | Falls back to the default accent |
data-radius | A number (pixels), clamped to 2–16 | Out-of-range values are clamped, not rejected |
data-font | system, serif, or mono | Falls back to system |
data-density | comfortable or compact | Falls 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.
Honest failure behavior
Section titled “Honest failure behavior”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-contactto atel:,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.
Next steps
Section titled “Next steps”- 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.