# Build a booking page

> A booking form on a site you build, from setup to taking a deposit.

This guide builds a booking form into a website: the customer picks a service
and a time, books, and pays a deposit if the service takes one. Everything runs
in the customer's browser.

It uses the publishable key. For bookings made from your own server, such as a
CRM or an internal tool, see [the server path](https://keptslot.com/docs/guides/booking-flow/).

## Set up the workspace first

Three settings in the dashboard, all under **Developer → API keys**.

**1. Add your site to the Booking page key.** Every workspace has a
publishable key named **Booking page**. Choose **Edit allowed origins** in its
menu and add every address your form runs on, for example
`https://www.northsidebarbers.ca` and `http://localhost:4321` for development.
The key itself stays the same.

**2. Turn on "Custom booking frontend"**, in the **Advanced** section of the
same page. Without it, every booking your form makes answers `403` with
`turnstile_required`. When it is on, bookings made with this workspace's
publishable keys need no Turnstile token. The
[rate limits and the daily booking limit](https://keptslot.com/docs/guides/limits/) still apply.

**3. For deposits:** the workspace must be on Pro, with Stripe connected under
**Payments**, and the service must have a deposit set. A deposit service on a
workspace that cannot take payments is refused with `422` on `payment`.

## Read the key from the workspace response

`GET /v1/public/workspaces/{slug}` takes no key. Send none: with one, the
browser refuses the request. Its response carries the Booking page key from
step 1 as `workspace.publishable_key`. Send that key on every other call:

```js
const API = 'https://api.keptslot.com'
const { workspace, services, staff, questions } = await (
  await fetch(`${API}/v1/public/workspaces/your-slug`)
).json()
const auth = { Authorization: `Bearer ${workspace.publishable_key}` }
```

The same response carries everything else the form needs: services, calendars,
the questions the form must ask, and the business's refund policy.

If `workspace.online_bookings_paused` is `true`, the business is not taking
online bookings. Availability, creating a booking and rescheduling answer `409`
with `bookings_paused`. Show that to the customer instead of the form.

If a call answers `403` with `origin_not_allowed`, the address your form runs
on is not on the key's list. If the Booking page key was revoked, the response
carries the oldest publishable key still active instead; add your site's
address to that one.

## Extras

A service in the workspace response carries `standalone`, `extra_scope` and
`extra_ids`. `extra_ids` lists the extras the customer can add to that service,
and it is empty when there are none. List the services with `standalone: true`
as the choices. A service with `standalone: false` is only ever an extra, and
the response includes it so that you can show its name.

After the customer picks a service, show its extras. Pass the ones they choose
to the availability call, then to the quote call, then to the booking.

```js
const extraIds = chosen.map((e) => e.id).join(',')
const params = new URLSearchParams({ service_id: service.id, from, to })
if (extraIds) params.set('extra_ids', extraIds)
const slots = await (await fetch(`${API}/v1/availability?${params}`, { headers: auth })).json()
```

Leave `extra_ids` out when nothing is chosen. An empty value answers `422`.

`GET /v1/quote` takes `service_id` and `extra_ids` and returns the figures for
the visit:

```js
const quoteParams = new URLSearchParams({ service_id: service.id })
if (extraIds) quoteParams.set('extra_ids', extraIds)
const quote = await (await fetch(`${API}/v1/quote?${quoteParams}`, { headers: auth })).json()
// quote.duration_minutes, quote.price_cents, quote.payment_amount_cents
```

Call it each time the customer changes their extras. Show `price_cents` as the
total and `payment_amount_cents` as the amount due now. `payment_amount_cents`
is `null` when nothing is due at booking, and also when the workspace cannot
take a payment. The service's own deposit does not describe a visit with
extras.

When the customer is moving an existing booking, pass `exclude_booking_id` to
availability and nothing else. The length and the extras come from the booking.
Sending `extra_ids` with it answers `422`.

## Prices by calendar

A calendar can charge its own price for a service. Each calendar in the
workspace response carries `prices`, a list of `{ service_id, price_cents }`
for the services it charges its own price for. A service it performs that is
not in the list costs the service's `price_cents`. The list is empty when the
calendar has no prices of its own.

`GET /v1/quote` also takes `staff_ids`, as comma-separated ids. Send the
calendar the customer chose to get that calendar's figures:

```js
const quoteParams = new URLSearchParams({ service_id: service.id })
if (extraIds) quoteParams.set('extra_ids', extraIds)
if (chosenStaff) quoteParams.set('staff_ids', chosenStaff.id)
const quote = await (await fetch(`${API}/v1/quote?${quoteParams}`, { headers: auth })).json()
// quote.from, quote.price_cents, quote.payment_amount_cents
```

Each id must be a calendar that performs the service and every chosen extra,
or the call answers `422` on `staff_ids`. Leave `staff_ids` out to price the
visit across every calendar that performs it.

When the calendars priced do not all charge the same, `from` is `true`, and
`price_cents` and `payment_amount_cents` are the cheapest calendar's. Show them
as a starting price, for example "from $80". The response's own `staff_ids`
lists every calendar that performs the visit, whatever you sent.

A booking with no `staff_id` goes to the cheapest of the calendars open at that
time. Among calendars at the same price, it goes to the one with the fewest
bookings on that date, then to one at random. The booking's `price_cents` and any
deposit are that calendar's.

## Book

The calls are the same as in [the booking flow guide](https://keptslot.com/docs/guides/booking-flow/):
read availability, then create the booking with an `Idempotency-Key`. Send the
same `extra_ids` that you gave to availability and to the quote, as an array.

```js
const res = await fetch(`${API}/v1/bookings`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({
    service_id: service.id,
    extra_ids: chosen.map((e) => e.id),
    starts_at: slot.start,
    customer: { name, email, phone },
    answers,
  }),
})
const booking = await res.json()
```

Keep `booking.access_token`. No later response carries it, and cancelling and
rescheduling need it.

The response lists the booked `extras`, each with its `name`, `duration_minutes`
and `price_cents`, and carries `price_cents` for the whole visit. On the manage
read, `booking.duration_minutes` is the length that was booked, extras
included. `booking.service.duration_minutes` is the service's length on the
menu today.

What happens next depends on the response:

- `status` is `confirmed`: the customer is booked.
- `status` is `pending` and there is **no** `payment` object: the customer has
  been emailed a 6-digit code. Send it to `POST /v1/bookings/{id}/verify`.
  With customer messages off, nothing is emailed, and the code reaches your
  server in the [`booking.held` webhook](https://keptslot.com/docs/guides/webhooks/#holds).
- `status` is `pending` and there **is** a `payment` object: take the deposit.
  A booking that takes a deposit never asks for a code. The payment takes its
  place.

## Take the deposit

The `payment` object has everything Stripe.js needs. `stripe_publishable_key`
is a Stripe key, not your Keptslot `pk_` key, and `connected_account_id` is the
business's Stripe account.

```html
<script src="https://js.stripe.com/v3/"></script>
<div id="payment"></div>
<button id="pay">Pay deposit</button>
```

```js
const { payment } = booking
const stripe = Stripe(payment.stripe_publishable_key, { stripeAccount: payment.connected_account_id })
const elements = stripe.elements({ clientSecret: payment.client_secret })
elements.create('payment').mount('#payment')

document.getElementById('pay').onclick = async () => {
  const result = await stripe.confirmPayment({
    elements,
    confirmParams: { return_url: `${location.origin}/booked?token=${booking.access_token}` },
    redirect: 'if_required',
  })
  if (result.error) {
    // Show result.error.message. The slot stays held, and the customer can try again.
    return
  }
  // result.paymentIntent.status is 'succeeded': show the confirmation.
}
```

**The booking is confirmed by Keptslot, not by your page.** Stripe tells us
when the payment succeeds, and we confirm the booking, usually within a second
or two. Your page can show the confirmation as soon as `confirmPayment`
reports `succeeded`.

**Some payments leave the page.** A bank that asks the customer to approve the
payment in its own app, and some payment methods, send the customer away and
back to your `return_url`. Stripe adds `redirect_status` to it. When the
customer arrives there, read the booking **once**:

```js
const params = new URLSearchParams(location.search)
const res = await fetch(`${API}/v1/public/bookings/${params.get('token')}`)
const { booking } = await res.json()
// booking.status and booking.payment.status say where things stand.
```

Only `booking.status` of `confirmed` means the customer is booked. If Stripe
has taken the payment and we have not confirmed the booking yet,
`booking.payment.status` is already `succeeded` while `booking.status` is
still `pending`, usually for a second or two. Do not call it in a loop.

**The slot is held while the customer pays, and not forever.** Show
`hold_expires_at` from the booking response as a deadline, not a countdown you
compute yourself. If a payment completes after the hold has ended, the booking
is not confirmed and the deposit is refunded automatically. So when
`confirmPayment` succeeds close to that time, read the booking once before you
tell the customer they are booked. A booking whose hold has ended answers
`404`.

**A refresh does not lose the payment.** While the deposit is still owed,
`GET /v1/public/bookings/{token}` returns a fresh `client_secret`,
`connected_account_id` and `stripe_publishable_key` in its `payment` object.
Mount the form again with them.

## Cancelling and rescheduling

The token addresses the booking's public routes:

- `GET /v1/public/bookings/{token}` returns the booking, with `can_cancel` and
  `can_reschedule`.
- `POST /v1/public/bookings/{token}/cancel` and
  `POST /v1/public/bookings/{token}/reschedule` do what they say.

`can_reschedule` does not account for a pause. While `online_bookings_paused`
on the same response is `true`, rescheduling answers `409` `bookings_paused`, so
offer Reschedule only when `can_reschedule` is true and the pause is off.
Cancelling still works.

A cancel after the business's cancellation cutoff is refused with
`too_late_to_cancel`, and `can_cancel` says so in advance.

A customer who cancels does not get the deposit back automatically. The
business's refund policy decides that, and `refund_policy` on the workspace
response is its wording, to show as written. After a cancel, the token answers
`404`.

The emails we send the customer carry a manage link, and so does the calendar
file for the booking. By default the link opens the business's Keptslot booking
page. To send customers to your page instead, set **Manage link address** in
the **Advanced** section of **Developer → API keys**. Write the full address
with `{token}` where the booking's token goes, for example
`https://www.northsidebarbers.ca/manage/{token}`. The address must start with
`https://` and contain `{token}` once, outside the domain. Leave it empty to
send customers to the Keptslot page again.

Your page gets the token in that address and does the rest with the routes
above. [The customer's own view](https://keptslot.com/docs/guides/booking-flow/#the-customers-own-view)
covers what they return.

## Bookings from a server take no payment

A booking created with a secret key never takes a deposit, whatever the
service charges. It is confirmed straight away, with no hold.
