Supabase booking system: plug in a booking engine instead of building one

The question usually arrives after the first demo works. Someone has built an app in Lovable or Bolt, Supabase is already the backend, there's a bookings table with a start time in it, and the calendar looks right. Then a friend books the same 10:30 as their mum, both get a confirmation, and the question becomes: how do I make this real?
My answer is to stop treating the booking table as something you have to build. Supabase is very good at the parts of your app that are yours: sign-in, profiles, your own data, file storage. Availability across staff and opening hours, slot holds, checkout, refunds, change-and-cancel links and reminder emails are a different product, and that's the one we build at Opencals. So the setup I'd recommend is both, wired together: Supabase for your app, the Opencals Storefront API for the bookings. You'd do the same with Stripe for payments rather than writing your own card processing.
The fastest way to a real Supabase booking system is to keep Supabase for users and app data and connect a booking API for the bookings. With Opencals, the browser uses a public sfk_ key for availability, cart and checkout; a Supabase Edge Function holds the private spk_ key for server-side calls; and a small Supabase table links each Opencals order to a Supabase user under RLS. You get double-booking protection, holds, Stripe checkout, refunds, reschedule links and reminders without writing them. You can hand-roll all of it in Postgres instead, and the second half of this article shows how, but expect weeks, not days.
Supabase keeps your users, Opencals keeps the calendar
The split is simple once you draw it. Everything a customer or a staff member is lives in Supabase. Everything about time lives in Opencals.
| Job | Where it lives | Why |
|---|---|---|
| Sign-in, profiles, your app's own tables | Supabase | It's your product data, under your RLS policies |
| Services, staff, schedules, locations | Opencals dashboard | The merchant edits hours and prices without a deploy |
| Availability and double-booking protection | Opencals API | Computed per staff, location and booking length, server-side |
| Cart holds and checkout | Opencals API | Carts expire on their own; payment goes to the merchant's own Stripe account |
| Refunds, reschedule and cancel links | Opencals | Inside the merchant's notice rules, from the confirmation email |
| Confirmation and reminder emails | Opencals | Sent for you, nothing to schedule |
| Which of your users made which booking | Supabase | A small table of order references, one row per booking |
Nothing in that table needs Supabase to give anything up. Your auth stays Supabase Auth, your database stays your database, and Opencals never sees your tables.
Two keys, and which side of the app each one goes on
Opencals has two kinds of storefront key, and the SDK's README is explicit about where each belongs. A public key starting sfk_ is meant to be visible in browser code, and you restrict it to your own domains with allowed origins in the dashboard. A private key starting spk_ is secret and belongs on a server only.
In a Supabase app that maps neatly:
# Frontend env (Vite / Next.js / Lovable): public, restricted to your domain
VITE_OPENCALS_KEY=sfk_...
# Supabase Edge Function secret: private, never shipped to the browser
supabase secrets set OPENCALS_API_KEY=spk_...Create both in the Opencals dashboard under your user's API keys. On a free development store they work against seeded sample businesses in test mode, so you can build the whole flow before anyone has a card out.
The booking flow runs in the browser
The customer-facing part is four calls with the Storefront SDK, @opencals/storefront-sdk (MIT, 0.3.19 on npm as of 27 October 2026). These are the calls from the SDK's own README:
import {
setupOpencals, ProductService, AppointmentService,
CartService, CheckoutService,
} from '@opencals/storefront-sdk';
setupOpencals({ apiKey: import.meta.env.VITE_OPENCALS_KEY }); // sfk_
// 1. What's free on the day the customer picked
const { data: slots } = await ProductService.getCurrentAvailabilities({
path: { productId },
query: { date: '2026-11-12', staffMemberId },
});
// 2. Reserve the chosen slot
const { data: appointment } = await AppointmentService.create({
body: {
slot: { productId, fromDate: '2026-11-12', fromTime: '10:30:00',
toDate: '2026-11-12', toTime: '11:30:00' },
numberOfAttendees: 1,
},
});
// 3. Put it in a cart, which holds it while they pay
await CartService.createOrGet();
await CartService.addItem({ body: { appointmentId: appointment.id } });
// 4. Check out with Stripe
await CheckoutService.start({
body: { provider: 'stripe', customer: { email: user.email } },
});
await CheckoutService.submit();Two customers racing for the same 10:30 is now Opencals' problem, not yours. The second request is refused instead of producing a duplicate confirmation, and your UI shows the next free times. The cart expires on its own if they abandon checkout, and there's an extendExpiration call for when they've started paying and need a few more minutes.
If the user is signed in with Supabase, pass their email from supabase.auth.getUser() into checkout so the booking lands under the same address they use in your app.
Server-side calls go through an Edge Function
Some calls shouldn't come from the browser: an availability check your server renders into a page, an AI agent asking for the next free slot, or an admin view that lists today's bookings. Those go through a Supabase Edge Function with the private key:
// supabase/functions/availability/index.ts
import { setupOpencals, ProductService } from 'npm:@opencals/storefront-sdk@0.3.19';
setupOpencals({ apiKey: Deno.env.get('OPENCALS_API_KEY')! }); // spk_
Deno.serve(async (req) => {
const { productId, date } = await req.json();
const { data } = await ProductService.getCurrentAvailabilities({
path: { productId },
query: { date },
});
return Response.json(data);
});With a private key you can also forward the buyer's IP through the SDK's buyerIp option, so rate limits and abuse protection count each visitor separately instead of treating your Edge Function as one very busy customer. The README covers the header it uses.
Link each booking to a Supabase user
The last piece is remembering which of your users booked what, so your app can show "your upcoming sessions" next to everything else it knows about them. That's one table and one policy:
create table public.booking_ref (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
opencals_order text not null unique,
starts_at timestamptz not null,
created_at timestamptz not null default now()
);
alter table public.booking_ref enable row level security;
create policy "own bookings" on public.booking_ref
for all using (user_id = auth.uid()) with check (user_id = auth.uid());Write the row when checkout succeeds. Treat it as a pointer, not the record: the booking itself, its status and its payment stay in Opencals, and your app reads the current state from the API when it needs it. That keeps one source of truth and means a customer who reschedules from their email doesn't leave your table claiming the old time.
If you're building in Lovable, you don't have to write any of this by hand. The Lovable setup guide connects Opencals as a connector, and Lovable generates the same flow on top of its Supabase backend.
If you'd rather hand-roll the bookings in Postgres
Some people want every line in their own database, and that's a fair requirement. Supabase gives you the parts. Here's the honest version of what it takes.
The constraint. Store each booking as a tstzrange and let Postgres refuse overlaps. Supabase lists btree_gist among its available extensions:
create extension if not exists btree_gist;
alter table public.booking
add constraint no_overlap
exclude using gist (resource_id with =, during with &&)
where (status <> 'cancelled');The full concurrency argument, including the 23P01 error and the payment race, is in the double-booking system design article.
Privacy. The Supabase publishable key ships in every browser. Supabase's docs tell you to enable RLS on every table in an exposed schema and warn that views bypass RLS by default unless you set security_invoker = true. A booking table full of emails should have no anonymous policies at all, with reads and writes going through Edge Functions.
Holds. An unpaid hold has to expire, and a constraint can't use now(), so you sweep with pg_cron. Supabase Cron runs jobs from every second to once a year and recommends no more than eight jobs at once, each under ten minutes.
Payment. Confirm only from a Stripe webhook in an Edge Function. Supabase's example reads the raw body, verifies with constructEventAsync, and deploys with verify_jwt = false. Duplicate events and payments that land after the hold expired are yours to handle.
Emails. Supabase's built-in SMTP only delivers to your project team, at two messages an hour, and isn't meant for production, so confirmations and reminders need a provider such as Resend or Postmark and another cron job.
The free plan pauses. Supabase's pricing page says free projects pause after one week of inactivity. A booking site with a quiet week then can't load availability. Budget for Pro, from $25 a month (checked 27 October 2026), before real customers book. That applies to both setups, by the way: if your Supabase app is paused, the booking page inside it is too.
Add it up and the first four take a few days if you know Postgres. Multi-staff availability with shifts, breaks and daylight saving, deposits and partial refunds, guest change-and-cancel links with repricing, and reminders that never silently fail are the weeks. That's the part I'd hand to an API, and it's the reason the first half of this article exists.
What isn't there yet
Three limits that change how you'd design the Opencals setup:
- Webhooks aren't live yet. Opencals can't push "booking changed" into a Supabase table today. Keep
booking_refas a pointer and read status from the API, or poll from a scheduled Edge Function if you need a local copy. When webhooks ship, this becomes a one-function job. - The engine is hosted, not open source. The SDK and the Next.js templates are MIT. The booking engine runs on our servers.
- The free development store runs in test mode. Every endpoint works and the sample data is real enough to test races with two browser tabs, but it can't take real money until you switch to a live store.
Pricing, from opencals.com/pricing on 27 October 2026: $0.99 per booking with no monthly fee on Elastic, or $14.98 a month including 300 bookings and $0.20 each after that, with features as add-ons.
If you're starting today, create a development store, put the sfk_ key in your frontend and the spk_ key in a Supabase secret, and get one real booking through test checkout before you write anything else. You'll know within an hour whether the booking side is solved, and everything left on your list will be the parts of the app that are actually yours.
Lovable + Opencals
The connector setup for Lovable apps, which run on Supabase.
The Storefront SDK
Typed TypeScript client for availability, carts, checkout and accounts.
Prevent double booking
The database design if you hand-roll bookings in Postgres.
Build a booking site with AI
Lovable, v0, Base44 and coding agents, all on one booking API.
Opencals for developers
API reference, SDK, agent skills and the free development store.
Booking systems for developers
What a booking system asks of you after launch.
Frequently Asked Questions
Get started
Ready to transform your service business?
Join 150+ businesses already using Opencals. Start on a free development store with every feature unlocked, and only pay once you go live.




