Skip to main content
RapidDev - Software Development Agency
Lovable PromptsB2C SaaSIntermediate

Build a Booking Platform in Lovable

A customer-facing booking platform where users browse services, pick a time slot against staff availability with server-side conflict prevention, pay a Stripe deposit, and receive email reminders — like building your own Cal.com for a barber shop, clinic, or class studio.

Time to MVP

1 day

Credits

~100-200 credits for full build

Difficulty

Intermediate

Cloud features

3

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt into Lovable Build mode and get a multi-step booking wizard where customers pick a service, choose a staff member and time slot, and pay a deposit. The DB-level EXCLUDE constraint prevents double-bookings — without it the system is structurally broken. Full build runs ~100-200 credits in about 1 day.

Setup checklist

Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.

Cloud tab settings

Database

Stores services, staff, availability rules, and bookings with the double-booking prevention constraint.

  1. 1Click the + button next to Preview in the top toolbar to open the Cloud tab
  2. 2Click Database — you'll see an empty Table Editor
  3. 3Leave it empty — the starter prompt creates the migration including the critical EXCLUDE constraint and btree_gist extension

Auth

Magic-link email for customers (lower friction for one-off bookings) and email+password for staff/admin.

  1. 1Cloud tab → Users & Auth
  2. 2Confirm Email sign-in is enabled (default)
  3. 3Enable Magic Links: Users & Auth → Auth settings → Enable magic link login
  4. 4Set Site URL to your production domain before going live — magic link redirects fail if Site URL is still the preview domain

Edge Functions

Two booking functions: get-available-slots (server-side slot computation) and create-booking (advisory lock + INSERT). Plus stripe-webhook and send-booking-email for deposits and reminders.

  1. 1Cloud tab → Edge Functions — starter prompt creates all four function files
  2. 2After the starter prompt runs, deploy each function from the Edge Functions panel
  3. 3Register the stripe-webhook function URL in Stripe Dashboard → Developers → Webhooks → Add endpoint

Secrets (Cloud tab → Secrets)

STRIPE_SECRET_KEY

Purpose: Used by create-deposit-session Edge Function to create Stripe Checkout sessions for deposits.

Where to get it: stripe.com → Dashboard → Developers → API keys → Secret key (sk_test_... for test mode).

STRIPE_WEBHOOK_SECRET

Purpose: Used by stripe-webhook Edge Function in constructEventAsync to verify requests are genuinely from Stripe.

Where to get it: stripe.com → Dashboard → Developers → Webhooks → your endpoint → Signing secret (whsec_...). Only available after registering the webhook URL.

RESEND_API_KEY

Purpose: Used by send-booking-email Edge Function to send booking confirmations and 24h reminders.

Where to get it: resend.com → API Keys → Create API Key. Free tier covers 3,000 emails/month.

Preflight checklist

  • You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
  • You're on Pro $25/mo — the full chain runs ~100-200 credits; Free plan's ~30/mo is exhausted by the first follow-up
  • Decide whether deposits are required (reduces no-shows) or optional (lower friction for first bookings). The starter prompt builds the booking flow without Stripe; deposits are added in follow-up #2
  • Collect your services list and staff names before prompting — you'll add them as seed data via the admin panel the starter builds

The starter prompt — paste this first

Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.

lovable-agent-mode.txt
~50-70 credits
Build a customer-facing booking platform. Stack assumptions: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6, Supabase JS client against Lovable Cloud.

## Database schema (create one migration)
Create four tables in the public schema. CRITICAL: enable btree_gist first.

Run this first in the migration:
```sql
CREATE EXTENSION IF NOT EXISTS btree_gist;
```

1. `services` — id (uuid pk default gen_random_uuid()), name (text not null), description (text), duration_min (int not null check(duration_min > 0)), price_cents (int not null), deposit_cents (int default 0), is_active (bool default true), created_at (timestamptz default now()). RLS: public SELECT WHERE is_active=true; admin write via JWT app_metadata.role='admin'.

