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

> Keep Supabase for users and data, plug in Opencals for availability, carts, payments and reminders. The setup and code, plus the hand-rolled alternative.

URL: https://opencals.com/blog/supabase-booking-system
Author: Stanislav Tyshchenko
Published: 2026-10-27
Updated: 2026-10-27
Categories: engineering

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](https://opencals.com/developers) for the bookings. You'd do the same with Stripe for payments rather than writing your own card processing.

> **Key takeaway**
>
> 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:

```bash
# 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](https://opencals.com/blog/booking-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:

```ts
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:

```ts
// 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:

```sql
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](https://opencals.com/blog/lovable-booking-system) 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:

```sql
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](https://opencals.com/blog/prevent-double-booking-system-design).

**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_ref` as 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](https://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](https://opencals.com/blog/lovable-booking-system): The connector setup for Lovable apps, which run on Supabase.
- [The Storefront SDK](https://opencals.com/blog/booking-sdk): Typed TypeScript client for availability, carts, checkout and accounts.
- [Prevent double booking](https://opencals.com/blog/prevent-double-booking-system-design): The database design if you hand-roll bookings in Postgres.
- [Build a booking site with AI](https://opencals.com/blog/build-booking-website-with-ai): Lovable, v0, Base44 and coding agents, all on one booking API.
- [Opencals for developers](https://opencals.com/developers): API reference, SDK, agent skills and the free development store.
- [Booking systems for developers](https://opencals.com/blog/booking-system-for-developers): What a booking system asks of you after launch.

## Frequently asked questions

### Can I build a booking system with Supabase?

Yes. The quickest route is to keep Supabase for users and app data and connect a booking API such as Opencals for availability, carts, checkout, refunds and reminders. You can also hand-roll it in Postgres with a tstzrange exclusion constraint, RLS, pg\_cron for expiring holds and a Stripe webhook in an Edge Function, but the availability, refund and reminder logic is yours to write and maintain.

### How do I add Opencals to a Supabase app?

Create two keys in the Opencals dashboard. Put the public sfk\_ key, restricted to your domain, in your frontend and use the Storefront SDK for availability, appointments, cart and checkout. Store the private spk\_ key as a Supabase Edge Function secret for server-side calls. Save each Opencals order id in a Supabase table linked to auth.uid() under RLS.

### Does Opencals replace Supabase Auth?

No. Your users keep signing in with Supabase Auth. Pass the signed-in user's email into Opencals checkout so the booking is under the same address, and link the order to the user in your own Supabase table.

### How do I prevent double bookings in Supabase?

With a booking API, the API refuses the second booking for you. If you hand-roll it, enable btree\_gist and add EXCLUDE USING gist (resource\_id WITH =, during WITH &&) WHERE (status <> 'cancelled') to the bookings table, so the second overlapping insert fails with SQLSTATE 23P01.

### Can Opencals send booking changes to Supabase automatically?

Not yet. Opencals webhooks aren't live, so keep a pointer to each order in Supabase and read the current status from the API, or poll from a scheduled Edge Function if you need a local copy.

### Will a Supabase free project work for a live booking site?

It's risky. Supabase pauses free projects after one week of inactivity, and a small business can easily have a quiet week. Move to the Pro plan, from $25 a month as of October 2026, before real customers book.
