Scheduling

Set up a booking embed with an AI assistant

Hand this page to ChatGPT, Claude, or Copilot. It carries the whole booking embed contract, eight copy-paste recipes, the limits, and the exact errors.

View as Markdown

You do not have to write the embed code yourself. This page is written to be pasted into an AI assistant such as ChatGPT, Claude, or Copilot, together with a sentence about your own site. Everything the assistant needs is here: every attribute, the JavaScript API, the limits, the phone rules, and the exact messages you will see when something is wrong.

Use Copy to LLM at the top of this article to copy the whole page as Markdown, paste it into your assistant, then ask for the recipe you want. Every recipe below is complete: replace WS_ID and SLUG and it works.

What you need first

Every ID, in one place. Open the booking page's Share & embed action and expand IDs for developers, the collapsed section under the snippet. It lists each value with a copy button:

RowUse it for
Workspace IDdata-workspace, and workspace in the JavaScript API
Page slugdata-page, and page in the JavaScript API. Leave it out entirely on the default page
Servicesdata-service and service; each service the page offers, name then ID
Hostsdata-member and member; each team member those services allow, display name then ID
Intake questionsThe keys of data-intake and prefill.intake; each question, label then ID

Most sites never need the service, host, and question IDs: leave them out and the visitor chooses on the page.

The workspace ID and page slug are also in the public link, https://369.dev/ws/WS_ID/book/SLUG; the default page drops the slug and answers https://369.dev/ws/WS_ID/book.

Fallback, if you cannot reach the app. This read-only address returns the same public data the booking page itself uses, for your own workspace and slug:

https://369.dev/api/booking/page?workspaceId=WS_ID&pageSlug=SLUG

page.service_ids and services[].id are the values for data-service, services[].team_members[].id are the values for data-member, and page.intake_questions[] lists each question's id, type, and options for data-intake. The shape of that response is not a public contract and can change without notice; IDs for developers is the supported way to read these IDs.

An active booking page. A paused page, or a workspace whose last active page was paused or deleted, answers "Booking is not available". See Create and share a booking page.

The complete contract

The script tag

<script src="https://369.dev/booking.js" data-booking data-workspace="WS_ID" async></script>

data-mode decides what one script tag renders of its own: an inline frame where the tag sits (inline, the default), a floating button (popup), or nothing at all (api). Whichever you pick, the tag installs window.W369Booking, the delegated data-w369-booking click listener, and the w369:ready event, so W369Booking.open() and W369Booking.mount() add overlays and frames on top of that, wherever you want them. A second script tag on the same page mounts its own frame or button and reuses the first tag's API.

AttributeRequiredPurpose
data-bookingYesMarks the script tag as a booking embed
data-workspaceYesYour workspace ID
data-pageNoThe booking page slug; omit it for the default page
data-mode="inline"NoThe default, and the fallback for any unrecognised value: the booking flow renders where the script tag sits and sizes itself
data-mode="popup"NoA floating button opens the booking flow in an overlay
data-mode="api"NoNo frame and no button: only the overlay, window.W369Booking, the data-w369-booking triggers, and w369:ready. Use it whenever the page brings its own button, link, or form
data-labelNoThe popup button text, up to 40 characters; defaults to "Book now"
data-colorNoThe popup button color, a six-digit hex value such as #111827
data-serviceNoA service ID; the flow starts with that service selected
data-memberNoA team member ID; the flow starts with that host selected
data-themeNoA theme name; see the list below
data-prefill-nameNoThe client's name, up to 200 characters
data-prefill-emailNoThe client's email, up to 200 characters
data-prefill-phoneNoThe client's phone in E.164, up to 200 characters
data-prefill-notesNoThe message the client would otherwise type, up to 2,000 characters
data-intakeNoIntake answers as JSON, keyed by question ID
data-metadataNoYour own fields as JSON
data-stepNotime skips the details form when the name, the email, and a valid phone are prefilled
data-originNoThe origin serving the booking runtime; defaults to the origin the script was loaded from. Only for testing another environment
data-w369-bookingNoGoes on any other element, never on the script tag: clicking that element opens the popup

