How to embed a booking system into your web app

"Can we add booking to the site?" is one of those requests that sounds like a script tag and turns into a sprint. Sometimes it genuinely is a script tag. Sometimes the script tag is the thing that makes the project fail six weeks later, when the client asks why the calendar looks nothing like the rest of the site and why Safari users keep losing their basket.
There are four real ways to put booking inside an existing website or web app, and they're not points on a single scale — they trade different things away. This is a walk through all four with the code, plus the failure modes I've watched teams hit with each one. I build Opencals, a booking API and platform, so the API section uses it; the first three approaches are vendor-agnostic and the caveats apply whichever tool you're embedding.
Pick the embed approach by what has to match your brand and what has to survive a browser update. Linking out is the most reliable and the least yours. An iframe is fast but constrained by the vendor's CSP and by third-party cookie rules. A vendor widget looks native until you need it to behave differently. Building on a booking API costs the most upfront and is the only option that gives you full control of the UI and the data.
The four approaches at a glance
| Approach | Time to ship | Design control | Main risk |
|---|---|---|---|
| Link out to a hosted booking page | An hour | None beyond vendor theming | Customers leave your site mid-funnel |
| Iframe the hosted page | A day | Low | Vendor CSP, iframe height, third-party cookies |
| Vendor JavaScript widget | A day or two | Medium | You inherit the vendor's UX and bundle |
| Build on a booking API / SDK | One to three weeks | Total | You own the frontend forever |
Two thirds of the projects I see should use approach 1 or 2 and don't, because "embedded" sounds more professional than "link". The other third try approach 2 for something that genuinely needed approach 4, and spend the difference in workarounds.
Approach 1 — link out to a hosted booking page
The vendor hosts the booking flow on their domain or a subdomain of yours. You link to it. That's the whole integration.
<a
href="https://yourshop.opencals.com/book?service=beard-trim&staff=marek"
class="btn btn-primary"
>
Book an appointment
</a>Most hosted booking pages accept query parameters to preselect a service, a staff member, a location, or a date. Use them. A link that lands on a generic service list converts noticeably worse than one that lands on the specific service the customer was just reading about — you already know what they clicked, so pass it along.
If the vendor supports a return URL, set it, so a completed booking sends the customer back to a page you control and you can fire your own conversion event there.
Pros
- Nothing to maintain — the vendor ships fixes, you get them
- No CSP, cookie, or iframe-height problems at all
- Works identically on every browser and every screen size
- Deep links let you preselect service, staff, and location
Cons
- The customer leaves your domain in the middle of the funnel
- Branding is limited to whatever theming the vendor exposes
- Analytics needs cross-domain setup or you lose attribution
- The booking page is rented — it isn't on your domain and doesn't leave with you
For a marketing site whose job is to get someone to book, this is very often the correct answer, and the "unprofessional" objection is mostly imaginary. Customers hand their card details to Stripe-hosted checkouts on a different domain every day.
Approach 2 — iframe the hosted page
An iframe keeps the customer on your page while the vendor renders the flow. It's a genuine middle ground, and it has three specific traps.
<div class="booking-frame">
<iframe
src="https://yourshop.opencals.com/book"
title="Book an appointment"
loading="lazy"
allow="payment"
></iframe>
</div>
<style>
.booking-frame {
position: relative;
width: 100%;
min-height: 720px;
}
.booking-frame iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
</style>Trap one: the vendor's CSP decides whether this works at all. Any booking page worth using sets a frame-ancestors directive so it can't be silently framed by a phishing site. If your domain isn't in that list, your iframe renders a blank box and a console error, and no amount of CSS fixes it. The Opencals storefront works this way — framing is allowlisted per domain rather than open by default, so you ask for your domain to be added before you build against it. Check this on day one with any vendor, not after the layout is done.
Trap two: height. A booking flow changes height as the customer moves from service list to calendar to checkout. A fixed-height iframe either clips the checkout or leaves 400px of dead space on the service list. The clean fix is postMessage — if the vendor emits resize events, listen for them:
window.addEventListener("message", (event) => {
if (event.origin !== "https://yourshop.opencals.com") return;
if (event.data?.type !== "resize") return;
document.querySelector(".booking-frame").style.minHeight =
`${event.data.height}px`;
});Always check event.origin. A message listener that trusts any sender is a real vulnerability, not a lint warning. If the vendor doesn't emit resize events, set a generous min-height and accept the whitespace — polling scrollHeight across origins is not possible, and the hacks that claim otherwise don't work anymore.
Trap three: third-party context. Inside an iframe, the vendor's cookies are third-party cookies. Safari's tracking prevention has blocked those for years, and Chrome's rules keep tightening. Vendors that depend on a session cookie to hold a basket across steps can drop bookings in the middle of checkout, and it reproduces only in the browsers you don't develop in. Test a full booking in Safari on an actual iPhone before you ship. Not the simulator, and not desktop Safari with default settings.
Test the iframe in Safari, on a phone, in a private window
Every iframe embed bug report I've seen started with "it works fine on my machine". Third-party storage restrictions, mobile keyboards resizing the viewport, and date pickers that expect the full screen all show up on a real phone and nowhere else. Ten minutes of testing here saves a support thread that runs for a month.
Approach 3 — a vendor JavaScript widget
A widget is a script the vendor gives you that mounts their UI into a container in your page. Calendly, Acuity and most appointment tools ship one; the typical shape is a container plus an init call.
<div id="booking"></div>
<script src="https://vendor.example.com/widget.js" defer></script>
<script>
window.addEventListener("load", () => {
VendorBooking.mount("#booking", {
shop: "yourshop",
service: "beard-trim",
theme: "light",
});
});
</script>Under the hood, most widgets are still an iframe — they just manage its height and lifecycle for you, which removes trap two above but not traps one and three. Some render directly into your DOM, which is better for styling and worse for isolation: their CSS reset and yours will disagree about something eventually.
The real cost of a widget is that you inherit the vendor's product decisions. If their flow asks for a phone number and your client insists it's optional, you can't change it. If they load 180KB of JavaScript on a page that had a 40KB budget, that's now your Lighthouse score.
Worth being straight about this one: Opencals doesn't ship a universal drop-in script for arbitrary websites today. The widget exists inside the Shopify integration, where it installs as a theme block and inherits your Shopify theme. Everywhere else, the supported paths are the hosted storefront (approaches 1 and 2, with design customization for colours, fonts and logo) or the API. If a one-line script for any website is your hard requirement, a vendor that ships one is the better fit and I'd rather say so than sell you a workaround.
Approach 4 — build the flow on a booking API
The last option isn't embedding someone else's UI at all. You call a booking API for availability, cart, and payment, and you write the frontend yourself. Everything the customer sees is your code, on your domain, in your component library.
This is the headless approach, and it's the right call when the booking flow is part of the product rather than a page on a brochure site — when it needs your auth, your design system, or your own multi-step logic.
With Opencals, install the typed SDK and keep the key server-side:
npm i @opencals/storefront-sdk// lib/opencals.ts
import { setupOpencals } from "@opencals/storefront-sdk";
export const opencals = setupOpencals({
apiKey: process.env.OPENCALS_API_KEY!, // sfk_live_...
});Then the flow is three calls: list what's bookable, ask for real availability, create the booking.
import { ProductService, AvailabilityService } from "@opencals/storefront-sdk";
import { opencals } from "@/lib/opencals";
// 1. What can be booked
const { data: services } = await ProductService.listProducts({ opencals });
// 2. When is it actually free — computed from staff hours,
// existing bookings, service duration and buffers
const { data: slots } = await AvailabilityService.getAvailability({
opencals,
productId: services[0].id,
from: "2026-08-18",
to: "2026-08-25",
});A storefront key can create real bookings and take payments, so it belongs in a Route Handler, a Server Action, or your own backend — never in a client bundle. Same rule as a Stripe secret key.
The availability call is the one that justifies the whole approach. Computing which slots are genuinely open means resolving staff schedules, service durations, buffers, existing bookings, group capacity and location hours together, and getting it race-safe so two customers can't hold the same slot. That's the part teams underestimate when they decide to build booking from scratch, and the part you're buying when you call an API instead.
For a complete walkthrough with the cart, checkout and confirmation screens wired up, see adding booking to a Next.js app with a REST API. The booking SDK reference covers the typed methods, and the booking API guide covers what to look for in any scheduling API, not just this one.
Pros
- Complete control of the UI, the copy, and the number of steps
- Booking lives on your domain, inside your auth and your analytics
- One integration can serve web, mobile, and a Shopify store
- No iframe, no third-party cookies, no CSP negotiation
Cons
- One to three weeks of frontend work, not an afternoon
- You own every bug in the booking UI from then on
- Overkill for a brochure site whose only goal is a booked slot
- Needs a developer available for changes the client used to self-serve
How to choose
Does the booking flow need your auth or your data?
If a logged-in customer should see their own history, pricing, or credits, an embedded vendor flow can't know who they are. Go to the API approach.
Does the design have to be exactly yours?
Not 'on-brand' — exactly yours, down to spacing and typography. If a themable vendor UI is acceptable, stop at the hosted page or a widget and save two weeks.
Who maintains it in a year?
If the client changes their own services and hours and has no developer, a hosted page they can configure themselves beats a custom frontend that needs a ticket for every change.
Check the vendor's frame-ancestors policy before designing
Framing an external booking page is only possible if the vendor allowlists your domain. Ask first — it decides whether approach 2 is on the table at all.
Test the whole flow on a real phone in Safari
Third-party storage, viewport resize on keyboard open, and native date pickers behave differently there. This is where embedded flows actually fail.
Decide where the conversion event fires
Whatever you choose, make sure a completed booking lands somewhere your analytics can see it, whether that's a return URL, a postMessage event, or your own confirmation route.
Where Opencals fits
Opencals is a booking and service-commerce platform with a public Storefront API, so it covers approaches 1, 2 and 4 directly, and approach 3 only inside Shopify.
If you want the fastest path: a hosted storefront on yourshop.opencals.com, styled with your colours, fonts and logo, linked from your site with deep links per service. If you want it framed inside your own page, that works once your domain is allowlisted. If you're an agency or a product team that needs the booking UI to be genuinely yours, the Storefront API and SDK give you the availability engine, cart, payments and post-booking management, and you build the rest — the MIT-licensed Next.js templates are a working starting point rather than a demo.
Pricing is $0.99 per completed booking or a custom monthly plan from $15, with no per-seat charge, which matters if you're an agency running this for several clients rather than one business.
Frequently Asked Questions
Add booking to a Next.js app
The full API build, with cart and checkout code you can copy.
What is a headless booking system?
When the API approach is worth the extra weeks — and when it isn't.
How to build a booking website
The three approaches compared: SaaS, Shopify app, and headless API.
Opencals for developers
Storefront API, typed TypeScript SDK, and open templates.
The short version
If the booking flow is a page on a marketing site, link to a hosted booking page and spend the saved time on the copy around it. If it has to stay visually inside your page and the vendor allowlists your domain, iframe it — and test it on a phone in Safari before you call it done. If booking is part of your product, build it on an API and accept that you now own the frontend.
The mistake worth avoiding is choosing approach 2 for a job that needed approach 4, then spending three weeks fighting postMessage and cookie policies to get 60% of what a proper API build would have given you in the same time. If you're not sure which side of that line you're on, get in touch — it's usually a ten-minute conversation.
Early Access — 3 Months Free
Ready to transform your service business?
Join 150+ businesses already using Opencals. Get 3 months completely free with all features unlocked.