# Booking kit: stop double-booking with range exclusions and live slots

> How the Booking kit blocks overlapping appointments at write time with range exclusions, keeps live slots honest, and confirms only after checkout survives.
> By Dave · 2026-09-19
> Source: https://otf-kit.dev/blog/booking-kit-no-double-booking

Double-booking is not a UI bug. It is a write-time integrity failure: two clients confirm the same provider window, both see a green check, and your calendar lies. A booking product you own should reject the second insert in the database, then refresh open slots for everyone still looking.

This post is the how-to for that spine on OTF's Booking kit (Cadence). It is not the ownership tour at [/blog/booking-kit-own-the-repo](/blog/booking-kit-own-the-repo), not the greenfield path at [/blog/how-to-build-a-booking-system-with-ai](/blog/how-to-build-a-booking-system-with-ai), and not the compare at [/blog/otf-vs-cal-calendly-booking](/blog/otf-vs-cal-calendly-booking). If your buyer question is "how do overlapping appointments get blocked when two phones hit Pay at once?", stay here.

## What the kit claims on the storefront

OTF lists the Booking kit on [https://otf-kit.dev/templates/booking-kit](https://otf-kit.dev/templates/booking-kit) and in the [templates catalog](https://otf-kit.dev/templates). Standalone price is $99; it is also in the Everything Bundle on [https://otf-kit.dev/pricing](https://otf-kit.dev/pricing). Public product claims that matter for this angle:

- Realtime slots plus database-enforced no-double-booking (Postgres gist)
- Auth and row-level rules at the data layer
- Stripe checkout and reminder email after a confirmed booking
- One product tree for phone and web, with agent configs so Cursor or Claude Code extend the repo instead of inventing a second scheduler

Treat demo chrome as demo UI. The durable promise is: the conflict dies on insert, not in a toast after the fact.

## Why UNIQUE on start_time is not enough

Builders who ship their first booking table often add `UNIQUE (provider_id, starts_at)`. That blocks exact duplicates. It does not block overlaps. A 9:00–10:00 session and a 9:30–10:30 session share no identical start timestamp, so both rows insert. The calendar looks fine until two humans arrive for the same chair.

PostgreSQL documents the right primitive for this: range types plus an exclusion constraint. In the [range types chapter](https://www.postgresql.org/docs/current/rangetypes.html), the docs show that `UNIQUE` is usually unsuitable for ranges, and that `EXCLUDE USING GIST (... WITH &&)` rejects overlapping range values at write time. The same page shows the `btree_gist` pattern that combines equality on a room (or provider) with overlap on a time range — exactly the "same provider, overlapping window" rule booking products need.

That is the load-bearing idea. Application checks help UX. The exclusion constraint is the last line that survives a race.

![Range exclusion blocks overlapping bookings at write time](https://cdn.otf-kit.dev/blog/booking-kit-no-double-booking/inbody1-20260919a.png)

*Write-time exclusion: same provider, overlapping range refused.*

## The write path: range + exclusion, not hope

Illustrative shape only — match the kit's public "gist / no-double-booking" claim after you clone; do not treat this as a dumped filename from the private repo:

```sql
-- illustrative pattern from PostgreSQL range + exclusion docs
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE appointment (
  id uuid PRIMARY KEY,
  provider_id uuid NOT NULL,
  client_id uuid NOT NULL,
  during tstzrange NOT NULL,
  status text NOT NULL,
  EXCLUDE USING gist (
    provider_id WITH =,
    during WITH &&
  )
);
```

What this does under concurrency:

1. Client A inserts `[2026-05-28 09:30+00, 2026-05-28 10:30+00)` for provider Maya.
2. Client B tries the overlapping window a few milliseconds later.
3. The second insert fails with an exclusion-constraint error before status ever becomes "confirmed".
4. Your API maps that error to a clean "slot taken — pick another" response and refreshes open slots.

PostgreSQL's own example on the range types page inserts a conflicting reservation and returns `ERROR: conflicting key value violates exclusion constraint` with a DETAIL that names both keys. That is the behavior you want in production booking — not a silent overwrite, not a soft warning.

Bounds matter. Prefer half-open ranges (`[start, end)`) so adjacent sessions can touch without overlapping. The docs cover inclusive vs exclusive bounds; get that wrong and you invent false conflicts at the hour mark.

## The read path: live slots without polling the database dead

A correct exclusion constraint still leaves a product problem: the slot picker must not show a slot that just sold. The Booking kit storefront calls out realtime slots alongside the gist guard. Practically that means:

- Open-slot queries read confirmed (and held, if you use short holds) ranges for the selected day.
- When an insert succeeds, subscribers on that provider's day refresh.
- When an insert fails the exclusion constraint, the failing client refreshes immediately; everyone else gets the same truth through the live channel.

You need one writer path that only confirms after a successful insert, and a slot UI that treats "open" as derived from remaining free windows — not a cached list baked in at page load.

A simple free-slot sketch (illustrative):

```ts
// illustrative — derive open windows from busy ranges for one provider/day
type Busy = { start: string; end: string };

function openSlots(
  dayStart: string,
  dayEnd: string,
  stepMinutes: number,
  busy: Busy[],
): Busy[] {
  const out: Busy[] = [];
  let cursor = Date.parse(dayStart);
  const end = Date.parse(dayEnd);
  const step = stepMinutes * 60_000;
  while (cursor + step <= end) {
    const slotStart = cursor;
    const slotEnd = cursor + step;
    const overlaps = busy.some((b) => {
      const bs = Date.parse(b.start);
      const be = Date.parse(b.end);
      return slotStart < be && slotEnd > bs;
    });
    if (!overlaps) {
      out.push({
        start: new Date(slotStart).toISOString(),
        end: new Date(slotEnd).toISOString(),
      });
    }
    cursor += step;
  }
  return out;
}
```

Keep the sketch as a teaching aid. After purchase, use the kit's real slot query and live subscription. The invariant is the same: busy ranges come from rows the exclusion constraint already protected.

## Confirm only after money and row both stick

Booking products that charge before the row exists create refunds. Products that mark confirmed before checkout create unpaid holds that clutter the calendar. The kit's public path pairs Stripe checkout with the booking ticket flow on the storefront. A durable order of operations:

1. Client selects a free slot (derived, not guessed).
2. Server creates a short-lived hold or goes straight to Checkout for your product rules — either way, the appointment row must not become a long-lived confirmed overlap.
3. Checkout Session completes; webhook (or confirmed session retrieve) is the only signal that money cleared.
4. Final confirmed insert runs under the exclusion constraint. If it fails, you refund or offer a new slot — you do not leave two confirmed rows.

Stripe's Checkout docs describe the payment surface; the durable part you own is the appointment row and the ticket screen after redirect. See [Stripe Checkout](https://docs.stripe.com/payments/checkout) and [custom success / redirect behavior](https://docs.stripe.com/payments/checkout/custom-success-page). Never invent a Checkout URL in sample code — create Sessions through your privileged route after clone.

Reminders (the storefront lists reminder email) should fire only for rows that survived confirmation. Reminding someone about a slot that lost the exclusion race trains customers to ignore you.

![Live slots refresh after a booking confirms](https://cdn.otf-kit.dev/blog/booking-kit-no-double-booking/inbody2-20260919a.png)

*After confirm, open slots refresh so the grid stays honest.*

## How agents should extend this without breaking the guard

Every OTF kit ships agent handoff files and tested prompts so Cursor or Claude Code extend the product instead of regenerating it. For the no-double-booking spine, put the invariant in language agents cannot shrug off:

- New appointment-like tables need an exclusion constraint (or an explicit documented reason they do not).
- Slot UIs must derive openness from busy ranges, not from a hard-coded array in a screen file.
- Status transitions that mean "booked" must go through the same write path that hits the constraint.
- Do not "fix" double-booking with a client-only disable on the Pay button.

Models change; the exclusion constraint and derived slot list stay the product contract. Link this how-to from agent docs after purchase.

## Decision checklist before you buy or extend

Use this when choosing a scheduling link, greenfield calendar, or owned booking kit:

| Question | If yes | If no |
| --- | --- | --- |
| Do two clients ever book the same provider concurrently? | You need write-time exclusion, not only UNIQUE starts | A single-operator calendar may tolerate app checks |
| Do you ship a client app (phone + web) around the appointment? | Prefer an owned booking product spine | A scheduling-link product may be enough |
| Will agents add services, packages, or ticket screens later? | Buy a kit with prompts and keep the gist guard sacred | Greenfield is fine if you will own the schema yourself |
| Is checkout part of the booking, not a side invoice? | Confirm only after payment + successful insert | Separate invoicing changes the hold model |

If your pain is "Calendly embeds vs owning the client app," read [/blog/otf-vs-cal-calendly-booking](/blog/otf-vs-cal-calendly-booking). If your pain is "I already bought Cadence — what do I own day one?", start at [/blog/booking-kit-own-the-repo](/blog/booking-kit-own-the-repo), then return here for the conflict rule.

## What to do today

1. Open the live kit page and confirm the no-double-booking + realtime slots claims still match what you need: [Booking kit](https://otf-kit.dev/templates/booking-kit).
2. Read PostgreSQL's range + exclusion section once end-to-end: [Range Types](https://www.postgresql.org/docs/current/rangetypes.html).
3. After purchase (or on a branch), find the appointment range column and the exclusion constraint; add a concurrent insert test that expects the second write to fail.
4. Trace one successful booking from slot tap → Checkout → confirmed row → ticket screen → reminder. Fail the path if any step marks confirmed without surviving the constraint.
5. Add one agent prompt that forbids removing or bypassing the exclusion constraint when adding a bookable resource.

Double-booking dies when the database refuses the second range. Live slots stay honest when the UI reads that truth.

## Sources

- [PostgreSQL range types — constraints on ranges (EXCLUDE USING GIST)](https://www.postgresql.org/docs/current/rangetypes.html)
- [OTF Booking kit storefront](https://otf-kit.dev/templates/booking-kit)
- [OTF pricing](https://otf-kit.dev/pricing)
- [Stripe Checkout](https://docs.stripe.com/payments/checkout)
- [Stripe Checkout custom success / redirect](https://docs.stripe.com/payments/checkout/custom-success-page)