An element carrying data-w369-booking can also carry data-workspace, data-page, data-service, and data-member to open a different page from that one trigger.

The "Powered by Workspace369" credit ships with every mode and cannot be turned off. inline puts it under the frame, popup next to the button, mount() right after the frame inside your target, and api, which renders neither, right after the script tag — or at the end of the body when the script sits in <head>.

data-theme accepts one of: light, dark, cupcake, bumblebee, emerald, corporate, synthwave, retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel, fantasy, wireframe, black, luxury, dracula, cmyk, autumn, business, acid, lemonade, night, coffee, winter, dim, nord, sunset, caramellatte, abyss, silk. An unknown name is ignored and the page keeps the theme set in its editor.

The JavaScript API

W369Booking.open(options?)            // opens the popup overlay
W369Booking.close()                   // closes the popup
W369Booking.mount(target, options?)   // renders the flow inside target

target is an element or a CSS selector. mount() returns a handle, { container, destroy() }, or null when nothing matches the selector.

options is the same for open() and mount(), and every field is optional:

{
  workspace?: string, page?: string, service?: string, member?: string, theme?: string,
  step?: '' | 'time',
  prefill?: { name?, email?, phone?, notes?, intake?: object, metadata?: object }
}

Each option overrides the matching attribute on the script tag.

Rules that matter when you mount:

  • A mounted frame is always inline, so it sizes itself to the booking flow whatever data-mode the script tag uses.
  • The frame is appended into your target, and the "Powered by Workspace369" credit goes right after it, inside the same target.
  • Mounting again into the same target destroys the previous mount first, so re-opening a modal never stacks frames.
  • destroy() removes the frame, the credit, and the event listener. Calling it twice does nothing.

Events

Both fire on window.

EventDetail
w369:readyevent.detail.api is the same object as window.W369Booking. Fires once, as soon as the API is installed
w369:bookedevent.detail is { bookingId, status, startMs }, where startMs is the appointment's start time in milliseconds

The script is loaded async, so it may not be ready when your own code runs. Use this helper before every call:

function w369Ready(fn) {
  if (window.W369Booking && typeof window.W369Booking.mount === 'function') fn(window.W369Booking)
  else window.addEventListener('w369:ready', function (e) { fn(e.detail.api) }, { once: true })
}

The iframe URL

The script builds the frame's address from the attributes and options. You only write it yourself for a plain iframe, where embed=1 is the one parameter you need.

ParameterValue
embed=1Required. Renders the page as an embed
modeinline or popup
hostThe origin of the page holding the embed. The script fills it in; without it the frame cannot resize itself or report a booking
service, member, themeThe same IDs and theme name as the attributes
name, email, phone, notesThe prefilled client details
step=timeSkips the details form
intakeThe intake answers as JSON
metaYour own fields as JSON
utm_source, utm_medium, utm_campaign, utm_term, utm_contentForwarded from the address of the page holding the embed

Limits

Everything you pass is cleaned before it reaches the booking page.

  • Metadata: at most 20 keys; 21 or more drops the whole map. A key is 1 to 40 characters from A-Z a-z 0-9 _ . -, and a key wrapped in double underscores such as __id__ is never accepted. A value is text of up to 500 characters, where line breaks become single spaces and an empty result is dropped, or a finite number, or true/false; anything else is dropped. The whole map, serialised as JSON, must stay under 4,096 bytes, or it is dropped.
  • Intake answers: keyed by question ID, each answer text, a list of text, or true/false, up to 2,000 characters per answer and 4,096 bytes for the whole map.
  • Notes: up to 2,000 characters.
  • Name, email, phone: up to 200 characters each.
  • The frame address: at most 8,192 characters. If your data would push it over, the intake answers are dropped first, then the metadata, and last the notes are cut to whatever still fits. Each of those is reported in the browser console.

