# @backendfree/bookings

> Services, staff, free times and taking a booking, with a token the customer manages it by.

A project's diary: what it sells, who does it, when they are free, and booking
it.

```sh
npm install @backendfree/core @backendfree/bookings
```

```ts
import { Client } from '@backendfree/core';
import { Bookings, localTime } from '@backendfree/bookings';

const bookings = new Bookings(new Client({ origin: 'https://backendfree.com', key: 'pk_live_...' }));

const { results: services } = await bookings.services();
const { slots, timezone } = await bookings.availability({
  service: services[0].id,
  from: '2026-06-10',
});

const booking = await bookings.book({
  service: services[0].id,
  starts_at: slots[0].start,
  customer_email: 'sam@example.com',
  success_url: 'https://salon.example/thanks',
  cancel_url: 'https://salon.example/book',
});
```

## A publishable key is enough to book

Taking a booking from a browser is the product, and what bounds it is what one
customer can do rather than who holds the key.

Reading the diary is the exception. `list()` needs a secret key, because it is
every customer's name, address and where they will be on Thursday.

## When a time was just taken

Free times are cached for up to a minute, so one can be gone by the time it is
booked. `book()` then throws with the code `slot_unavailable`, and nothing was
booked. Asking `availability()` again straight away can offer the same time for
the rest of that minute, so stop showing the one that was refused:

```ts
import { BackendFreeError } from '@backendfree/core';

try {
  await bookings.book(request);
} catch (error) {
  if (error instanceof BackendFreeError && error.code === 'slot_unavailable') {
    refused.add(request.starts_at);   // and leave it out of what you show
  }
}
```

Two people can never hold one time: the booking call is what decides, not the
list of free times.

## The manage token

`book()` returns `manage_token` exactly once. It is a capability: whoever holds
it may read, cancel or reschedule that one booking and nothing else. Keep it if
the customer will manage their own appointment from a browser, and treat it like
a password.

```ts
await bookings.cancel(booking.id, { token: booking.manage_token, reason: 'Away' });
await bookings.reschedule(booking.id, { token, starts_at: otherSlot.start });
```

No later call hands it over again, so a listing cannot leak one.

A confirmed booking that moves, whoever moved it, emails the customer the new
time with a calendar file that replaces the old one, so a page that reschedules
need not send its own.

## The manage link

The confirmation email links to a page where the customer reads, moves or cancels
their booking. Unless you name one, it is a page BackendFree hosts for each
booking. To use a page on your own site instead, set *Where customers manage
their booking* on the booking rules screen to an https address holding `{token}`,
and usually `{id}`:

```text
https://salon.example/booking/{id}?token={token}
```

Both are filled in for each booking. That page reads the booking with them:

```ts
const booking = await bookings.booking(id, { token });
```

With the field empty, the link is the hosted page, its token in the address's
fragment so no server sees it.

## Paying

A booking that has to be paid for comes back `pending_payment` with somewhere to
send the customer:

```ts
if (booking.payment?.required) {
  window.location.href = booking.payment.checkout_url;
}
```

**Nothing confirms a booking except the provider's webhook.** The page the
customer lands on afterwards proves they got back to your site, not that they
paid. Poll `booking()` or listen for your own webhook.

`{id}` in `success_url` or `cancel_url` becomes the booking's id, so that page
knows which booking to read: `success_url: 'https://salon.example/thanks?booking={id}'`.
Only the id: the manage token is never put in an address, so keep it from
`book()` if the page has to read the booking with a publishable key.

The slot is held only until `payment.expires_at`. If the payment page could not
be opened, or the customer closed the tab, `checkout()` offers it again:

```ts
const withPage = await bookings.checkout(booking.id, { token, success_url, cancel_url });
```

Deliberately not a retry of `book()`: booking again would ask for a second
appointment in a slot the first one is already holding.

## Times

Send instants with an offset. A time with no zone is refused at the boundary,
because the browser's clock, the server's and the business's are three different
answers and a naive time silently picks one.

Read them on the business's clock, which `availability()` tells you:

```ts
localTime(slot.start, timezone);                       // ten in the morning, the reader's way
localTime(slot.start, timezone, undefined, 'de-CH');   // '10:00' for a Bern salon
```

The zone is the business's and the wording is the reader's. Ten o'clock in Bern
is ten o'clock however it is written.

## Money

Always `{ amount, currency }` in minor units: `{ amount: 6500, currency: 'CHF' }`
is CHF 65.00. Never divide by a hundred without checking the currency, and never
put it in a float.

## Rules

`settings()` publishes every rule the create call enforces, so a form can grey
out what would be refused rather than finding out afterwards: how far ahead
bookings are taken, how close to the appointment, the grid times sit on, whether
a phone number is wanted, and whether customers may move their own booking.