2. `staff` — id (uuid pk default gen_random_uuid()), user_id (uuid references auth.users(id)), display_name (text not null), photo_path (text), timezone (text not null default 'America/New_York'), is_active (bool default true). RLS: public SELECT WHERE is_active=true; admin write.

3. `availability` — id (uuid pk default gen_random_uuid()), staff_id (uuid references staff(id) on delete cascade), weekday (int check(weekday between 0 and 6)), start_time (time not null), end_time (time check(end_time > start_time)). RLS: public SELECT; admin write.

4. `bookings` — id (uuid pk default gen_random_uuid()), customer_id (uuid references auth.users(id)), staff_id (uuid references staff(id)), service_id (uuid references services(id)), starts_at (timestamptz not null), ends_at (timestamptz not null), status (text default 'pending' check in ('pending','confirmed','cancelled','no_show','completed')), stripe_payment_intent_id (text), deposit_paid (bool default false), intake_notes (text), customer_email (text), created_at (timestamptz default now()).

ADD THIS CONSTRAINT — it is the double-booking prevention:
```sql
ALTER TABLE bookings ADD CONSTRAINT no_double_booking
  EXCLUDE USING gist (
    staff_id WITH =,
    tstzrange(starts_at, ends_at) WITH &&
  ) WHERE (status IN ('pending','confirmed'));
```

Enable RLS on all four tables. Bookings RLS: customer reads own WHERE customer_id = auth.uid(); staff reads WHERE staff_id IN (SELECT id FROM staff WHERE user_id = auth.uid()); admin reads all via JWT app_metadata.role='admin'. INSERT: any authenticated user.

Create a SECURITY DEFINER plpgsql function `compute_open_slots(p_staff_id uuid, p_service_id uuid, p_date date)` that:
- Gets the staff member's availability for the weekday of p_date
- Gets the service duration in minutes
- Generates all possible slot start times (e.g., every 30 min from start_time to end_time - duration_min)
- Excludes times where a booking already exists with status IN ('pending','confirmed') using tstzrange overlap
- Returns a TABLE(slot_start timestamptz)

## Layouts
- `PublicLayout.tsx` — sticky header with logo, business name, 'Book Now' CTA button linking to /book. Footer.
- `AdminLayout.tsx` — sidebar with tabs: Services, Staff, Availability, Bookings. AuthGuard reads JWT app_metadata.role='admin'.

## Pages
- / (Home) — services grid (ServiceCard components), brief about section, 'Book your appointment' CTA
- /book — 5-step BookingWizard:
  Step 1: pick service (ServiceCard grid)
  Step 2: pick staff (StaffPicker — shows photo, name, available days)
  Step 3: pick date + slot (DateGridSlotPicker — month view calendar, click date to load slots from compute_open_slots RPC, click slot to select)
  Step 4: enter info (name, email, optional intake notes — send magic link OTP to email for identity)
  Step 5: review + confirm (shows summary, Confirm button — no Stripe yet, added in follow-up #2)
- /book/confirmation — 'Booking confirmed' page with booking details and calendar download link
- /my-bookings — customer's own bookings list (filter by upcoming/past), Cancel button (sets status='cancelled')
- /admin/services — CRUD table for services
- /admin/staff — CRUD table for staff + photo upload
- /admin/availability — AvailabilityWeekEditor: 7-day grid, click cell to add/remove availability window for each staff member
- /admin/bookings — upcoming bookings list, status filter (pending/confirmed/cancelled), BookingDetailDrawer on row click
- /admin/settings — placeholder

## Key components
- `ServiceCard.tsx` — card with name, duration, price, 'Select' button
- `StaffPicker.tsx` — row or grid of staff cards with photo (or initials avatar), display_name, available weekdays summary
- `DateGridSlotPicker.tsx` — shadcn Calendar month view; on day click, calls supabase.rpc('compute_open_slots', params), renders slot time buttons (min 48×48px touch targets) in a grid below the calendar
- `BookingSummary.tsx` — shows selected service, staff, slot, customer info for review
- `AvailabilityWeekEditor.tsx` — 7-column grid (Mon-Sun), each cell has staff + time window, click to toggle
- `BookingDetailDrawer.tsx` — Sheet with full booking info + status change dropdown

## Styling
Light mode default. Primary color: teal-600. Mobile-first (most bookings happen on phones). Slot time buttons large tap targets. Friendly empty states on /my-bookings when no upcoming bookings.

Generate migration (with EXTENSION and EXCLUDE constraint) first, then layouts, then pages in order above.

What this prompt generates

  • Creates a SQL migration with 4 tables, the btree_gist EXCLUDE constraint for double-booking prevention, RLS policies, and the compute_open_slots SECURITY DEFINER function
  • Scaffolds the 5-step BookingWizard with DateGridSlotPicker that calls the server-side slot function
  • Builds the AvailabilityWeekEditor for admin staff schedule management
  • Generates the admin panel with Services, Staff, Availability, and Bookings CRUD
  • Wires the magic-link customer identity flow so customers can book without creating a full password account

Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)

