Back to engineering

Engineering

Widget events, written as if they were published

The Bookatu engineering team7 min read

Our booking widget now tells the website it sits on what happens inside it, so owners can count bookings in their own analytics. That website is someone else's, and so is every tag on it. Here is the event contract we wrote on that assumption, and three bugs review found before it shipped.

The short version

  • Release 13 gave the booking widget nine events, from ready to closed, that the page it sits on can listen for. That page runs someone else's analytics, so every field is treated as published.
  • One module defines the fields. The frame builds every message with it, the loader on the host page checks every message by the same rules, and one test runs both.
  • A booking is named by a reference that opens nothing, never the appointment id, and it is announced once, even when the done screen reloads.

A business can put Bookatu on its own website with one script, /embed.js. In the inline and pop-up modes the booking happens in an iframe from our origin. For a business running ads, each booking is a conversion, so the widget now tells the page with postMessage. Every script on that page can read what we send. So we started from one rule: whatever an event carries, assume it ends up in a report someone else can read.

A fixed list of fields, checked on both sides

lib/embed-events.ts is the contract. Every event has a version, a name, the business's slug and a dedupe id. It may add the service, the start time, the time zone, the price, the currency and a few more. There is no name, email, phone, note, appointment id or manage key, and no way to add one. embedEvent() rebuilds every event from the allowed fields alone, and drops a field that fails its check. The test hands it everything a careless caller might:

ts
const ev = mod!.embedEvent({
  ...BOOKED,
  dedupeId: undefined,
  start: new Date("2026-11-03T21:30:00.000Z"),
  // None of these may ever get through.
  ...({ name: "Rae Client", email: "rae@example.com", phone: "+64 21 555 0142", notes: "allergic", appointmentId: "apt_123", manageKey: "mk_secret" } as object),
});
expect(ev).toEqual(BOOKED);

On the host page, public/embed.js is plain ES5 with no build step, because it lands in old site builders, so it carries its own copy of the checks. tests/unit/embed-events-contract.test.ts loads the real embed.js into a pretend website, posts the same good and bad messages to both copies, and fails if they ever disagree.

A booking reference that opens nothing

An analytics tool wants a transaction id to count a booking once, and the appointment id is the wrong one. Since release 12 a new booking is changed only with a signed manage key, but a booking made before that has no key, and its id alone can cancel or move it. So ref is an HMAC of the business and the appointment, under a signing key of its own purpose:

ts
export function embedBookingRef(orgId: string, appointmentId: string): string {
  return `bk_${signToken("embed-ref", `${orgId}:${appointmentId}`).slice(0, 24)}`;
}

It is the same for the same booking, cannot be turned back into the id, and opens nothing. Our post Magic links done right: why a login token is not a share link covers how we sign and scope tokens.

One booking, one conversion

A booking's dedupe id is fixed by its reference, so a reloaded done screen announces the same id. The frame skips a booking it has sent where its storage allows. The loader also keeps the last 50 booking ids it announced in the tab's sessionStorage, so each booking fires once:

js
function remember(key) {
  seen[key] = true;
  try {
    var list = JSON.parse(window.sessionStorage.getItem(SEEN_KEY) || "[]");
    if (!(list instanceof Array)) list = [];
    list.push(key);
    window.sessionStorage.setItem(SEEN_KEY, JSON.stringify(list.slice(-50)));
  } catch (e) {}
}

A test reloads the host page on the same tab's storage: the booking counts once, and nothing stored looks like an email or an appointment id.

Who may receive a postMessage

With no allowed domains, the default, the frame posts to *. It cannot know which site it is on, and nothing in an event is personal. A business can list its own sites under Grow, Website widget, Only allow your own websites. Then the frame posts only to the framing page's origin, and only if it is on the list:

ts
const list = opts.embedDomains ?? [];
if (list.length === 0) return ["*"];
if (opts.ancestorOrigin && embedOriginAllowed(list, opts.ancestorOrigin)) {
  return [new URL(opts.ancestorOrigin).origin];
}
const host = (opts.host ?? "").toLowerCase();
if (host && HOST.test(host) && hostAllowed(list, host)) return [`https://${host}`, `http://${host}`];
return [];

The ancestor origin is the Referer of the page the site framed directly. Later steps send our own origin, so they fall back to the hostname the loader passed as ?host=. The loader, in turn, ignores any message not from the origin it was loaded from.

We never load a tag

Each event reaches the page as a window event, bookatu:<name>, and, if the page has a dataLayer, a push named bookatu_<name>. With data-track="gtag meta" on the snippet, the loader also calls the site's Google tag and Meta pixel, but only if the site has already loaded them. If a consent banner has kept a tag from loading, we do not load it either. Values travel in minor units, and the loader converts them with the currency's own digits, so 4500 is 45 in NZD and 4500 in JPY:

js
function major(value, currency) {
  var digits = 2;
  try {
    digits = new Intl.NumberFormat("en", { style: "currency", currency: currency }).resolvedOptions().maximumFractionDigits;
  } catch (e) {}
  return value / Math.pow(10, digits);
}

Strict fields, cleaned inputs

  • An error carries one of ten fixed codes. Anything else becomes generic, because free text could carry what the visitor typed.
  • Times are RFC 3339 in UTC. Zones must be IANA names: an analytics tool shown EST would be an hour wrong all summer.
  • The host page's utm_ values are cleaned, and one that looks like an email address or a phone number is dropped. A gclid or fbclid never leaves the page, only a marker that it was an ad click.
  • Old listeners keep working: bookatu:booked and bookatu_booking_complete still fire. /embed.v1.js is a pinned copy for sites that check an integrity hash.

What review found

The details never arrived

The done screen sends the v1 event and, for older copies of embed.js, the old { slug, at } message. It drew the old one first. The loader fired bookatu:booked from that with a slug and a time only, and the details we document never reached it. The frame now marks its old message with contract: 1, and the loader leaves it to the v1 event, in either order:

js
if (d.type === "bookatu:embed:booked") {
  // From a v1 frame (contract: 1): the same booking's v1 event, sent with
  // it, announces it, with its details, whichever of the two arrives first.
  if (d.contract === 1) return;

tests/unit/embed-booked-alias.test.ts draws the done screen's two real senders against the real loader in both orders, plus a frame from before v1. The pinned copy had not been released yet, so it took the fix too, with a new integrity hash.

A funnel step went missing

With data-date, the menu opens a service already on that day. The step counted as service_selected only when its address named no day, so the host's funnel went from ready straight to time_selected. The page now hands the frame an opening event, sent only when the visitor came from outside this service's steps. tests/unit/embed-deep-date-opening.test.ts covers each case.

Two forms, one score

When nothing is free and the business has its waitlist on, the frame shows a waitlist form. It minted its reCAPTCHA token as waitlist, the storefront form's action, so a flood against one would drag down the other's score. Our test that rejects two forms with one action name caught it. The frame now mints its own:

ts
// Its own reCAPTCHA action (the frame's form mints embed_waitlist), never the storefront's.
const outcome = await joinWaitlistFromForm(org.slug, formData, { serviceIds: [serviceId], recaptchaAction: "embed_waitlist" });

What we take from it

  • Anything that crosses into someone else's page is published. Fix the fields once and rebuild every message from them.
  • An id that works as a key never goes into analytics. Hand out a reference that opens nothing.
postmessageiframesanalyticsprivacyengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs