Skip to content
BuildBase

Booking calendars

Sell a time on a seller's calendar: weekly hours, slots and seats, held at checkout, confirmed on payment, and moved or cancelled within the seller's rules.

A booking item sells a time on the seller's calendar: a coaching call, a lesson, a class with a few seats, a room for an hour. The seller sets their hours once; buyers see the open slots, pick one at checkout, and the slot is theirs once the order is paid.

Note

Arrives with selling. Bookings arrive with selling and run on the same Stripe keys: test keys in Staging for test sales, live keys in Production once live selling is open for your app.

Requires @buildbase/sdk 1.2.0 or later. The slot picker, the seller's availability editor and the booking hooks arrive with it.

Walkthrough

New to selling? Zero to first sale: marketplace and Zero to first sale: storefront walk the whole flow once, from Stripe keys to a refund and going live.

When to use it

Coaching calls, lessons, classes with a few seats, a room by the hour: anything sold as a time on the seller's calendar.

Before you start

An item type in the Book a time checkout mode (the Appointment starter), selling on, and a seller who can sell.

Set it up in the console

  1. Billing, Item types: start from Appointment, or set Checkout and buyers to Book a time, and publish.
  2. Watch bookings under Billing, Selling, Bookings and shared calendars under Calendars. Cancel, move or mark attendance from a booking.

The Bookings tab on Upcoming: each booking's time in the seller's zone with its length and seats, item, buyer, status and Move

Marketplace and storefront

A buyer cancels within the seller's rules in both shapes. A seller may cancel (with a full refund) only in the storefront shape; in the marketplace shape that is yours (cancel_needs_app_owner). Moving a booking is open to the seller in both.

What the seller sees

Their hours and rules (<BookingAvailabilityEditor> in the item form), <SellerCalendars> for shared calendars, and <SellerBookings> to cancel, move and mark attendance.

What the buyer sees

<SlotPicker>: the open slots in their own zone, one picked before paying, then the booking with its calendar file and links for Google Calendar and Outlook.

SlotPicker at 360 pixels wide: a week of days, open times for Tuesday in the seller's time zone with the buyer's own under each, 10:00 chosen, and Book and pay

Emails and webhooks

The seller gets New Booking; the buyer and the seller get Booking Cancelled and Booking Moved; the buyer gets Booking Reminder. Your server gets booking.confirmed, booking.cancelled, booking.rescheduled and booking.updated (events and notices).

Limits

One slots read covers at most 31 days; booking types sell one-time prices only, with no stock of their own.

Staging and Production

Nothing of its own: bookings follow their orders.

The item type

Bookings are an item type checkout mode: Book a time (checkoutMode: "booking"). The Appointment starter has it on. A booking type:

  • has no stock (booking_stock_none): the calendar's seats are its stock;
  • sells at one-time prices only (booking_one_time_only): no subscriptions, deposits or payment plans;
  • cannot include other items (booking_no_bundle); a bundle may include a booking item (see In a bundle).

The calendar

Every item of a booking type carries one calendar, booking (booking_required without it; any other item refuses one with booking_not_allowed): its own, as below, or a seller's shared calendar. Its variants are price tiers of the same slot, or of a longer one (see Longer bookings).

{
  "booking": {
    "timeZone": "America/New_York",
    "slotMinutes": 60,
    "bufferMinutes": 15,
    "minNoticeMinutes": 720,
    "maxDaysAhead": 60,
    "seatsPerSlot": 1,
    "weekly": {
      "mon": [{ "from": "09:00", "to": "17:00" }],
      "wed": [
        { "from": "09:00", "to": "12:00" },
        { "from": "14:00", "to": "18:00" }
      ]
    },
    "exceptions": [{ "date": "2027-12-24", "windows": [] }],
    "buyerCancel": { "minutesBefore": 1440, "refund": "full" },
    "buyerReschedule": { "minutesBefore": 120, "maxTimes": 2 },
    "reminders": [{ "minutesBefore": 1440 }, { "minutesBefore": 60 }]
  }
}
FieldMeans
timeZoneThe seller's IANA time zone. Hours and dates are wall times there
slotMinutes5 to 720, in steps of 5
bufferMinutesKept free after each slot, 0 to 240
minNoticeMinutesThe soonest a slot can be booked, up to 30 days
maxDaysAheadThe furthest ahead, 1 to 365 days; 60 when unset
seatsPerSlotBuyers per slot, 1 to 1000; 1 when unset. A checkout's quantity takes that many
weeklyUp to 6 windows a day, mon to sun, HH:MM to HH:MM (24:00 ends at midnight)
exceptionsUp to 200 dates with other hours, or none ([])
buyerCancelUntil how many minutes before the start a buyer may cancel, and the refund (full or none)
buyerRescheduleUntil when a buyer may move the booking, and how many times
remindersUp to 3 reminders to the buyer, each 15 minutes to 7 days before; unset is one a day before