Expected output

What Lovable will generate after the starter prompt runs successfully.

Files
supabase/migrations/0001_booking_schema.sql

4 tables + btree_gist EXTENSION + EXCLUDE constraint + RLS + compute_open_slots function

src/layouts/PublicLayout.tsx

Sticky header with logo and Book Now CTA

src/layouts/AdminLayout.tsx

Sidebar for admin tabs with AdminGuard role check

src/components/ServiceCard.tsx

Service card with name, duration, price, Select button

src/components/StaffPicker.tsx

Staff selection grid with photo/avatar and availability summary

src/components/DateGridSlotPicker.tsx

Month-view calendar with server-loaded slot time buttons

src/components/BookingSummary.tsx

Review card showing selected service, staff, slot, and customer info

src/components/AvailabilityWeekEditor.tsx

7-column weekly grid editor for admin staff schedules

src/components/BookingDetailDrawer.tsx

Sheet with full booking detail and status change dropdown

src/pages/Home.tsx

Services grid + Book Now CTA landing page

src/pages/Book.tsx

5-step BookingWizard component

src/pages/BookConfirmation.tsx

Post-booking confirmation page

src/pages/MyBookings.tsx

Customer's own booking history with cancel button

src/pages/admin/Services.tsx

Services CRUD

src/pages/admin/Staff.tsx

Staff CRUD with photo upload

src/pages/admin/Availability.tsx

AvailabilityWeekEditor wrapped in admin page

src/pages/admin/Bookings.tsx

Upcoming bookings list with status filter and detail drawer

supabase/functions/get-available-slots/index.ts

Calls compute_open_slots RPC and returns available slot timestamps

supabase/functions/create-booking/index.ts

Advisory lock + booking INSERT + returns conflict error if slot taken

Routes
/

Landing page with services grid and Book Now CTA

/book

5-step booking wizard (service → staff → slot → info → confirm)

/book/confirmation

Post-booking confirmation with details

/my-bookings

Customer's own booking history with cancel button

/admin/services

Services CRUD

/admin/staff

Staff CRUD with availability link

/admin/availability

Weekly availability grid editor per staff member

/admin/bookings

Upcoming and past bookings with status filter

/admin/settings

Business settings placeholder

DB Tables
services
ColumnType
iduuid primary key default gen_random_uuid()
nametext not null
descriptiontext
duration_minint not null check(duration_min > 0)
price_centsint not null
deposit_centsint default 0
is_activebool default true
created_attimestamptz default now()

RLS: Public SELECT WHERE is_active=true; admin INSERT/UPDATE/DELETE via JWT app_metadata.role='admin'

staff
ColumnType
iduuid primary key default gen_random_uuid()
user_iduuid references auth.users(id)
display_nametext not null
photo_pathtext
timezonetext not null default 'America/New_York'
is_activebool default true