Phone numbers

Every booking stores the client's phone in E.164: a +, the country calling code, then the national number, digits only, 8 to 15 digits in total. +14155550100, +442071234567, and +48221234567 are all valid.

  • The embed removes spaces, brackets, dots, and hyphens, and turns a leading 00 into +. So +1 (415) 555-0100 and 0044 20 7123 4567 are handed over as +14155550100 and +442071234567.
  • A number it cannot put into E.164 is passed through unchanged, so the visitor can fix it in the frame, and the console explains why.
  • In the frame, the phone field is checked as the visitor types. While the number cannot be read, the field shows "Include your country code, for example +1 415 555 0100." and the booking cannot be submitted. A number typed without a country code is read with your workspace's own country.
  • step: 'time' only skips the details form when the name, the email, and a valid phone are all prefilled.
  • A booking that still reaches the server with an unreadable number is refused with INVALID_PHONE.

Give your assistant +14155550100 as the example phone, and ask it to send E.164 from your own form.

Instant booking or approval

The booking page, not the embed, decides what happens when the visitor finishes.

ModeResult
Instant bookingA booking that passes the service's rules, and any payment step, goes straight to the calendar
Request approvalThe request waits in the Booking Queue until a team member accepts or declines it. With Hold pending slots on, the time stays unavailable while it waits

Both are set in the page editor. The default demo page books instantly; the deposit-review page asks for approval and holds the slot.

Recipe 1: Inline on a page

Use this for a dedicated booking page or a booking section. The frame renders where the tag sits and grows with the flow.

<script src="https://369.dev/booking.js" data-booking
  data-workspace="WS_ID"
  data-page="SLUG" async></script>

Paste it into the body of the page, at the spot the booking flow belongs. A tag in <head> has nowhere to render in place; use recipe 5 instead.

Ask your assistant: "Add this inline Workspace369 booking snippet to my booking page, in the body where the scheduler should appear, with WS_ID and SLUG filled in."

Recipe 2: Floating popup button

Use this to offer booking from every page without changing any layout.

<script src="https://369.dev/booking.js" data-booking
  data-workspace="WS_ID"
  data-page="SLUG"
  data-mode="popup"
  data-label="Book now"
  data-color="#111827" async></script>

Paste it just before the closing </body> tag of every page, or into your site's site-wide footer or custom-code area.

Ask your assistant: "Add this Workspace369 popup snippet site-wide before the closing body tag, and set the button label and color to match my brand."

Use this when you already have a button in your design and only want it to open the scheduler.

<script src="https://369.dev/booking.js" data-booking
  data-workspace="WS_ID"
  data-page="SLUG"
  data-mode="api" async></script>

<a href="https://369.dev/ws/WS_ID/book/SLUG" data-w369-booking>Book a consultation</a>

Any element with data-w369-booking opens the overlay, anywhere on the page, including elements added later. data-mode="api" is what keeps our floating button off the page, so your trigger is the only one. Keep a real href: before the script has loaded, and without JavaScript, the link still goes to the booking page.

Ask your assistant: "Add data-w369-booking to my existing Book now button and load the Workspace369 script once on the page with data-mode set to api, with WS_ID and SLUG filled in."

Recipe 4: Your own form, then the scheduler

Use this when your site already asks for the visitor's details. Pass them over and the visitor only picks a time; everything you pass travels with the booking.

<script src="https://369.dev/booking.js" data-booking
  data-workspace="WS_ID"
  data-page="SLUG"
  data-mode="api" async></script>

<form id="book-form">
  <input name="name" placeholder="Full name" required>
  <input name="email" type="email" placeholder="you@example.com" required>
  <input name="phone" type="tel" placeholder="+14155550100" required>
  <input name="company" placeholder="Company">
  <button type="submit">Pick a time</button>
