Embedded Schedulers
Mount a working scheduler for any supported provider with one call, with booking events tracked and saved to the response automatically
mountScheduler reads the record's scheduler configuration, renders a working scheduler, tracks the booking lifecycle, and writes the confirmed booking into the response. It works for every provider native Surface forms support.
The default path
import { mountScheduler } from "@surface-labs/forms-sdk";
const teardown = mountScheduler({
form, // the SurfaceForms.init handle
container: document.querySelector("#booking"),
onEvent(e) {
if (e.type === "booking_confirmed") form.submit(); // or next()/goToStep
},
});That is the whole integration: persistence is automatic, and events also arrive on form.on("scheduler", handler). Never bind a scheduler question in HTML, and never iframe the hosted booking page.
Mount once, as soon as form resolves, into the step container even while it is still hidden. Do not mount on step entry.
Options
| Option | Default | What it does |
|---|---|---|
form | required | The init handle; config, transport, identity, and persistence come from it. |
container | required | The embed mounts here; teardown removes it. |
questionId | auto | Only needed with more than one scheduler question (the error names the candidates). stepId works too. |
styled | true | Surface provider: injects a stylesheet scoped under .surface-scheduler on this container. Override with --surface-scheduler-* custom properties; false keeps everything bare. |
persist | true | Auto-persist the booking into the response. false still emits events, never writes. |
prefill | from record | { name?, lastName?, email? } overrides the record's linked answers. |
formatters | locale | Surface provider only: { month?, date?, time? } label formatters. |
calendarId | auto | DynamicScheduler with multiple calendars: pick one by name. Ambiguous routing throws. |
onEvent | none | Same events as form.on("scheduler", ...). |
onComplete | none | Fires alongside booking_confirmed. Not needed for persistence. |
The returned teardown is idempotent and independent of the engine; call it before or after form.destroy().
Lifecycle events
Every event carries { questionId, stepId, provider } plus:
| { type: "viewed" }
| { type: "date_selected"; date? } // "YYYY-MM-DD"
| { type: "slot_selected"; slot? } // { start, end? }
| { type: "form_opened" }
| { type: "booking_confirmed"; booking } // SchedulerBookingSummary
| { type: "booking_cancelled"; reason? }SchedulerBookingSummary is { provider, uri, meetingTime?, meetingEndTime?, inviteeName?, guestEmails?, ownerEmails?, joinLink?, raw? }; uri is the provider's native booking ID, raw the untouched payload.
Supported providers
| Provider | Embed | Booking detection |
|---|---|---|
| Surface | SDK booking widget, styled by default | Widget booking, full sub-step lifecycle |
| Calendly | iframe | postMessage, plus a server-poll fallback that also reports cancellations |
| Cal.com | Vendor script | Booking event |
| SavvyCal | Vendor script | Booking event |
| Chili Piper | iframe | postMessage |
| HubSpot | Vendor script | postMessage |
| RevenueHero | Vendor script | postMessage |
| Zoom | iframe | postMessage, plus a server poll that backfills host email, times, join link |
| Reclaim.ai | iframe | Server poll only |
| Clari | iframe | None; nothing is tracked or persisted, drive navigation yourself |
All postMessage listeners are strictly origin-checked.
Persistence
- A confirmed booking is merged into the scheduler question's answer (
eventScheduled,uri, meeting fields) and saved; a finished form is amended instead. Surface bookings also stampmeta.bookingIdand fire themeeting_bookedad conversion trigger. - A poll-reported cancellation writes
eventCancelled: trueand saves. - Calendly, Zoom, and Reclaim embeds carry the
responseIdin their URL, so mounting them creates the response if none exists. The Surface widget resolves identity lazily at book time; visitors who never book create nothing. - With
preview: true, every write and poll is blocked and the Surface widget books synthetically.
Prefill
Name and email prefill comes from the record's linkNameToStep, linkLastNameToStep, and linkEmailToStep references to earlier answers. Set them at record creation, or pass prefill: { name, email } yourself.
Custom booking UI
Building your own booking interface for a Surface scheduler: mount the unstyled widget directly and write the answer yourself.
import {
createSchedulerClient,
createTransport,
mountBookingWidget,
toSchedulerAnswerState,
} from "@surface-labs/forms-sdk";
const scheduler = createSchedulerClient({ transport: createTransport({ apiBaseUrl }) });
mountBookingWidget({
container: document.querySelector("#booking"),
scheduler,
eventTypeId: "evt_xxx", // from the record's scheduler content
identity: () => ({ responseId: form.state().responseId ?? undefined }),
prefill: { name, email },
onComplete(booking) {
form.setAnswer("q_booking", { ...toSchedulerAnswerState(booking), type: "CalendlyScreen" });
form.submit();
},
});The widget walks date, time, name and email, booked. It injects no CSS; style its stable surface-booking-* class tree. Month, date, and slot elements carry raw values in data-value. toSchedulerAnswerState emits type: "DynamicScheduler"; override type to the record's component type as shown.
A persistent "No availability" means the event type really has no bookable slots (host schedule, maximum advance, no connected host). list_schedulers with includeAvailability: true returns each scheduler's nextAvailableDate.