Events
const off = widget.on("reservation:confirmed", ({ reservation }) => { analytics.track("booking", { id: reservation.publicId });});
off(); // stop listeningon() returns its own unsubscribe function. Call it when your component
unmounts; nothing else cleans up for you.
The catalog
Section titled “The catalog”| Event | Payload | When |
|---|---|---|
ready | — | The instance has mounted and its methods are live. |
opened | — | The popover became visible. popover / sticky only. |
closed | — | The popover was hidden. popover / sticky only. |
reservation:created | { reservation } | A reservation exists server-side — any outcome. |
reservation:confirmed | { reservation } | The booking is genuinely final. |
step:changed | { step } | The guest moved to another step — transitions only. |
ready replays
Section titled “ready replays”Subscribing to ready after the widget has already mounted calls your
handler once, immediately — the way promise.then() does on a settled promise.
So you never have to race it:
widget.on("ready", () => {}); // fires even if the widget mounted a second agoawait widget.ready; // equivalent, if you prefer promisesstep:changed has no event for the first step
Section titled “step:changed has no event for the first step”step:changed fires on transitions. There is no event for the step the funnel
opens on, and that is deliberate: in popover and sticky modes the widget
builds its booking tree when the page loads and merely hides it, so an event at
that point would fire for every page view rather than for every funnel entry.
Take the entry from the event that already means it:
| Mode | Entry event | Because |
|---|---|---|
popover / sticky | opened | Fires for every path that opens the widget. |
inline | ready | An inline widget is visible from the moment it mounts. |
The steps reported are availability, details, payment and confirmation.
payment occurs only where the booking requires a card guarantee, so a funnel
built from these events has to treat it as optional.
Going back a step is a transition and is reported. So is starting a second
booking from the confirmation screen: it returns to availability, and a funnel
that hid it would under-count repeat bookings.
let step = null;widget.on("opened", () => (step = "availability")); // the entry, for a popoverwidget.on("step:changed", ({ step: next }) => analytics.track("funnel", { step: (step = next) }));created and confirmed
Section titled “created and confirmed”reservation:created fires for every booking that reaches an outcome. This
includes bookings awaiting restaurant approval and bookings holding a table
pending a card guarantee. The reservation exists; the guest does not necessarily
hold a table.
reservation:confirmed fires only when the guest holds a confirmed table.
The two are separate events so that a host cannot read “created” as “booked”.
Sending a confirmation message on reservation:created will eventually notify a
guest who holds an unpaid hold rather than a table.
Use reservation:created to record that the funnel completed. Use
reservation:confirmed to confirm the booking to the guest.
The payload
Section titled “The payload”{ publicId: string; // the join key status: string; // "confirmed", "pending", … date: string; // "2026-09-03" startTime: string; // "19:30" partySize: number; sectionKey: string | null; // null = any seating}status is the reservation’s status as of that event, which is why a
created payload can legitimately say pending.
Contact details are excluded
Section titled “Contact details are excluded”The payload contains no name, e-mail or phone number. The host already holds this information: it sent the guest, and where a token was minted it asserted the identity itself.
publicId is the join key. Resolve it through the API or match it in a
webhook, both of which run server-side. A browser
event is a notification rather than a source of truth — it is delivered in a
context the guest can inspect and modify — so decisions involving money or
access should be made from the server-side record.
Analytics
Section titled “Analytics”The widget writes nothing to the host page’s globals. There is no dataLayer
push, no gtag call and no counter it maintains for you, so a tag manager sees
the funnel only through a handler you write:
widget.on("reservation:confirmed", ({ reservation }) => { window.dataLayer?.push({ event: "reservation_confirmed", id: reservation.publicId });});A deep link has no host page and therefore no handler:
the guest is on book.useservice.app, and the booking is not visible to the
host site’s analytics at all. Count those from the API or a
webhook.
Example
Section titled “Example”const widget = ServiceWidget.create({ slug: "chez-marie", mode: "popover" });
// Funnel completed — the booking exists in some form.widget.on("reservation:created", ({ reservation }) => { analytics.track("booking_started", { id: reservation.publicId });});
// The guest actually has a table. Safe to tell them so.widget.on("reservation:confirmed", ({ reservation }) => { showThankYou(reservation);});To attach the booking to a stay, an invoice or a CRM record, use the webhook rather than the browser event. The webhook carries the booking record and is delivered regardless of whether the guest closed the tab.
Google Tag Manager
Section titled “Google Tag Manager”Every event above is also pushed onto window.dataLayer, so a site running GTM
measures the booking funnel without writing any JavaScript:
{ event: "service_widget_reservation_created", service_widget: { slug: "chez-marie", event: "reservation:created", reservation: { publicId: "resv_…", status: "confirmed", date: "2026-09-03", startTime: "19:30", partySize: 4, sectionKey: null } }}The event name is the widget event with its colon replaced by an underscore —
service_widget_ready, service_widget_opened, service_widget_closed,
service_widget_step_changed, service_widget_reservation_created,
service_widget_reservation_confirmed. Use it as the Custom Event trigger name
in GTM.
step:changed adds service_widget.step; the two reservation events add
service_widget.reservation. The rest carry only slug and event.
The widget pushes an object and stops. It loads no analytics vendor, sets no cookie, and sends no request of its own. Whether anything leaves the browser is decided by your GTM container and by your consent platform — which is the right place for that decision, since it is your page and your consent relationship.
The payloads carry no contact details: name, e-mail and phone are deliberately absent from every event, so what reaches your dataLayer is a booking identifier and its shape.
If GTM has not loaded yet when the widget mounts, the events still arrive — the
widget creates window.dataLayer the same way GTM’s own snippet does, and GTM
processes what is already queued when it starts.
Turning it off
Section titled “Turning it off”<script src="…/widget.js" data-slug="chez-marie" data-analytics="off" async></script>ServiceWidget.create({ slug: "chez-marie", analytics: false });Only the exact string off disables it on the script tag. Anything else leaves
the feed on.