Leave buyerCancel or buyerReschedule out (or null) and buyers cannot do it; the seller and you still can. A variant's most per checkout (quantity.max) is at most seatsPerSlot.

Slots start at each window's start and step by slotMinutes + bufferMinutes; a slot must end inside its window. They step in real time, so on the night clocks go back a window has an hour more of slots, and on the night they go forward an hour fewer. A window that opens in the skipped hour opens after it.

Problem codes, at booking.<field>: invalid_time_zone, invalid_slot_length, invalid_buffer, invalid_notice, invalid_days_ahead, invalid_seats, invalid_window, windows_overlap, too_many_windows, invalid_exception, duplicate_exception, too_many_exceptions, invalid_cancel_rule, no_hours, invalid_reminder, too_many_reminders, duplicate_reminder.

Longer bookings

A variant can last several slots of the grid in a row: set bookingSlots on it, 1 to 12 (1 when unset). On a grid of 45-minute slots with a 15-minute buffer, a variant with "bookingSlots": 2 is a 105-minute session: two slots and the buffer between them. Every slot of the run is inside one window of one day, and takes its seats: a 2-slot booking at 09:00 holds seats on the 09:00 slot and the next one.

{
  "variants": [
    {
      "id": "single",
      "prices": [
        { "id": "usd", "currency": "usd", "mode": "fixed", "amount": 6000 }
      ],
      "stock": { "mode": "none" }
    },
    {
      "id": "double",
      "bookingSlots": 2,
      "prices": [
        { "id": "usd", "currency": "usd", "mode": "fixed", "amount": 11000 }
      ],
      "stock": { "mode": "none" }
    }
  ]
}

Refusals, at variants.<id>.bookingSlots: invalid_booking_slots (not 1 to 12), booking_slots_too_long (no window of the calendar is long enough), booking_slots_not_allowed (the item is not booked by time).

Checkout, its hold, payment, an unpaid end, a cancel, a move, a full refund and a lost dispute take or give every slot of the run together: if one slot of the run is full, none is taken and the checkout gets SLOT_UNAVAILABLE. The booking's end, the order line's and the calendar file's are the end of the whole run.

Showing the open slots

GET /api/v1/public/items/:id/slots?from=2027-06-01T00:00:00Z&to=2027-06-15T00:00:00Z
GET /api/v1/guest/:orgId/items/:id/slots
{
  "itemId": "6a1b...",
  "timeZone": "America/New_York",
  "slotMinutes": 60,
  "calendarId": null,
  "from": "2027-06-01T00:00:00.000Z",
  "to": "2027-06-15T00:00:00.000Z",
  "slots": [
    {
      "start": "2027-06-07T13:00:00.000Z",
      "end": "2027-06-07T14:00:00.000Z",
      "remaining": 1
    }
  ]
}

from defaults to now and to to 14 days later; one read covers at most 31 days. Slots inside the notice, beyond the days ahead, or full are left out. On a shared calendar calendarId names it, and a slot booked through any of its items is full here too.

Seats are counted by time, not by the slot's start. After a seller changes a grid (its hours, an item's own slot length, a buffer or the zone), a booking made on the old grid keeps the time it covers: a new slot that overlaps it, with the buffers the grid keeps free before and after each slot, counts that booking's seats, so on a 1-seat calendar it is not listed, and a checkout or a move onto it gets 409 SLOT_UNAVAILABLE. Each slot is named by its start, a UTC instant; show it in the buyer's or the seller's zone as you like.

