Now liveBe one of the first 50 founders of Malleable. Founding members keep a lifetime discount.

Documentation

Embed SDK & Booking Pages

Creating a Booking Page

A booking page is your public scheduling surface, the Malleable equivalent of a Calendly link. Open /booking/new to walk through the five-step wizard: connect Google Calendar, name the page, customize branding, optionally attach an AI agent, and grab your embed code.

What gets created

The wizard POSTs to /api/booking/pages with the slug, title, description, welcome message, brand color, and an optional bucket_id. Linking a bucket means every guest who books is auto-added to that bucket's CRM contacts. A profile photo can be uploaded afterwards via POST /api/booking/pages/<id>/photo (PNG, JPG, or WebP, max 2 MB).

Required fields

Only the page title and URL slug are mandatory. The slug must be lowercase letters, numbers, and hyphens, and the wizard auto-generates it from the title and re-slugifies as you type. Your public URL ends up as https://malleable.cloud/book/<slug>.

Calendar style

Step 3 lets you pick how visitors land on the calendar: floating widget (bottom-right popup), inline embed (rendered inside a host page), or full page (just share the link). Theme is light or dark; the brand color drives the today-cell, accent dots, and primary buttons across both the hosted page and the embedded widget.

Setting Availability

Availability is computed live against your connected Google Calendar, there is no separate "working hours" UI to keep in sync. When a guest opens your page, the widget calls GET /api/booking/pages/<slug>/availability which runs the server-side calculateAvailability helper: it fetches busy blocks from Google Calendar, subtracts them from the meeting type's working window, and returns slots in the guest's timezone.

Meeting types (event types)

A booking page can host multiple meeting types, and each one has a name, description, color, duration_minutes, and a location type (Google Meet, phone, in-person, etc). Guests pick a meeting type first, then a slot. If you only publish one meeting type, the widget skips the picker step.

Query parameters

GET /api/booking/pages/<slug>/availability
  ?event_type_id=<uuid>          # required
  &start_date=2026-05-03          # optional, defaults to today
  &end_date=2026-05-17            # optional, defaults to +14 days
  &timezone=America/Chicago       # optional, guest's IANA tz

The endpoint is rate-limited to 20 requests per minute per IP. Bookings themselves go through POST /api/booking/pages/<slug>/book, which is rate-limited to 3 successful bookings per hour per IP to keep spammers from filling your calendar.

Time-zone handling

All slots are stored as UTC scheduled_at ISO-8601 timestamps. The guest's browser timezone is auto-detected by getBrowserTimezone() in the SDK and forwarded to the API, so the times the guest sees are always rendered in their local zone, and you keep working in yours.

Sharing your Booking Link

After the wizard finishes you get two things on the final step: the public URL and the embed snippet. Either one is enough to start taking bookings.

Public link

https://malleable.cloud/book/<slug>

That route renders the full hosted booking flow at app/book/[slug]/page.tsx: meeting-type picker, time slot grid, guest details form, and confirmation. Use it directly in email signatures or DMs when you don't need the widget.

Embed snippet

<script
  src="https://malleable.cloud/embed.js"
  data-slug="your-booking-slug"
  data-dark-mode="true"
  defer
></script>

Drop that into the <body> of any site. The loader script (public/embed.js) is the bundled SDK output, it auto-initializes from the script's data-* attributes when a data-slug is present.

Manage existing pages

Visit /booking to list, edit, or delete pages you've created. The wizard at /booking/new always creates a new page; edits use the per-page settings panel.

Installing the Embed Widget

The supported way to ship the widget today is a one-line script tag (zero build step), which loads the bundled SDK from https://malleable.cloud/embed.js. The widget renders the same Preact component under the hood, with inline CSS so nothing leaks into the host site. A standalone npm package is planned but not yet published (see the note below).

Script tag (the supported path)

<script
  src="https://malleable.cloud/embed.js"
  data-slug="your-booking-slug"
  data-type="chatbot"
  data-position="bottom-right"
  data-greeting="Hi — what can I help you book?"
  data-primary-color="#f97316"
  data-dark-mode="true"
  data-branding="true"
  data-debug="false"
  async
></script>

Recognized data attributes: data-slug (required), data-type (chatbot or omitted for the calendar widget), data-position, data-greeting, data-primary-color, data-dark-mode, data-branding, data-debug, and data-api-url (override for self-hosted Malleable).

NPM package (coming soon, not yet on npm)

A standalone @malleable/embed-sdk package is planned but is not yet published to npm, so npm install @malleable/embed-sdk will 404 today. Use the script tag above for now. When the package ships it will expose the Malleable namespace, the lower-level MalleableApiClient, the TabWidget React component, the darkTheme / lightTheme presets, and date/locale helpers like formatTime, getBrowserTimezone, and mergeLocaleConfig, with subpath imports @malleable/embed-sdk/calendar and @malleable/embed-sdk/chat to bundle just one widget.

Initializing the Widget

The import-based examples below use the @malleable/embed-sdk package, which is not yet published to npm. They show the planned API surface; until the package ships, use the script-tag embed above. When you import the SDK directly, you call Malleable.init() with your config. slug is the only required field; everything else has sensible defaults.

Minimal example

import { Malleable } from '@malleable/embed-sdk';

Malleable.init({
  slug: 'your-booking-slug',
  widget: 'calendar',          // 'calendar' | 'chatbot' | 'both'
  position: 'bottom-right',    // 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
  greeting: 'Hi — what can I help you book?',
  theme: {
    primaryColor: '#f97316',
    darkMode: true,
  },
  host: { name: 'Ryan', avatar: 'https://...' },
  prefill: {
    name: 'Acme Corp',
    email: 'lead@acme.com',
    customFields: { utm_source: 'docs' },
  },
  debug: false,
});