</form>

<script>
  function w369Ready(fn) {
    if (window.W369Booking && typeof window.W369Booking.mount === 'function') fn(window.W369Booking)
    else window.addEventListener('w369:ready', function (e) { fn(e.detail.api) }, { once: true })
  }

  document.querySelector('#book-form').addEventListener('submit', function (event) {
    event.preventDefault();
    var form = event.target;
    w369Ready(function (api) {
      api.open({
        step: 'time',
        prefill: {
          name: form.name.value,
          email: form.email.value,
          phone: form.phone.value,
          metadata: { company: form.company.value, source: 'website-form' }
        }
      });
    });
  });
</script>

Send the phone in E.164, as the placeholder shows. Metadata lands on the client record under Other details. data-mode="api" gives you W369Booking.open() and the overlay with no floating button of ours: your form is the trigger.

Ask your assistant: "Wire my existing contact form to the Workspace369 overlay with W369Booking.open, loading the script with data-mode set to api, passing name, email, an E.164 phone, and my extra fields as metadata."

Recipe 5: Your own modal

Use this when the scheduler should live inside your own dialog, drawer, or tab instead of our overlay.

<script src="https://369.dev/booking.js" data-booking
  data-workspace="WS_ID"
  data-page="SLUG"
  data-mode="api" async></script>

<button type="button" id="open-booking">Book an appointment</button>

<dialog id="booking-dialog" style="width:min(920px,92vw);border:0;border-radius:16px;padding:16px">
  <button type="button" id="close-booking">Close</button>
  <div id="booking-slot"></div>
</dialog>

<script>
  function w369Ready(fn) {
    if (window.W369Booking && typeof window.W369Booking.mount === 'function') fn(window.W369Booking)
    else window.addEventListener('w369:ready', function (e) { fn(e.detail.api) }, { once: true })
  }

  var dialog = document.querySelector('#booking-dialog');
  var handle = null;

  document.querySelector('#open-booking').addEventListener('click', function () {
    dialog.showModal();
    w369Ready(function (api) {
      handle = api.mount('#booking-slot', {
        step: 'time',
        prefill: { name: 'Ada Lovelace', email: 'ada@example.com', phone: '+14155550100' }
      });
    });
  });

  document.querySelector('#close-booking').addEventListener('click', function () {
    dialog.close();
    if (handle) { handle.destroy(); handle = null; }
  });
</script>

Always call destroy() when the modal closes, and mount only after the modal is on the page. data-mode="api" keeps our floating button off the page, so your dialog is the only booking surface.

Ask your assistant: "Open my existing modal and render the Workspace369 scheduler inside it with W369Booking.mount, loading the script with data-mode set to api, then call destroy on close."

Recipe 6: React or Next.js

Use this in a component-driven app. Load the script once, mount into a ref, and destroy on unmount.

import { useEffect, useRef } from 'react';

function loadBooking() {
  return new Promise((resolve) => {
    if (window.W369Booking && typeof window.W369Booking.mount === 'function') {
      resolve(window.W369Booking);
      return;
    }
    window.addEventListener('w369:ready', (event) => resolve(event.detail.api), { once: true });
    if (document.querySelector('script[data-booking]')) return;
    const script = document.createElement('script');
    script.src = 'https://369.dev/booking.js';
    script.async = true;
    script.setAttribute('data-booking', '');
    script.setAttribute('data-workspace', 'WS_ID');
    script.setAttribute('data-page', 'SLUG');
    script.setAttribute('data-mode', 'api');
    document.body.appendChild(script);
  });
}

export default function BookingScheduler() {
  const slot = useRef(null);

  useEffect(() => {
    let handle = null;
    let cancelled = false;

    loadBooking().then((api) => {
      if (cancelled || !slot.current) return;
      handle = api.mount(slot.current, {
        step: 'time',
        prefill: { name: 'Ada Lovelace', email: 'ada@example.com', phone: '+14155550100' },
      });
    });

    return () => {
      cancelled = true;
      if (handle) handle.destroy();
    };
  }, []);

  return <div ref={slot} />;
}

