Two working examples of a guest booking flow built on the Barberly Booking API — the programmable way to take bookings for a shop that runs on Barberly. Same endpoints, same state machine, two different ways of presenting the flow.
| Live demo | ||
|---|---|---|
| Appointment hub → | hub/, src/demos/hub/ |
One card with four editable fields; the guest fills them in any order. |
| Guided wizard → | wizard/, src/demos/wizard/ |
Five gated steps with a progress bar and a single forward action. |
Both cover: location → barber → services (with add-ons) → date & time → guest details → confirmation, with no login, plus loading, empty and error states.
A barbershop runs its business in Barberly: locations, team, services and add-ons, working hours, pricing, and the rules that decide how clients are allowed to book. On top of that configuration Barberly gives the shop its client-facing tools — a mobile app, a website, booking widgets, a queue kiosk, Google Reserve.
The Booking API is one more of those tools. It is not a back-office API and it does not open up the business side of the account. It books the way a client books, and it obeys exactly what the shop set up for its own booking page:
- A slot that is closed to clients is closed here too. Availability returns the client's view of the calendar, never the staff one.
- If the shop approves appointments by hand, bookings come back
Unconfirmedhere as well. - If the shop requires a registered customer, this API requires a
customerIdtoo. - Working hours, lead times, service durations, which barber does what — all read from the same settings, live.
The API invents no rules of its own and can bypass none. That is the point: whatever the shop already configured for its own booking page applies unchanged to whatever you build on top — your own booking UI in your own brand, a booking bot in WhatsApp or a chat widget, a kiosk, a booking step inside a site you already run.
You need a Barberly account. The catalog these calls return is the one you create there: locations, team members, services, working hours, booking rules. The API reads that configuration — it does not create any of it. So the first step is setting the shop up in Barberly, exactly as you would to launch its website or its app.
Then issue a key in Apps → Booking API → Create key. Each key carries the scopes you grant it:
| Scope | Grants |
|---|---|
catalog:read |
locations, team members, services, availability |
bookings:read |
read a booking |
bookings:write |
create, update and cancel bookings |
accounts:write |
register, authenticate and recover customer accounts |
Send it as an X-Api-Key header on every request. Keys can be revoked or deleted from the same
screen — revoked keys stay listed with their dates, so an incident can be reviewed afterwards.
- API reference — every endpoint and field
- OpenAPI document — generate a client from it
- Barberly — where the account, the configuration and the keys live
npm install
npm run devNo signup needed to look around: the examples ship with a key for a public demo shop, already
configured with locations, barbers and services, so they show live data straight away. Open the
printed URL for the index, or go straight to /hub/ or /wizard/.
The key committed in src/config.js is deliberately public. It belongs to a throwaway demo shop
and carries only the scopes this flow needs — catalog:read, bookings:read, bookings:write.
It cannot register customers, cannot send mail, and cannot see anything outside that shop. Book
as often as you like; the data is disposable.
Your own key is not like that. Everything in a browser bundle is readable by anyone who loads the page, so a key with real scopes does not belong in the client. Keep it on a server and proxy the API:
browser → your backend (adds X-Api-Key) → booking-api.barberly.com
A serverless function, an nginx location, or a route in the app you already run — it only has
to forward the request and attach the header. Point VITE_API_BASE_URL at that proxy and leave
VITE_API_KEY empty.
To try the examples against your own tenant while developing, copy .env.example to .env and
set VITE_API_KEY. Don't deploy that build.
The API sends CORS headers for any origin, so a static build like this one can call it directly from the browser with no proxy in between.
index.html plain-HTML landing page linking to the demos
hub/index.html page for example 1
wizard/index.html page for example 2
src/
config.js API base URL + key (the one place to change them)
api/client.js fetch wrapper: base URL, key, ProblemDetails → ApiError
api/booking.js one function per endpoint, with the request shapes
state/useBooking.js the whole draft + catalog in one reducer
lib/format.js money, duration, slot/day labels, slot grouping
ui/States.jsx Skeleton / EmptyState / ErrorState, themed per demo
demos/hub/ Example 1 — HubDemo.jsx + theme.js + main.jsx
demos/wizard/ Example 2 — WizardDemo.jsx + theme.js + main.jsx
Each example is its own page and its own bundle, so you can read, copy or delete one without
touching the other. Everything they share sits in src/api, src/state, src/lib and src/ui.
Adding a third example means adding one folder under src/demos, one */index.html, and one
line in vite.config.js.
Styling is inline, on purpose: no CSS framework, no build step beyond Vite, so a customer can lift
a single file into their own project. Each demo's palette and type live in its theme.js.
| Step | Call |
|---|---|
| Locations | GET /v1/locations |
| Barbers | POST /v1/locations/{locationId}/team-members |
| Services + add-ons | POST /v1/locations/{locationId}/services |
| Days and slots | POST /v1/locations/{locationId}/availability/{year}/{month} |
| Create | POST /v1/bookings |
| Cancel | POST /v1/bookings/{id}/cancel |
The three catalog calls are POSTs because they take an AvailabilityQuery — everything the guest
has already picked. Sending it narrows the response to what is still bookable.
One endpoint the examples do not use: PUT /v1/bookings/{id}, which reschedules or otherwise
changes a booking. It is a full replacement — send every field again, not a partial patch — and
it takes the same body as POST /v1/bookings. Two things to know before building on it:
- Pass
bookingIdin the availability queries while the guest is picking a new slot. All three catalog calls accept it, and it stops the booking's own slot from looking taken by itself.api/booking.jsalready threads it through; the demos just never set it. - The salon can refuse. If the minimum notice for changes has passed, the call returns
400with the reason inProblemDetails, the same shape as every other error here.
- Single options prefill. One location, or one team member, means the step is skipped and the
value is preselected (
useBooking.js,locations/teamMembersactions). - Add-ons keep their parent.
service.extras[]renders only once the parent service is selected. Deselecting the parent removes its extras. For queries, add-ons are sent asselectedServices[{ serviceId, parentServiceId }]instead of the flatserviceIds, so the parent relationship survives the round trip. - Availability depends on the selection. Changing the barber or any service clears the chosen slot and the loaded days, because the API's slot list is computed from the total duration and the barber's calendar.
durationMinuteson the slot.POST /v1/bookingsneeds atimeSlotwithdate,startMinutesOfDayanddurationMinutes; the demos take the slot's own duration and fall back to the summed service duration.- Dates are salon-local and date-only.
TimeSlotRequest.dateisyyyy-MM-dd— never a timestamp, never with an offset. Slots also carrytimeFrom/timeToalready formatted for the salon's 12h/24h setting;lib/format.jsprefers those and usesstartMinutesOfDayfor maths. - A booking may come back unconfirmed. Salons that approve manually return
status: "Unconfirmed"andisConfirmed: false. Both confirmation screens read the status rather than assuming success wording. - Availability includes neighbouring days. The month response pads the calendar weeks; each
day carries
isAvailable, and the hub demo offsets the grid by the first day's weekday. - Errors are
ProblemDetails.client.jsturns them intoApiErrorwithstatus,titleas the message anddetail; every list has a retry. - Accounts are optional. The
/v1/accounts/*endpoints exist for salons that require a registered customer — pass the resultingcustomerIdascustomer.customerId. These examples book as a guest and don't use them.
No design system, no router, no data-fetching library, no tests. The point is to be small enough to read in one sitting and copy from.