Online bookings
Crafty takes appointments for any site that works by the hour: barbershops, salons, spas, studios, clinics. Visitors pick a service, a professional (or "whoever is free") and a free time; Crafty's hosted checkout asks for their details and confirms the booking. The business sees it in the panel's calendar, the customer gets a confirmation with a calendar file and a reminder, and both can cancel. Crafty charges no commission.
Payments are manual for now: the customer pays at the venue or by bank transfer. Card deposits through the business's own gateway come later and plug into the same bookings.
The frontend never decides which times are free and never touches payment data. It lists services, asks Crafty for the free slots of one service, and sends the visitor to the hosted checkout with the slot they chose, which stays held for them while they fill in their details.
How it fits together
| Piece | Where | What it does |
|---|---|---|
| Plan | Crafty central (bookings_enabled) |
The workspace's plan must include online bookings. It is separate from the store. |
| Settings | Panel → site → Citas → Ajustes, or configure_bookings (admins) |
Switch bookings on per environment; time zone, weekly hours, closed dates, notice, how far ahead, slot interval, cancellation window, auto-confirm, reminder, payment methods, address, phone, the booking's own fields. |
| Services | The servicios collection |
One published entry per service, with its duration and price. |
| Professionals | The profesionales collection |
One entry per person who takes bookings. Each can have their own hours and blocked times. |
| Booking page | Your frontend, with the SDK | crafty.bookings.config() and crafty.bookings.availability(); crafty.checkout.start({ booking }). |
| Checkout | checkout.crafty.do/{store}/c/{session} (hosted) |
Customer details, the booking's fields, payment method, captcha; confirms the booking. |
| Booking page for the customer | checkout.crafty.do/{store}/o/{number}?t={token} |
Date, time, who, price, payment instructions, add to calendar (.ics), cancel. Linked from every email. |
| Calendar | Panel → Citas | Day, week and list views per professional; create, move, confirm, complete, no-show, cancel, mark paid, internal note, resend the email; block time. |
| Integrations | Webhooks booking.created, booking.updated, booking.cancelled, booking.reminder_sent; the authenticated API and the MCP tools |
Sync with another calendar, a CRM or a POS. |
Every booking is also an order (kind: "booking"), so it shares the store's order numbers, emails, taxes, checkout look and analytics.
1. Switch bookings on
In the panel: site → Citas → Ajustes → Activar citas. Through MCP (admins):
configure_bookings {
"site_id": 3,
"enabled": true,
"timezone": "America/Santo_Domingo",
"hours": {
"mon": [],
"tue": [{ "start": "09:00", "end": "13:00" }, { "start": "14:00", "end": "19:00" }],
"sat": [{ "start": "08:00", "end": "18:00" }],
"sun": [{ "start": "09:00", "end": "13:00" }]
},
"closed_dates": ["2026-12-25"],
"min_notice_minutes": 60,
"max_days_ahead": 30,
"slot_interval": 15,
"cancel_notice_minutes": 120,
"auto_confirm": true,
"reminder_hours": 24,
"payment_methods": { "pay_at_venue": true, "transfer": false },
"address": "Calle El Conde 101, Zona Colonial",
"phone": "809-555-0101"
}
Switching it on:
- creates the
serviciosandprofesionalescollections, or adopts existing ones with those slugs when their booking fields have the types below (otherwise it reports the conflicting fields); - gives the environment a checkout URL (the same handle as the store);
- keeps every setting you leave out.
hoursmerges by weekday: a day left out keeps its hours,[]closes it; up to 3 ranges per day.
| Setting | Default | Meaning |
|---|---|---|
timezone |
America/Santo_Domingo |
Every local time (hours, slots, emails) is in it. |
hours |
Mon–Sat 09:00–18:00 | The shop's weekly hours. A professional's own hours always fall inside them. |
min_notice_minutes |
120 | The latest a visitor can book before a slot. |
max_days_ahead |
30 | How far ahead visitors can book (1–365). |
slot_interval |
15 | Minutes between offered start times (5, 10, 15, 20, 30, 60). |
cancel_notice_minutes |
120 | Until when the customer can cancel online (0 = until it starts). |
auto_confirm |
true |
false leaves site bookings pending until the shop confirms them. |
reminder_hours |
24 | Hours before the booking to email a reminder (0 turns it off). |
Switching it off stops new bookings; existing bookings and the collections stay. Send configure_bookings with only site_id to read the current settings, the service and professional slugs and the checkout URL.
Notification emails, the transfer accounts, the return URL and the checkout's look are the store's settings (configure_store), so they are shared with orders.
2. Services and professionals
While bookings are on, these fields can be relabeled but not removed or retyped, and the collections can't be deleted. Add any other fields you need.
| Collection | Field | Type | Notes |
|---|---|---|---|
servicios |
nombre |
string, required | Name (also the title and slug source). |
duracion |
number, required | Minutes the booking takes (5–720). | |
precio |
decimal, required | Price in the store currency. A service without a price can't be booked. | |
impuesto |
string | A tax key of the store (itbis-18…). Empty uses the default. |
|
imagenes |
multiReference → Media | ||
profesionales |
multiReference → profesionales |
Who does it. Empty means everyone. | |
margen |
number | Minutes kept free after it (cleanup); never shown to the customer. | |
deposito |
decimal | Deposit when card payments arrive. | |
profesionales |
nombre |
string, required | |
foto |
reference → Media | ||
activo |
boolean | false stops taking bookings without deleting the entry. |
Only published services and active, published professionals are bookable. A shop without professionals still works: bookings go to the shop as a whole, one at a time.
Each professional's hours and blocks (Panel → Citas → click their name, or MCP):
set_professional_schedule { site_id, professional: "luis", mode: "own", hours: { … } }gives them their own weekly hours;mode: "shop"goes back to the shop's.manage_booking_blockswithaction: "create"blocks a range once (from_date,to_date,all_dayorstart_time/end_time) or every week (repeat: "weekly",weekdays, times), for one professional or the whole shop. It answers how many bookings already fall inside (they stay; move or cancel them).action: "list"andaction: "delete"read and remove blocks.
3. The booking page in the frontend
import { createCraftyClient, CraftyError } from "@craftware/crafty-sdk";
const crafty = createCraftyClient({ baseUrl, apiKey, siteSlug: "navaja" });
const config = await crafty.bookings.config();
// { enabled, timezone, currency, services: [{ slug, name, duration_minutes, price, professionals }],
// professionals: [{ slug, name, bio }], hours, closed_dates, min_notice_minutes, max_days_ahead, … }
const availability = await crafty.bookings.availability({
service: "corte-y-barba",
professional: "luis", // leave it out for whoever is free
from: "2026-11-05", // local dates; default today → today + 6 days, at most 62 days
to: "2026-11-11",
});
// { timezone, days: [{ date, closed, slots: [{ start: "2026-11-05T14:00:00-04:00", time: "14:00", professionals: ["luis"] }] }] }
When the visitor picks a slot:
try {
await crafty.checkout.start(
{ booking: { service: "corte-y-barba", professional: "luis", start: slot.start } },
{ returnUrl: `${location.origin}/gracias`, cancelUrl: location.href },
);
} catch (error) {
if (error instanceof CraftyError && error.code === "SLOT_UNAVAILABLE") {
// Someone took it meanwhile: reload the slots and ask for another time.
}
}
checkout.start opens a session, holds the slot for 10 minutes (held_until) and sends the browser to the hosted checkout. checkout.create does the same without redirecting. With professional left out, Crafty assigns whoever is free at that time.
Rules for the page:
- Show
config.services(they are the bookable ones, with the price Crafty will charge) and only the slots fromavailability(). Never compute free times in the frontend: hours, blocks, other bookings, holds, notice and buffers all count. - Fetch slots in the browser, never at build time: a static build would show times that are long gone.
- Show the time zone's local times (
slot.time, orslot.startwith its offset). config.enabled === falsemeans the shop isn't taking bookings: show the phone instead.
A customer's booking can be read with the number and the token from their link: crafty.bookings.get(number, token).
Discount coupons
Coupons are the store's (panel → Cupones, or upsert_coupon) with kinds including "booking"; a shop without the store finds Cupones under Citas. Percent and fixed coupons take their discount off the service price, with the same dates, total uses, uses per email and minimum; free-delivery and specific-product coupons never apply to a service. Pass the code to the checkout and the visitor sees the discount there, or why it doesn't apply, and can remove it or type another:
const coupon = new URLSearchParams(location.search).get("cupon"); // a shared link: /reservar?cupon=PRIMERA
await crafty.checkout.start({ booking: { service, professional, start: slot.start } }, { returnUrl, cancelUrl, coupon });
The booking keeps discount_total and coupon_code; cancelling it gives the use back. A coupon that leaves nothing to pay books with payment method none, already paid.
4. Checkout, emails and cancellation
The hosted checkout shows the service, who, when and the price, asks for name, email and phone plus the booking's own fields (checkout_fields of configure_bookings, same shape as the store's), a payment method and the captcha, and confirms the booking (or leaves it pending when auto_confirm is off). It then shows the booking page.
Emails, with the store's look: confirmation to the customer with a calendar file, a notice to the store's notify emails, the reminder reminder_hours before, and a notice when the booking is moved or cancelled by either side. The customer cancels from the booking page until cancel_notice_minutes before the start.
5. The calendar in the panel
Panel → site → Citas: day and week views with a column per professional, and a list. From there the business creates bookings for walk-ins or phone calls (they may go outside the hours with a warning), moves them by date, time or professional, changes the status (pending → confirmed → completed or no_show, or cancelled), marks them paid, keeps an internal note and resends the email. Editors need the Citas section (read to see, write to change).
REST endpoints
Public
No API key is required; the site is in the URL. Errors use { error, message }.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/sites/{site}/bookings |
Public settings, services and professionals. |
| GET | /api/v1/sites/{site}/bookings/availability |
service, professional?, from?, to? (local dates). |
| POST | /api/v1/sites/{site}/bookings/checkout |
Body { service, professional?, start, fields?, return_url?, cancel_url?, coupon?, locale? } → 201 { data: { id, url, expires_at, held_until } }. |
| GET | /api/v1/sites/{site}/bookings/{number} |
Token in ?token= or X-Order-Token. |
Rejections: SLOT_UNAVAILABLE (409), SERVICE_UNAVAILABLE, PROFESSIONAL_UNAVAILABLE, PAYMENT_METHOD_DISABLED, TOO_MANY_BOOKINGS, COUPON_INVALID (bookings API and MCP), and BOOKINGS_DISABLED when bookings are off.
Authenticated bookings API
For another calendar, a CRM or a POS: an OAuth token (the same as MCP) with crafty:read to read and crafty:write to create or change; editors need the bookings permission. Pass site (slug) and optionally environment (default production) in the query or body.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/bookings |
Filters from, to (local dates), status, professional (slug), search (number, name, email, phone), metadata[crm_id]=… (up to 5 pairs), per_page (max 100). |
| POST | /api/v1/bookings |
Body { service, professional?, start, customer: { name, email?, phone? }, payment_method?, fields?, metadata?, outside_hours?, internal_note?, coupon?, locale? } → 201. |
| GET | /api/v1/bookings/{number} |
The booking with next_statuses, internal_note, customer_url and events. |
| PATCH | /api/v1/bookings/{number} |
Any of start, professional, outside_hours, status, paid, payment_reference, internal_note, metadata. Returns the booking and applied. |
Each booking carries starts_at/ends_at in the shop's time zone plus starts_at_utc/ends_at_utc, and metadata (up to 20 string pairs) to tie it to the other system's record.
MCP tools
| Tool | Scope | What it does |
|---|---|---|
configure_bookings |
admins | Read or change the settings above. |
get_booking_availability |
read | Without service: the services and professionals. With it: free slots per day. |
list_bookings, get_booking |
read | Same filters and shape as the API. |
create_booking |
write | A booking for a customer, e.g. from a phone call. |
update_booking |
write | Move, change status, mark paid, note, metadata. |
set_professional_schedule |
write | A professional's own hours or the shop's. |
manage_booking_blocks |
write (list: read) | Block time once or weekly. |
Webhooks
Content webhooks (upsert_webhook) can listen to booking.created, booking.updated (with changes: status, start, professional, payment, metadata), booking.cancelled and booking.reminder_sent. The body's data is the booking (type: "booking") without the internal note. Bookings also fire the order events (order.created, order.status_changed, order.paid), whose data includes kind: "booking".
Starter
npm create crafty -- --template=astro-booking creates a barbershop site in Astro: services with price and duration, the team, and a /reservar page (service → professional → day and time) that opens the hosted checkout. It creates the servicios and profesionales collections with demo services and barbers, and switches bookings on when the workspace plan includes them.
Common pitfalls
- Free times computed or cached in the frontend. Always ask
availability()in the browser; Crafty checks the slot again when the checkout opens. - Services as drafts or without a price. Only published services with a price are bookable.
- Everyone does every service. That's what an empty
profesionalesfield means; fill it to restrict a service to some people. - A professional's hours outside the shop's. Slots are the overlap: widen the shop's hours first.
- Times off by hours. Set
timezonebefore taking bookings; slots and emails use it. - Checkout 404. Bookings are off, the plan doesn't include them, or the session expired: start a new one.