@backendfree/bookings
v0.1.1
Published
Typed access to a BackendFree project's diary: services, staff, availability and booking.
Maintainers
Readme
@backendfree/bookings
A project's diary: what it sells, who does it, when they are free, and booking it.
npm install @backendfree/core @backendfree/bookingsimport { 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: '[email protected]',
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:
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.
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.
The manage link
The confirmation email can link to a page on your site where the customer reads,
moves or cancels their booking. On the booking rules screen, set Where customers
manage their booking to an https address holding {token}, and usually {id}:
https://salon.example/booking/{id}?token={token}Both are filled in for each booking. That page reads the booking with them:
const booking = await bookings.booking(id, { token });With the field empty, the email carries the details and no link.
Paying
A booking that has to be paid for comes back pending_payment with somewhere to
send the customer:
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:
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:
localTime(slot.start, timezone); // ten in the morning, the reader's way
localTime(slot.start, timezone, undefined, 'de-CH'); // '10:00' for a Bern salonThe 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.
