# Kibble installation contract: section 15 of 29 (guide version 3)

> This is **one section only**, not the whole contract. Complete document: https://www.usekibble.dev/AI/guide.md

## 15. Booking engines and booking widgets {#booking}

> **Status: direct integration.** The real SDK is used as written; exercised in the repository demos and browser tests.

### Booking-engine hooks

- Generic page discovery cannot confirm a booking. The engine must push the events below, and `booking_confirmed` only after the reservation is confirmed, never on submit.
- Use `data-datalayer="on"` on the booking-engine page. If the engine cannot push events, call `kibble('outcome', ...)` from its confirmation step ([Outcomes](https://www.usekibble.dev/AI#outcomes)).
- `booking_confirmed` needs `booking_ref`. Without one only an outcome candidate is recorded, which staff review in Data tracking → Events (Needs a decision).
- The engine may be on another host. If the host is a provider's (shared with other customers) register it as an **external connection** with its tenant (the business ID in Kibble), never as a website host; see [Website hosts, external connections and first-party gateways](https://www.usekibble.dev/AI#gateways) and the rezStream recipe below.

Booking engine hooks (copy, then call from the engine's own code):

```js
(function () {
  var dl = (window.dataLayer = window.dataLayer || []);
  var sent = {}, STEPS = { guest: 1, room: 1, payment: 1, review: 1 };
  window.KibbleBooking = {
    availability: function (o) {
      var e = { event: 'availability_search', check_in: o.checkIn, nights: o.nights, guests: o.guests };
      if (o.available === true || o.available === false) e.available = o.available;   // unknown stays absent, never false
      if (o.hotelId) e.hotel_id = String(o.hotelId); else if (o.destination) e.destination = String(o.destination);
      dl.push(e);
    },
    step: function (name) { if (STEPS[name]) dl.push({ event: 'booking_step', step: name }); },
    confirmed: function (o) {   // ONLY after the engine confirmed the reservation
      if (!o || !o.reference || sent[o.reference]) return false;
      sent[o.reference] = true;
      dl.push({ event: 'booking_confirmed', booking_ref: String(o.reference), value: o.value, currency: o.currency });
      return true;
    }
  };
})();
// KibbleBooking.availability({ hotelId: 'harbor-boston', checkIn: '2027-03-12', nights: 2, guests: 2, available: true });
// KibbleBooking.step('room');
// KibbleBooking.confirmed({ reference: 'BK-48213', value: 612, currency: 'USD' });
```

- Call `availability` when the engine shows results. Pass `available: true` only when rooms are actually offered and `false` only when the engine explicitly reports none; **omit it when unknown** (absent items are not proof of zero).
- `kibble_availability` is accepted as an alias of `availability_search`.
- A hotel id of six or more digits needs a prefix with a colon (for example `hotel:1234567`); a bare 1234567 is rejected. A five-digit id such as `58308` is fine.

### Golf courses: tee times

A property whose business type is **Golf course (tee times)** monitors tee-time searches and bookings. It reuses the booking signals above; nothing is a relabelled hotel stay. The tee-time search is an `availability` event for a course; choosing a time is the checkout step `tee_time`; the confirmed booking is the same `booking_confirmed` with a reference. Kibble reports them as tee-time searches by play date, tee-time bookings and a Tee-time booking funnel; it never shows the hotel Booking funnel for a golf property.

| Moment | dataLayer event | Becomes | Fields |
| --- | --- | --- | --- |
| The engine shows tee times for a search | `tee_time_search` | `availability` with `subject_type` `course` | `course_id` (public id of the course), `play_date` (`YYYY-MM-DD`), `tee_time` (`HH:MM`), `players` (1 to 8), `holes` (9 or 18), `available` |
| The visitor chooses a tee time | `tee_time_selected` | `checkout_step` `tee_time` | nothing |
| Booking details, then payment | `booking_step` with `step` `details` (or `guest`), then `payment` | `checkout_step` | `step` |
| The booking is confirmed | `booking_confirmed` | outcome: booking, only with `booking_ref` | `booking_ref`, `value`, `currency` |

Tee-time hooks (the engine's own code, or a Google Tag Manager tag that reads the engine's results):

```js
window.dataLayer = window.dataLayer || [];
// When the engine shows tee times for a search (omit any field the engine does not know; never guess).
dataLayer.push({ event: 'tee_time_search', course_id: 'championship', play_date: '2027-05-14', tee_time: '07:40', players: 4, holes: 18, available: true });
// When the visitor picks a time, then moves through details and payment.
dataLayer.push({ event: 'tee_time_selected' });
dataLayer.push({ event: 'booking_step', step: 'details' });
dataLayer.push({ event: 'booking_step', step: 'payment' });
// ONLY after the engine confirmed the booking, with its reference.
dataLayer.push({ event: 'booking_confirmed', booking_ref: 'TT-48213', value: 240, currency: 'USD' });
```

- **Date and time are the course's local calendar date and wall-clock time.** `play_date` and `tee_time` are sent exactly as the engine shows them and are never converted. The property's reporting time zone is the course's time zone: set it in Manage → Settings (a course in Denver reports in `America/Denver`). Hours in reports are those local hours.
- `players` is a whole number 1 to 8 and `holes` is 9 or 18; any other value is dropped by the SDK and the collector, never clamped. `tee_time` outside `00:00` to `23:59`, with seconds or with a zone, is dropped. `available` follows the hotel rule: `true` only when times are offered, `false` only when the engine says none, omitted when unknown.
- The hour and party-size breakdowns count only searches that sent the field. A site that sends neither still gets tee-time searches by play date; the breakdown reads "not collected", never zero. Days before the feature have no breakdown. Hotel fields (`nights`, `guests`) are not used for tee times.
- A **click** from the website to the booking engine is a `click`, never a booking. A booking counts only from the engine's own confirmed-booking signal (`booking_confirmed` with a reference, or `kibble('outcome', ...)`), or a reviewed mapping in Data tracking → Events. Kibble never infers a booking from a click, a URL or the next page.
- **Course ids** are public business ids (`championship`, `north-9`); a run of six or more digits needs a prefix with a colon, as for hotels. Reports show the id made readable. The [Site catalog](https://www.usekibble.dev/AI#site-catalog) has no course entity type, so do not write one in a manifest.
- **Staff event decisions** (Data tracking → Events) can map a site-authored name to `availability` with subject type `course`, or to the step `tee_time`; those mapped events carry the course, the play date and the result only (a mapping does not read time, players or holes). Use `tee_time_search` for the full detail.

### Golf booking engines: where it can and cannot be measured

| Setup | What Kibble can observe | What you must do |
| --- | --- | --- |
| The tee-sheet and booking pages are on the main website host | Everything above, from the same snippet | Push the events from the booking pages (or a GTM tag on them); no connection is needed. |
| A separately hosted engine where the snippet or a tag can run | Everything above, with the visit continuing from the website when both sides are registered and consent is granted on both | Register the engine host as an external connection (role booking engine, with its tenant when the host is shared), install the snippet there with `data-connected-hosts`, and push the events from the engine ([gateways](https://www.usekibble.dev/AI#gateways)). Links from the website to the engine carry the handoff; the first engine page view reads `handoff`. |
| A separately hosted engine where neither the SDK nor a tag can run | Only the click on the website (`click` events marked with `data-kibble-click`). No searches, no tee times, no bookings | Say so in the report. Do not send a booking from the click, a thank-you URL on the website or a redirect back. Staff may review a candidate only when the engine's own confirmation page can run the snippet. |
| An engine that runs the snippet on its pages but cannot push dataLayer events | Page views on the engine; bookings only through `kibble('outcome', ...)` from its confirmation step | Ask the engine's owner for the confirmation hook; do not scrape the page. |

- Do not assume the engine's visits continue the website's visit: continuity exists only with the registered connection, the handoff link and consent on both hosts. Without it the engine's visits stand alone and their source reads Unknown or Direct as observed.
- Do not assume access to a third party's tag, page or data. A vendor that will not add the snippet or a tag is a boundary: record "tee-time booking not measured" and stop.
- Check the setup in Validation (Site Check): "Tee-time search" shows the course, play date, time, players and holes the collector stored; "Tee time chosen" shows the selection; a test booking in the engine's test mode shows "Booking confirmed". Only an authorized test booking may be completed.

### Website booking widget: criteria at submission

The main-site booking widget records the visitor's criteria on the form's own submission event, `form_submit`, from fields the site explicitly marks with `data-kibble-criteria`. It is a submission attempt with criteria: not results, not availability and not acceptance.

Booking widget markup:

```html
<form data-kibble-form="booking-widget" data-kibble-form-type="booking" action="/book" method="GET">
  <input type="hidden" name="hotel" value="58308" data-kibble-criteria="property">
  <input type="hidden" name="dest" value="nyc" data-kibble-criteria="destination">
  <input type="text" name="arrive" value="2026-10-15" readonly data-kibble-criteria="start">
  <input type="text" name="depart" value="2026-10-17" readonly data-kibble-criteria="end">
  <select name="adult" data-kibble-criteria="adults">...</select>
  <select name="child" data-kibble-criteria="children">...</select>
  <input type="text" name="promo">   <!-- never marked, never read -->
  <button type="submit">Book now</button>
</form>
```

- Roles: `property`, `destination`, `start`, `end`, `nights`, `adults`, `children`, `guests`. Read once, at the submit event, never on change or keystroke. The form must have both `data-kibble-form` and `data-kibble-form-type="booking"`.
- `property` and `destination` are read only from a `<select>`, a hidden input or a checked radio, and must be public business ids (for example a hotel code), never a typed name.
- `start` and `end` are `YYYY-MM-DD`. `start` must be within yesterday to 730 days ahead, otherwise it and `nights` are omitted.
- `nights` is 1 to 60: a marked nights field wins, otherwise end minus start. `guests` is 1 to 20: a marked guests field wins, otherwise adults plus children (children absent counts as 0).
- Marked copies of a criterion that disagree omit that criterion. Disabled fields are skipped.
- Only select-one, hidden, radio, date, number and text inputs are read. Textarea, email, tel, password, search and url inputs are never read, even if marked.
- Missing or invalid criteria are omitted (shown as "Not recorded" in Validation), never guessed.
- The event is `form_submit` with attributes `form`, `subject`, `subject_type` (`property` or `destination`) and `intended_start`, and numbers `nights` and `guests`.
- Hotel and destination names come from the [Site catalog](https://www.usekibble.dev/AI#site-catalog), keyed by the same ids. Never send names or labels as subjects.
- A programmatic `form.submit()` fires no submit event, so nothing is recorded. Submit programmatically with `form.requestSubmit()` or use the explicit call below.

When the form cannot carry markup (GTM-only, or a widget rendered by a third party), make the explicit call at the real submission instead. Use markup **or** this call, never both on a form that has `data-kibble-form`: the SDK already sends `form_submit` for marked forms. Out-of-range nights or guests are dropped by the collector, never clamped.

Explicit alternative:

```js
kibble('track', 'form_submit',
  { form: 'booking-widget', subject: '58308', subject_type: 'property', intended_start: '2026-10-15' },
  { nights: 2, guests: 2 });
```

### Do not

- Do not call `kibble('track', 'availability', ...)` from the website booking widget submit. `availability` is only the booking engine's results signal; sending it here counts one intent as two availability searches.
- Do not mark a promo, member or corporate code, name, email, phone, special requests or payment field.
- Do not read values on change or keystroke.
- Do not mark a typed hotel name as `property` or `destination`.
- Do not use `kibble('search', ...)` for booking criteria: a booking widget is not site search.

### rezStream booking engine (recipe)

> **Status: importable template.** Structure and logic are tested offline. Not imported into or run on a live platform.

**Status: recipe, not verified on a live booking.** It is built from public pages observed on 2026-10-06 and tested only against a local fixture that imitates them. Nobody has installed it in a rezStream account or completed a booking with it. Say so in the report.

- **Host and tenant.** rezStream serves every customer's booking engine from `guest.rezstream.com` (for example `https://guest.rezstream.com/search/harmans-north-fork-cottages`). The host is shared, so the connection must have a tenant: the business slug. The page renders a per-tenant object `RezBe` with `BusinessSlug` (the slug), `Step` (`search` on the search page, `thankyou` on the confirmation step), `CurrentPagePath` and a `Booking` object whose `ThankYouRenderCount` the business's own conversion script uses as a once-only guard. The links seen use `/search/<slug>`, `/Select/<slug>?CartId=…` and `/giftcertificate/search/<slug>`; later steps are not documented publicly, so the route list is not exhaustive and the recipe does not set `data-routes`.
- **Where the tag runs.** rezStream's documentation says tracking scripts are added by contacting its support team, and that TCMS customers have rezStream implement them ([Booking Engine Configuration Options](https://support.rezstream.com/knowledge-base/hc/en-us/articles/223719468-booking-engine-configuration-options), [Revenue Tracking Using GA4](https://support.rezstream.com/knowledge-base/hc/en-us/articles/10502633400091-revenue-tracking-using-ga4)). On the page observed, the business's own Google Tag Manager container also loaded, so a Custom HTML tag there is the first choice; otherwise give rezStream support the tag below as a tracking script. rezStream publishes no dataLayer event names and no route list, so the dataLayer adapter does not apply and `data-datalayer` is not used.
- **Self-guarding.** The tag returns unless the page host is `guest.rezstream.com` **and** `RezBe.BusinessSlug` equals `TENANT` (ignoring case), runs once per page, installs the queue stub, then loads the SDK with `data-tenant`, `data-connected-hosts` (the website hosts, so links back to the website carry the visitor home) and the consent mode. In Google Tag Manager also scope the trigger to Page Hostname equals `guest.rezstream.com`; the tag's own check is what separates two businesses on the shared host.
- **Consent is never inferred.** No consent banner was observed on the rezStream page, and the business website's own consent cookie is not readable on `guest.rezstream.com`. The tag therefore follows Google Consent Mode (`data-consent="google-consent-mode"`) when the business's container provides it; otherwise set `CONSENT` to `manual` and call `kibble('consent', 'granted')` or `kibble('consent', 'denied')` from the consent tool that runs on that host. With no consent source nothing is collected and Tracking shows the gap. Ask the business which source applies; do not assume.
- **Outcome gate.** `BOOKING_CONFIRMED_FROM_THANKYOU` stays `false`. While false, the thank-you step (`Step` is `thankyou` and `ThankYouRenderCount` is exactly `1`) sends one `outcome_candidate` with signal `rezstream_thankyou` and source `adapter`, which staff review in Data tracking → Events; it is never a booking. An account manager sets it to `true` only after a test booking showed that `RezBe.Booking.Confirmation` is the real confirmation number and that the step renders once. Then the tag sends `kibble('outcome', {type: 'booking', ref, source: 'adapter'})` with the confirmation number hashed in the browser and no value (no value field is documented). Gift certificate pages (`RezBe.GC` present) send nothing.
- **Privacy.** The tag reads only `BusinessSlug`, `Step`, `Booking.ThankYouRenderCount` and, once the gate is true, `Booking.Confirmation`. It never reads or sends guest names, emails or the `CartId` (the booking id in the URL and in `RezBe.Booking.Id`).
- **Link token.** The token a link from the website carries is visible to rezStream's servers and to scripts that run before Kibble on the booking engine (it loads its own Datadog RUM, for instance). It works once and expires in about two minutes ([Website hosts, external connections and first-party gateways](https://www.usekibble.dev/AI#gateways)).

rezStream booking-engine tag (GTM Custom HTML, or give to rezStream support). Same file: integrations/examples/rezstream/kibble-rezstream-tag.html:

```html
<!--
  Kibble tag for a rezStream booking engine (guest.rezstream.com). STATUS: recipe. Not verified on a live booking.

  Where it runs: a GTM Custom HTML tag in the tenant's own Google Tag Manager container that already loads on the
  booking engine, or a tracking script that rezStream support installs on the engine for the tenant (rezStream documents
  that tracking scripts are added through its support team:
  https://support.rezstream.com/knowledge-base/hc/en-us/articles/223719468-booking-engine-configuration-options).

  guest.rezstream.com is shared by every rezStream customer, so this tag is self-guarding: it does nothing unless the page
  is on that host AND the page's own RezBe.BusinessSlug equals TENANT. Register the host as an external connection (provider
  rezStream, tenant = the slug) on the property in Kibble first.

  Edit the values below (placeholders: KEY, ORIGIN, TENANT, WEBSITE_HOSTS). The key is public, never put a token here.
  Never read or send guest names, emails or the CartId. Only RezBe.BusinessSlug, RezBe.Step, RezBe.Booking.ThankYouRenderCount
  and, once an account manager has tested a booking, RezBe.Booking.Confirmation (hashed in the browser by Kibble) are read.
-->
<script>
(function () {
  var KEY = 'pk_YOUR_PROPERTY_KEY';
  var ORIGIN = 'https://use.example-kibble-host';
  var TENANT = 'your-reservation-slug';             // RezBe.BusinessSlug of this business, as in /search/<slug>
  var WEBSITE_HOSTS = 'www.example.com';            // the property's website host(s), comma separated: links back to them are linked
  // Consent is never inferred. 'google-consent-mode' follows analytics_storage from Google Consent Mode (the tenant's GTM
  // container). To use the tenant's own consent banner instead, set CONSENT = 'manual' and call
  //   kibble('consent', 'granted')  or  kibble('consent', 'denied')
  // from that banner's callback. With no consent source nothing is collected.
  var CONSENT = 'google-consent-mode';
  // Booking outcome. Stays false until an account manager has made a test booking and seen that, on the thank-you step,
  // RezBe.Booking.Confirmation is the real confirmation number and ThankYouRenderCount is 1 only once. While false only an
  // outcome candidate is sent (staff review it in Data tracking > Events); it is never counted as a booking.
  var BOOKING_CONFIRMED_FROM_THANKYOU = false;
  var HOST = 'guest.rezstream.com';

  if (location.hostname !== HOST) return;
  var be = window.RezBe;
  if (String(be && be.BusinessSlug).toLowerCase() !== String(TENANT).toLowerCase()) return;
  if (window.__kibbleRezstreamTag) return;          // one execution per page (GTM may fire a tag more than once)
  window.__kibbleRezstreamTag = true;

  window.kibble = window.kibble || function () { (window.kibble.q = window.kibble.q || []).push(arguments); };
  if (!window.__kibble && !document.querySelector('script[data-key][src*="/sdk/v1/kibble"]')) {
    var s = document.createElement('script');
    s.async = true;
    s.src = ORIGIN + '/sdk/v1/kibble.js';
    s.setAttribute('data-key', KEY);
    s.setAttribute('data-endpoint', ORIGIN);
    s.setAttribute('data-tenant', TENANT);
    s.setAttribute('data-connected-hosts', WEBSITE_HOSTS);
    s.setAttribute('data-consent', CONSENT);
    document.head.appendChild(s);
  }

  var booking = be.Booking || {};
  if (String(be.Step || '').toLowerCase() === 'thankyou' && booking.ThankYouRenderCount === 1 && !be.GC) {
    var ref = booking.Confirmation == null ? '' : String(booking.Confirmation);
    if (BOOKING_CONFIRMED_FROM_THANKYOU && ref) {
      window.kibble('outcome', { type: 'booking', ref: ref, source: 'adapter' });
    } else {
      window.kibble('track', 'outcome_candidate', { signal: 'rezstream_thankyou', source: 'adapter' });
    }
  }
})();
</script>

```

- **Check locally.** Open GTM Preview (or the page with the tag installed): on the tenant's pages the tag fires once; on another business's pages (or a changed `TENANT`) there is no `kibble.js` request. In the network tab: one `kibble.js`, then after consent a `/v1/collect` answered 200 whose body has `tn` set, and no second `page_view` for the same load.
- **Check the link.** Click from the website to the booking engine: the address bar loses `_kbh` before any request and the first page view has `nav: handoff`. If it does not, the booking link passes through a redirect page, or consent was not granted on one side.
- **Check the outcome gate** only with the account manager and a real test booking; leave it `false` until then.

### Approved hosts

Every website host that runs the snippet must be registered on the property (Manage → Settings), and the match includes the port. A provider's host (a booking engine shared with other customers) is registered as an external connection with its tenant instead, and only events for that tenant, inside its routes, are accepted. Register `localhost:4101` or `127.0.0.1:4102` exactly as the browser shows them. Events from other hosts receive HTTP 403 and the SDK stops sending on that page. A newly added host can take up to about fifteen seconds to be accepted.

Cross-domain setup between the website and the booking engine is in [Website hosts, external connections and first-party gateways](https://www.usekibble.dev/AI#gateways).

END OF SECTION booking