Add variantId for a variant that lasts several slots: the answer then lists only the starts where its whole run fits and has the seats, with each end at the end of the run, and says "variantId" and "bookingSlots". Without it, the answer is for one slot ("variantId": null, "bookingSlots": 1).

Checkout

A checkout of a booking item names the slot's start, slotStart:

{
  "variantId": "session",
  "priceId": "usd",
  "slotStart": "2027-06-07T13:00:00.000Z",
  "successUrl": "https://example.com/thanks",
  "cancelUrl": "https://example.com/coach"
}
RefusalMeans
422 CHECKOUT_REFUSED, slot_requiredA booking item needs slotStart
422 CHECKOUT_REFUSED, slot_not_bookableslotStart on an item that takes no bookings
422 CHECKOUT_REFUSED, slot_invalidNot a slot of the calendar
422 CHECKOUT_REFUSED, slot_too_soonInside the notice, or past
422 CHECKOUT_REFUSED, slot_too_farBeyond the days ahead
409 SLOT_UNAVAILABLESomeone took the last seat; pick another time

Checkout holds the slot's seats while Stripe's page is open, as it holds stock: two buyers at the last seat get one booking and one SLOT_UNAVAILABLE. A checkout that ends unpaid gives the seats back. Once the order is paid (or claimed free) the booking is confirmed and booking.confirmed is sent. A payment that arrives after its checkout ended still confirms the booking, even if the slot filled meanwhile, or its time now overlaps a booking made before the seller changed the slot length, a buffer or the hours; it is then marked overbooked for the seller to sort out.

The order line carries its booking (lines[].booking: bookingId, start, end, timeZone, status), and the sales export a booking_start column: every booked start of the order, a bundle's included ones after their line's, joined with "; ".

Shared calendars

Two items can sell the same time: a wheel lesson and a glazing lesson in one studio, a call of 30 minutes and one of 60 with the same coach. The seller makes a shared calendar, the grid and its seats, and each item names it instead of carrying a grid of its own:

{
  "booking": {
    "calendarId": "6a1d...",
    "minNoticeMinutes": 720,
    "buyerCancel": { "minutesBefore": 1440, "refund": "full" },
    "reminders": [{ "minutesBefore": 60 }]
  }
}

The calendar holds timeZone, slotMinutes, bufferMinutes, seatsPerSlot, weekly and exceptions, and a name. Each item keeps its own sales rules: minNoticeMinutes, maxDaysAhead, buyerCancel, buyerReschedule, reminders, and its variants' bookingSlots (a longer variant is a run of the calendar's slots). The seats are the calendar's: a slot booked through one item is taken for every item on it, and two buyers at its last seat through two items get one booking.

Refusal, at booking.<field>Means
booking_grid_on_calendarAn item on a calendar sets a grid field itself
calendar_not_foundNo such calendar
calendar_other_sellerThe calendar is another seller's
calendar_too_many_itemsThe calendar already has 50 items
calendar_switch_in_useThe item has bookings to come: it keeps its calendar for now
booking_seats_capA seat cap above the calendar's seats, or on an item's own grid

An item with bookings to come cannot change calendars by a save; its bookings stay where they were counted. To bring them along, move them onto the calendar. A seller keeps up to 20 calendars.

WhoRoutes
The seller/me/calendars and /workspaces/:id/calendars (SELLING_VIEW to read, SELLING_MANAGE to change)
Your server/api/v1/server/commerce/calendars, any seller's (sellerId in the body to make one), items grant
The console/api/marketplace/calendars, marketplace_calendars grant: read, and PATCH /:id/settings

Each takes GET / (a page, page and limit), POST /, GET /:id, PUT /:id (the name and whole grid) and DELETE /:id; the console reads them and changes only their settings. A calendar reads back with its seller and the items on it (items: id, title, status).

Refusal, 422 CALENDAR_REFUSEDMeans
a grid code, at its fieldinvalid_time_zone, invalid_slot_length, ... as for an item's own grid
invalid_nameA name of 1 to 80 characters
too_many_calendarsThe seller already keeps 20
calendar_in_useA change of zone, slot length or a buffer while a booking on it has not ended, or a narrower capacity model
calendar_seats_in_useFewer seats than a slot to come already has taken
calendar_has_itemsA delete while items are booked on it

