You need admin or owner rights in the Lovable workspace to create a custom
connector. Anyone in the workspace can connect to it afterwards.
1
Create a storefront key without allowed origins
In the Opencals dashboard open Settings → API Keys and create a key
(
sfk_…). Name it “Lovable” and leave allowed origins empty: Lovable
calls the API from its gateway, which sends no Origin header, so a key
with an allowlist fails with 401 Origin not allowed for this API key.2
Create the connector
In Lovable open Connectors → + → Custom connector and fill in the details:
| Field | Value |
|---|---|
| Display name | Opencals |
| Short description | Headless commerce for bookable services: cart, checkout, payments |
| Category | E-commerce |
| Documentation URL | https://opencals.com/docs |
| Logo | icon.png |
Description (copy)
Description (copy)
Opencals is a headless commerce API for selling time: services, sessions, classes, and rentable resources such as courts, rooms and studios. It works like a storefront API: a catalogue of bookable products with variants and add-ons, a cart, checkout, payments, discounts, orders and customer accounts. Every product is time-based, and Opencals handles the availability, capacity and double-booking. Lovable builds the storefront; the merchant's live Opencals store supplies the products, prices and rules.
What the app can do:
- Catalogue: products with variants, prices, images, collections, staff, locations, and add-ons (fixed or charged per hour)
- Real-time availability: exact slots per day or ranges for a calendar, filtered by staff, location and booking length
- Cart: hold slots with an expiry timer, add or change add-ons, apply promo codes, automatic discounts, and customer credit (money or prepaid hours)
- Checkout: customer details, billing address with company and VAT, delivery address for mobile services, marketing consent per channel, and custom checkout questions including file uploads
- Payments: cards through Stripe (paid into the merchant's own connected account), cash, bank transfer, or instant confirmation when nothing is due. Tax-inclusive or tax-exclusive pricing, with authoritative totals from the API
- Orders and receipts: an order number, line items, payment status, and invoices
- Group classes with capacity and spots left, invited guests, and variable-length bookings (e.g. a court for 60 or 90 minutes)
- Customer accounts: email-code or password sign-in, upcoming and past bookings, self-service reschedule and cancel within the merchant's notice rules, profile, and marketing preferences
The server side handles availability, timezones, double-booking prevention, pricing, tax, discounts, payment capture, and confirmation and reminder emails. The merchant manages products, staff, schedules, discounts and payment settings in the Opencals dashboard (app.opencals.com). You need an Opencals store and a Storefront API key (sfk_…) created in the Opencals dashboard.
3
Authentication: Advanced, two headers
Set API base URL to
Test request:
https://api.opencals.com and choose Advanced:| Key | Label | Send as | Secret |
|---|---|---|---|
X-Api-Key | Storefront API key (sfk_…) | Header | Yes |
X-Api-Version | API version (enter 1) | Header | No |
GET /storefront/stores/public-settings. It returns 200
with the store name and currency when the key and version header are right.Every storefront route returns
404 Cannot GET … without X-Api-Version: 1,
including the test request.4
Add the two knowledge files
Click Add knowledge file twice. Use the name and description exactly;
Lovable reads the description to decide which file to open.File 1. Name:
File 2. Name:
Raw files: opencals-storefront-api.md ·
lovable-booking-site-playbook.md.
Then click Create.
Opencals Storefront API. Description:
Complete endpoint reference: catalogue, availability, cart, appointments, discounts, checkout and payment, customer accounts, reschedule/cancel, errors and rate limits.Content: Opencals Storefront API (~36k chars)
Content: Opencals Storefront API (~36k chars)
opencals-storefront-api.md
# Opencals Storefront API — complete reference
The Opencals connector is a hosted booking API for service businesses (salons, clinics, studios, courts, rooms, classes). It owns availability maths, timezones, double-booking prevention, pricing, discounts, tax and payments. The app's job is to call the endpoints below in the right order and render the results. Never implement any of that logic in the app.
Everything here is copied from the live API. **Never invent endpoints, fields or enum values.** If something isn't listed here, it doesn't exist.
## How to call the API
- All paths are relative to the connector's base URL. The connector adds the store's API key and the `X-Api-Version: 1` header on every request. **Do not add `X-Api-Key` or `X-Api-Version` yourself.**
- Call Opencals from server-side code only, through the connector. The browser must never call Opencals directly or hold the key.
- Request and response bodies are JSON. Send `Content-Type: application/json` on requests with a body.
- The connector's key identifies **one store**. Every endpoint is scoped to that store automatically. There is no store id to pass.
- Two per-request headers the app must pass through itself:
- `X-Cart-Id: <cartId>` on cart and checkout calls (see "Cart").
- `Authorization: Bearer <accessToken>` for a signed-in customer (see "Customer accounts"). Optional on cart, checkout and booking calls; required on account calls.
## Key endpoints
### Catalogue (read-only)
| Method | Path | Purpose |
|---|---|---|
| GET | `/storefront/stores/public-settings` | Store name, currency, timezone, time/date format, checkout field rules, marketing opt-in display, contact info |
| GET | `/storefront/products` | Paginated list of services (products), each with `variants[]` |
| GET | `/storefront/products/{productId}` | One product with staff, locations, add-ons, variants, checkout questions |
| GET | `/storefront/products/slug/{slug}` | Same, by slug |
| GET | `/storefront/products/{productId}/add-ons` | Add-ons bookable with this product (query: `locationId`, `staffMemberId`) |
| GET | `/storefront/product-collections` | Collections/categories (query: `isVisible`) |
| GET | `/storefront/product-collections/slug/{slug}` | One collection with its products |
| GET | `/storefront/locations` | Locations |
| GET | `/storefront/locations/{locationId}` · `/storefront/locations/slug/{slug}` | One location |
| GET | `/storefront/staff-members` | Staff members |
| GET | `/storefront/staff-members/slug/{slug}` | One staff member |
| GET | `/storefront/add-ons` · `/storefront/add-ons/{addOnId}` · `/storefront/add-ons/slug/{slug}` | Add-ons |
| GET | `/storefront/images/{imageId}` | Image record (URLs) |
| GET | `/storefront/checkout-questions/{productId}/translations/{language}` | Checkout questions for a product |
| GET | `/storefront/feedback-questions/{productId}/translations/{language}` | Post-appointment feedback questions |
### Availability
| Method | Path | Purpose |
|---|---|---|
| GET | `/storefront/products/{productId}/current-availabilities` | Exact bookable slots for one date |
| GET | `/storefront/products/{productId}/current-availability-ranges` | Merged availability windows across the booking horizon (for calendar highlighting) |
| GET | `/storefront/products/{productId}/nearest-availability` | The next available slot (204 when none) |
### Cart
| Method | Path | Purpose |
|---|---|---|
| POST | `/storefront/cart` | Create a cart, or return the existing one named by `X-Cart-Id` |
| GET | `/storefront/cart` | Read the cart (`X-Cart-Id`) |
| POST | `/storefront/cart/items` | Add an existing appointment to the cart |
| DELETE | `/storefront/cart/items/{itemId}` | Remove a cart item (and release its held slot) |
| POST | `/storefront/cart/extend` | Push the cart's `expiresAt` further out |
| POST | `/storefront/cart/items/{cartItemId}/add-ons` | Attach an add-on to a cart item |
| PATCH | `/storefront/cart/add-ons/{cartAddOnItemId}` | Change a fixed add-on's quantity |
| DELETE | `/storefront/cart/add-ons/{cartAddOnItemId}` | Remove an add-on |
| POST | `/storefront/cart/discount-code` | Apply a promo code |
| DELETE | `/storefront/cart/discount-code` | Remove the promo code |
### Appointments
| Method | Path | Purpose |
|---|---|---|
| POST | `/storefront/appointments` | Create (hold) an appointment for a slot, attached to a cart |
| GET | `/storefront/appointments` | The signed-in customer's appointments (paginated) |
| GET | `/storefront/appointments/{appointmentId}` | One appointment |
| PUT | `/storefront/appointments/{appointmentId}/reschedule` | Move to a new slot |
| PUT | `/storefront/appointments/{appointmentId}/cancel` | Cancel |
| POST | `/storefront/appointments/{appointmentId}/guests` | Invite a guest by email |
| DELETE | `/storefront/appointments/{appointmentId}/guests/{guestId}?notify=true` | Remove a guest (`notify` query is required) |
| POST | `/storefront/appointments/{appointmentId}/feedback` | Submit feedback answers |
| GET | `/storefront/appointments/preferences/booking-history` | The customer's usual product/staff/location, for pre-filling |
### Checkout and payment
| Method | Path | Purpose |
|---|---|---|
| GET | `/storefront/cart/checkout/questions/{language}` | Checkout questions for everything in the cart |
| POST | `/storefront/cart/checkout/save-customer` | Attach customer details, billing/delivery address and marketing consent to the cart |
| POST | `/storefront/cart/checkout/save-answers` | Save checkout-question answers |
| GET | `/storefront/payment/providers` | Payment options for this cart (`X-Cart-Id`) |
| POST | `/storefront/cart/checkout/start` | Start payment with the chosen provider; returns a Stripe client secret or redirect |
| POST | `/storefront/cart/checkout/submit` | Finalise: creates the order and confirms the appointments |
| POST | `/storefront/uploads/presign` | Get an upload URL for a file-upload checkout question |
### Orders
| Method | Path | Purpose |
|---|---|---|
| GET | `/storefront/orders` | The signed-in customer's orders |
| GET | `/storefront/orders/{orderId}` | One order |
| GET | `/storefront/orders/{orderId}/invoices` | Invoices/receipts for an order |
### Customer accounts
| Method | Path | Purpose |
|---|---|---|
| POST | `/storefront/auth/sign-up` | Register with email + password (204) |
| POST | `/storefront/auth/sign-in` | Email + password → tokens |
| POST | `/storefront/auth/request-login-code` | Email a 6-digit login code (204) |
| POST | `/storefront/auth/verify-login-code` | Email + code → tokens |
| GET | `/storefront/auth/resolve-link?token=…` | Resolve a magic link from an email → tokens + where to go |
| GET | `/storefront/auth/refresh` | Refresh token (as `Authorization: Bearer <refreshToken>`) → new tokens |
| POST | `/storefront/auth/request-password-reset` | Email a reset link (204) |
| POST | `/storefront/auth/reset-password` | Token + new password (204) |
| POST | `/storefront/auth/request-email-verification` | Resend verification email (204) |
| POST | `/storefront/auth/verify-email` | Token → tokens |
| GET / PUT | `/storefront/self-service/profile` | Read / update name and phone |
| PUT | `/storefront/self-service/password` | Set or change password |
| GET / PUT | `/storefront/self-service/marketing-consent` | Read / update per-channel marketing consent |
## Core concepts
### IDs and products vs variants
- A **product** is a bookable service. `GET /storefront/products` returns items with a `variants[]` array (e.g. "Haircut" → "Short hair", "Long hair"). Each variant is a full product row with its own `id`, `price`, `duration`, `staffMembers` and `locations`.
- **The bookable id is always a variant's `id`.** Use it in `/storefront/products/{productId}/…` paths and as `slot.productId`. Don't use the `productId` field on a product row: that's a grouping key shared by all its variants. A product with one variant has `variants[0].id === id`.
- IDs are UUID strings.
### Time and money
- **All slot dates and times are UTC.** Slots use `fromDate`/`toDate` (`YYYY-MM-DD`) and `fromTime`/`toTime` (`HH:MM:SS`). Appointment `from`/`to` are ISO 8601 UTC timestamps.
- Convert to the store's timezone (`settings.timezone` in public settings) or the visitor's timezone **for display only**. Always send back the exact UTC values the API gave you.
- Durations and gaps are in **seconds** (`duration: 3600` = 1 hour).
- Prices are decimal numbers in the store currency (`currency` in public settings, `paymentCurrencyCode` on carts and orders), e.g. `45` or `12.5`. Not cents. `taxesIncluded` says whether prices already include tax.
### Pagination
List endpoints take `page` (1-based, default 1) and `take` (default 50, max 100), and optionally `q` (search), `orderBy`, `order` (`ASC`/`DESC`). They return:
```json
{
"data": [ ... ],
"meta": { "page": 1, "take": 50, "itemCount": 12, "pageCount": 1, "hasPreviousPage": false, "hasNextPage": false }
}
```
Single-resource endpoints return the object directly (no wrapper).
## Store settings
`GET /storefront/stores/public-settings` → `{ name, domain, currency, settings, storefrontSettings, contactInfo, checkoutSettings, features }`.
- `settings.timezone`, `settings.timeFormat` (`12H` | `24H`), `settings.dateFormat`, `settings.defaultAdvanceScheduleDays` (-1 = unlimited).
- `checkoutSettings` controls the customer form:
- `customerFirstNameRequired: boolean`. Last name and email are always required.
- `customerPhoneRequirement`, `customerCompanyNameRequirement`, `customerCompanyVatNumberRequirement`, `customerBillingAddressRequirement`: each `hidden` | `optional` | `required`.
- `emailMarketingOptIn`, `smsMarketingOptIn`, `whatsappMarketingOptIn`: each `dont_show` | `checkout_only` | `checkout_and_account`.
- Fetch this once and cache it. Build the checkout form from it rather than hard-coding fields.
## Products
Useful fields on a product/variant:
| Field | Meaning |
|---|---|
| `id`, `slug`, `title`, `variantTitle`, `description` | Identity and copy |
| `price`, `taxable`, `duration` (s) | Pricing and length |
| `image`, `images[]` | Images; use `image.url` (can be null) |
| `staffMembers[]`, `locations[]` | Who can perform it, and where |
| `maxAttendees` | Capacity per slot (1 = private; >1 = group class) |
| `allowCustomDuration`, `maxDuration` (s, -1 = unlimited) | Variable-length bookings (see below) |
| `allowGuests`, `maxGuests` (null = unlimited) | Whether named guests can be invited |
| `allowCustomerReschedule`, `rescheduleGap` (s) | Self-service reschedule, and the minimum notice |
| `allowCustomerCancel`, `cancelGap` (s) | Self-service cancel, and the minimum notice |
| `advanceScheduleThreshold` | How many days ahead it can be booked |
| `status` | `active` \| `inactive`. Show active only |
| `collections[]`, `addOns[]`, `checkoutQuestions[]` | On the detail endpoint |
`GET /storefront/products?status=active&take=100` is the usual catalogue call. Filter by location with `locationId`.
Locations have `type`: `physical` (has an address; use `displayAddress` if set, else `address`), `online` (has a `link`), or `delivery` (the business comes to the customer, so a customer address is needed at checkout).
## Availability
### Exact slots for a day
```
GET /storefront/products/{productId}/current-availabilities?date=2026-10-14&timezone=Europe/London&locationId=…&staffMemberId=…
```
Query: `date` (required, `YYYY-MM-DD`), `timezone` (IANA name; aligns the "day" to local time, so always send it), `locationId`, `staffMemberId`, `duration` (seconds as a string, for variable-length bookings), `excludeAppointmentId` (for rescheduling).
Response: an array of slots:
```json
[
{
"productId": "b2c1…",
"fromDate": "2026-10-14", "fromTime": "09:00:00",
"toDate": "2026-10-14", "toTime": "09:45:00",
"staffMemberIds": ["5f0e…", "91aa…"],
"locationIds": ["c3d4…"],
"attendees": 0,
"maxAttendees": 1
}
]
```
- `staffMemberIds` lists who is free for that slot. If the customer picked "anyone", you may omit `staffMemberId` when booking. If they picked a person, filter slots by it (or pass `staffMemberId` in the query) and send it in the slot.
- For group products, spots left = `maxAttendees - attendees`.
- An empty array means no availability that day.
### Calendar highlighting
```
GET /storefront/products/{productId}/current-availability-ranges?timezone=Europe/London&locationId=…&staffMemberId=…&duration=…
```
Returns the same slot shape, but each entry is a merged **window** of availability across the horizon (`date` is optional; omit it for the full horizon). Use it to enable or disable days in a date picker, then call `current-availabilities` for the day the customer picks. This costs one request per product instead of one per day.
### Next available
`GET /storefront/products/{productId}/nearest-availability` → one slot (`productId, locationId, staffMemberId, fromDate, fromTime, toDate, toTime`), or HTTP 204 when nothing is bookable. Use it to open the picker on the first bookable day.
### Many units in a grid (courts × time, rooms × time)
There is no multi-product availability endpoint. Call `current-availabilities` once per unit for the selected date, cache each result by product + date + duration, and mark a cell available if that unit has a slot starting at that time. Watch the rate limit (see Notes).
## Booking flow (the happy path)
1. `POST /storefront/cart` with header `X-Cart-Id: ""` (empty on the first call). Store `cart.id` (e.g. in `localStorage`) and send it as `X-Cart-Id` on every later cart/checkout call.
2. `GET …/current-availabilities`, and the customer picks a slot.
3. `POST /storefront/appointments` with the slot and `cartId`. The appointment is held in the cart; you don't need to call `/cart/items`.
4. `GET /storefront/cart` to render the summary (items, add-ons, discounts, totals, `expiresAt`).
5. Optional: `POST /storefront/cart/discount-code`.
6. `POST /storefront/cart/checkout/save-customer`.
7. If `GET /storefront/cart/checkout/questions/{language}` returns questions: `POST /storefront/cart/checkout/save-answers`.
8. `GET /storefront/payment/providers`, then the customer picks one, then `POST /storefront/cart/checkout/start`.
9. Pay (Stripe in the browser) if needed, then `POST /storefront/cart/checkout/submit`.
10. Show the confirmation from the returned `order`. Clear the stored cart id.
### Create an appointment
```
POST /storefront/appointments
```
```json
{
"slot": {
"productId": "b2c1…",
"fromDate": "2026-10-14", "fromTime": "09:00:00",
"toDate": "2026-10-14", "toTime": "09:45:00",
"staffMemberId": "5f0e…",
"locationId": "c3d4…"
},
"cartId": "8e7d…",
"numberOfAttendees": 1,
"addOns": [{ "addOnId": "a1b2…", "quantity": 1 }],
"guests": [{ "email": "friend@example.com" }],
"customAttributes": { "source": "lovable-site" }
}
```
- `slot` is required. Copy the date/time fields exactly from a returned availability slot. `staffMemberId` and `locationId` are optional (nullable).
- Optional: `cartId` (always send it on the booking path), `numberOfAttendees` (default 1, capped by remaining capacity), `addOns[]`, `guests[]` (only if the product has `allowGuests`), `checkoutQuestionAnswers[]`, `address` (service address for a `delivery` location), `customAttributes` (string→string map, max 250 keys; keys ≤255 chars, values ≤5000 chars).
- Response (201): the appointment (`id`, `name` (a human number like `1001`), `status: "pending"`, `from`, `to`, `product`, `staffMember`, `location`, `addOns`, `guests`, …). It stays `pending` until checkout is submitted.
- 4xx here usually means the slot was just taken, is outside the booking window, or capacity was exceeded. Re-fetch availability and ask the customer to choose again.
To remove a booking from the cart: `DELETE /storefront/cart/items/{itemId}` using the cart item's `id` (not the appointment id).
## Cart
`POST /storefront/cart` (header `X-Cart-Id`, empty or an existing id) → `CartResponse`:
| Field | Meaning |
|---|---|
| `id` | Cart id, sent as `X-Cart-Id` |
| `status` | `active` \| `converted` \| `abandoned`. If it's not `active`, start a new cart |
| `expiresAt` | When held slots are released (ISO UTC, a few minutes out) |
| `paymentCurrencyCode`, `taxesIncluded` | Currency and tax mode |
| `subtotal`, `totalTax`, `total` | **Authoritative totals.** Display these; never recompute |
| `appliedDiscounts[]` | `{ id, title, totalAmount, code? }` |
| `appliedDiscountCode` | The promo code currently applied, or null |
| `creditCovered`, `creditRemaining`, `creditValueType` | Customer balance used (`time` = minutes, `fixed_amount` = money) |
| `items[]` | One per appointment (below) |
Each `items[]` entry: `id` (cart item id), `appointmentId`, `appointment` (with `from`, `to`, `product`, `staffMember`, `location`), `quantity`, `originalUnitPrice`, `originalSubtotal`, `totalDiscount`, `discountedSubtotal`, `totalTax`, `discountedTotal`, `discounts[]`, `addOnItems[]`, `addOnSubtotal`, `addOnTotalTax`. Use the line totals (`discountedSubtotal`, `discountedTotal`) for exact per-line amounts. `discountedUnitPrice` and `unitTaxAmount` are approximate.
### Expiry
- Show a countdown from `expiresAt`, and recompute it when the tab regains focus.
- While the customer is actively on checkout, call `POST /storefront/cart/extend` (with `X-Cart-Id`) to push `expiresAt` out.
- If the cart has expired, or a call fails because of it, start a new cart (`X-Cart-Id: ""`). Tell the customer their held time was released and let them pick again. Don't retry the old id.
### Add-ons
- Product add-ons: `GET /storefront/products/{productId}/add-ons`. Each has `price`, `durationMultiplied` and `maxQuantity`.
- `durationMultiplied: true`: charged per base-duration unit of the appointment. Quantity is set automatically; don't send one.
- `durationMultiplied: false`: a fixed add-on. The customer picks a quantity from 1 to `maxQuantity`.
- Attach at creation (`addOns` on the appointment) or later with `POST /storefront/cart/items/{cartItemId}/add-ons` and body `{ "addOnId": "…", "quantity": 2 }`. Change the quantity with `PATCH /storefront/cart/add-ons/{cartAddOnItemId}` and body `{ "quantity": 3 }`. Each returns the updated cart.
## Discounts
Three kinds, all resolved server-side and reflected in the cart. Never calculate discounts in the app.
1. **Automatic**: configured by the merchant and applied when the cart qualifies. No call needed; they appear in `appliedDiscounts` and `items[].discounts`.
2. **Promo codes**: `POST /storefront/cart/discount-code` with body `{ "code": "SUMMER10" }` returns the updated cart. `DELETE /storefront/cart/discount-code` removes it. On failure you get HTTP 400:
```json
{ "message": "This code has expired", "code": "CODE_EXPIRED", "statusCode": 400 }
```
`code` is one of `CODE_NOT_FOUND`, `CODE_EXPIRED`, `CODE_DISABLED`, `CODE_LIMIT_REACHED`, `CODE_CUSTOMER_INELIGIBLE`, `CODE_NO_MATCHING_ITEMS`, `CODE_NO_CREDIT`, `minimum_requirement_not_met`. Show a friendly message next to the code field and keep the customer in checkout.
3. **Balance / credit**: a customer can hold a money or time balance (e.g. "10 free hours"). It's used up automatically for a signed-in customer and shows as `creditCovered` / `creditRemaining` / `creditValueType` on the cart.
Per-line discount rows (`items[].discounts[]`, `addOnItems[].discounts[]`): `{ discountId, title, amountPerUnit, valueType: "percentage" | "fixed_amount" | "time", value, targetType: "base" | "add_on" }`.
If `checkout/start` returns `discountCodeCleared: true`, the code stopped applying (e.g. the cart changed). Tell the customer.
## Checkout
### Customer object: the `kind` field is required
Every `customer` object in `save-customer`, `checkout/start`, `checkout/submit` and `POST /storefront/appointments` **must** include `kind`:
- New customer: `{ "kind": "new", "email": "…", "firstName": "…", "lastName": "…", "phone": "…" }`
- Existing customer: `{ "kind": "existing", "customerId": "…" }`, e.g. a signed-in customer, or one whose `customerId` came back from `save-customer`.
If you leave out `kind`, the API can't tell which shape you sent, and the request is rejected or the customer isn't saved.
### Save the customer
```
POST /storefront/cart/checkout/save-customer (X-Cart-Id)
```
```json
{
"customer": {
"kind": "new",
"email": "jo@example.com",
"firstName": "Jo",
"lastName": "Smith",
"phone": "+447700900000",
"billingAddress": {
"companyName": "Smith Ltd",
"taxRegistrationId": "GB123456789",
"addressLine1": "1 High St", "city": "London", "postalCode": "N1 1AA", "country": "GB"
},
"deliveryAddress": {
"firstName": "Jo", "lastName": "Smith",
"addressLine1": "2 Park Rd", "city": "London", "postalCode": "N2 2BB", "country": "GB"
},
"marketingConsent": { "email": true, "sms": false, "whatsapp": false }
}
}
```
Response: `{ "customerId": "…" }`. Keep it and use `{ "kind": "existing", "customerId }` in the next steps.
- `billingAddress` (optional): `firstName`, `lastName`, `companyName`, `taxRegistrationId` (VAT/GST), `addressLine1`, `addressLine2`, `city`, `state`, `postalCode`, `country`, `email`, `phone`. Company name and VAT id go **here**, not on the customer. Show or require it according to `checkoutSettings`.
- `deliveryAddress` (optional): same fields without `taxRegistrationId`. Needed **only** when the cart has an appointment at a `delivery`-type location. It applies to every delivery appointment in the order and overrides any per-appointment `address`.
- `marketingConsent` (optional): per-channel booleans. Only show the channels whose `…MarketingOptIn` setting isn't `dont_show`. Leave them unticked by default.
- `country` is always a 2-letter uppercase ISO code (`GB`, `US`, `DE`).
- A signed-in customer sends `{ "kind": "existing", "customerId": "<profile id>", "firstName"?, "lastName"?, "phone"?, "billingAddress"?, "deliveryAddress"?, "marketingConsent"? }`.
### Checkout questions
`GET /storefront/cart/checkout/questions/en` (use the site language code) returns an array of `{ id, type, required, order, translations: [{ title, description, options }], existingAnswer?, fileConfig? }`. Use `translations[0].title` as the label (and as `question` when saving). `type` is one of `single-line-text-field`, `multi-line-text-field`, `dropdown`, `rating`, `checkbox`, `file-upload`. Render them in `order`, enforce `required`, and pre-fill from `existingAnswer`.
```
POST /storefront/cart/checkout/save-answers (X-Cart-Id)
```
```json
{
"answers": [
{ "questionId": "q1…", "question": "Any allergies?", "answer": "None" },
{ "questionId": "q2…", "question": "Upload your referral", "answer": "", "fileIds": ["f9…"] }
]
}
```
`questionId`, `question` (the question text as shown) and `answer` (a string, even for ratings and checkboxes) are all required. For `file-upload` questions (limits in `fileConfig`):
1. `POST /storefront/uploads/presign` with `{ "questionId": "…", "questionKind": "checkout", "filename": "referral.pdf", "mime": "application/pdf", "size": 48213 }` → `{ fileId, presignedUrl, expiresIn }`.
2. From the browser, `PUT` the file bytes to `presignedUrl` with a matching `Content-Type`. This is a direct storage upload, not through the connector.
3. Send `fileIds: [fileId]` in the answer.
### Payment providers
`GET /storefront/payment/providers` (with `X-Cart-Id`) → `[{ name, displayName, description?, checkoutMode, icon?, mode? }]`
- `name`: `stripe` | `cash` | `bank_transfer` | `shopify` | `no_payment_required`
- `checkoutMode`: `internal` (card form in the app, i.e. Stripe), `redirect` (send the browser to `redirectUrl`), `manual` (pay later: cash on arrival or bank transfer), `none` (nothing to pay)
- `mode`: `test` | `live` for Stripe. Show a "test mode" badge when it's `test`.
Show only what's returned. If there's exactly one option, preselect it.
### Start checkout
```
POST /storefront/cart/checkout/start (X-Cart-Id)
{ "provider": "stripe", "customer": { "kind": "existing", "customerId": "…" } }
```
Response:
```json
{
"provider": "stripe",
"clientSecret": "pi_…_secret_…",
"publishableKey": "pk_live_…",
"stripeAccountId": "acct_…",
"redirectUrl": null,
"paymentId": "…",
"cart": { "...": "updated cart" },
"invalidItemIds": [],
"revivedItemIds": [],
"discountCodeCleared": false
}
```
**Branch on the `provider` in the response, not the one you requested.** When nothing is due (a £0 total, or below the minimum card charge), the API switches the provider to `no_payment_required`.
- `no_payment_required`, `cash`, `bank_transfer`: call `submit` straight away (no payment step).
- `stripe`: in the browser, load Stripe.js with `publishableKey` and `{ stripeAccount: stripeAccountId }`, mount the Payment Element with `clientSecret`, then call `stripe.confirmPayment({ elements, redirect: 'if_required' })`. On success, call `submit` with the PaymentIntent id. The app needs no Stripe secret key. The merchant's Stripe account is connected in Opencals.
- `redirectUrl` present: send the browser there.
- `invalidItemIds` not empty: those items were no longer bookable and were removed. Tell the customer and show the updated cart.
### Submit
```
POST /storefront/cart/checkout/submit (X-Cart-Id)
{ "stripePaymentIntentId": "pi_…", "appointmentsSettings": { "markAsScheduled": true } }
```
- `stripePaymentIntentId`: only for Stripe.
- `appointmentsSettings.markAsScheduled: true` confirms the appointments (sends the confirmation email). Send it on a normal booking flow. `notifyCustomer` (optional) controls the confirmation email when marking as scheduled.
- Optional `trackingData` (e.g. `{ "gclid": "…" }`) for ad attribution.
Response: `{ order, auth?, customer? }`.
- `order`: `id`, `name` (e.g. `1042`), `total`, `subtotal`, `totalTax`, `paidTotal`, `dueToPay`, `paymentStatus` (`unpaid` | `paid` | `partially-paid`), `lineItems[]`, `paymentCurrencyCode`.
- `auth`: `{ accessToken, refreshToken }` for new or passwordless customers. Store them to sign the customer in automatically so they can see their booking.
- After a successful submit, the cart is `converted`. Discard the stored cart id.
## Customer accounts
Storefront reads and bookings work anonymously. Account features (my appointments, reschedule, cancel, profile, consent) need the customer's access token, sent as `Authorization: Bearer <accessToken>`.
### Sign in options
- **Passwordless (recommended)**:
1. `POST /storefront/auth/request-login-code` with `{ "email" }` → 204, and the customer receives a 6-digit code.
2. `POST /storefront/auth/verify-login-code` with `{ "email", "code" }` → `{ accessToken, refreshToken }`.
- **Password**: `POST /storefront/auth/sign-up` with `{ email, password (min 8), firstName?, lastName? }` → 204 (a verification email is sent). Then `POST /storefront/auth/sign-in` with `{ email, password }` → tokens.
- **Magic links** from Opencals emails: the link carries a `token`. Call `GET /storefront/auth/resolve-link?token=…` → `{ purpose, redirectPath, accessToken?, refreshToken?, requiresAction?, metadata? }`. Store the tokens if present, then route to `redirectPath`.
- **Password reset**: `request-password-reset` with `{ email }`, then `reset-password` with `{ token, newPassword }`.
- **Email verification**: `verify-email` with `{ token }` → tokens. `request-email-verification` with `{ email }` resends it.
### Tokens
- The access token is short-lived. When a call returns 401, call `GET /storefront/auth/refresh` with `Authorization: Bearer <refreshToken>`, store the new pair, and retry once. If refresh also fails, sign the customer out.
- Store tokens for the session and forward the access token to the server function that calls Opencals. Never log tokens.
### Self-service
- `GET /storefront/self-service/profile` → `{ id, email, firstName, lastName, phone, isEmailVerified, isPasswordSet, language, … }`. Use `id` as the `customerId` for `{ "kind": "existing" }` at checkout. Display only name, email and phone.
- `PUT /storefront/self-service/profile` with `{ firstName?, lastName?, phone? }`.
- `PUT /storefront/self-service/password` with `{ currentPassword?, newPassword }`. `currentPassword` is required only if `isPasswordSet`.
- `GET /storefront/self-service/marketing-consent` → `{ emailState, smsState, whatsappState }`, each `not_subscribed` | `subscribed` | `unsubscribed`. It can be `null` if never set.
- `PUT /storefront/self-service/marketing-consent` with `{ email?, sms?, whatsapp? }` (booleans). Only show channels whose opt-in setting is `checkout_and_account`.
### My appointments and orders
- `GET /storefront/appointments?status=scheduled&orderBy=from&order=ASC&take=20` is the customer's own appointments. `status` values: `pending`, `scheduled`, `completed`, `canceled`. Other filters: `date` (`YYYY-MM-DD`), `products`, `staffMembers`, `locations` (arrays).
- `GET /storefront/appointments/{appointmentId}` returns the detail, including product (with the reschedule/cancel rules), staff, location and guests.
- `GET /storefront/orders?take=20` and `GET /storefront/orders/{orderId}` return orders. `GET /storefront/orders/{orderId}/invoices` returns receipts/invoices.
- `GET /storefront/appointments/preferences/booking-history` returns the customer's usual choices, for a "book again" shortcut.
## Reschedule and cancel
Show these actions only when the product allows them (`allowCustomerReschedule` / `allowCustomerCancel`) **and** the appointment starts more than `rescheduleGap` / `cancelGap` seconds from now. Otherwise disable the button and explain the notice period.
### Reschedule
1. Fetch availability **excluding the appointment being moved**, so its current slot doesn't show as busy:
```
GET /storefront/products/{productId}/current-availabilities?date=2026-10-15&timezone=Europe/London&excludeAppointmentId={appointmentId}&staffMemberId=…&locationId=…
```
2. Move it:
```
PUT /storefront/appointments/{appointmentId}/reschedule
{ "slot": { "productId": "…", "fromDate": "2026-10-15", "fromTime": "10:00:00", "toDate": "2026-10-15", "toTime": "10:45:00", "staffMemberId": "…", "locationId": "…" }, "notifyCustomer": true }
```
`address` is optional (for delivery locations only). The response is the updated appointment.
### Cancel
```
PUT /storefront/appointments/{appointmentId}/cancel
{ "notifyCustomer": true }
```
The API applies refund and cancellation policy. Never calculate refunds in the app. Ask the customer to confirm before cancelling.
## Attendees and guests
- **Attendees** is how many people take up capacity: `numberOfAttendees` on create. For group products (`maxAttendees > 1`), show "X spots left" from the slot (`maxAttendees - attendees`) and cap the input at that number.
- **Guests** are named people (by email) who get copies of the appointment emails. They don't use up capacity. Add them at creation (`guests: [{ email }]`) or later with `POST /storefront/appointments/{id}/guests` and body `{ "email": "…", "notify": true }`. Remove one with `DELETE /storefront/appointments/{id}/guests/{guestId}?notify=true`. Only offer guests when `allowGuests` is true, and respect `maxGuests`.
## Custom duration (variable-length bookings)
When a product has `allowCustomDuration: true`, the customer books a whole multiple of the base `duration`, up to `maxDuration` (-1 = unlimited). Example: base 1800 s (30 min) at £15, `maxDuration` 5400, so the options are 30/60/90 min for £15/£30/£45.
1. Offer the lengths `duration × n` for n = 1, 2, 3 … while the total is ≤ `maxDuration`.
2. Fetch slots for the chosen length: `…/current-availabilities?date=…&timezone=…&duration=5400`. Each returned slot already spans the full length.
3. Book that slot as-is. The API prices it (`ceil(booked / base) × price`). Never send a price.
If `fixedTimes` is true, bookings must start at `fixedStartTime` and end at `fixedEndTime` (e.g. check-in/check-out style). Just use the slots the API returns.
Don't model "60 min" and "90 min" as separate products for the same physical resource. They wouldn't block each other and the resource could be double-booked.
## Errors
Error responses are JSON:
```json
{ "message": "Invalid input. Please check your form fields.", "statusCode": 400,
"errors": [{ "property": "email", "messages": ["Email must be an email"] }] }
```
- `errors[]` appears on validation failures. Map `property` to the form field and show `messages`.
- Always show the real `message` and keep the status. Never replace it with a generic "Something went wrong".
| Status / symptom | Likely cause | Fix |
|---|---|---|
| 404 `Cannot GET /storefront/...` | `X-Api-Version` header missing | The connector sends it. Check the connector's auth config |
| 401 `X-Api-Key header is required` / `Invalid or inactive storefront API key` | Key missing, wrong or revoked | Reconnect with a valid `sfk_…` key |
| 401 `Origin not allowed for this API key` | Key has an allowed-origins list; server calls send no Origin | Use a key with no allowed-origins restriction for the connector |
| 401 on account endpoints | Missing or expired customer token | Refresh, then sign in again |
| 400 on save-customer/start/submit | Missing `kind` on `customer`, or a required field | Add `kind`; show `errors[]` |
| 400 with a discount `code` | Promo code rejected | Show the mapped message |
| 4xx on create/reschedule | Slot taken, outside the window, over capacity, or inside the notice gap | Re-fetch availability; explain |
| Cart calls act on a new/empty cart | `X-Cart-Id` not forwarded | Forward the stored id on every cart/checkout call |
| Slots at the wrong hour | UTC shown as local | Convert for display only; send `timezone` |
| Customer can't pick their own current slot when rescheduling | `excludeAppointmentId` not sent | Add it |
| 429 | Rate limit | Back off and retry (below) |
## Notes
- **Rate limits** apply per API key plus client IP: 20 requests/second, 60 per 10 seconds and 120 per minute. Through the connector, all visitors share the gateway's IP, so treat these as **app-wide** limits:
- Cache the catalogue (products, collections, locations, staff, public settings) for a few minutes server-side instead of fetching on every page view.
- Use `current-availability-ranges` for the calendar, then `current-availabilities` only for the chosen day. Cache results by product + date + duration for ~30–60 s.
- Don't poll. Don't prefetch availability for every product on the home page.
- On 429, retry with exponential backoff and jitter (500 ms, 1 s, 2 s, 4 s, each plus up to 30%, max 4 attempts). The `Retry-After` header isn't reliable, so don't depend on it.
- **Versioning** uses the `X-Api-Version` header (the connector sends `1`). Paths don't carry a version.
- **Dates**: `YYYY-MM-DD` dates and `HH:MM:SS` times in UTC for slots; ISO 8601 UTC for timestamps.
- **Languages**: question endpoints take a language code (`en`, `de`, `fr`, `es`, `pt_br`, …). Use the site's language, falling back to `en`.
- **What the API doesn't do**: there's no merchant/admin access, no creating services or staff, and no editing prices. That's all done by the merchant in the Opencals dashboard (app.opencals.com). If a service is missing or has no slots, it's a dashboard configuration issue, not a code issue.
- **Don't store or display** internal fields you don't need (`storeId`, `externalId`, `scheduleId`, `internalNote`, staff emails). Show customers only names, images, descriptions, prices, durations and times.
- Human-readable docs: https://opencals.com/docs
Opencals booking site playbook. Description:
How to build a booking website on Opencals: choosing the booking flow for the business type, pages and components, slot picker, checkout and payment UX, design and launch checklist.Content: Opencals booking site playbook (~14k chars)
Content: Opencals booking site playbook (~14k chars)
lovable-booking-site-playbook.md
# Opencals booking site playbook
How to build a production booking website (or add booking to an existing app) on the Opencals connector. The booking engine lives in Opencals. This app is the customer-facing frontend: catalogue, slot picker, cart, checkout, confirmation and a customer account area. For exact endpoints, bodies and response fields, use the **"Opencals Storefront API"** knowledge file. This file covers what to build and in what order.
## Ground rules
- **Never hard-code services, prices, durations, staff, locations or opening hours.** They come from the API and are managed by the merchant in the Opencals dashboard (app.opencals.com). Copy about the business (hero text, about, FAQs, contact) can live in the app.
- **Never build availability, pricing, discount or tax logic.** Render what the API returns. The cart's totals are the truth.
- **All API calls go through the Opencals connector from server-side code.** The browser talks to your own server functions, never to Opencals directly. Build one thin server function per operation (e.g. `get-services`, `get-slots`, `book-slot`, `checkout-start`), forward the `X-Cart-Id` and customer `Authorization` headers the browser sends, and return the API's status and body unchanged.
- **Times are UTC in the API.** Display them in the store timezone (from public settings), and send back exactly what you received.
- **Show real errors.** Surface the API's `message` and field `errors[]`. Never collapse them into "Something went wrong".
- **Watch the rate limit.** It's app-wide (about 120 requests/minute across all visitors). Cache catalogue data, use availability ranges for calendars, and never prefetch slots for every service.
## Step 1: understand the business, then choose the booking shape
Ask, or infer from the brief:
1. **What is being booked?**
- *People's time* (stylist, therapist, clinician, coach, tutor). Staff-based booking: customers pick a service, optionally a person, then a time.
- *Things* (courts, rooms, studios, lanes, bays, equipment). Resource booking: each unit is its own bookable product, often shown as a grid.
- *Both* (a padel club with court rentals and coached sessions). Show both paths.
2. **One-to-one or group?** Groups (classes, workshops) have `maxAttendees > 1`. Show "X spots left" and a party-size picker.
3. **Fixed or variable length?** If `allowCustomDuration` is set, show a length picker (30/60/90 min) before the time.
4. **Where?** Several locations mean a location picker first. Online locations show a meeting link after booking. `delivery` locations (a mobile service) need the customer's address at checkout.
5. **Extras?** Add-ons (products' `addOns`) can be picked after the time is chosen.
Match the page flow to the booking shape first, and the visual style second. Restyling is cheap; changing the flow isn't.
| Business | Recommended flow |
|---|---|
| Salon, barber, spa | Services list → service → (staff: "Anyone" or a person) → date → time → extras → checkout |
| Clinic, therapist | Department/collection → service → practitioner → date → time → intake questions → checkout. Keep the tone calm and clinical |
| Fitness classes, workshops | Timetable by day (group products) → class → party size → checkout |
| Courts, rooms, studios | Sport/room type toggle → date → units × time grid → length → checkout |
| Coaches, tutors | Coach profiles → service → date → time → checkout |
## Step 2: pages and components
Build these. They cover most booking sites.
1. **Home**: hero, a short intro, featured services (from the API; the first 3–6 active products or one collection), location/contact block, and a clear "Book now" call to action.
2. **Services** (`/services`): the catalogue from `GET /storefront/products?status=active`, grouped by `GET /storefront/product-collections?isVisible=true` when collections exist. The card shows image, title, duration (formatted from seconds), and price, as "from £X" when variants differ. Products with several `variants` show the variants as options.
3. **Booking page** (`/book/:slug`): product detail by slug, then the flow for its shape (below).
4. **Cart / checkout** (`/checkout`): summary, promo code, customer form, questions, payment.
5. **Confirmation** (`/booking/confirmed`): order number (`order.name`), date/time in local time, location or online link, "Add to calendar", and a link to the account area.
6. **Account** (`/account`): sign in (6-digit email code), upcoming and past appointments, appointment detail with reschedule/cancel, profile, marketing preferences.
7. **Staff / team** (optional): from `GET /storefront/staff-members`. Show first name, last name and image only.
8. **Locations** (optional, multi-location): from `GET /storefront/locations`.
Reusable components: service card, variant selector, staff selector (with "Anyone available"), date picker (days enabled from availability ranges), time-slot grid (grouped into morning/afternoon/evening), party-size stepper, add-on selector, cart summary with countdown, checkout form driven by settings, payment step, appointment card.
## Step 3: the booking flow in detail
### Staff-based (salon, clinic, coaching)
1. Load the product by slug. If `variants.length > 1`, the customer picks a variant first. Every later call uses the **variant `id`**.
2. If the product has several `locations`, pick a location (or skip if there's one).
3. Staff: offer "Anyone available" plus the variant's `staffMembers`.
4. Date picker: call `current-availability-ranges` once for the variant (plus `locationId`/`staffMemberId` when chosen, and `timezone`) and enable only the days covered by a range. Open on the first available day (`nearest-availability`).
5. Time slots: `current-availabilities?date=…&timezone=…` for the chosen day. Display `fromTime` converted to local time. For "Anyone", a slot is bookable if `staffMemberIds` isn't empty. Don't send `staffMemberId` in the booking unless the customer picked someone.
6. On slot click: create the cart if needed, then `POST /storefront/appointments` with the slot, `cartId`, `numberOfAttendees` and any add-ons. Then go to checkout.
7. If booking fails with a 4xx, the slot was probably just taken. Refresh the slots, show "That time was just booked, please choose another", and keep the selections.
### Group classes
- The slot list doubles as a timetable. Show `maxAttendees - attendees` spots left, grey out full slots, and cap the party size at the remaining spots. Send `numberOfAttendees`.
- Optional guests: if `allowGuests`, let the customer add friends' emails (up to `maxGuests`). Guests get copies of the emails and don't use up spots.
### Resource grid (courts, rooms)
- Rows are units: the variants of the "Court" product, or the products in a collection. Columns are times on the selected date at the base duration.
- Fetch `current-availabilities` per unit for the chosen date (cache each result; retry on 429 with backoff). A cell is free if that unit has a slot starting at that time.
- On cell click: if the product allows custom duration, show the lengths (base × n up to `maxDuration`). Re-query that unit with `duration=<seconds>` to confirm the block fits, then book the returned slot.
- Each court is one product. Never model the 60-min and 90-min options as separate products; they wouldn't block each other.
### Variable length
Show the length picker before the date/time. Pass `duration` (seconds) to both availability calls, and show the price for each length as `ceil(length / base) × price`, clearly marked as an estimate. The cart total is authoritative.
## Step 4: cart and checkout
- Persist `cart.id` in `localStorage`. Send it as `X-Cart-Id` to your server functions, which forward it.
- Summary: each item's service, staff, local date/time, add-ons, and the line `discountedTotal`; then `subtotal`, discounts (`appliedDiscounts`), `totalTax` and `total`, formatted in `paymentCurrencyCode`.
- **Countdown**: "Your time is held for 9:32". Compute it from `expiresAt`, recompute on tab focus, call `/cart/extend` while the customer is typing, and on expiry start a new cart and send them back to pick a time.
- **Promo code**: a small "Have a code?" field. Show the mapped error message on failure. Show "Code SUMMER10 applied −£5" on success, with a remove button.
- **Customer form** from `checkoutSettings`: email and last name are always required; first name if `customerFirstNameRequired`; phone, company, VAT and billing address according to their `hidden` / `optional` / `required` values. Marketing checkboxes appear only for channels not set to `dont_show`, and are unticked by default. Show a delivery address only when a cart item is at a `delivery` location.
- A signed-in customer gets the form pre-filled from the profile and sent as `{ "kind": "existing", "customerId" }`. A guest sends `{ "kind": "new", … }`. **`kind` is always required.**
- **Questions**: fetch the cart's checkout questions. If there are any, show them as a step, and send each answer with its question text.
- **Payment**: list the providers from `/payment/providers`. Start checkout with the chosen one and branch on the returned `provider`:
- `stripe`: Stripe Payment Element in the page using the returned `publishableKey`, `stripeAccountId` and `clientSecret`; confirm, then submit with the PaymentIntent id. Install `@stripe/stripe-js` and `@stripe/react-stripe-js`. **No Stripe secret or Stripe connector is needed**, because payments go to the merchant's Stripe account connected in Opencals.
- `cash` / `bank_transfer`: "Pay at the venue" or "Pay by bank transfer" copy, then submit immediately.
- `no_payment_required`: the button reads "Confirm booking", then submit immediately.
- Submit with `appointmentsSettings: { markAsScheduled: true }`. On success, clear the cart id, store `auth` tokens if returned (this signs the customer in), and go to the confirmation page.
## Step 5: customer account
- Sign in with a 6-digit email code (`request-login-code` → `verify-login-code`) as the main path. Password sign-in is optional. Handle magic links at a `/link/:token` route with `resolve-link`, then redirect to `redirectPath`.
- Keep tokens for the session, and send the access token to your server functions, which forward it as `Authorization: Bearer …`. On 401, refresh once, then sign out.
- **Appointments**: two tabs, Upcoming (`status=scheduled`, `orderBy=from`, `order=ASC`) and Past (`completed`, `canceled`). Each card shows service, staff, local date/time, location, and status badge.
- **Appointment detail**: reschedule and cancel buttons only if the product allows them and the start is more than `rescheduleGap` / `cancelGap` away; otherwise explain the notice period. Rescheduling reuses the slot picker with `excludeAppointmentId`. Cancelling asks for confirmation first.
- **Profile**: first name, last name, phone. **Preferences**: marketing channels with `checkout_and_account`. **Receipts**: from order invoices.
## Step 6: design
- Take the look from the brief or inspiration: colour palette, typography, photography style, density. Put colours and fonts in theme tokens (CSS variables / Tailwind theme) so they're easy to change.
- Match the tone to the business: warm and editorial for salons, dark and bold for barbers, calm and trustworthy for clinics, energetic and technical for sports venues.
- The slot picker is the core of the product. Give it large tap targets, a clear selected state, a disabled state for full or past slots, a sticky "Continue" on mobile, and local-time labels with the timezone shown when the visitor's timezone differs from the store's.
- Use skeleton loaders for the catalogue and slots. Show a friendly empty state ("No times left on this day — next available: Thu 16 Oct").
- Format prices with `Intl.NumberFormat` in the store currency and durations from seconds ("1 h 30 min"). Use the store's 12H/24H `timeFormat`.
- Build mobile first. Most bookings happen on phones.
- Accessibility: slot buttons with `aria-pressed`, labelled form fields, visible focus, and error messages tied to their fields.
- White-label: the site can carry only the merchant's brand. There's no need to show Opencals branding.
## Step 7: before calling it done
Walk through this with the connected store:
- [ ] Services load from the API; nothing is hard-coded.
- [ ] Days without availability are disabled; slots show in local time.
- [ ] A test booking completes end to end (use a Stripe test-mode store, or cash / no-payment).
- [ ] Refreshing the checkout page keeps the cart; letting it expire recovers cleanly.
- [ ] An invalid promo code shows a clear message; a valid one changes the total.
- [ ] Required checkout fields follow the store's settings.
- [ ] Sign in with the email code works; the new booking shows under Upcoming.
- [ ] Reschedule (including keeping the same day) and cancel work, and are hidden inside the notice window.
- [ ] Errors from the API are shown to the user, not swallowed.
- [ ] No page makes more than a handful of API calls on load; catalogue data is cached.
## Troubleshooting
- **A service is missing or has no times**: the product is inactive, has no schedule/availability, isn't offered at the chosen location, or is outside the advance-booking window. The merchant fixes this in the Opencals dashboard; it isn't a code bug.
- **Everything returns 404 `Cannot GET`**: the connector isn't sending `X-Api-Version: 1`. Fix the connector configuration.
- **401 "Origin not allowed"**: the API key has an allowed-origins list. Use a storefront key without origin restrictions for the connector.
- **Checkout 400**: usually a missing `kind` on `customer`, or a field the store requires. Show `errors[]`.
- **Bursts of 429**: too many parallel availability calls. Cache, use ranges, and back off.
5
Connect and describe the business
Open the connector, Add connection, enter your
sfk_… key and 1,
choose who can use it, and link it to a project. Then prompt with the
business, not the booking system:Build a booking website for Fade & Co, a three-chair barbershop in Manchester.
Use the Opencals connector for services, prices, barbers and availability.
Customers pick a service, then a barber or "anyone", then a time.
Dark and bold: black, off-white, one brass accent. Pages: home, services,
booking flow, checkout with card payment, confirmation.
How it works
Lovable generates one thin server function per operation (list services, get slots, book, start checkout) that calls Opencals through the connector; the browser only talks to those functions. Card payments render Stripe’s Payment Element with thepublishableKey, stripeAccountId and clientSecret that
checkout/start returns, so no Stripe connector or Stripe secret is needed
in Lovable.
Limits
- Rate limits are app-wide. The API allows 20 req/s, 60 per 10 s and 120 per minute per key and client IP. Lovable’s gateway sends all visitors from the same IP range, so one Lovable site shares ~120 requests/minute. The playbook tells Lovable to cache the catalogue and use availability ranges for calendars.
- Development stores run Stripe in test mode. Switch the store to production to take real payments.
Troubleshooting
| Symptom | Fix |
|---|---|
404 Cannot GET /storefront/... | The X-Api-Version header field is missing or the connection value isn’t 1 |
401 Origin not allowed for this API key | Use a key with no allowed origins |
| Service missing or no times | Product inactive, no schedule, or outside the booking window. Fix in the dashboard |
Bursts of 429 | Too many parallel availability calls; cache and back off |