Scheduling

Embed booking on your website

Add your booking page to any site inline, as a popup, inside your own modal with the mount API, or as a plain iframe, and drive it from JavaScript.

View as Markdown

Your booking page does not have to live only at 369.dev. Paste one snippet from Share & embed into your website and clients can book without leaving your site. The embed shows the same services, availability, and payment steps as the public page.

Choose an embed style

StyleBest for
InlineA dedicated booking page or section on your site; the booking flow renders in place and grows to fit its content
Popup buttonAny page on the site; a floating button opens the booking flow in an overlay
Your own button or linkYou already have a Book now button in your design; data-mode="api" gives it the overlay and adds no button of ours
Inside your own popup or modal (mount)A design you control; W369Booking.mount() renders the scheduler inside an element you name, and destroy() removes it again
Your own form, then the schedulerYour site already asks for the visitor's name, email, and phone; you pass them over and the visitor only picks a time
Plain iframeSite builders that block custom scripts; fixed height, no popup, no JavaScript API

Share & embed copies a ready-made snippet with your workspace ID and page slug already filled in: Inline, Popup button, Your form, which covers both W369Booking.open() and mount(), and Plain iframe.

Branding

Every embed, including the plain iframe, carries a small "Powered by Workspace369" credit with the Workspace369 logo that links to workspace369.com. The credit is part of the embed: it cannot be removed, hidden, or turned off with an attribute or a JavaScript option, and it appears next to inline embeds, mounted frames, popup buttons, and popup overlays alike. With data-mode="api", which renders neither a frame nor a button, the credit is placed right after the script tag, or appended to the end of the body when the script sits in <head>.

Add an inline booking page

  1. Open the booking page's Share & embed action and copy the inline snippet.
  2. Paste it into your site's HTML where the booking flow should appear.
<script src="https://369.dev/booking.js" data-booking
  data-workspace="WS_ID"
  data-page="SLUG" async></script>

WS_ID is your workspace ID and SLUG is the page's link slug; the snippet from Share & embed already contains both, and the default page needs no data-page at all. The frame sizes itself to the booking flow as the client moves through it, so it needs no fixed height.

The inline snippet has to sit in the body of your page, at the spot where the booking flow belongs. A script tag in <head> has nowhere to render in place; use mount instead.

Add a popup button

  1. Open the booking page's Share & embed action and copy the popup snippet.
  2. Paste it just before the closing </body> tag of every page where the button should appear, or into your site builder's site-wide footer or custom-code area so the button appears everywhere.
<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>

data-label sets the button text and data-color its color. The booking flow opens in an overlay that scrolls inside itself; the client closes it with the close control or by pressing Escape.

To keep the overlay but drop our button, because your own design already has one, use data-mode="api" instead — see Open from your own button or link.

Add data-w369-booking to any element on the page and clicking it opens the booking flow in the overlay. Every script tag listens for those clicks, whatever its mode; use data-mode="api" when your trigger should be the only one, because that mode installs the overlay, the JavaScript API, and the click listener while rendering no inline frame and no floating button of ours.

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

The click is intercepted wherever the element is on the page, including elements added later. Keep a real href on a link: before the script has loaded, and for anyone without JavaScript, it still goes to the booking page.

The same element can carry data-workspace, data-page, data-service, and data-member to open a different page or a preselected service from that one trigger.

See it running on the live examples page.

Put the scheduler inside your own popup or modal

W369Booking.mount() renders the booking flow inside an element you name, so the scheduler can live in your own modal, drawer, tab, or accordion instead of our overlay.

const handle = W369Booking.mount('#booking-slot', { step: 'time', prefill: { name: 'Ada Lovelace' } });
  • The first argument is an element or a CSS selector. When nothing matches, mount() inserts nothing, returns null, and the browser console shows [w369 booking.js] mount: no element matches "#booking-slot".
  • The second argument takes the same options as W369Booking.open(), described in the JavaScript API reference.
  • A mounted frame is always inline, so it sizes itself to the booking flow whatever data-mode the script tag uses.
  • The "Powered by Workspace369" credit is placed right after the frame, inside your element.
  • Mounting again into the same element destroys the previous mount first, so re-opening your modal never stacks frames.
  • mount() returns a handle with the frame's container and a destroy() method. Call destroy() when your modal closes; calling it twice does nothing.

Load the script with async and wait for the w369:ready event before you call mount(). This helper covers both cases, whether the script has already loaded or not:

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 })
}

A complete <dialog> example, ready to paste. data-mode="api" keeps our floating button off the page, so your dialog is the only booking surface, and layout: 'minimal' trims the frame down to the scheduler alone — see Choose how much of the page to show.

<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', {
        layout: 'minimal',
        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>

See it running on the live examples page.

Choose how much of the page to show

