# Embed the booking widget

# Embed the booking widget

The booking widget is an iframe you drop into any external site. It renders a localized calendar of the company's upcoming **published** events and handles the full booking flow — including the waitlist and widget auth handoff.

## Three ways in

Open **Company Settings → Booking widget tab** (`/companies/{company}/edit?tab=widget` — owner/admin). Pick the path that matches the platform:

- **Share a link** — the direct booking link (`/book/{company}`) works everywhere: nav buttons, bios, emails. Channel links pre-tag UTM source/medium. No code at all.
- **One-line script** *(default)* — a single `<script src="…/api/embed/snippet.js?public_key=bw_pk_…">` tag. It injects the booking button where you paste it and loads the runtime itself. The button opens the calendar as an overlay. Needs a page that runs JavaScript — most platforms do.
- **Inline frame** — an `<iframe src="…/w/bw_pk_…">` tag. `/w/{public_key}` mints the embed session server-side and redirects into `/embed?token=…`, so no host-page JS is required at all — the format that survives script-stripping CMSs (Wix, Squarespace embed blocks, wordpress.com free). Trade-off: the calendar renders inline instead of as a pop-up overlay. When **Allowed domains** is configured the `/w/` URL only works inside pages on those domains — opened as a bare link (email, chat, or a host that sends `referrerpolicy="no-referrer"`) it answers "Widget unavailable".
- **Advanced HTML** — the original `<button>` + inline `<script>` blob. Byte-identical to what older installs already have deployed, but it bakes the current selection into the markup: after changing filters or styling you must re-copy it (the one-line script always serves the saved options).

**Platform guides** in the tab give per-CMS steps (WordPress, Wix/Squarespace, Shopify, custom HTML), and **Check your embed** fetches a page URL you paste and reports whether it finds your public key, the widget runtimes, or a `/book/` link — gated to widget managers, rate-limited, and hardened against private-network fetches.

Hosts that mint embed sessions are remembered (newest first, capped at 20) and offered as one-click **suggestions** above the allowed-auth-domains textarea — they only land in the textarea; nothing persists until you save. Auto-trusting a seen host would be unsafe, so the add is always explicit.

`generateWidgetSnippet()` creates the company's `WidgetIntegration` on first use (public key `bw_pk_…`, secret `bw_sk_…` — the secret is never used client-side). Button fields resolve per-field: event → event type → company → `config/booking-widget.php` defaults. The company layer is edited under **Advanced → Default button styling** — it feeds every widget button (booking, inquiry, voucher). The dashboard welcome page also renders the live button when the company has no bookings yet.

## oEmbed

`/book/{company}` and `/book/{company}/{eventType}` pages advertise `GET /oembed?url=…` via `<link rel="alternate" type="application/json+oembed">`. oEmbed-aware CMSs resolve a pasted booking link into an inline widget iframe automatically. The provider only answers for URLs on this app's own host (`/book/*` and `/w/*`), returns `type: rich` HTML pointing at `/w/{key}`, and clamps `maxwidth`/`maxheight` to 1200/720. Note: CMSs that sandbox untrusted oEmbed providers (WordPress wraps non-whitelisted providers in a sandboxed frame where cookies and forms are blocked) — use the script snippet or the `/w/` iframe URL there instead.

## Filters and styling

Under **Advanced** you can pick **event types** and/or **events** to whitelist what the widget shows (empty = all). A single picked event also deep-links the widget straight into it. Filters flow into every embed format — the script URL, the `/w/` iframe URL, and the raw HTML — so generate once, then copy. Override **button text / classes / style**; the live preview renders the real button.

## How the embed works

