Build a booking page
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.
Build this with your AI coding tool
Paste the prompt into Cursor, Claude Code, ChatGPT or whatever you build with.
See the prompt
Add Keptslot online booking to this website: the customer picks a service and a time, books, and pays a deposit if the service takes one.
Read these first:
- Guide: https://keptslot.com/docs/guides/booking-page.md
- API description (OpenAPI 3.1): https://keptslot.com/openapi.yaml
Start by asking me for my workspace slug, and remind me to do the dashboard setup in the guide's first section. Fit the work into this site's existing framework and styling; don't add a new framework or build step. If this site already calls Keptslot, check that code against the rules below and tell me what doesn't follow them before changing anything.
The calls, in order, all to https://api.keptslot.com:
1. GET /v1/public/workspaces/{slug}: services, calendars and the questions the form must ask.
2. GET /v1/availability: open times for one service.
3. GET /v1/quote: the length, price and amount due for a service with the extras chosen.
4. POST /v1/bookings: create the booking.
5. POST /v1/bookings/{id}/verify: only when the booking asks for a 6-digit code.
Rules:
- Call 1 takes no key: send none, or the browser refuses the request. Send workspace.publishable_key from its response on calls 2 to 5. Never put a secret key (sk_) in the page.
- If a call answers 403 origin_not_allowed, tell me to add this site's address to the Booking page key.
- A service may offer extras (extra_ids on the service in the workspace response). List only services with standalone true as the choices, and show a service's extras after it is chosen.
- Pass the chosen extra_ids to availability, to the quote and to the booking. Leave extra_ids out when none are chosen; an empty value is refused. When rescheduling, pass only exclude_booking_id to availability.
- Show the quote's price_cents and payment_amount_cents, not the service's own figures. A null payment_amount_cents means nothing is due at booking.
- Send an Idempotency-Key when creating a booking, and keep the access_token from the response. No later response returns it.
- Handle all three outcomes: confirmed, pending with a 6-digit code, and pending with a payment object.
- For the deposit, use stripe_publishable_key and connected_account_id from the payment object.
- Only booking.status "confirmed" means booked. Read it once after a redirect; never poll.
- If workspace.online_bookings_paused is true, the business is not taking online bookings: show that instead of the form. Availability, creating a booking and rescheduling answer 409 bookings_paused. Cancelling still works.
- On the manage page, offer Reschedule only when can_reschedule is true and online_bookings_paused is false. can_reschedule does not account for the pause.
- Show times in the business's timezone (timezone on the workspace response), not the visitor's.
Optional: if I give you a secret key (sk_) in an environment variable, you can set up the workspace from the terminal: calendars with their titles, bios, weekly hours and time off, services (including deposit amounts) and their categories, the booking form's questions, and workspace settings such as timezone, booking policy and "Custom booking frontend". https://keptslot.com/docs/guides/first-call.md shows the calls.
- List what already exists first, and ask me before changing anything that was already there.
- Setting a calendar's working hours, a service's calendars or a question's services replaces the whole list, and editing a question replaces the whole question. Send everything that should stay.
- The secret key never goes in the site's code or in any file the site serves.
- These stay in the dashboard for me: the publishable key and its allowed origins, connecting Stripe, the Pro plan (deposits need both), and photos.
Set up the workspace first
Section titled “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 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
Section titled “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:
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
Section titled “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.
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:
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_centsCall 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
Section titled “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:
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_centsEach 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.
The calls are the same as in the booking flow guide:
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.
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:
statusisconfirmed: the customer is booked.statusispendingand there is nopaymentobject: the customer has been emailed a 6-digit code. Send it toPOST /v1/bookings/{id}/verify. With customer messages off, nothing is emailed, and the code reaches your server in thebooking.heldwebhook.statusispendingand there is apaymentobject: take the deposit. A booking that takes a deposit never asks for a code. The payment takes its place.
Take the deposit
Section titled “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.
<script src="https://js.stripe.com/v3/"></script><div id="payment"></div><button id="pay">Pay deposit</button>const { payment } = bookingconst 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:
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
Section titled “Cancelling and rescheduling”The token addresses the booking’s public routes:
GET /v1/public/bookings/{token}returns the booking, withcan_cancelandcan_reschedule.POST /v1/public/bookings/{token}/cancelandPOST /v1/public/bookings/{token}/rescheduledo 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 covers what they return.
Bookings from a server take no payment
Section titled “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.