Forms SDK

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

OptionDefaultWhat it does
formrequiredThe init handle; config, transport, identity, and persistence come from it.
containerrequiredThe embed mounts here; teardown removes it.
questionIdautoOnly needed with more than one scheduler question (the error names the candidates). stepId works too.
styledtrueSurface provider: injects a stylesheet scoped under .surface-scheduler on this container. Override with --surface-scheduler-* custom properties; false keeps everything bare.
persisttrueAuto-persist the booking into the response. false still emits events, never writes.
prefillfrom record{ name?, lastName?, email? } overrides the record's linked answers.
formatterslocaleSurface provider only: { month?, date?, time? } label formatters.
calendarIdautoDynamicScheduler with multiple calendars: pick one by name. Ambiguous routing throws.
onEventnoneSame events as form.on("scheduler", ...).
onCompletenoneFires 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

ProviderEmbedBooking detection
SurfaceSDK booking widget, styled by defaultWidget booking, full sub-step lifecycle
CalendlyiframepostMessage, plus a server-poll fallback that also reports cancellations
Cal.comVendor scriptBooking event
SavvyCalVendor scriptBooking event
Chili PiperiframepostMessage
HubSpotVendor scriptpostMessage
RevenueHeroVendor scriptpostMessage
ZoomiframepostMessage, plus a server poll that backfills host email, times, join link
Reclaim.aiiframeServer poll only
ClariiframeNone; 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 stamp meta.bookingId and fire the meeting_booked ad conversion trigger.
  • A poll-reported cancellation writes eventCancelled: true and saves.
  • Calendly, Zoom, and Reclaim embeds carry the responseId in 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.

On this page