RLS: Public SELECT WHERE is_active=true; admin write

availability
ColumnType
iduuid primary key default gen_random_uuid()
staff_iduuid references staff(id) on delete cascade
weekdayint check(weekday between 0 and 6)
start_timetime not null
end_timetime check(end_time > start_time)

RLS: Public SELECT; admin write

bookings
ColumnType
iduuid primary key default gen_random_uuid()
customer_iduuid references auth.users(id)
staff_iduuid references staff(id)
service_iduuid references services(id)
starts_attimestamptz not null
ends_attimestamptz not null
statustext default 'pending'
stripe_payment_intent_idtext
deposit_paidbool default false
intake_notestext
customer_emailtext
created_attimestamptz default now()

RLS: EXCLUDE USING gist(staff_id WITH =, tstzrange(starts_at,ends_at) WITH &&) WHERE status IN ('pending','confirmed'). Customer reads own; staff reads own; admin reads all.

Components
ServiceCard

Service name, duration badge, price, and Select button

StaffPicker

Staff photo/avatar grid with available-days summary

DateGridSlotPicker

Month-view calendar + server-loaded time slot buttons

AvailabilityWeekEditor

7-column weekly grid for managing staff availability windows

BookingDetailDrawer

Right Sheet with full booking detail and status change

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Move slot check to Edge Function with advisory lock

Server-side booking creation with advisory lock + EXCLUDE constraint catch, returning 409 on conflict

~50 credits
prompt
Replace the current client-side slot availability check with an Edge Function that uses a Postgres advisory lock to prevent race conditions.

Update get-available-slots/index.ts:
1. Accept POST with { staff_id, service_id, date } in the request body
2. Call supabase.rpc('compute_open_slots', { p_staff_id, p_service_id, p_date }) to get available timestamps
3. Return the list of slot timestamps as JSON

Create create-booking/index.ts:
1. Accept POST with { staff_id, service_id, starts_at, service_duration_min, customer_email, intake_notes }
2. Verify caller is authenticated via Authorization header
3. Run: SELECT pg_advisory_xact_lock(hashtext('booking:' || staff_id::text)) — this holds a transaction-scoped advisory lock per staff member so two concurrent requests queue instead of racing
4. Compute ends_at = starts_at + duration interval
5. INSERT into bookings. If the INSERT fails with Postgres error code 23P01 (exclusion violation), return HTTP 409 with body { error: 'Slot was just taken by someone else — please pick another time' }
6. Return the new booking id and confirmation details

Update the BookingWizard Step 5 Confirm button to call supabase.functions.invoke('create-booking', {...}) instead of a direct Supabase INSERT.

When to use: IMMEDIATELY after the starter prompt, before any real bookings — the client-side INSERT is not safe under concurrent load

2

Add Stripe deposit checkout

Stripe deposit collection with raw-body webhook updating booking status to confirmed

~80 credits
prompt
Add Stripe deposit collection to the booking flow.

1. Cloud tab → Secrets: confirm STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set.

2. Create supabase/functions/create-deposit-session/index.ts:
   - Accept POST with { booking_id }
   - Fetch the booking + service from the database
   - If service.deposit_cents = 0, return { skip: true } — no deposit needed
   - Create Stripe Checkout session: mode='payment', line_items with service.deposit_cents, metadata.booking_id, success_url='/book/confirmation?booking_id={booking_id}', cancel_url='/book'
   - Return { url: session.url }

3. Update supabase/functions/stripe-webhook/index.ts:
   - Read raw body: const body = await req.text();
   - Verify: const event = await stripe.webhooks.constructEventAsync(body, req.headers.get('Stripe-Signature'), Deno.env.get('STRIPE_WEBHOOK_SECRET'));
   - On checkout.session.completed: read metadata.booking_id, UPDATE bookings SET deposit_paid=true, status='confirmed' WHERE id=booking_id
   - Return 200