Imperative controls

Once initialized, the global Malleable object exposes open(), close(), prefill(data), sendMessage(text), on(event, callback), destroy(), isInitialized(), and getVersion(). Calling init() a second time auto-destroys the previous instance, so it's safe to call on route changes in SPAs.

React entry points

import { TabWidget } from '@malleable/embed-sdk';

<TabWidget
  calendarConfig={{
    apiBaseUrl: 'https://malleable.cloud',
    bookingPageSlug: 'your-slug',
  }}
  enableAgent
  agentConfig={{ botName: 'Ryan', greeting: 'Ask me anything.' }}
/>

Per-Contact Branding & Configuration

When you ship the widget into a client's site, you often want it to look like their brand, not yours. Per-contact embed configs let you override branding and routing on a per-customer basis without spinning up a new booking page. The data lives in the customer_embed_configs table and is edited from the Embed panel inside any contact's slide-over on /contacts.

What you can override

Each row keys off contact_id and stores:

{
  brand_color:           string | null,   // hex, drives accent across widget
  logo_url:              string | null,   // shown in header
  greeting:              string | null,   // first chatbot line for this contact
  allowed_origin:        string | null,   // origin lock — widget rejected elsewhere
  callback_webhook_url:  string | null,   // per-contact override of profile webhook
  has_secret:            boolean          // whether a signing secret is set
}

API surface

GET    /api/contacts/<contactId>/embed-config
PATCH  /api/contacts/<contactId>/embed-config

// PATCH body — all fields optional, send only what changes
{
  "brand_color": "#0ea5e9",
  "logo_url": "https://acme.com/logo.svg",
  "greeting": "Welcome to Acme — book a call below.",
  "allowed_origin": "https://acme.com",
  "callback_webhook_url": "https://acme.com/hooks/malleable",
  "rotate_secret": true
}

The callback_webhook_secret is reveal-once: when you set rotate_secret: true, the response includes the newwhsec_* string a single time. Save it, subsequent GETs only return has_secret. PATCH is CSRF-protected and only succeeds for contacts owned by the authenticated user.

Embed Webhook Overrides

Every successful embed booking can fire a signed HTTPS webhook to your own backend, push leads into HubSpot, ping a Slack channel, kick off an onboarding flow. There are two layers: a profile-level default and per-contact overrides.

Profile-level default

Configure your default URL + secret at /settings/embed/webhook. Behind it lives /api/profile/embed-webhook:

GET    /api/profile/embed-webhook
  → { embed_webhook_url, has_secret }

PATCH  /api/profile/embed-webhook
  body: { embed_webhook_url?: string | null, rotate_secret?: boolean }
  → { embed_webhook_url, has_secret, rotated_secret? }

URLs must start with http:// or https://. Setting rotate_secret: true generates a fresh whsec_* token and returns it once in rotated_secret, this is the only chance to copy it.

Per-contact override

If a contact has its own callback_webhook_url on their embed config (see the previous section), bookings made through that contact's embedded widget hit the override URL with that contact's secret instead of the profile default. This is how you let agency clients receive their own copy of leads while you keep the master ledger in Malleable.

Signing

Webhook deliveries are HMAC-signed by the dispatcher in lib/webhooks/dispatch.ts, queued in webhook_deliveries, and drained by a worker so a slow consumer never blocks a booking. Verify by recomputing HMAC-SHA256(secret, raw_body) and comparing in constant time on your end.

JavaScript Events from the Widget

The widget surfaces lifecycle and booking events through two mechanisms: the Malleable.on() registrar (works for both script-tag and npm callers) and postMessage events (useful when the widget is rendered inside an iframe).

Subscribing via Malleable.on()

Malleable.on('open',           () => console.log('opened'));
Malleable.on('close',          () => console.log('closed'));
Malleable.on('slotSelected',   (slot)    => console.log('picked', slot));
Malleable.on('bookingCreated', (booking) => console.log('booked', booking));
Malleable.on('message',        (msg)     => console.log('chat', msg));
Malleable.on('error',          (err)     => console.error(err));

You can also pass these as an on object on the original init() config, under the hood Malleable.on() just rewrites that record. The fullEventCallbacks shape includes onOpen, onClose, onMessage, onSlotSelected, onBookingCreated, onAvailabilityLoaded, and onError.

postMessage events (iframe mode)

window.addEventListener('message', (e) => {
  switch (e.data?.type) {
    case 'MALLEABLE_READY':           break;
    case 'MALLEABLE_OPEN':            break;
    case 'MALLEABLE_CLOSE':           break;
    case 'MALLEABLE_SLOT_SELECTED':   /* e.data.slot */    break;
    case 'MALLEABLE_BOOKING_CREATED': /* e.data.booking */ break;
    case 'MALLEABLE_ERROR':           /* e.data.error */   break;
  }
});

Booking confirmation payload

The bookingCreated callback (and the matching MALLEABLE_BOOKING_CREATED postMessage) both deliver the same shape:

{
  booking: {
    id: string,
    status: string,
    location_details: string,
    scheduled_at: string  // ISO 8601 UTC
  },
  event: {
    id: string,
    start_time: string,
    end_time: string
  },
  guest: { name: string, email: string, phone?: string, notes?: string }
}

Use it to fire a pixel, redirect to a thank-you page, or hand the booking off to a downstream tool while the signed webhook (above) lands in your backend in parallel.