1. The snippet's button calls `POST /api/embed/session` with the `public_key` → returns a 15-minute `bw_et_…` token and the iframe URL `/embed?token=…`.
2. The iframe loads `/embed` through the `widget.locale` middleware; `ConfigureEmbedSession` sets `SameSite=None` on HTTPS so the session cookie survives cross-site iframes.
3. If the integration has `allowed_domains`, the `Origin`/`Referer` header must match — the `origin` parameter is never trusted. The app's own origin always passes: the allowlist keeps foreign embedders out, never the host serving the page.
4. When the iframe cannot carry the session cookie (cross-site embed on a non-HTTPS host can't mint `SameSite=None`), `booking/*` pages accept an `embed` + `embed_parent_origin` fallback — validated against the integration's `allowed_domains` (or the app's own origin) before relaxing `frame-ancestors`.

Each successful `/embed` render also stamps the validated host origin into the session with a timestamp. `booking/*` and security headers trust that marker only while it is fresh — `booking-widget.embed_marker_ttl` seconds, six hours by default (the `bw_et_…` iframe token itself still expires after 15 minutes). A marker that is missing, malformed, in the future or stale fails closed: the page falls back to the app's own origin, `?embed=1` alone can only refresh an already-fresh marker — never revive a dead one — and the allowlisted `embed_parent_origin` fallback still applies.

Before rolling out an allowlist requirement, run `php artisan widget:allowlist-audit`: it lists every active, widget-enabled integration whose `allowed_domains` is still empty (exit 1 when any exist, read-only), so you can populate the list first instead of locking live embeds out.

## Direct booking links

- `/api/{company}/direct-booking` — opens the widget straight to a company's events.
- `/api/{eventType}/event-type-booking` — pre-selects an event type.
- Append `?open=1` to any `/book/...` landing URL — the widget overlay opens on page load, no click needed (the "Instant booking link" shown in widget settings).
- `data-area-id` on the snippet button (or `area_id` on `POST /api/embed/session`) — deep-links straight into an area for companies that offer area booking.
- `data-event-type-ids` / `data-event-ids` on the snippet button (or `event_type_ids` / `event_ids` arrays on `POST /api/embed/session`, CSV on `GET /api/embed/snippet`) — comma-separated whitelists: only the listed types/events render in the widget calendar. The singular `data-event-type-id` / `data-event-id` attributes still work; `data-event-id` additionally pins+opens that event.
- `data-cal-display` on the snippet button (or a `cal_display` object on `POST /api/embed/session`) — per-embed display-policy overlay, see below.

## Notify me when dates open

An event type's public page (`/book/{company}/{eventType}`) with no open dates shows a **Notify me** form: email plus an explicit, unchecked marketing-consent box. Submitting upserts the guest (consent receipt recorded, tag `event-interest`) and stores an `event_type_interests` row; the answer is the same whether or not the email was already known. When a date of that type enters *Published* and is bookable, each waiting guest gets **one** announcement email with the booking link (consent re-checked at send time, one-click unsubscribe). The send is claimed atomically via `notified_at`, so repeated publishes never mail the same guest twice; only a fresh opt-in re-arms it. The event type editor shows how many guests are waiting. Guest erasure deletes their interests.

## Attribution

`POST /api/embed/session` already receives the host page URL (`page_url`, sent by `widget.js`). At session mint the five `utm_*` params on that URL are whitelisted, length-capped and stored on the embed session; bookings created inside the iframe inherit them into `bookings.utm` (`{source, medium, campaign, term, content}`). Explicit `utm_*` query params on a `/booking/confirm` URL win over the session copy — they are the freshest hop. Bookings with neither carry `utm = null`. The dashboard **By source** section reports bookings per `bookings.source` (web/widget/staff/walkin…) plus the top `utm_campaign` values, company-scoped.

## Display rules

**Company Settings → Calendar display** (`/companies/{company}/edit?tab=display` — owner/admin) decides what the public calendar lists and how each item reads, per condition. Event conditions: `available`, `sold_out`, `suspended`, `closed`, `draft`, `unavailable` (limits/children), `upcoming` (sales not open), `sales_expired` (sales window closed), `ended`. Seat/time rows take `available`, `sold_out`, `closed`, `unavailable`; areas take the same plus a `closed_day` toggle for days whose whole schedule was removed by closures.

Each rule picks **show/hide**, **opens panel** (`auto` reproduces the old behavior — bookable opens, sold-out opens only with the waitlist on, children-only failures open a read-only panel, everything else stays shut — `always`/`never` override it), and a **badge**: status label, places left, total booked, booked/capacity, percent booked, custom text (`{availables}` `{booked}` `{capacity}` `{from}` `{to}` tokens), or none. `capacity.threshold` hides "places left"-style badges while more than N spots remain; a bookable's own `show_bookable_threshold` still wins.

Rules cascade company → event type → event (each layer overrides per condition) and saving them broadcasts `venue.updated`, so open widgets repaint live. These gates are display-only: hidden items are never serialized or bookable, and nothing here loosens booking authorization, capacity, or archived checks.

A single embed can tailor the policy with `data-cal-display='{"events":{"closed":{"visible":false}}}'` — the overlay merges last in the cascade, so it wins per key it sets. It is whitelist-sanitized: unknown sections, conditions and fields are dropped, `visible` can't be forced on `available`, and it can never bypass tenant, archived, or capacity checks.

## Areas mode

Which calendar the iframe renders depends on the company's booking model:

- **`areas`** companies always get the areas calendar (`/widget/areas`) — pick a day → an area → a `[from, to)` window.
- **Hybrid** companies (`events` + *Allow no-event bookings*) render the unified events calendar, which also offers the "no event" area choice; a `data-area-id` deep link opens the areas calendar directly on that area.
- When at least one area has a drawn shape, both widgets render the venue floor map as the area picker (tappable shapes, undrawn areas on a chip rail); tables placed on the floor paint as decoration. With nothing drawn, customers see the plain area list. Inside seat-based event bookings the same map also serves as the position picker — seats paint their parent area's shape — including on events-only companies that enable `area_map_picker`.

See [Area booking](/docs/venue/area-booking) for the area-level settings that drive it.

## Sellable vouchers

Companies can sell gift vouchers from their own site — **Company Settings → Vouchers tab** (`?tab=vouchers`) configures preset amounts, the custom-amount range, validity days, the terms link and shows the purchase history. Enabling the shop unlocks the voucher mode on the unified embed builder (**Booking widget tab**, mode picker → *Gift vouchers*): a `data-promo-voucher` button that opens the voucher shop in the same overlay (`POST /api/embed/session` with `mode: 'voucher'`). Button text/classes/style persist on the integration's meta under `buttons.voucher` — empty fields inherit the company default button styling.

The buyer picks an amount, enters their email, optionally sends it as a gift (recipient email + message), and pays through the company's connected Stripe account — same destination-charge mechanics as bookings. On `checkout.session.completed` the system mints a whitelisted `PromoCode` (`source='purchase'`, `balance` = amount) and emails it to the buyer or the gift recipient.

Voucher codes are **multi-use**: each redemption decrements `balance` under the promo-row lock, so leftover value keeps working on later bookings until it runs out. Expired/refunded purchase → `status='expired'`/`'refunded'`; a full Stripe refund deactivates the minted code.

**Targeted vouchers**: picking events or event types in the builder's voucher mode bakes `data-event-id`/`data-event-ids` into the voucher button — every voucher bought through it is then locked to that selection. The shop shows the restriction before payment, the minted code refuses any other booking (hard validation error for bookers AND staff approvals), and the delivery mail states the scope. `data-area-id` is ignored for targeting. The same pickers exist on the promo-code editor for manual codes.

**Resend + printable card**: each paid row in the purchase history has a **Resend** action that re-delivers the code mail to the holder (gift recipient when set, buyer otherwise). The delivery mail also links to a branded printable gift card at `GET /voucher/{code}/card` — logo, amount, remaining balance, target and expiry, with a print/"save as PDF" button. The code itself is the credential for that page (it already travels in the mail); the route is rate-limited and only purchased vouchers resolve — staff promo codes 404.

## Lead-gen widgets: inquiry & capture

The mode picker on the **Booking widget tab** also generates the lead-gen surfaces — same embed formats (script / iframe / HTML), same session pipeline, same origin allowlist:

- **Private-event inquiry** (`mode: 'inquiry'`, `data-inquiry` trigger) opens the company's lead form inside `/embed` with `source=widget` stamped at mount. Calendar params (`event_id`, `area_id`, `cal_display`, …) are ignored. Leads land in the [lead inbox](/docs/venue/leads).
- **Email capture** (`mode: 'capture'`, `data-capture` trigger) opens the consent signup form inside `/embed`. Signups upsert the guestbook with first-consent-wins semantics.

Both forms also keep their bare public URLs — `/inquiry/{company}` and `/capture/{company}` — for direct links. Once `allowed_domains` is configured those pages can only be *framed* by allowlisted sites (`frame-ancestors`); with no list configured they stay openly embeddable as before.

## Per-mode kill switches

Under **Advanced → Widget surfaces** each mode has a toggle. Switching one off writes it to the integration's `meta.disabled_modes` and every entrypoint refuses it instantly — session mints, `snippet`/`snippet.js`, `/w/` redirects, live `/embed` renders, and the bare public pages. Other modes are unaffected; toggling back restores service immediately.

## Channel booking links

The booking widget tab also lists **channel deep links**: ready-to-paste booking URLs pre-tagged with UTM source/medium so the [source attribution](/docs/bookings/manage-bookings) on each booking splits traffic by channel — Google Business Profile, Instagram, Facebook, plus a custom-source box that slugifies whatever you type. An optional campaign tag applies to every row. Copy the link for Google Business Profile's "Book online" field, Instagram's bio/"Reserve" action, or a Facebook "Book Now" button and bookings arriving through it carry the source automatically — no embed needed.

## Inside the widget

Customers see events that are published, inside their booking window and not sold out (sold-out ones offer the waitlist when enabled). Language and theme can be switched inside the iframe. Events of **private** companies are bookable by company members only.

## Permissions

Snippet management is owner/admin via company settings. The widget itself is public — anyone with the embed URL can browse and book (subject to the company's `private` flag). A user signed in to a **different** company sees the widget exactly like an anonymous visitor — no staff permissions, no force-booking — and their active company is never switched.


---
> Source: https://placeful-app.com/en/docs/widget/embed-widget
