Skip to content

Events

const off = widget.on("reservation:confirmed", ({ reservation }) => {
analytics.track("booking", { id: reservation.publicId });
});
off(); // stop listening

on() returns its own unsubscribe function. Call it when your component unmounts; nothing else cleans up for you.

EventPayloadWhen
readyThe instance has mounted and its methods are live.
openedThe popover became visible. popover / sticky only.
closedThe 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.

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 ago
await widget.ready; // equivalent, if you prefer promises

step: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:

ModeEntry eventBecause
popover / stickyopenedFires for every path that opens the widget.
inlinereadyAn 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 popover
widget.on("step:changed", ({ step: next }) => analytics.track("funnel", { step: (step = next) }));

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.

{
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.

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.

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.

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.

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.

<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.