Another seller's calendar is 404 CALENDAR_NOT_FOUND. Hours and dated exceptions may change at any time; a booking already made stands, and its time stays taken. A booking on a shared calendar says so (calendarId), and a booking list narrows to one calendar with calendarId.

Capacity on a shared calendar

A shared calendar's seats are one pool by default. Studios, clinics and rental desks share them differently, so the calendar has a setting, capacityModel:

capacityModelHow its items share a slot's seats
shared_pool (default)One pool: any item takes any seat
item_capsThe pool, and each item at most its own seatsCap of it. Room A with 6 seats, the wheel lesson capped at 4 and the glazing lesson at 6: wheel buyers take at most 4, the rest is any
exclusiveThe first booking on a slot locks it to its item until every seat is given back: a room booked for a lesson is not booked for a party at the same time

An item on the calendar sets seatsCap (at most the calendar's seatsPerSlot; an item without one may take the whole pool). Any booking item may set seatsPerUnit (default 1): each unit sold takes that many seats, so a family ticket of 4 takes 4 seats a unit and a checkout of 2 takes 8. The slots an item reads say what is left for it, in its own units (remaining), and the seats beside (seats); a booking says the seats it holds (seatsTaken).

While bookings on the calendar have not ended, its capacity model changes only to shared_pool; any other change is refused with calendar_in_use, since an item's share and a slot's lock are only counted from when the model asks for them.

The calendar's other settings: moveConflicts (below) and bookingLimits. All three are optional in PUT /:id, and a save that leaves one out keeps it, so an older client's save of the grid changes none of them. The console sets them with PATCH /:id/settings.

Buffers and limits

bufferMinutes is kept free after each slot; bufferBeforeMinutes before it. Both are part of the grid: the first slot of a window starts after the buffer before, and two slots are the buffer after plus the buffer before apart. With hours from 09:00, 30 minute slots, 10 after and 15 before, the slots start at 09:15, 10:10, 11:05.

bookingLimits caps the bookings taken on one local day or week, in the calendar's time zone ({ "perDay": 4, "perWeek": 12 }; a week runs Monday to Sunday). An item's limits count its own bookings; a shared calendar's count every booking on it, through any item. Each booking counts once, whatever its seats. A day at its limit offers no slots, and a checkout or a move onto it is refused with 409 SLOT_UNAVAILABLE, problem code booking_limit_reached. A booking at 23:30 counts on its local day even where the clocks change that night or UTC has moved to the next date.

Moving bookings onto a shared calendar

An item with bookings to come can move onto a shared calendar, bringing them along: from its own grid, or from another calendar of the same seller. First the preview, which reads only:

POST /me/items/:id/booking/calendar-move/preview
{ "calendarId": "6a1d..." }

It answers each booking to come with where it would land:

outcomeMeans
fitsIts slots are slots of the calendar, with the seats for it (by the calendar's capacity model, counted with the ones before it)
over_capacityIts slots are there, but not its seats
off_gridIts time is not a slot of the calendar (another slot length, hours or buffer)

and canMove. Then the move, the same body without /preview. A booking off_grid always refuses the move. A booking over_capacity refuses it too, unless the calendar's moveConflicts is overbook (default refuse): then it moves and is marked overbooked for the seller to sort out. A refused move answers 409 CALENDAR_MOVE_CONFLICTS with problems naming each booking (calendar_move_off_grid, calendar_move_over_capacity) and the preview; nothing changes.

While a move runs, the item's checkouts and booking moves answer 409 BOOKING_CALENDAR_MOVING. A move that stops halfway (a deploy, a crash) is finished by sending it again with the same calendar: each booking is taken on the new calendar, moved, then given back on the old one, once. When it is done the item is booked on the calendar, keeping its own rules, and each moved booking sends booking.updated with change: "calendar".

WhoRoutes
The seller/me/items/:id/booking/calendar-move and /workspaces/:id/items/:id/booking/calendar-move (SELLING_MANAGE), each with /preview
Your server/api/v1/server/commerce/items/:id/booking/calendar-move, items grant (the preview reads, the move updates)
The console/api/marketplace/items/:id/booking/calendar-move, marketplace_items and marketplace_calendars grants

In a cart

A cart takes booking lines: each names its slotStart, as a single checkout does, and a bundle line its slotStarts.

{
  "lines": [
    {
      "itemId": "6a1b...",
      "variantId": "session",
      "priceId": "usd",
      "slotStart": "2027-06-07T15:00:00.000Z"
    },
    {
      "itemId": "6a1e...",
      "variantId": "session",
      "priceId": "usd",
      "slotStart": "2027-06-07T16:00:00.000Z"
    }
  ],
  "successUrl": "https://example.com/thanks",
  "cancelUrl": "https://example.com/cart"
}

Every booking is held after the stock, in cart order, all or nothing: if one slot is full the whole cart is refused with 409 SLOT_UNAVAILABLE at lines.<n>.slotStart, and nothing stays held. Two lines on one slot of a calendar take its seats twice. Payment confirms every booking; a cart left unpaid gives every seat back. A slot's problem (slot_required, slot_invalid, ...) names its line the same way.

  • With a subscription in the cart: a booking is paid once, so it goes on the subscription's first invoice as a one-time line, and that invoice's payment confirms it.
  • With a price paid in parts: the booking is paid in full on the same page as the first part.
  • In the storefront shape (a cart paid in steps), every step's bookings are held when the buyer checks out, with the stock, all or nothing. A later step's bookings are held while the steps before it are paid; opening its page moves its bookings' hold with its stock's. A step that expires, or whose page is left unpaid, gives its seats back on its own; the steps already paid keep theirs.
  • A coupon's check before checkout reads the prices only; the slots are checked when the cart checks out.

In a bundle

A bundle may include a booking item. Its checkout takes a start for each included booking item, by the bundle's item benefit id:

{
  "variantId": "kit",
  "priceId": "usd",
  "slotStarts": { "lesson": "2027-06-07T13:00:00.000Z" },
  "successUrl": "https://example.com/thanks",
  "cancelUrl": "https://example.com/kit"
}

The buyer reads each included item's open slots from that item's own GET /items/:id/slots; a bundle's includes say which are booked by time (bookable). Problems are at slotStarts.<benefitId>: slot_required, slot_invalid, slot_too_soon, slot_too_far, and slot_not_bookable for a start given for anything else. A full slot is 409 SLOT_UNAVAILABLE there, and the checkout holds nothing.

  • Seats. Bundles bought times the included quantity. A bundle that includes more than a slot's seats of an item cannot be published (bundle_item_booking_seats).
  • The order. The booking is on the line's component (lines[].components[].booking, with its benefitId).
  • Changes. The buyer cancels or moves it by the included item's rules, but a cancel refunds nothing: the bundle has one price (inBundle: true, and the buyer's actions.refund is none). A full refund of the bundle line, or a lost dispute, cancels its bookings that have not started.

The booking

{
  "_id": "6a1c...",
  "orderId": "6a1b...",
  "itemTitle": "Coaching call",
  "start": "2027-06-07T13:00:00.000Z",
  "end": "2027-06-07T14:00:00.000Z",
  "timeZone": "America/New_York",
  "calendarId": null,
  "benefitId": null,
  "inBundle": false,
  "seats": 1,
  "status": "confirmed",
  "overbooked": false,
  "rescheduled": 0,
  "cancelledBy": null,
  "calendarUrl": "https://api.example.com/api/v1/guest/.../calendar.ics?token=..."
}

status is held (checkout open), confirmed, cancelled or expired. calendarUrl downloads the booking as a calendar file (.ics) that any calendar app opens; its token opens that file only. A confirmed booking also carries addToCalendar: links that add it to Google Calendar and Outlook on the web. The buyer's view adds actions: whether they may cancel or move it now, and what a cancel refunds.

Cancelling and moving

WhoCancelMove
The buyerWithin buyerCancel; refunds the line in full or not at all, as the rule says, in both shapesWithin buyerReschedule
The seller, storefrontAny time before the start; refunds the line in fullAny time before the start
The seller, marketplaceRefused (cancel_needs_app_owner): you are the merchantAny time before the start
You (console) or your serverAny time before the start; refunds in full unless refund: falseAny time before the start

A booking that has started, or is not confirmed, does not change (booking_past, booking_not_active). Other refusals are cancel_not_allowed, cancel_window_closed, reschedule_not_allowed, reschedule_window_closed, reschedule_limit and reschedule_into_cancel_window, all as 422 BOOKING_CHANGE_REFUSED.

When buyers may cancel (buyerCancel), a buyer or guest cannot move a booking to a time that starts inside the cancel window: with a 24-hour cancel window, a move to a slot 20 hours away is refused with reschedule_into_cancel_window, so a move never carries a booking past the point where it could still be cancelled. The seller, you and your server may move it there.

The other way round, a buyer may still move a booking that is already inside its cancel window (the move window is often shorter), but the cancel stays closed after the move, wherever it lands: with a 24-hour cancel window and a 2-hour move window, a booking 3 hours away can be moved to next week, and a buyer's cancel of it is then refused with cancel_window_closed (its actions read "cancel": false). A move cannot reopen a cancel, and its refund, that the time it held had already closed. A move by the seller, you or your server leaves the buyer's cancel to follow the new time (unless a buyer's own move had already closed it), and you and your server may always cancel and refund. A move needs a free slot of the item (409 SLOT_UNAVAILABLE otherwise); the seller, you and your server may move a booking inside the notice.

A cancel answers the booking and the refund it made. If Stripe refuses the refund the booking stays cancelled and the answer says refundFailed; refund the order from Orders.

A full refund of the line, or a lost dispute, cancels a booking that has not started (cancelledBy: "system") and frees its seats.

WhoRoutes
The buyerGET /me/bookings, GET /me/bookings/:id, POST /me/bookings/:id/cancel, POST /me/bookings/:id/reschedule, GET /me/bookings/:id/calendar.ics
A guestGET /api/v1/guest/:orgId/orders/:id/booking, POST .../booking/cancel, POST .../booking/reschedule, with the order token in x-order-token
The seller/me/sales/bookings and /workspaces/:id/sales/bookings: list, one, cancel, reschedule
Your server/api/v1/server/commerce/bookings: list, one, cancel, reschedule

Attendance

Once a confirmed booking has started, the seller, you and your server may mark whether the buyer came: PUT .../bookings/:id/attendance with { "attendance": "attended" }, "no_show", or null to clear it. It is a record only: nothing is refunded or charged. A booking that has not started is refused with attendance_too_early. The booking reads back with attendance and attendanceAt, and sends booking.updated with change: "attendance".

A move takes { "slotStart": "..." }; a cancel takes an optional reason, and from you or your server refund: false. A booking keeps its length when it moves.

A list narrows by status, itemId, calendarId, from and to (by start), pages with page and limit (at most 100), and sorts by start with sort: start, soonest first (the default), or -start, latest first. from and to are times, such as 2027-06-01T00:00:00Z; one that is not answers 400 with path naming it.

Events and notices

EventWhen
booking.confirmedThe order was paid or claimed free
booking.cancelledCancelled, with cancelledBy and the refund made (or null)
booking.rescheduledMoved, with the slot it left (previous) and who moved it (movedBy)
booking.updatedChanged in place: change is attendance or calendar (moved onto a shared calendar with its item, previousCalendarId), with updatedBy

The seller gets a New Booking email (and push) on each confirmed booking; the buyer and the seller get Booking Cancelled and Booking Moved. The buyer's receipt names the booked time and links the calendar file. Turn any of them off under Notifications.

Reminders

Before each confirmed booking the buyer gets a Booking Reminder email, signed in or a guest, at each of the calendar's reminders. It names the time in the seller's zone, and in the buyer's own zone too when a signed-in buyer's is known and differs; it links the calendar file, and a guest's links their order when you have set your app's order page under Selling, Settings. A signed-in buyer also gets a push.

  • Unset reminders is one a day before (the Appointment starter's calendars); [] sends none for that item.
  • A reminder whose time has already gone by when the booking is made (a booking for tomorrow morning, with a reminder a day before) is skipped, never sent late.
  • Each reminder is sent once. If several fall due together, the one nearest the start is sent.
  • A move starts the reminders again from the new time; a cancel or an ended checkout stops them.
  • They go out within five minutes of their time. Your organization's notification email and push switches apply; there is no webhook event for a reminder.

Not in this release

Waiting lists, syncing with an outside calendar (Google, Outlook), choosing a room or a person for a booking by itself, a refund or a fee for a no-show, and refunding a time a bundle included on its own.