4. In BookingWizard Step 5: if service.deposit_cents > 0, after create-booking succeeds, immediately call create-deposit-session with the new booking_id and redirect to session.url. If deposit_cents = 0, go straight to /book/confirmation.

When to use: When you want to reduce no-shows by requiring a deposit at booking time

3

Send Resend confirmation and reminder emails

Booking confirmation emails on create, plus 24h and 1h reminder emails via hourly pg_cron

~70 credits
prompt
Add booking confirmation and reminder emails via Resend.

1. Create supabase/functions/send-booking-email/index.ts. Accept POST with { booking_id, type } where type is one of: 'confirmation', 'reminder_24h', 'reminder_1h', 'cancelled'.
   - Fetch booking + service + staff from the database using the service role client
   - Build the email subject and body based on type:
     - confirmation: 'Your booking is confirmed — [Service] with [Staff] on [Date] at [Time]'
     - reminder_24h: 'Reminder: [Service] with [Staff] tomorrow at [Time]'
     - reminder_1h: '[Service] with [Staff] in 1 hour'
     - cancelled: 'Your booking has been cancelled'
   - POST to Resend API with RESEND_API_KEY
   - Return { success: true }

2. Call send-booking-email with type='confirmation' from the stripe-webhook handler on checkout.session.completed (if deposit required) and from the create-booking Edge Function for free bookings.

3. Set up reminder scheduling via pg_cron:
   In Cloud tab → Database → SQL Editor:
   CREATE EXTENSION IF NOT EXISTS pg_cron;
   SELECT cron.schedule('hourly-reminders', '0 * * * *', $$
     SELECT net.http_post(
       url := '[your-edge-function-url]/send-booking-email',
       body := row_to_json(b)::text
     )
     FROM bookings b
     JOIN services s ON s.id = b.service_id
     WHERE b.status = 'confirmed'
       AND b.starts_at BETWEEN now() + interval '23 hours' AND now() + interval '25 hours'
       AND b.reminder_24h_sent = false;
   $$);

When to use: When you have real bookings flowing and want to reduce no-shows with reminders

4

Add cancellation policy and deposit refund flow

24-hour cancellation policy with automatic Stripe refund for eligible cancellations

~40 credits
prompt
Add a cancellation policy that refunds the deposit if the customer cancels more than 24 hours before the appointment.

1. Create supabase/functions/cancel-booking/index.ts:
   - Accept POST with { booking_id }
   - Verify caller owns the booking (customer_id = auth.uid())
   - Check hours until appointment: EXTRACT(EPOCH FROM (starts_at - now())) / 3600
   - If > 24 hours AND deposit_paid = true:
     - Call stripe.refunds.create({ payment_intent: stripe_payment_intent_id })
     - UPDATE bookings SET status='cancelled', deposit_paid=false
     - Send cancellation email via send-booking-email
   - If <= 24 hours: return 400 with message 'Cancellations within 24 hours of the appointment are non-refundable per our policy'
   - If deposit not paid: just UPDATE status='cancelled' and send cancellation email

2. On /my-bookings, the Cancel button should call this Edge Function instead of doing a direct Supabase UPDATE. Show the 24-hour policy text next to the button: 'Cancel for free if more than 24h before appointment. Cancellations within 24h are non-refundable.'

3. Show the policy text on the /book Step 5 review page too, before the Confirm button.

When to use: Once deposits are live and you want to enforce a cancellation policy

5

Add timezone-aware slot display

Correct timezone-aware slot display with staff TZ for admin and browser TZ for customers

~30 credits
prompt
Fix slot display to correctly handle staff and customers in different timezones.

1. Confirm the compute_open_slots function works in the staff member's timezone:
   - In the function, convert p_date to the staff's timezone: p_date AT TIME ZONE staff.timezone
   - Generate slot start times in staff timezone: availability.start_time, availability.end_time
   - Return slots as timestamptz (UTC) — Postgres will handle the conversion