By default the embed renders the whole booking page: the step indicator, the Book appointment heading with its service cards, and the Availability pane with its heading and instructions. Three options trim that down.

Reach for minimal when you have already collected the details yourself and the only thing left is for the visitor to pick a time and confirm it — the hand-off in Use your own form, and the usual choice for your own modal, where the surrounding page already sets the scene.

What the minimal layout shows, and nothing else:

  1. A top bar with the service name, its duration, the location kind when the service has one — phone, video, or in person — and a quiet timezone control on the right.
  2. The calendar, and the times available on the day the visitor picks.
  3. A confirm step: the chosen date and time on one line with a Change link back to the calendar, then the details summary card or the details form, any required questions still unanswered, the payment summary when something is due, the security check, the terms row, and the confirm button.

What it removes: the step indicator, the Book appointment heading and the service cards under it, and the Availability heading with the instructions band. The "Powered by Workspace369" credit stays, as it does in every embed. Any value other than minimal keeps the page's own layout.

accent is a six-digit hex value such as #111827. It colors the confirm button and the selected day and time, and nothing else; the theme set in the page editor, or with data-theme, still governs the rest. A value that is not a six-digit hex is ignored.

hide drops parts of the minimal layout you do not need: any of header, instructions, and timezone, as a comma-separated list or, in JavaScript, an array. Unknown names are ignored. Hiding timezone removes the control only — the visitor's timezone still applies to the times they see.

You can set all three the same three ways data-theme works. On the script tag:

<script src="https://369.dev/booking.js" data-booking
  data-workspace="WS_ID"
  data-page="SLUG"
  data-layout="minimal"
  data-accent="#111827"
  data-hide="instructions,timezone" async></script>

In the JavaScript API, where each one overrides the matching attribute:

W369Booking.mount('#booking-slot', {
  layout: 'minimal',
  accent: '#111827',
  hide: ['instructions', 'timezone'],
  step: 'time',
  prefill: { name: 'Ada Lovelace', email: 'ada@example.com', phone: '+14155550100' }
});

On a plain iframe address, where hide is a comma-separated list and the accent's # is written as %23:

<iframe src="https://369.dev/ws/WS_ID/book/SLUG?embed=1&layout=minimal&accent=%23111827&hide=instructions,timezone"
  title="Book an appointment" width="100%" height="720" style="border:0;display:block" loading="lazy"></iframe>

Language

The booking page renders in the visitor's language, one of 32. Set it directly with data-lang, or leave it to resolve from the page and the browser.

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

The value is one of ar, cs, da, de, el, en, es, fi, fr, he, hu, id, is, it, ja, ko, ms, nb, nl, pl, pt, pt-PT, ro, ru, sv, th, tl, tr, uk, vi, zh, zh-TW, or auto. An unrecognised value is ignored, the same as leaving the attribute out entirely. In the JavaScript API, the lang option on open() or mount() does the same thing and overrides the attribute.

The language resolves in this order:

  1. data-lang, or the lang option passed to mount() or open().
  2. The booking page's own Page language setting, when available and not set to Auto.
  3. The visitor's browser language.
  4. English, if nothing above matches a supported language.

Arabic and Hebrew render right to left. Dates, times, and prices follow the resolved language throughout the flow.

Use your own form and hand off to the scheduler

If your website already collects the visitor's name, email, phone, and any questions of your own, you do not have to ask for them again. Pass what you collected to the embed and the visitor only picks a time; everything you pass travels with the booking. This needs the script embed, not the plain iframe.

Prefill through attributes on the script tag when the details are already known as the page loads:

  • data-prefill-name, data-prefill-email, and data-prefill-phone fill the client's details.
  • data-prefill-notes fills the message the client would otherwise type.
  • data-intake answers the page's intake questions, as JSON keyed by question ID; see Where to find the IDs.
  • data-metadata carries your own fields as JSON, up to 20 keys; each value is text up to 500 characters, a number, or true/false.
  • data-step="time" skips the details form. It applies when the name, the email, and a valid phone are all prefilled; the visitor sees a summary of those details with a short Edit details link, then picks a time.
<script src="https://369.dev/booking.js" data-booking
  data-workspace="WS_ID"
  data-page="SLUG"
  data-step="time"
  data-prefill-name="Ada Lovelace"
  data-prefill-email="ada@example.com"
  data-prefill-phone="+14155550100"
  data-metadata='{"company":"Acme","seats":12,"existing_customer":true}' async></script>

When the details come from a form the visitor fills in on your page, load the script with data-mode="api" and open the scheduler from your form's submit handler with the JavaScript API instead. Your form is the trigger, so there is no floating button to hide:

<script src="https://369.dev/booking.js" data-booking data-workspace="WS_ID" data-page="SLUG" data-mode="api" async></script>
<script>
  const form = document.querySelector('#book-form');
  form.addEventListener('submit', (e) => {
    e.preventDefault();
    W369Booking.open({ layout: 'minimal', step: 'time', prefill: {
      name: form.name.value, email: form.email.value, phone: form.phone.value,
      notes: form.notes.value,
      metadata: { company: form.company.value, seats: Number(form.seats.value) }
    } });
  });
</script>

layout: 'minimal' is what turns the overlay into a time picker rather than the whole booking page, which is usually what you want once your own form has done the asking; see Choose how much of the page to show.

If the visitor can submit before the script has finished loading, wrap the open() call in the w369Ready helper above.

To continue your own flow once the visitor has booked, for example to show a thank-you page, listen for the w369:booked event described in the JavaScript API reference.

Where the details show up

  • On the request in the Booking Queue, together with the intake answers and notes.
  • On the calendar event.
  • In the new-booking email the host receives.
  • On the client record in Clients: metadata fields appear under Other details, or under the matching custom field when you have defined one with the same name.

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.

What the embed does with the number you pass in data-prefill-phone or prefill.phone:

  • Spaces, brackets, dots, and hyphens are removed, and a leading 00 becomes +. 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 correct it in the frame, and the browser console shows [w369 booking.js] phone "415-555-0100" is not in E.164 format (+14155550100); passed through for the visitor to correct.

What the visitor sees: the phone field is checked as they type. 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, so local formats still work for local clients.

data-step="time" only skips the details form when the name, the email, and a valid phone are all prefilled. A phone the embed could not read keeps the details form open.

If a booking still reaches the server with an unreadable number, it is refused with "Enter a valid phone number with its country code, for example +14155550100." and the error code INVALID_PHONE.

Use a plain iframe

  1. Open the booking page's Share & embed action and copy the iframe snippet.
  2. Paste it into your site's HTML where the booking flow should appear.
<iframe src="https://369.dev/ws/WS_ID/book/SLUG?embed=1" title="Book an appointment" width="100%" height="900" style="border:0;display:block" loading="lazy"></iframe>

Use this when your site builder strips or blocks script tags. The iframe cannot resize itself, so adjust height to fit the longest step of the booking flow. The popup mode, the data- attributes, and the JavaScript API are not available with a plain iframe.

Attributes reference

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 what any unrecognised value falls back to: the booking flow renders where the script tag sits and sizes itself
data-mode="popup"NoShows a floating button that opens the booking flow in an overlay, instead of an inline frame
data-mode="api"NoRenders neither a frame nor a button: only the overlay, window.W369Booking, the data-w369-booking triggers, and the w369:ready event. For a page that 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 as a six-digit hex value such as #111827
data-serviceNoStarts the flow with a service already selected
data-memberNoStarts the flow with a host already selected
data-themeNoApplies a specific theme to the embed
data-langNoThe visitor's language: one of 32 codes, or auto; see Language
data-layout="minimal"NoShows only the scheduler: a top bar, the calendar and times, then a confirm step. Any other value keeps the page's own layout; see Choose how much of the page to show
data-accentNoA six-digit hex value such as #111827, used in the minimal layout for the confirm button and the selected day and time
data-hideNoParts of the minimal layout to drop: any of header, instructions, timezone, comma-separated. Hiding timezone removes the control, not the timezone
data-prefill-name, data-prefill-email, data-prefill-phoneNoPrefill the client's details, up to 200 characters each; the phone should be E.164
data-prefill-notesNoPrefills the message the client would otherwise type, up to 2,000 characters
data-intakeNoAnswers to the page's intake questions, as JSON keyed by question ID
data-metadataNoYour own fields, as JSON; up to 20 keys with text up to 500 characters, numbers, or true/false
data-step="time"NoSkips the details form when the name, the email, and a valid phone are prefilled; see Use your own form
data-originNoThe origin serving the booking runtime; defaults to the origin the script was loaded from. Only useful for testing against another environment
data-w369-bookingNoGoes on any element, not on the script tag: clicking that element opens the popup

Advanced — data-service, data-member, and data-theme take internal identifiers and are meant for developer integrations; most sites should let the visitor choose.

Where to find the IDs

data-service, data-member, and data-intake address services, hosts, and intake questions by ID rather than by name. Open the booking page's Share & embed action and expand IDs for developers, under the snippet. It lists, each with a copy button:

  • Workspace ID — the value for data-workspace.
  • Page slug — the value for data-page. Leave data-page out altogether on the default page.
  • Services — each service the page offers, name then ID, for data-service.
  • Hosts — each team member those services allow, display name then ID, for data-member.
  • Intake questions — each question the page asks, label then ID, for the keys of data-intake.

JavaScript API reference