This component runs in the browser only, so in the Next.js app router mark the file 'use client'. data-mode="api" means the injected script tag renders nothing by itself, so the component's mounted frame is the only scheduler on the page; switch it to popup only if you also want the floating Book now button, and then set data-label and data-color to match your site.

Ask your assistant: "Turn this into a React component in my project, keep the script tag with data-mode set to api and the mount and destroy behavior, and take WS_ID and SLUG from my environment variables."

Recipe 7: Site builders

The snippets are plain HTML, so they work anywhere you can add custom code. Product menus change, so look for the wording rather than the exact path.

  • Webflow — the popup snippet goes in Project settingsCustom code → the footer code area. The inline snippet goes in an Embed element placed on the canvas where the scheduler belongs.
  • Squarespace — the popup snippet goes in WebsiteWebsite toolsCode injection → footer. The inline snippet goes in a Code block on the page itself.
  • Wix — the popup snippet goes in SettingsCustom code, loaded at the end of the body on all pages. For a scheduler in the layout, add an embed element and paste the plain iframe.
  • WordPress — the inline snippet goes in a Custom HTML block in the editor. The popup snippet goes in the theme's footer, or in a header-and-footer scripts plugin. Do not paste it into a paragraph block; the editor will escape it.
  • Shopify — the popup snippet goes in theme.liquid, just before </body>, in the theme code editor. The inline snippet goes in a Custom Liquid section or block on the page.

Inline snippets have to sit in the body of the page. A builder that only offers a site-wide header slot can still run the popup, or load the script there and use mount() from recipe 5.

Ask your assistant: "I use Webflow. Tell me exactly where to paste this popup snippet, and what to add to a page so the scheduler renders inline in one section."

Recipe 8: Google Tag Manager

Use this when marketing owns the site's tags. GTM injects code into the page, so it suits the popup, not an inline frame at a fixed spot.

  1. Create a Custom HTML tag.
  2. Paste the code below and turn Support document.write off.
  3. Trigger it on All Pages, or only the pages that should offer booking.
<script>
  (function () {
    var s = document.createElement('script');
    s.src = 'https://369.dev/booking.js';
    s.async = true;
    s.setAttribute('data-booking', '');
    s.setAttribute('data-workspace', 'WS_ID');
    s.setAttribute('data-page', 'SLUG');
    s.setAttribute('data-mode', 'popup');
    s.setAttribute('data-label', 'Book now');
    document.body.appendChild(s);
  })();

  window.addEventListener('w369:booked', function (event) {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'w369_booked',
      bookingId: event.detail.bookingId,
      status: event.detail.status,
      startMs: event.detail.startMs
    });
  });
</script>

The second block pushes every completed booking into the data layer, so you can build a conversion trigger on the w369_booked event.

Ask your assistant: "Write a Google Tag Manager Custom HTML tag that loads the Workspace369 popup on all pages and pushes a w369_booked conversion event to the data layer."

Verify it works

Work through this list once, on the published page, before you call the embed done.

  1. Open the page in a private browser window. The scheduler renders, and the "Powered by Workspace369" credit sits with it. With data-mode="api" nothing renders until your own button, link, or form opens it, but the credit is on the page from the start.
  2. Open the browser console. There should be no message starting with [w369 booking.js].
  3. Book a test appointment, using +14155550100 as the phone if you are only checking the plumbing.
  4. In Workspace369, open SettingsScheduling & TasksCalendar SettingsBooking Pages. On a Request approval page, the test booking waits in the Booking Queue; on an Instant booking page it is already on the calendar.
  5. Open the calendar event and check the client's name, email, phone, notes, and intake answers.
  6. Check the host's inbox for the new-booking email.
  7. Open Clients. The test booking created or matched a client record with the lead source Online booking, and your metadata fields are under Other details.
  8. If you listen for w369:booked, confirm your own analytics or thank-you step fired.

