Bookings
Appointment booking for teams that see clients: a clinic, a studio, a coach, a salon. Staff run the schedule on the web,
clients book from the web or the mobile app, and people with no account can book as guests and confirm by email. This is
the pack-booking pack. The Clinic and
Personal Trainer templates are built on it.
What you need
- Postgres, plus object storage for the business logo and photos on client notes (bucket
booking-media, created on first upload). Both are base services. - Optionally a Resend key (
RESEND_API_KEYfrom the base template). Without it no email goes out, which also means guests never get their confirmation link. Outside production the link is printed to the backend log instead. - No payment provider. Payments, deposits, waitlists, SMS, and multiple locations are out of scope.
What it depends on
- A SaaS starter for auth, organizations, and permissions. The business is an organization and its staff are members.
Who uses it
| Who | What they are | Where they work |
|---|---|---|
| Team | Members of the organization | The web app, under the Control center |
| Specialist | A team member people can book. A front desk can run the schedule without being bookable | Same |
| Client | Someone who books. Signed in, but not a member of the organization | /book and /my-bookings, web and mobile |
| Guest | Someone who books with no account | The public booking page and email links |
What you get
For the team (web only, each page needs manage:booking)
/booking: the schedule. Every specialist's week, drag to move, click an empty slot to book./booking/requests: approve or decline new bookings and requests to move, with undo./booking/clients: the client list, late cancels and no-shows, notes with photos or video, block and unblock./booking/events: workshops and group classes with several hosts and a capacity./booking/availability: weekly hours and time off, with a preview of which bookings a change would hit./booking/setup: services, specialist profiles, and business settings.
For clients
/book: specialists with their open times, saved favourites, and events./my-bookings: upcoming, past, and cancelled visits. Move, cancel, or book again.- On mobile, the same two screens plus a client app at
/clientfor people who belong to no organization. The mobile app has no staff screens.
For guests
- A public directory API and a
GuestBookingFormcomponent. The pack does not ship a public landing page; the templates put an open calendar on theirs, and you can do the same. /booking/visit?token=: the guest's own visit page, linked from the email.
How guest booking works
- The guest picks a time and enters a name and email. The booking is saved as
unconfirmedand holds the time for 30 minutes. - One email goes out, to the guest only, with a link.
- Opening the link and confirming turns it into a normal booking: approval rules apply and the usual emails go out.
- An unconfirmed hold frees its time after 30 minutes on its own. A job deletes the row later.
- One email address can hold at most 3 unconfirmed bookings per business.
Guests cannot cancel or move from the visit page. When someone later signs up with the same verified email, every booking under that email moves to their account. An unverified email links nothing.
Online booking is on by default. Turn it off in Setup > Business, and only the team can book new people; existing clients keep booking.
Who can see what
| Permission | Who has it | What it allows |
|---|---|---|
manage:booking | members and admins | Run the schedule, requests, clients, events, and their own hours and time off |
manage:booking_settings | admins (via manage:all) | Services, business settings, logo, and location. Also see every specialist's private notes and edit another specialist's hours |
Without booking_settings a member manages only their own hours, profile, and notes.
Clients never pass an organization permission check. /booking-client/* finds the business from the client's own
record, not from the session's active organization, so a client sees only their own bookings at the businesses they
booked with. A blocked client can no longer book from the app; the team still can book for them.
Use it from your code
Every write goes through two functions. They take the row lock, check the transition is allowed, record history for
undo, and queue the emails. Do not write to the booking table yourself.
import { createBooking } from "@backend/features/booking/modules/create-booking";
import { changeBooking } from "@backend/features/booking/modules/change-booking";
const { booking } = await createBooking({
organizationId,
actor: { kind: "team", id: user.id },
sessionTypeId,
specialistId: "any",
clientId,
startAt: new Date("2026-11-04T10:00:00Z"),
});
await changeBooking({
organizationId,
bookingId: booking.id,
action: "cancel",
actor: { kind: "system", id: "" },
reason: "Clinic closed",
});actor.kind is team, client, or system, and it decides which transitions are allowed and who gets emailed.
specialistId: "any" lets the pack pick whoever is free.
Other entry points in features/booking/modules/:
| Module | Use it for |
|---|---|
availability | findAvailableSlots, checkSlot: open times for a service and specialist |
public-directory | getPublicDirectory: what a signed-out visitor can book |
guest-booking | bookAsGuest, getGuestVisit, confirmGuestVisit |
settings | getOrgSettings, updateOrgSettings |
clients | listClients, createClient, blockClient |
On the frontend, embed GuestBookingForm with a pick of { sessionTypeId, specialistId, startAt } to let signed-out
visitors book from any public page. Fetch open times with sdk.bookingPublic.directory.
Background work
Three jobs on the template's scheduler and one queue:
| Job | Schedule | What it does |
|---|---|---|
booking-reminders | every 5 minutes | Queues reminders and "confirm you are coming" requests that are due |
booking-daily-agenda | every 5 minutes | Sends each specialist their day, at 08:00 local or earlier before an early first visit |
booking-reschedule-deadlines | every 15 minutes | Cancels bookings whose "pick a new time" deadline passed, and deletes expired guest holds |
Every email goes through the booking-notify queue. A change is queued in the same transaction, 10 seconds out, so an
undo inside that window sends nothing and several quick edits arrive as one email. Each email carries an .ics file
that updates the same calendar entry instead of adding a new one. A ledger table makes sure no message is sent twice.
Emails
Clients get: confirmed, requested, approved, declined, moved, cancelled, move requested, move declined, pick a new time, reminder, and the guest confirmation. Specialists get: new booking, new request, client cancelled, client moved, move request, attendance confirmed or declined, and the daily agenda.
Every client email except the reminder is mandatory. The rest follow the business defaults set in Setup, then each
person's own preferences. All emails use one React Email component in modules/notify/emails/booking-email.tsx, and the
wording lives in the booking_email_* keys of the backend message catalog.
In-app notifications are off. The pack has an in-app channel in modules/notify/channels/in-app.ts, but it is a no-op,
and installing the Notifications pack does not switch it on. To get
booking updates in the inbox, set IN_APP_AVAILABLE = true there and call sendNotification from sendInAppMessage.
Customize
| What | Where |
|---|---|
| Services, lengths, buffers, seats, prices, intake questions | Setup > Services in the app |
| Weekly hours and time off | /booking/availability |
| How far ahead people can book | Setup > Team, per specialist (1 to 365 days) |
| Time zone, currency, reminder lead time, approval policy, online booking | Setup > Business |
| Approval rules in code | helpers/approval-policy.ts: client override, then business policy, then the service rule |
| Guest hold length and per-email cap | helpers/holds.ts |
| Email layout and copy | modules/notify/emails/booking-email.tsx, booking_email_* messages |
Data model
| Model | Table | What it holds |
|---|---|---|
BookingSettings | booking_settings | One row per business: name, time zone, policies, defaults |
BookingLocation | booking_location | Address and payment note |
BookingSpecialist | booking_specialist | A bookable team member |
BookingSessionType | booking_session_type | A service |
BookingSpecialistHours | booking_specialist_hours | Weekly hours, several ranges per day |
BookingTimeOff | booking_time_off | Time away |
BookingClient | booking_client | Someone who books, with an optional user account |
BookingFavorite | booking_favorite | A specialist a client saved |
Booking | booking | A booking or a recurring series |
BookingEvent | booking_event | A workshop; each seat is a Booking |
BookingOccurrenceState | booking_occurrence_state | Attendance per visit of a series |
BookingActivity | booking_activity | Every change with before and after, used for undo |
BookingNotification | booking_notification | Every message sent, so nothing is sent twice |
BookingClientNote, BookingNoteAttachment | booking_client_note, booking_note_attachment | Team notes about a client, with files |
API
| Prefix | Who can call it |
|---|---|
/booking | Staff: manage:booking, settings routes need manage:booking_settings |
/booking-client | Any signed-in user, scoped to their own client records |
/booking-public | Anyone: the directory, guest booking, and the guest's visit page |
/booking-attendance | Anyone with a token from an email |
The public routes have no rate limit beyond the guest cap and a 30-second cache on the directory. Put a per-IP limit in
front of /booking-public before you point real traffic at it.
Environment variables
None of its own. RESEND_API_KEY from the base template turns email on.
After installing
Run pnpm prisma generate and then pnpm prisma migrate dev in your backend app, sign in, and open /booking/setup to
add your services and specialists.