The script installs window.W369Booking on the page whatever data-mode it uses, so the API is available from an inline embed, a popup button, or a data-mode="api" tag that renders nothing of its own.

MemberResult
W369Booking.open(options)Opens the booking flow in the popup overlay
W369Booking.close()Closes the popup
W369Booking.mount(target, options)Renders the booking flow inside target, an element or a CSS selector, and returns a handle; returns null when nothing matches
The mount handlecontainer, the frame's own element, and destroy(), which removes the frame and the credit

options is the same for open() and mount(), and every field is optional: { workspace, page, service, member, theme, lang, layout, accent, hide, step, prefill }, where prefill takes { name, email, phone, notes, intake, metadata } and step: 'time' skips the details form. Each option overrides the matching attribute on the script tag; lang follows the same resolution order as data-lang, described in Language.

The three presentation options work exactly as their attributes do, and are described in Choose how much of the page to show:

  • layout: 'minimal' shows only the scheduler; any other value keeps the page's own layout.
  • accent is a six-digit hex value used for the confirm button and the selected day and time.
  • hide takes 'header', 'instructions', and 'timezone', as an array or a comma-separated string.

Two events fire on window:

  • w369:ready, once, as soon as the API is installed. event.detail.api is the same object as window.W369Booking. Use it when the script is loaded async.
  • w369:booked, when a client completes the flow. event.detail carries bookingId, status, and startMs, the appointment's start time in milliseconds.
<button type="button" onclick="W369Booking.open({ prefill: { name: 'Ada Lovelace', email: 'ada@example.com' } })">Book an appointment</button>
<script>
  window.addEventListener('w369:booked', (event) => {
    const { bookingId, status, startMs } = event.detail;
    // record a conversion, show a thank-you message, and so on
  });
</script>

Keep campaign attribution

utm_source, utm_medium, utm_campaign, utm_term, and utm_content on the URL of the page hosting the embed are forwarded to the booking page, so campaign links keep their attribution.

Restrict which websites can embed your page

By default any website can embed your booking page. To limit it to sites you control, open the booking page's Share & embed action, go to its settings, and add origins under Allowed websites: one origin per line, such as https://example.com, up to 20.

Once that list holds at least one origin, every script embed — inline, popup, data-mode="api", and mount() — is refused on any other website: the frame shows "This website is not allowed to embed this booking page." in place of the booking flow. A direct visit to the 369.dev page is never restricted, and neither is a plain iframe, because a plain iframe carries no origin for the check to read. Leave the list empty to allow any website, including plain iframes and every script embed.

Instant booking or approval

The booking page decides what happens when a visitor finishes the flow in your embed. You choose between the two in the page editor.

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 Request approval, turn on Hold pending slots to keep the requested time unavailable to other clients while it waits for a decision.

Try both: the default demo page books instantly, and the deposit-review page asks for approval and holds the slot. See Create and share a booking page to set this on your own page.

Live examples

Every embed style 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

Every page follows the pattern https://369.dev/ws/WS_ID/book/SLUG, and the default page drops the slug.

Payments inside an embed

When a service requires a deposit or full payment, the embed does not try to take the card inside the frame. The client sees a Continue to secure payment button that opens Stripe Checkout at the top level of the browser window. After paying, the client lands on a confirmation page on 369.dev with a Back to example.com button, where example.com is your site's domain, that returns them to your website.

The slot stays held for 30 minutes during payment, exactly as on the public page.

Troubleshooting

SymptomCheck
Nothing rendersYour site builder may block custom scripts; confirm the script tag survived publishing, or switch to the plain iframe
Nothing renders where I pasted the inline snippetThe script tag is in <head>, which has nowhere to render in place. Move it into the body, or keep it in <head> and call mount
W369Booking is not definedThe script is loaded async and has not finished yet. Wait for the w369:ready event with the w369Ready helper
mount: no element matches in the consoleThe selector you passed does not match anything yet. Mount after your modal has been added to the page
A floating Book now button appears and I did not ask for oneThe script tag uses data-mode="popup". Switch it to data-mode="api", which keeps the overlay and the JavaScript API but adds no button
The phone is rejectedPhones are stored in E.164. Send +14155550100 rather than 415-555-0100; see Phone numbers
"Booking is not available"The page is paused, or the last active page was paused or deleted. Resume it in Booking Pages
"This website is not allowed to embed this booking page."The page's Allowed websites list is non-empty and does not include this site's origin. Add it in Share & embed → settings, or clear the list to allow any website; see Restrict which websites can embed your page
The frame is cut offInline and mounted embeds size themselves; a plain iframe needs a larger height; popups scroll inside the overlay

Continue to Set up a booking embed with an AI assistant.

Every embed carries the "Powered by Workspace369" credit. 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.