# 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](/billing/selling)
and run on the same Stripe keys: test keys in Staging for test sales, live keys
in Production once [live selling](/billing/selling#test-in-staging-then-go-live) 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](/billing/first-sale-marketplace) and [Zero to first sale: storefront](/billing/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](/images/billing/selling/console-bookings-tab.webp)

### 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](/images/billing/selling/sdk-slot-picker.webp)

### 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](#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](/billing/item-types) 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](#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](#shared-calendars). Its variants are price tiers of the same slot, or
of a longer one (see [Longer bookings](#longer-bookings)).

```json
{
  "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 }]
  }
}
```

| Field              | Means                                                                                         |
| ------------------ | --------------------------------------------------------------------------------------------- |
| `timeZone`         | The seller's IANA time zone. Hours and dates are wall times there                             |
| `slotMinutes`      | 5 to 720, in steps of 5                                                                       |
| `bufferMinutes`    | Kept free after each slot, 0 to 240                                                           |
| `minNoticeMinutes` | The soonest a slot can be booked, up to 30 days                                               |
| `maxDaysAhead`     | The furthest ahead, 1 to 365 days; 60 when unset                                              |
| `seatsPerSlot`     | Buyers per slot, 1 to 1000; 1 when unset. A checkout's quantity takes that many               |
| `weekly`           | Up to 6 windows a day, `mon` to `sun`, `HH:MM` to `HH:MM` (`24:00` ends at midnight)          |
| `exceptions`       | Up to 200 dates with other hours, or none (`[]`)                                              |
| `buyerCancel`      | Until how many minutes before the start a buyer may cancel, and the refund (`full` or `none`) |
| `buyerReschedule`  | Until when a buyer may move the booking, and how many times                                   |
| `reminders`        | Up 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.

```json
{
  "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

```http
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
```

```json
{
  "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`:

```json
{
  "variantId": "session",
  "priceId": "usd",
  "slotStart": "2027-06-07T13:00:00.000Z",
  "successUrl": "https://example.com/thanks",
  "cancelUrl": "https://example.com/coach"
}
```

| Refusal                                     | Means                                         |
| ------------------------------------------- | --------------------------------------------- |
| `422 CHECKOUT_REFUSED`, `slot_required`     | A booking item needs `slotStart`              |
| `422 CHECKOUT_REFUSED`, `slot_not_bookable` | `slotStart` on an item that takes no bookings |
| `422 CHECKOUT_REFUSED`, `slot_invalid`      | Not a slot of the calendar                    |
| `422 CHECKOUT_REFUSED`, `slot_too_soon`     | Inside the notice, or past                    |
| `422 CHECKOUT_REFUSED`, `slot_too_far`      | Beyond the days ahead                         |
| `409 SLOT_UNAVAILABLE`                      | Someone 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](/billing/orders)
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:

```json
{
  "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_calendar`    | An item on a calendar sets a grid field itself                  |
| `calendar_not_found`          | No such calendar                                                |
| `calendar_other_seller`       | The calendar is another seller's                                |
| `calendar_too_many_items`     | The calendar already has 50 items                               |
| `calendar_switch_in_use`      | The item has bookings to come: it keeps its calendar for now    |
| `booking_seats_cap`           | A 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](#moving-bookings-onto-a-shared-calendar). A seller keeps
up to 20 calendars.

| Who         | Routes                                                                                               |
| ----------- | ---------------------------------------------------------------------------------------------------- |
| 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](#capacity-on-a-shared-calendar). A calendar reads back with its seller and the items on it
(`items`: `id`, `title`, `status`).

| Refusal, `422 CALENDAR_REFUSED` | Means                                                                                                       |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| a grid code, at its field       | `invalid_time_zone`, `invalid_slot_length`, ... as for an item's own grid                                   |
| `invalid_name`                  | A name of 1 to 80 characters                                                                                |
| `too_many_calendars`            | The seller already keeps 20                                                                                 |
| `calendar_in_use`               | A change of zone, slot length or a buffer while a booking on it has not ended, or a narrower capacity model |
| `calendar_seats_in_use`         | Fewer seats than a slot to come already has taken                                                           |
| `calendar_has_items`            | A 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`:

| `capacityModel`         | How its items share a slot's seats                                                                                                                                                    |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shared_pool` (default) | One pool: any item takes any seat                                                                                                                                                     |
| `item_caps`             | The 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 |
| `exclusive`             | The 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:

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

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

| `outcome`       | Means                                                                                                                          |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `fits`          | Its slots are slots of the calendar, with the seats for it (by the calendar's capacity model, counted with the ones before it) |
| `over_capacity` | Its slots are there, but not its seats                                                                                         |
| `off_grid`      | Its 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"`.

| Who         | Routes                                                                                                                               |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| 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](/billing/orders#carts-across-sellers) takes booking lines: each
names its `slotStart`, as a single checkout does, and a bundle line its
`slotStarts`.

```json
{
  "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](/billing/orders#storefront-carts-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](/billing/bundles) may include a booking item. Its checkout
takes a start for each included booking item, by the bundle's `item`
benefit id:

```json
{
  "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

```json
{
  "_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

| Who                          | Cancel                                                                                         | Move                      |
| ---------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------- |
| The buyer                    | Within `buyerCancel`; refunds the line in full or not at all, as the rule says, in both shapes | Within `buyerReschedule`  |
| The seller, storefront       | Any time before the start; refunds the line in full                                            | Any time before the start |
| The seller, marketplace      | Refused (`cancel_needs_app_owner`): you are the merchant                                       | Any time before the start |
| You (console) or your server | Any time before the start; refunds in full unless `refund: false`                              | Any 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](/billing/orders).

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

| Who         | Routes                                                                                                                                              |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| The buyer   | `GET /me/bookings`, `GET /me/bookings/:id`, `POST /me/bookings/:id/cancel`, `POST /me/bookings/:id/reschedule`, `GET /me/bookings/:id/calendar.ics` |
| A guest     | `GET /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

| Event                 | When                                                                                                                                          |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `booking.confirmed`   | The order was paid or claimed free                                                                                                            |
| `booking.cancelled`   | Cancelled, with `cancelledBy` and the `refund` made (or `null`)                                                                               |
| `booking.rescheduled` | Moved, with the slot it left (`previous`) and who moved it (`movedBy`)                                                                        |
| `booking.updated`     | Changed 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.