Then delete the test booking, or run the whole check on the demo workspace instead.

Live examples

Every recipe runs on the booking embed examples page, backed by the Apex Auto Spa demo workspace. Bookings made there are test data in that demo workspace, so you can click all the way through.

PageShows
bookInstant booking, three services, services-first layout
round-robin-teamAuto-assign the next available specialist
choose-specialistThe visitor picks the host
intake-formsIntake questions on the details step
deposit-reviewRequest approval with held slots
buffer-limitsBuffers and daily limits, calendar-first layout
calendar-first-no-imagesCalendar-first layout with no service images
large-galleryA large service gallery
theme-corporate, theme-dark, theme-luxury, theme-nord, theme-emerald, theme-cupcakeSix of the themes you can set with data-theme

The intake-forms page is the one to copy when you want to test data-intake: its questions are vehicle_year_make_model (text), detail_priorities (multiple choice, with the options "Odor removal", "Stain treatment", "Leather conditioning", and "Child seat cleanup"), and access_notes (long text).

Troubleshooting

Every message the embed writes starts with [w369 booking.js], so filter the browser console on that string.

Message or symptomWhat it means
[w369 booking.js] Missing or invalid data-workspaceThe script tag has no data-workspace, or the value is not a workspace ID
[w369 booking.js] mount: no element matches "#booking-slot"The selector you passed to mount() matched nothing. Mount after your modal is on the page
[w369 booking.js] phone "415-555-0100" is not in E.164 format (+14155550100); passed through for the visitor to correctThe phone you prefilled is not E.164. The booking still works; the visitor has to fix the number
[w369 booking.js] data-metadata ignored: not valid JSON or outside the contract capsThe JSON does not parse, is not an object, or breaks the metadata limits. data-intake reports itself the same way
[w369 booking.js] metadata ignored: not an object or outside the contract caps after mergingThe metadata you passed to open() or mount() broke the limits once merged with the script tag's own fields. intake reports itself the same way
[w369 booking.js] intake dropped: the booking URL would exceed 8192 charactersToo much data. The intake answers went first; ask the questions in the frame instead
[w369 booking.js] metadata dropped: the booking URL would exceed 8192 charactersStill too long after the intake answers were dropped. Send fewer or shorter metadata fields
[w369 booking.js] notes truncated to 1200 characters: the booking URL would exceed 8192 charactersThe notes were cut to whatever fit
W369Booking is not definedThe script is async and has not finished loading. Wrap your call in the w369Ready helper
"code": "INVALID_PHONE" with "error": "Enter a valid phone number with its country code, for example +14155550100."The server refused the booking's phone number. Send E.164
Nothing renders where you pasted the inline snippetThe script tag is in <head>, or the builder stripped it. Move it into the body, use mount(), or fall back to the plain iframe
A floating Book now button you did not ask forThe script tag uses data-mode="popup". Switch it to data-mode="api": the overlay, the API, and the data-w369-booking triggers stay, the button goes
data-mode="api" renders nothing at allThat is what it does. It only installs the overlay and the API; something on your page has to call W369Booking.open() or mount(), or carry data-w369-booking
The frame never grows past its first screenThe page holding the embed is not served over HTTPS, so the frame cannot report its height. Publish the page over HTTPS
"Booking is not available"The page is paused, or the last active page was paused or deleted

Continue to Manage booking requests, or go back to Embed booking on your website for the version written for people rather than assistants.

Every embed carries the "Powered by Workspace369" credit, which cannot be removed. Deposits and full payments open Stripe Checkout in the top-level window and need a connected Stripe account.

Was this article useful?

Your answer helps us decide what to clarify next.