2. In DateGridSlotPicker, display slot times in the customer's browser timezone using Intl.DateTimeFormat:
   const formatted = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(slotDate);
   This automatically shows times in the customer's local time with the timezone abbreviation.

3. On the admin /admin/bookings page, show times in the staff member's timezone (use staff.timezone string with Intl.DateTimeFormat({ timeZone: staff.timezone })).

4. Add a visible label below the slot picker: 'All times shown in your local timezone ([detected TZ])'. Detect browser TZ with: Intl.DateTimeFormat().resolvedOptions().timeZone.

5. Add a test: create a staff member in EST, load the slot picker as a browser in PST, and verify the slot times display correctly offset (an EST 9am slot should show as 6am PST).

When to use: When you onboard your first out-of-state customer or staff member in a different timezone

6

Add admin blocked times

Staff blocked-time management so vacation/sick/lunch days are excluded from the slot picker

~30 credits
prompt
Add a blocked-time feature so staff can block specific date ranges for vacations, sick days, or lunch breaks.

1. Create a blocked_times table: id (uuid pk), staff_id (uuid references staff(id) on delete cascade), starts_at (timestamptz not null), ends_at (timestamptz not null), reason (text). RLS: public SELECT (needed for slot computation); admin write.

2. Update compute_open_slots to subtract blocked_times from the generated slots:
   In the function body, after generating candidate slots, filter out any slot where starts_at OVERLAPS with a blocked_times row for that staff member:
   AND NOT EXISTS (
     SELECT 1 FROM blocked_times bt
     WHERE bt.staff_id = p_staff_id
     AND tstzrange(bt.starts_at, bt.ends_at) && tstzrange(slot_start, slot_start + (p_duration_min || ' minutes')::interval)
   )

3. On /admin/availability, add a 'Block time' button per staff member. Clicking opens a Dialog with: start datetime picker, end datetime picker, reason text input, and Save button. The blocked time appears as a red chip on the availability grid.

4. Allow staff members to add their own blocked times: add a /my-availability page accessible to users where staff.user_id = auth.uid(), showing upcoming blocked times + an 'Add block' button.

When to use: When staff start asking for personal time-off management

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

Two customers booked the same slot simultaneously and both got confirmation emails

The slot-availability check ran in client JavaScript — two browsers raced past the check before either INSERT landed. Without a DB-level EXCLUDE constraint, both writes succeed.

Fix — paste into Lovable Agent Mode
Confirm the EXCLUDE constraint exists: SELECT conname, contype FROM pg_constraint WHERE conrelid = 'bookings'::regclass AND contype = 'x'; If it's missing, run: CREATE EXTENSION IF NOT EXISTS btree_gist; ALTER TABLE bookings ADD CONSTRAINT no_double_booking EXCLUDE USING gist (staff_id WITH =, tstzrange(starts_at, ends_at) WITH &&) WHERE (status IN ('pending','confirmed')); Then in create-booking Edge Function, catch Postgres error code 23P01 and return HTTP 409 with message 'Slot was just taken — please pick another time'.
function tstzrange(timestamp with time zone, timestamp with time zone) does not exist

The starts_at or ends_at column is defined as timestamp without time zone instead of timestamptz. The tstzrange function requires both arguments to be timestamptz.

Fix — paste into Lovable Agent Mode
Alter the bookings table: ALTER TABLE bookings ALTER COLUMN starts_at TYPE timestamptz USING starts_at AT TIME ZONE 'UTC'; ALTER TABLE bookings ALTER COLUMN ends_at TYPE timestamptz USING ends_at AT TIME ZONE 'UTC'; Then drop and recreate the EXCLUDE constraint: ALTER TABLE bookings DROP CONSTRAINT no_double_booking; ALTER TABLE bookings ADD CONSTRAINT no_double_booking EXCLUDE USING gist (staff_id WITH =, tstzrange(starts_at, ends_at) WITH &&) WHERE (status IN ('pending','confirmed'));
permission denied for table bookings when customer tries to view their own

Customer booked via magic link but the booking was inserted with customer_id=null (the magic link OTP hadn't resolved to a user ID yet when the INSERT ran), so auth.uid() doesn't match the null customer_id.

Fix — paste into Lovable Agent Mode
In create-booking Edge Function, after calling supabase.auth.getUser() from the Authorization header, set customer_id = user.id on the INSERT. Also write customer_email = the email from the booking form as a fallback for lookup. Add a query in /my-bookings: WHERE customer_id = auth.uid() OR customer_email = user.email so both linked and unlinked bookings appear.
Slot picker shows slots that overlap into the next day or wraps past midnight in the wrong timezone

compute_open_slots generated slots in UTC but the availability.start_time and end_time were entered as local-time values without timezone conversion. Slots near midnight EST are shown as early-morning slots in UTC.

Fix — paste into Lovable Agent Mode
In compute_open_slots, convert the input date to the staff member's timezone before computing availability windows: SELECT start_time, end_time FROM availability WHERE staff_id = p_staff_id AND weekday = EXTRACT(DOW FROM (p_date AT TIME ZONE staff.timezone)). Then generate slot timestamps in staff timezone and return as timestamptz. Client formats display in browser TZ via Intl.DateTimeFormat.
Stripe webhook fires but booking.deposit_paid stays false

Either the STRIPE_WEBHOOK_SECRET in Cloud Secrets doesn't match the signing secret in Stripe Dashboard for your endpoint, or metadata.booking_id was not passed when creating the Checkout session.

Fix — paste into Lovable Agent Mode
Check Stripe Dashboard → Developers → Webhooks → your endpoint → Recent deliveries → look at the response body. If you see signature failure, update STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets with the current whsec_... value. If signature passes but deposit_paid is still false, add a console.log(event.data.object.metadata) to the webhook and redeploy — confirm booking_id is in the metadata. It must be passed in create-deposit-session via { metadata: { booking_id: bookingId } }.

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo recommended. The full chain runs ~100-200 credits — comfortably within Pro's 100 monthly + 5 daily credits if you don't loop on the timezone or EXCLUDE constraint issues. One top-up ($15 for 50 credits) if you do.

Monthly run cost breakdown

~100-200 credits (starter ~50-70, six follow-ups ~305 if all done; most readers ship after 3 follow-ups for ~150 total) total
ItemCost
Lovable Pro

Drop after build — app runs on Lovable Cloud even on Free plan

$25/mo
Lovable Cloud (Supabase)

500MB DB easily holds 100K+ bookings. 50K MAU more than enough for an SMB booking page.

$0/mo at MVP
Stripe

Plus 1% for instant payouts if you use them. At 30 bookings/mo × $25 deposit = $37 in Stripe fees.

2.9% + 30¢ per deposit
Resend

A 10-booking-per-day shop sends ~600 emails/mo (confirmation + reminder × 30 days) — stays free.

$0/mo up to 3K emails
Custom domain

Required for a professional booking page

~$10-15/yr

Scaling notes: Resend 3K/mo free tier covers ~50 bookings/day with confirmation + 24h reminder (2 emails each). DB 500MB holds ~500K bookings. The actual scaling ceiling is Stripe fees — at 1,000 bookings/mo with $30 deposit average, Stripe takes ~$898 in fees.

Production checklist

Steps to take before you share the URL with real users.

Domain & SSL

  • Connect a custom domain so booking confirmation emails and OAuth links point to your brand

    Lovable top-right → Publish → Custom domain → enter domain → copy CNAME → add to your DNS provider. Then Cloud tab → Users & Auth → URL Configuration: set Site URL to your custom domain so magic-link emails redirect to the right URL.

Cancellation policy

  • Display your cancellation policy prominently on the booking flow BEFORE taking deposits

    Add a visible text block on BookingWizard Step 5 (the review screen) before the Confirm button: 'Cancellation policy: full deposit refund if cancelled more than 24 hours before appointment. No refund for cancellations within 24 hours.' This is legally required in most jurisdictions before charging a deposit.

Backups

  • Verify daily backups are running before taking real deposits

    Lovable Cloud (Supabase Free) includes daily automated backups with 7-day retention. Confirm by checking Cloud tab → Database — if you see 'Daily backups enabled', you're covered. For booking data with financial records, consider migrating to your own Supabase Pro project for point-in-time recovery.

Monitoring

  • Check the pg_cron reminder schedule is actually running once bookings go live

    Cloud tab → Database → SQL Editor: SELECT * FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10; If you see recent rows with status='succeeded', the cron is running. If empty, the schedule may have failed — rerun the cron.schedule() call from follow-up #3.

Frequently asked questions

Will this actually prevent double-bookings under real traffic?

Yes, if you implement the starter prompt correctly. The EXCLUDE USING gist constraint on the bookings table is enforced at the Postgres level — two concurrent INSERTs for the same staff + overlapping time range will result in one succeeding and one getting Postgres error code 23P01 (exclusion violation). The create-booking Edge Function catches that error and returns a 409 so the second customer sees 'slot just taken' instead of a silent duplicate. The only remaining race is the very narrow window between the slot check and the INSERT — the advisory lock in follow-up #1 closes that.

How do I handle timezones for staff and customers in different states?

The rules are simple: store everything in UTC (timestamptz columns do this automatically in Postgres), compute availability in the staff member's timezone (using staff.timezone field in the compute_open_slots function), and display to customers in their browser's local timezone (using Intl.DateTimeFormat with no timezone override, which defaults to the browser's detected TZ). The follow-up #5 prompt walks through implementing this correctly. Always label slot times with the timezone abbreviation so customers know what timezone they're booking in.

Do I really need to take a deposit, or can I just ask people to show up?

Deposits reduce no-show rates dramatically (typically from 20-30% to 2-5% for service businesses). If you're just validating your booking flow or your customers are regulars who always show up, skip deposits for the first month — the starter prompt builds the full booking flow without Stripe and deposits are an optional follow-up. Add deposits when you see actual no-shows causing revenue loss.

How do recurring bookings (every Tuesday at 6pm) work?

Recurring bookings are not in this prompt kit — they're a significant add-on. The simplest approach for a Lovable build is to create a 'recurring booking' record that stores the pattern (weekday, time, service, staff) and a script or pg_cron job that creates individual booking rows for the next 4-8 weeks on a weekly schedule. The alternative is to let customers manually re-book each week, which works fine for low-volume studios. If your entire business model is recurring appointments (gym memberships, weekly therapy), consider the subscription-system kit instead.

Can customers book without creating an account?

Yes — the starter prompt uses Supabase magic-link OTP at Step 4 of the booking wizard. The customer enters their email, receives a magic link, clicks it, and their session is established. This is lower friction than password creation for one-off bookings. Their booking is associated with their auth.uid() once they verify. If they book again without clicking the magic link, you can look up bookings by customer_email as a fallback.

What happens if Stripe is down during peak booking hours?

If Stripe is down, the create-deposit-session Edge Function will return an error and the customer won't be redirected to checkout. The booking row is already created with status='pending' and deposit_paid=false at this point. Handle this gracefully: show the customer a message like 'Booking saved — complete your deposit payment within 2 hours to confirm your slot.' Then add a link to a /pay/:booking_id route that retries the Stripe session creation. The EXCLUDE constraint ensures the slot is held as 'pending' until either the deposit is paid or the booking expires (add a pg_cron that cancels unpaid pending bookings after 2 hours).

When does it stop making sense to keep building this in Lovable?

Export to GitHub and finish in Cursor when you need: multi-location support (separate availability per location), complex recurring rules (every other Tuesday, skip holidays), package-based booking (buy 10 sessions upfront), or waiting-list logic when slots are full. If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.

Need a production-grade version?

RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.