# Base44 Source: https://opencals.com/docs/ai-agents/base44 Connect a Base44 app to Opencals with one backend function, an app secret and two workspace skills: live availability, cart, checkout and Stripe payments. Base44 builds the app; Opencals runs the booking. Every call goes through one backend function, `opencals`, which adds the API key and version headers and passes each visitor's cart ID and customer token through. Two workspace skills teach Base44's AI the API and the booking flow. Needs a Base44 **Builder** plan or higher (backend functions and workspace skills), and a workspace owner or admin to add skills. In the Opencals dashboard open **Settings → API Keys** and create a key (`sfk_…`) with **no allowed origins**. The backend function sends no `Origin` header. Workspace name (bottom left) → **Settings** → **Plugins → Skills** → **Add skill** → **Start from scratch**. **Skill 1** · name `opencals-storefront-api` · description: `Use for any code that calls the Opencals booking API: catalogue, availability, cart, appointments, discounts, checkout and payment, customer accounts, reschedule and cancel, errors and rate limits.` ````markdown opencals-storefront-api.md theme={"theme":{"light":"github-light","dark":"github-dark-default"}} # 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: ` on cart and checkout calls (see "Cart"). - `Authorization: Bearer ` 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 `) → 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. If the email belongs to an existing customer and the request isn't signed in as them (no customer `Authorization` token), the response is `{}` with no `customerId`. The booking is still attached to that customer, but nothing on their profile changes (name, phone, billing address, marketing consent). In the next steps, send the same `kind: "new"` details again or leave `customer` out: the cart already holds the customer. Save-customer and `checkout/start` don't write anything to the customer: until the order is placed, the billing and delivery addresses entered belong to the cart only, and their `customerId` is `null`. When `checkout/submit` places the order, those addresses are added to the customer's saved addresses (the order and invoice use them as entered). - `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": "", "firstName"?, "lastName"?, "phone"?, "billingAddress"?, "deliveryAddress"?, "marketingConsent"? }`. - Required billing fields are checked against the `billingAddress` in the request or, if it has none, against the billing already saved on the cart. You don't have to send the billing address again at `checkout/start` or `checkout/submit`. ### 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` and `customer`: only when the request is signed in as the order's customer, or when this checkout created a new customer. Store the `auth` tokens (`{ accessToken, refreshToken }`) to sign the customer in automatically so they can see their booking. When the email belonged to an existing customer and the request wasn't signed in, both are missing: the booking is in that customer's account, and they see it after signing in with a login code. Always treat `auth` as optional. - 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 `. ### 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 `, 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 (with `fixedStartTime` and `fixedEndTime`, e.g. check-in 15:00 and check-out 11:00), the product is booked check-in to check-out: the slot must start at `fixedStartTime` on the first day and end at `fixedEndTime` on the last day, in the product's schedule timezone (the store timezone if the product has no schedule). The server enforces this on `POST /storefront/appointments` and on customer reschedules, and returns `400` ("This product must be booked from HH:MM to HH:MM …") for any other times. Merchant bookings from the dashboard are exempt. Such a stay isn't a whole multiple of the base duration (3 nights from 15:00 to 11:00 is 68 hours), and it's priced by rounding up, so N nights cost N × `price`. To build the date picker, read `…/current-availability-ranges` (or `getCurrentAvailabilitiesMerged`) without `duration`, check that check-in → check-out fits inside one range, then send the slot built from the product's fixed times. 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`). Show customers only names, images, descriptions, prices, durations and times. - Human-readable docs: https://opencals.com/docs ```` **Skill 2** · name `opencals-booking-site` · description: `Use when building a booking website or booking flow on Opencals in Base44: the opencals backend function, choosing the flow for the business type, pages, slot picker, checkout and payment UX, and the launch checklist.` ```markdown base44-booking-site-playbook.md theme={"theme":{"light":"github-light","dark":"github-dark-default"}} # Opencals booking site playbook (Base44) How to build a production booking website (or add booking to an existing app) on Opencals inside Base44. 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"** skill. This file covers how to connect and what to build, in what order. ## Step 0: the `opencals` backend function (build this first) Every Opencals call goes through **one** backend function named `opencals`. The API reference calls it "the connector": wherever it says the connector adds a header, this function does it. - The store's Storefront API key is saved in the app's Secrets as `OPENCALS_API_KEY` (it starts with `sfk_`). Read it inside the function. Never put it in frontend code, entities or logs. - Input: `{ method, path, query?, body?, cartId?, accessToken? }`. - Allow only paths that start with `/storefront/`. Reject anything else with 400. - Call `https://api.opencals.com` + `path` (+ `query` as a query string) with these headers: - `X-Api-Key: ` - `X-Api-Version: 1` (without it every route returns 404 `Cannot GET …`) - `Content-Type: application/json` when there is a body - `X-Cart-Id: ` when `cartId` is given - `Authorization: Bearer ` when `accessToken` is given - Return `{ status, data }` with the API's HTTP status and parsed JSON body unchanged (`data` is null for 204). Don't throw on 4xx; the UI needs the API's `message` and `errors[]`. - The frontend calls it with `base44.functions.invoke('opencals', { ... })`. Keep the cart id and customer tokens in the browser (`localStorage` / session) and pass them in on every call that needs them. - Test it first: `GET /storefront/stores/public-settings` must return 200 with the store name and currency. 404 `Cannot GET` means the version header is missing; 401 `Origin not allowed` means the key has an allowed-origins list and needs replacing with a key that has none. This app doesn't need Base44 entities for services, bookings or customers. Opencals stores all of that; use entities only for content that belongs to the site itself. ## 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` backend function.** The browser never calls Opencals directly and never sees the key. Pass `cartId` and the customer's `accessToken` into the function; it forwards them as headers and returns 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.** Treat it as 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=` 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`. Pass it as `cartId` to the `opencals` function, which forwards it as `X-Cart-Id`. - 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 pass the access token as `accessToken` to the `opencals` function, which forwards 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 `opencals` function isn't sending `X-Api-Version: 1`. - **401 "Origin not allowed"**: the API key has an allowed-origins list. Save a storefront key without origin restrictions as `OPENCALS_API_KEY`. - **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. ``` Raw files: [opencals-storefront-api.md](https://opencals.com/ai/opencals-storefront-api.md) · [base44-booking-site-playbook.md](https://opencals.com/ai/base44-booking-site-playbook.md) In the app editor: **Dashboard → Secrets → Add Secret**. Name `OPENCALS_API_KEY`, value your `sfk_…` key. ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}} Use the opencals-booking-site and opencals-storefront-api skills. First, create the opencals backend function exactly as the playbook's Step 0 describes, using the OPENCALS_API_KEY secret, and test it with GET /storefront/stores/public-settings. Show me the store name it returns. Don't build any pages yet. ``` Once it returns your store name, describe the business (services, prices and times come from Opencals, so leave them out of the prompt). ## The `opencals` function contract | | | | - | - | | Input | `{ method, path, query?, body?, cartId?, accessToken? }` | | Allowed paths | Only `/storefront/*` | | Headers sent | `X-Api-Key` (from secret), `X-Api-Version: 1`, `Content-Type` with a body, `X-Cart-Id` from `cartId`, `Authorization: Bearer` from `accessToken` | | Output | `{ status, data }`, the API's status and JSON unchanged; doesn't throw on 4xx | | Frontend call | `base44.functions.invoke('opencals', { ... })` | Why not a Base44 custom OpenAPI integration? Those send fixed, admin-configured headers. Opencals carts and customer sessions need a per-visitor `X-Cart-Id` and `Authorization` header, which only a backend function can pass through. ## Related * [Lovable](/docs/ai-agents/lovable) · [v0](/docs/ai-agents/v0) * [Storefront API reference](/docs/api-reference/introduction) # opencals-build-booking-site skill Source: https://opencals.com/docs/ai-agents/build-booking-site-skill The Agent Skill that builds a whole booking website on Opencals — pick an official Next.js template, scaffold, wire availability and checkout, rebrand, and deploy to Vercel. The build skill. Install it when you want an agent to stand up a complete booking website — not just call the API. It picks the closest official template, scaffolds it, wires it to a store, restyles it to your brand, and deploys it. For exact API calls it defers to the [opencals-storefront-api skill](/docs/ai-agents/storefront-api-skill). **Repo:** [github.com/letsopencals/skills](https://github.com/letsopencals/skills) · **Install:** [see the overview](/docs/ai-agents/overview#install) ## What it covers * **Choosing a template** by booking shape (people vs resources, groups, multi-location). * **The workflow**: scaffold → configure env → understand the SDK wiring → rebrand → verify → deploy to Vercel. * **Resource-booking** (courts, lanes, rooms, equipment): the three conflict rules, `allowCustomDuration`, and the units × time grid strategy — the [VOLT template](/docs/open-source/volt) is the worked example. * **Rentals by the day** (cars, boats, equipment, villas): 1-day base duration with custom duration on a 24/7 schedule, merged availability ranges with a client-side fit, 00:00-aligned slots, handover details as custom attributes. The [NOIR Drive template](/docs/open-source/noir) is the worked example. * **Nightly stays** (villas, cabins, guest houses, rooms): a one-night base with custom duration and fixed check-in/check-out times that the API enforces, turnover days, guest and arrival details as custom attributes. The [Bali Villa Rentals template](/docs/open-source/bali-villas) is the worked example. * **Schools and classes** (languages, music, coding, tutoring): subjects as collections, levels as variants, teachers as staff, seats left from slot availability, and the online location's `link` for a Join button. The [Fluently template](/docs/open-source/fluently) is the worked example. * **Customising**: colour/font tokens, `siteConfig`, and what comes from the API vs. what you edit in code. ## Templates it builds on ## When it triggers "Build me a booking website", "I run a \[salon / clinic / padel club] and need a booking site", or handing the agent a business idea plus inspiration. ## Related * [Open-source templates](/docs/open-source/templates) — the template gallery * [opencals-storefront-api skill](/docs/ai-agents/storefront-api-skill) — the exact API/SDK reference this skill builds on * [Build a booking page](/docs/guides/build-a-booking-page) — the manual tutorial # Lovable Source: https://opencals.com/docs/ai-agents/lovable Add Opencals to Lovable as a custom connector so Lovable apps take real bookings: live availability, cart, checkout and Stripe payments. Every field ready to copy. A Lovable **custom connector** registers the Opencals API once for the whole workspace. Two knowledge files teach Lovable how to use it, and the key lives in Lovable's encrypted connector credentials, never in project code. Describe a business and Lovable builds a site that reads your real services, shows live availability, holds the slot during checkout and takes payment through your own Stripe account. You need **admin or owner** rights in the Lovable workspace to create a custom connector. Anyone in the workspace can connect to it afterwards. 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`. 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](https://opencals.com/images/logo/icon.png) | ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}} 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. ``` Set **API base URL** to `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 | **Test request:** `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. Click **Add knowledge file** twice. Use the name and description exactly; Lovable reads the description to decide which file to open. **File 1**. Name: `Opencals Storefront API`. Description: `Complete endpoint reference: catalogue, availability, cart, appointments, discounts, checkout and payment, customer accounts, reschedule/cancel, errors and rate limits.` ````markdown opencals-storefront-api.md theme={"theme":{"light":"github-light","dark":"github-dark-default"}} # 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: ` on cart and checkout calls (see "Cart"). - `Authorization: Bearer ` 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 `) → 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. If the email belongs to an existing customer and the request isn't signed in as them (no customer `Authorization` token), the response is `{}` with no `customerId`. The booking is still attached to that customer, but nothing on their profile changes (name, phone, billing address, marketing consent). In the next steps, send the same `kind: "new"` details again or leave `customer` out: the cart already holds the customer. Save-customer and `checkout/start` don't write anything to the customer: until the order is placed, the billing and delivery addresses entered belong to the cart only, and their `customerId` is `null`. When `checkout/submit` places the order, those addresses are added to the customer's saved addresses (the order and invoice use them as entered). - `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": "", "firstName"?, "lastName"?, "phone"?, "billingAddress"?, "deliveryAddress"?, "marketingConsent"? }`. - Required billing fields are checked against the `billingAddress` in the request or, if it has none, against the billing already saved on the cart. You don't have to send the billing address again at `checkout/start` or `checkout/submit`. ### 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` and `customer`: only when the request is signed in as the order's customer, or when this checkout created a new customer. Store the `auth` tokens (`{ accessToken, refreshToken }`) to sign the customer in automatically so they can see their booking. When the email belonged to an existing customer and the request wasn't signed in, both are missing: the booking is in that customer's account, and they see it after signing in with a login code. Always treat `auth` as optional. - 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 `. ### 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 `, 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 (with `fixedStartTime` and `fixedEndTime`, e.g. check-in 15:00 and check-out 11:00), the product is booked check-in to check-out: the slot must start at `fixedStartTime` on the first day and end at `fixedEndTime` on the last day, in the product's schedule timezone (the store timezone if the product has no schedule). The server enforces this on `POST /storefront/appointments` and on customer reschedules, and returns `400` ("This product must be booked from HH:MM to HH:MM …") for any other times. Merchant bookings from the dashboard are exempt. Such a stay isn't a whole multiple of the base duration (3 nights from 15:00 to 11:00 is 68 hours), and it's priced by rounding up, so N nights cost N × `price`. To build the date picker, read `…/current-availability-ranges` (or `getCurrentAvailabilitiesMerged`) without `duration`, check that check-in → check-out fits inside one range, then send the slot built from the product's fixed times. 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`). Show customers only names, images, descriptions, prices, durations and times. - Human-readable docs: https://opencals.com/docs ```` **File 2**. Name: `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.` ```markdown lovable-booking-site-playbook.md theme={"theme":{"light":"github-light","dark":"github-dark-default"}} # 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=` 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. ``` Raw files: [opencals-storefront-api.md](https://opencals.com/ai/opencals-storefront-api.md) · [lovable-booking-site-playbook.md](https://opencals.com/ai/lovable-booking-site-playbook.md). Then click **Create**. 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: ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}} 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 the `publishableKey`, `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 | ## Related * [v0](/docs/ai-agents/v0) · [Base44](/docs/ai-agents/base44) * [Storefront API reference](/docs/api-reference/introduction) # AI Agents & App Builders Source: https://opencals.com/docs/ai-agents/overview Agent Skills that teach AI coding tools — Claude Code, Cursor, Codex, Gemini CLI — to build booking integrations on the Opencals API correctly. Install once and let your agent scaffold, wire, and debug. Most Opencals integrations are now written inside an AI coding agent. **Agent Skills** give that agent ground-truth knowledge of the Opencals API and SDK — the real method names, argument shapes, and flows — so it builds working code instead of guessing. Skills follow the open [Agent Skills](https://code.claude.com/docs/en/skills) standard (a folder with a `SKILL.md`), supported by Claude Code, Cursor, OpenAI Codex, Gemini CLI and more. They live in one public repo: **[github.com/letsopencals/skills](https://github.com/letsopencals/skills)**. ## The two skills Ground truth for any code that talks to the Opencals API / SDK — availability, carts, appointments, checkout, discounts, customer accounts, guests & attendees, custom durations, reschedule/cancel, rate limits. [Details →](/docs/ai-agents/storefront-api-skill) Build a whole booking website — pick an official template, scaffold, wire it up, rebrand, deploy. Uses the API skill for exact call shapes. [Details →](/docs/ai-agents/build-booking-site-skill) ## The primary journey Hand your agent a business idea and some inspiration — screenshots, a competitor site, a brand feel. `opencals-build-booking-site` picks the closest template and scaffolds it; `opencals-storefront-api` supplies the exact API calls to wire availability, booking and checkout. The agent restyles to your inspiration and deploys to Vercel. You add your Opencals API key. Bookings, checkout and payments work out of the box. ## Install ``` /plugin marketplace add letsopencals/skills /plugin install opencals@opencals ``` Both skills load automatically and trigger on relevant requests. Agent Skills are portable — a folder with a `SKILL.md`. Clone the repo and copy the skill folders into your tool's skills directory: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/skills opencals-skills # copy opencals-storefront-api/ and opencals-build-booking-site/ # into your agent's skills folder ``` The exact skills directory differs per tool — check your tool's current Agent Skills / rules docs for where skill folders go. Clone the repo and point your agent at the relevant `SKILL.md`: > Follow the skill in `opencals-storefront-api/SKILL.md` when writing Opencals > API code, and `opencals-build-booking-site/SKILL.md` to scaffold a site. Each AI app builder has its own setup, with every field ready to copy: * **[Lovable](/docs/ai-agents/lovable)**: a custom connector with two knowledge files. * **[v0](/docs/ai-agents/v0)**: attach these skills from skills.sh (`letsopencals/skills`). * **[Base44](/docs/ai-agents/base44)**: one backend function, an app secret and two workspace skills. For other builders (Bolt, Replit), paste the [API reference](https://opencals.com/ai/opencals-storefront-api.md) and [booking-site playbook](https://opencals.com/ai/lovable-booking-site-playbook.md) into the project instructions. ## AI app builders Custom connector + knowledge files Opencals skills from skills.sh Backend function + workspace skills ## For AI agents reading this The full documentation is available as machine-readable Markdown: Structured index of every docs page. The complete docs in one file for maximum context. The [API reference](/docs/api-reference/introduction) is generated from the OpenAPI spec — download it via the **Download spec** action, or fetch [openapi.json](/docs/api-reference/openapi.json) directly. # opencals-storefront-api skill Source: https://opencals.com/docs/ai-agents/storefront-api-skill The ground-truth Agent Skill for building on the Opencals API and @opencals/storefront-sdk — availability, carts, appointments, checkout, discounts, customer accounts, guests, custom durations, reschedule/cancel and rate limits. The ground-truth reference skill. Install it whenever an agent writes, reviews or debugs code against the Opencals API / `@opencals/storefront-sdk`. It carries the real method names, argument shapes and enums copied from the generated SDK — so the agent stops inventing APIs. **Repo:** [github.com/letsopencals/skills](https://github.com/letsopencals/skills) · **Install:** [see the overview](/docs/ai-agents/overview#install) ## What it covers A `SKILL.md` router plus focused reference files, loaded on demand: | Reference | Covers | | - | - | | `setup.md` | `setupOpencals`, service classes, `X-Api-Version`, `OpencalsApiError`, the `throwOnError` gotcha | | `availability.md` | `getCurrentAvailabilities` vs `getCurrentAvailabilitiesMerged`, the `duration` param, `excludeAppointmentId`, slot shape, grid fan-out | | `custom-duration.md` | `allowCustomDuration` / `maxDuration`, the quantity = duration-units pricing rule | | `guests-attendees.md` | `numberOfAttendees`, `attendees` / `maxAttendees`, `addGuest` / `removeGuest` | | `cart-lifecycle.md` | create/get, `X-Cart-Id`, `expiresAt`, `extendExpiration`, expiry countdown | | `checkout.md` | `saveCustomer` timing, billing vs delivery address, marketing consent, checkout questions, payment | | `auth-self-service.md` | password + passwordless + OAuth login, sessions, `SelfService` (profile, consent) | | `reschedule-cancel.md` | `reschedule` / `cancel`, `rescheduleGap` / `cancelGap`, excluding the moved appointment | | `discounts.md` | automatic vs code vs balance credit (`valueType: 'time'`), error codes | | `rate-limiting.md` | 429 handling, exponential-backoff retry pattern, allowed origins, per-key throttling | | `debugging.md` | reading `OpencalsApiError`, the top recurring mistakes and how to spot them | ## When it triggers Requests like "show available slots", "book an appointment", "apply a discount code", "let customers reschedule", "add a guest", "collect a billing address", "why am I getting a 429", "custom booking length", "customer login". ## Related * [Build a booking page](/docs/guides/build-a-booking-page) — the same flow, walked through * [Storefront SDK](/docs/guides/storefront-sdk) — SDK setup and reference * [opencals-build-booking-site skill](/docs/ai-agents/build-booking-site-skill) — scaffold a whole site # v0 Source: https://opencals.com/docs/ai-agents/v0 Attach the Opencals agent skills in v0 from skills.sh, add OPENCALS_API_KEY, and v0 builds a Next.js booking site on the Opencals SDK with live availability and Stripe checkout. v0 supports [Agent Skills](/docs/ai-agents/overview), so it uses the same two Opencals skills as Claude Code and Cursor. It generates a standard Next.js app on `@opencals/storefront-sdk`, which you can keep developing anywhere. In the Opencals dashboard open **Settings → API Keys** and create a key (`sfk_…`). Leave allowed origins empty: the SDK runs in server code, which sends no `Origin` header. In the v0 prompt bar open the skills menu (type `/` in the composer) and add the Opencals skills from skills.sh: ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}} letsopencals/skills ``` Attach both **opencals-build-booking-site** and **opencals-storefront-api**, and save them to your personal or team scope. v0 asks for missing environment variables above the composer, or add them to the project yourself: | Variable | Value | | - | - | | `OPENCALS_API_KEY` | Your `sfk_…` key (required) | | `OPENCALS_API_URL` | `https://api.opencals.com` (optional, default) | | `AUTH_SECRET` | Random string, only if the site adds customer sign-in | ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}} Build a booking website for Ridge Padel, a six-court padel club in Valencia. Use the Opencals skills. Players pick a date and see every court against time in one grid, then choose 60 or 90 minutes. Coached group sessions are bookable separately, with spots left shown. Dark, sporty, one electric-lime accent. Pages: home, book a court, coaching, checkout, confirmation. ``` ## Other tools, same skills ```bash skills CLI theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npx skills add letsopencals/skills ``` ```text Claude Code theme={"theme":{"light":"github-light","dark":"github-dark-default"}} /plugin marketplace add letsopencals/skills /plugin install opencals@opencals ``` ## Check before publishing * Slot times from the API are **UTC**. If the picker shows raw UTC, tell v0: "slot times are UTC, show them in the store's timezone". * Book end to end with Stripe's test card `4242 4242 4242 4242` and confirm the booking appears in the dashboard. * Publish to Vercel, then switch the store to production to take real payments. ## Related * [opencals-build-booking-site skill](/docs/ai-agents/build-booking-site-skill) · [opencals-storefront-api skill](/docs/ai-agents/storefront-api-skill) * [Lovable](/docs/ai-agents/lovable) · [Base44](/docs/ai-agents/base44) # Get an add-on by ID Source: https://opencals.com/docs/api-reference/add-ons/get-an-add-on-by-id /api-reference/openapi.json get /storefront/add-ons/{addOnId} # Get an add-on by slug Source: https://opencals.com/docs/api-reference/add-ons/get-an-add-on-by-slug /api-reference/openapi.json get /storefront/add-ons/slug/{slug} # List add-ons for the current store Source: https://opencals.com/docs/api-reference/add-ons/list-add-ons-for-the-current-store /api-reference/openapi.json get /storefront/add-ons # Add a guest to an appointment Source: https://opencals.com/docs/api-reference/appointments/add-a-guest-to-an-appointment /api-reference/openapi.json post /storefront/appointments/{appointmentId}/guests Adds a guest (by email) to an appointment. Guests are copied on appointment notifications. # Cancel appointment Source: https://opencals.com/docs/api-reference/appointments/cancel-appointment /api-reference/openapi.json put /storefront/appointments/{appointmentId}/cancel Cancels an existing appointment. Subject to cancel_gap restrictions defined in the service configuration. # Create appointment Source: https://opencals.com/docs/api-reference/appointments/create-appointment /api-reference/openapi.json post /storefront/appointments Creates a new appointment for a customer. Authentication is optional - if authenticated, the appointment will be associated with the logged-in user. # Get appointment by external order name Source: https://opencals.com/docs/api-reference/appointments/get-appointment-by-external-order-name /api-reference/openapi.json get /storefront/appointments/by-external-order-name/{externalOrderName} Deprecated: look the order up instead. Returns a single appointment by the name of its order on the store's platform (e.g. a Shopify order name). # Get appointment by ID Source: https://opencals.com/docs/api-reference/appointments/get-appointment-by-id /api-reference/openapi.json get /storefront/appointments/{appointmentId} Returns a single appointment by ID for the authenticated customer # Get customer booking preferences Source: https://opencals.com/docs/api-reference/appointments/get-customer-booking-preferences /api-reference/openapi.json get /storefront/appointments/preferences/booking-history Returns frequently booked services, locations, and staff members based on customer booking history. Results are cached for 24 hours to optimize performance. # List customer appointments Source: https://opencals.com/docs/api-reference/appointments/list-customer-appointments /api-reference/openapi.json get /storefront/appointments Returns all appointments for the authenticated customer with pagination # Remove a guest from an appointment Source: https://opencals.com/docs/api-reference/appointments/remove-a-guest-from-an-appointment /api-reference/openapi.json delete /storefront/appointments/{appointmentId}/guests/{guestId} Removes a guest from an appointment and (by default) notifies them of the removal. # Reschedule appointment Source: https://opencals.com/docs/api-reference/appointments/reschedule-appointment /api-reference/openapi.json put /storefront/appointments/{appointmentId}/reschedule Reschedules an existing appointment to a new time. Subject to reschedule_gap restrictions defined in the service configuration. # Submit appointment feedback Source: https://opencals.com/docs/api-reference/appointments/submit-appointment-feedback /api-reference/openapi.json post /storefront/appointments/{appointmentId}/feedback Allows customers to submit feedback for a completed appointment # Customer OAuth sign in Source: https://opencals.com/docs/api-reference/auth/customer-oauth-sign-in /api-reference/openapi.json post /storefront/auth/oauth Authenticate or register a storefront customer using an OAuth provider (e.g. Google) # Customer sign in with credentials Source: https://opencals.com/docs/api-reference/auth/customer-sign-in-with-credentials /api-reference/openapi.json post /storefront/auth/sign-in Authenticate a storefront customer using email and password # Refresh customer tokens Source: https://opencals.com/docs/api-reference/auth/refresh-customer-tokens /api-reference/openapi.json get /storefront/auth/refresh Exchange a valid customer refresh token for a new pair of access and refresh tokens # Register a new customer Source: https://opencals.com/docs/api-reference/auth/register-a-new-customer /api-reference/openapi.json post /storefront/auth/sign-up Create a new customer account on the storefront and send a verification email # Request a login code Source: https://opencals.com/docs/api-reference/auth/request-a-login-code /api-reference/openapi.json post /storefront/auth/request-login-code Send a 6-digit login code to the customer email for passwordless authentication # Request customer email verification Source: https://opencals.com/docs/api-reference/auth/request-customer-email-verification /api-reference/openapi.json post /storefront/auth/request-email-verification Send a verification email to the customer for account activation within the store context # Request customer password reset Source: https://opencals.com/docs/api-reference/auth/request-customer-password-reset /api-reference/openapi.json post /storefront/auth/request-password-reset Send a password reset email to the customer within the store context # Reset customer password Source: https://opencals.com/docs/api-reference/auth/reset-customer-password /api-reference/openapi.json post /storefront/auth/reset-password Reset the customer password using a valid reset token received via email # Resolve a magic link token Source: https://opencals.com/docs/api-reference/auth/resolve-a-magic-link-token /api-reference/openapi.json get /storefront/auth/resolve-link Validate a magic link token, authenticate the customer, and return redirect information # Verify a login code Source: https://opencals.com/docs/api-reference/auth/verify-a-login-code /api-reference/openapi.json post /storefront/auth/verify-login-code Verify the 6-digit login code and return auth tokens for passwordless sign-in # Verify customer email Source: https://opencals.com/docs/api-reference/auth/verify-customer-email /api-reference/openapi.json post /storefront/auth/verify-email Confirm customer email ownership using the verification token and return auth tokens # Add an add-on to a cart item Source: https://opencals.com/docs/api-reference/cart/add-an-add-on-to-a-cart-item /api-reference/openapi.json post /storefront/cart/items/{cartItemId}/add-ons # Add appointment to cart Source: https://opencals.com/docs/api-reference/cart/add-appointment-to-cart /api-reference/openapi.json post /storefront/cart/items # Apply a discount/promo code to the cart Source: https://opencals.com/docs/api-reference/cart/apply-a-discountpromo-code-to-the-cart /api-reference/openapi.json post /storefront/cart/discount-code # Create or fetch cart Source: https://opencals.com/docs/api-reference/cart/create-or-fetch-cart /api-reference/openapi.json post /storefront/cart # Extend cart expiration by 5 minutes Source: https://opencals.com/docs/api-reference/cart/extend-cart-expiration-by-5-minutes /api-reference/openapi.json post /storefront/cart/extend # Fetch cart Source: https://opencals.com/docs/api-reference/cart/fetch-cart /api-reference/openapi.json get /storefront/cart # Remove a cart add-on item Source: https://opencals.com/docs/api-reference/cart/remove-a-cart-add-on-item /api-reference/openapi.json delete /storefront/cart/add-ons/{cartAddOnItemId} # Remove cart item Source: https://opencals.com/docs/api-reference/cart/remove-cart-item /api-reference/openapi.json delete /storefront/cart/items/{itemId} # Remove the applied discount/promo code from the cart Source: https://opencals.com/docs/api-reference/cart/remove-the-applied-discountpromo-code-from-the-cart /api-reference/openapi.json delete /storefront/cart/discount-code # Update quantity of a cart add-on item Source: https://opencals.com/docs/api-reference/cart/update-quantity-of-a-cart-add-on-item /api-reference/openapi.json patch /storefront/cart/add-ons/{cartAddOnItemId} # List checkout questions translations for a product Source: https://opencals.com/docs/api-reference/checkout-questions/list-checkout-questions-translations-for-a-product /api-reference/openapi.json get /storefront/checkout-questions/{productId}/translations/{language} # Get all checkout questions for current cart Source: https://opencals.com/docs/api-reference/checkout/get-all-checkout-questions-for-current-cart /api-reference/openapi.json get /storefront/cart/checkout/questions/{language} # Save checkout question answers to appointments Source: https://opencals.com/docs/api-reference/checkout/save-checkout-question-answers-to-appointments /api-reference/openapi.json post /storefront/cart/checkout/save-answers # Save customer information to cart and appointments Source: https://opencals.com/docs/api-reference/checkout/save-customer-information-to-cart-and-appointments /api-reference/openapi.json post /storefront/cart/checkout/save-customer # Start checkout for cart (create payment, validate items) Source: https://opencals.com/docs/api-reference/checkout/start-checkout-for-cart-create-payment-validate-items /api-reference/openapi.json post /storefront/cart/checkout/start # Submit checkout (capture payment, create order, auto-authenticate customer) Source: https://opencals.com/docs/api-reference/checkout/submit-checkout-capture-payment-create-order-auto-authenticate-customer /api-reference/openapi.json post /storefront/cart/checkout/submit Creates order and returns auth tokens for automatic customer authentication # Generate a presigned S3 URL for a customer file upload Source: https://opencals.com/docs/api-reference/customer-uploads/generate-a-presigned-s3-url-for-a-customer-file-upload /api-reference/openapi.json post /storefront/uploads/presign # List feedback questions translations Source: https://opencals.com/docs/api-reference/feedback-questions/list-feedback-questions-translations /api-reference/openapi.json get /storefront/feedback-questions/{productId}/translations/{language} # Get image by ID Source: https://opencals.com/docs/api-reference/images/get-image-by-id /api-reference/openapi.json get /storefront/images/{imageId} # API Reference Source: https://opencals.com/docs/api-reference/introduction Base URL, authentication, and conventions for the Opencals Storefront API — plus an interactive playground for every endpoint. The Storefront API is a REST API for building custom booking experiences: services, real-time availability, staff members, locations, carts, checkout, payments, and customer self-service. ## Base URL ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}} https://api.opencals.com ``` All endpoint paths are prefixed with `/storefront`. Responses are JSON with camelCase property names, and all date/time values are UTC. ## Authentication Every request requires a Storefront API key in the `X-Api-Key` header: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} curl "https://api.opencals.com/storefront/products" \ -H "X-Api-Key: sfk_your_api_key" ``` Keys are scoped to a single store and start with `sfk_`. Create one in the dashboard under **Settings → API Keys** — or [contact us](https://opencals.com/contact) if you don't have an account yet and want to evaluate the API. Customer-scoped endpoints (appointments, orders, profile) additionally require a customer JWT as a Bearer token — see the [Authentication guide](/docs/authentication) for the full token flow. Every endpoint page in this reference includes an **interactive playground**: paste your `sfk_` key into the authorization field, fill in the parameters, and send real requests against your store directly from the docs. ## Conventions * **Pagination** — list endpoints accept `page` (1-indexed) and `take` (max 100) and return a `meta` object with `itemCount`, `pageCount`, `hasPreviousPage`, and `hasNextPage`. * **Carts** — cart and checkout endpoints identify the active cart with the `X-Cart-Id` header. * **Errors** — conventional HTTP status codes with a JSON body; `400` responses include a per-field `errors` array. See [error responses](/docs/authentication#error-responses). * **Rate limits** — 100 requests / 60 seconds for general endpoints, stricter limits on auth flows. See [rate limits](/docs/quickstart#rate-limits). # List invoices issued for the authenticated customer order Source: https://opencals.com/docs/api-reference/invoices/list-invoices-issued-for-the-authenticated-customer-order /api-reference/openapi.json get /storefront/orders/{orderId}/invoices # Get location by ID Source: https://opencals.com/docs/api-reference/locations/get-location-by-id /api-reference/openapi.json get /storefront/locations/{locationId} Retrieve a location by its internal ID for current store # Get location by slug Source: https://opencals.com/docs/api-reference/locations/get-location-by-slug /api-reference/openapi.json get /storefront/locations/slug/{slug} Retrieve a location by its URL-friendly slug for current store # List store locations Source: https://opencals.com/docs/api-reference/locations/list-store-locations /api-reference/openapi.json get /storefront/locations Retrieve a paginated list of locations for the current store # Get order by ID Source: https://opencals.com/docs/api-reference/orders/get-order-by-id /api-reference/openapi.json get /storefront/orders/{orderId} Returns a single order by ID for the authenticated customer # List customer orders Source: https://opencals.com/docs/api-reference/orders/list-customer-orders /api-reference/openapi.json get /storefront/orders Returns all orders for the authenticated customer with pagination # Get available payment providers for checkout Source: https://opencals.com/docs/api-reference/payment/get-available-payment-providers-for-checkout /api-reference/openapi.json get /storefront/payment/providers Returns the active, eligible payment providers configured for this store. When the cart is identified (X-Cart-Id header or cart_id cookie) the list is amount-aware: only providers that can charge the current total are returned, and when nothing is collectible (fully discounted, or below every method’s minimum) a single "no_payment_required" provider is returned. # Get public payment settings Source: https://opencals.com/docs/api-reference/payment/get-public-payment-settings /api-reference/openapi.json get /storefront/payment/settings Returns public payment configuration like currency and capture method. # Get product collection by slug Source: https://opencals.com/docs/api-reference/product-collections/get-product-collection-by-slug /api-reference/openapi.json get /storefront/product-collections/slug/{slug} Retrieve a product collection by its URL-friendly slug for the current store # List store product collections Source: https://opencals.com/docs/api-reference/product-collections/list-store-product-collections /api-reference/openapi.json get /storefront/product-collections Retrieve a paginated list of product collections for the current store # Get a product by external variant ID Source: https://opencals.com/docs/api-reference/products/get-a-product-by-external-variant-id /api-reference/openapi.json get /storefront/products/by-external-variant-id/{productExternalVariantId} Retrieve a product by its external variant ID (e.g., Shopify variant ID) for the current store # Get current availabilities for a product Source: https://opencals.com/docs/api-reference/products/get-current-availabilities-for-a-product /api-reference/openapi.json get /storefront/products/{productId}/current-availabilities Retrieve individual bookable time slots for a product on a given date. # Get merged availability ranges for a product Source: https://opencals.com/docs/api-reference/products/get-merged-availability-ranges-for-a-product /api-reference/openapi.json get /storefront/products/{productId}/current-availability-ranges Retrieve merged availability ranges (broad time windows) for a product. Useful for calendar date highlighting. # Get product by ID Source: https://opencals.com/docs/api-reference/products/get-product-by-id /api-reference/openapi.json get /storefront/products/{productId} Retrieve a product by its internal ID for the current store # Get product by slug Source: https://opencals.com/docs/api-reference/products/get-product-by-slug /api-reference/openapi.json get /storefront/products/slug/{slug} Retrieve a product by its URL-friendly slug for the current store # Get products by external ID Source: https://opencals.com/docs/api-reference/products/get-products-by-external-id /api-reference/openapi.json get /storefront/products/by-external-id/{productExternalId} Retrieve products by their external platform ID (e.g., Shopify product ID) for the current store # Get the nearest available slot for a product Source: https://opencals.com/docs/api-reference/products/get-the-nearest-available-slot-for-a-product /api-reference/openapi.json get /storefront/products/{productId}/nearest-availability Find the nearest upcoming availability slot for a product. Returns null/204 if no slot exists within the advance schedule window. # List available add-ons for a product Source: https://opencals.com/docs/api-reference/products/list-available-add-ons-for-a-product /api-reference/openapi.json get /storefront/products/{productId}/add-ons Returns active add-ons available for the given product, filtered by location and staff member assignments. # List available add-ons for a product by slug Source: https://opencals.com/docs/api-reference/products/list-available-add-ons-for-a-product-by-slug /api-reference/openapi.json get /storefront/products/slug/{slug}/add-ons Returns active add-ons available for the given product slug, filtered by location and staff member assignments. # List store products Source: https://opencals.com/docs/api-reference/products/list-store-products /api-reference/openapi.json get /storefront/products Retrieve a paginated list of active products for the current store, for the current store # Change current customer password Source: https://opencals.com/docs/api-reference/self-service/change-current-customer-password /api-reference/openapi.json put /storefront/self-service/password # Get current customer marketing consent Source: https://opencals.com/docs/api-reference/self-service/get-current-customer-marketing-consent /api-reference/openapi.json get /storefront/self-service/marketing-consent # Get current customer profile Source: https://opencals.com/docs/api-reference/self-service/get-current-customer-profile /api-reference/openapi.json get /storefront/self-service/profile # Update current customer marketing consent Source: https://opencals.com/docs/api-reference/self-service/update-current-customer-marketing-consent /api-reference/openapi.json put /storefront/self-service/marketing-consent # Update current customer profile Source: https://opencals.com/docs/api-reference/self-service/update-current-customer-profile /api-reference/openapi.json put /storefront/self-service/profile # Get staff member by slug Source: https://opencals.com/docs/api-reference/staff-members/get-staff-member-by-slug /api-reference/openapi.json get /storefront/staff-members/slug/{slug} Retrieve a staff member by their URL-friendly slug for current store # List store staff members Source: https://opencals.com/docs/api-reference/staff-members/list-store-staff-members /api-reference/openapi.json get /storefront/staff-members Retrieve a paginated list of staff members for the current store # Get store public settings Source: https://opencals.com/docs/api-reference/store/get-store-public-settings /api-reference/openapi.json get /storefront/stores/public-settings # Authentication Source: https://opencals.com/docs/authentication How authentication works in the Opencals Storefront API: Storefront API keys, customer JWT tokens, refresh flows, and error responses. The Storefront API uses two layers of authentication: a **Storefront API key** that identifies your store on every request, and optional **customer tokens** (JWTs) for endpoints that act on behalf of a logged-in customer. ## Storefront API key Every request must include your Storefront API key in the `X-Api-Key` header: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} curl "https://api.opencals.com/storefront/products" \ -H "X-Api-Key: sfk_your_key_here" ``` Keys are created in the dashboard under **Settings → API Keys**, are scoped to a single store, and start with `sfk_`. Treat the key like a secret: keep it in server-side environment variables and proxy requests through your own backend rather than calling the API directly from the browser. ## Customer authentication Endpoints that read or modify a specific customer's data — their appointments, orders, and profile — additionally require a customer access token as a Bearer token: ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}} Authorization: Bearer X-Api-Key: sfk_your_key_here ``` ### Sign in Exchange a customer's credentials for a token pair: ```bash Request theme={"theme":{"light":"github-light","dark":"github-dark-default"}} curl -X POST "https://api.opencals.com/storefront/auth/sign-in" \ -H "X-Api-Key: sfk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "email": "customer@example.com", "password": "securepassword" }' ``` ```json Response theme={"theme":{"light":"github-light","dark":"github-dark-default"}} { "accessToken": "eyJhbGciOi…", "refreshToken": "eyJhbGciOi…" } ``` ### Refresh tokens Access tokens expire. Get a fresh pair by calling the refresh endpoint with the **refresh token** as the Bearer token: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} curl "https://api.opencals.com/storefront/auth/refresh" \ -H "X-Api-Key: sfk_your_key_here" \ -H "Authorization: Bearer " ``` ### All auth endpoints | Method | Endpoint | Description | | - | - | - | | `POST` | `/storefront/auth/sign-up` | Register a new customer (sends a verification email) | | `POST` | `/storefront/auth/sign-in` | Sign in with email and password | | `POST` | `/storefront/auth/oauth` | Sign in or register via an OAuth provider | | `GET` | `/storefront/auth/refresh` | Refresh tokens (pass the refresh token as Bearer) | | `POST` | `/storefront/auth/request-email-verification` | Resend the verification email | | `POST` | `/storefront/auth/verify-email` | Verify an email with the emailed token | | `POST` | `/storefront/auth/request-password-reset` | Send a password reset email | | `POST` | `/storefront/auth/reset-password` | Reset a password with the emailed token | See the [API reference](/docs/api-reference/introduction) for full request and response shapes. ### Which endpoints need a customer token? Customer-scoped endpoints — appointments, orders, and self-service profile management — require a customer Bearer token. Catalog endpoints (products, availability, staff members, locations, collections, store settings) need only the API key. Cart and checkout endpoints accept an *optional* customer token: guests can book without an account, and checkout can return auth tokens for new customers so they're signed in automatically after their first booking. Checkout never returns tokens for an existing customer identified only by the email in the request; that customer signs in themselves to see the booking. ## Error responses Errors use conventional HTTP status codes with a JSON body: ```json theme={"theme":{"light":"github-light","dark":"github-dark-default"}} { "statusCode": 400, "message": "Validation failed", "errors": [ { "property": "email", "messages": ["Email is required"] } ] } ``` | Status | Meaning | | - | - | | `400` | Validation error — check the `errors` array for per-field details | | `401` | Invalid or missing API key or customer token | | `404` | Resource not found | | `429` | Rate limit exceeded — see [rate limits](/docs/quickstart#rate-limits) | ## Security checklist * Store `sfk_` keys in environment variables, never in client bundles or repositories. * Call the Storefront API from your server (API routes, server components, edge functions) and keep the key out of browser network requests. * Store customer refresh tokens in `httpOnly` cookies rather than `localStorage` where possible. * Rotate API keys from the dashboard if a key is ever exposed. # Changelog Source: https://opencals.com/docs/changelog What's new in the Opencals developer platform — Storefront API, TypeScript SDK, open-source templates, and developer docs. Stay up to date with releases across the Opencals developer platform. Subscribe to releases on [GitHub](https://github.com/letsopencals) to get notified. Each release is its own page in the sidebar, grouped by month. The [Storefront SDK](/docs/changelog/storefront-sdk) page tracks every version so you can see exactly which release a feature first shipped in. ## October 2026 October 18 · API — no tokens or profile changes for an existing customer named only by email; addresses entered for that booking are saved to the customer once the order is placed. October 12 · Template + API — open-source villa-rental site with nightly stays, a `villa_resort` seed dataset, and fixed check-in/check-out times enforced on customer bookings. October 9 · Breaking · SDK v0.3.17 — staff `email` and `userId` are no longer in Storefront API responses or types. October 9 · Template — open-source language-school site and a `language_school` seed dataset with online classroom links. October 7 · Template + fix — open-source luxury car-rental site, a `car_rental` seed dataset, and correct validation for bookings longer than 24 hours. ## September 2026 September 27 · Deprecation · SDK v0.3.17 — use `appointment.order` instead of `externalOrderId` / `externalOrderName`. September 20 · Template — open-source padel & squash club booking site, with a multi-court grid and custom durations. ## August 2026 August 15 · Template — open-source medical clinic booking site. August 1 · New feature · SDK v0.3.11 — attach arbitrary key-value data to appointments at booking time. ## June 2026 June 21 · New feature · SDK v0.3.2 — automatic and promo-code discounts. June 17 · Template — open-source barbershop booking site. June 13 · New feature · SDK v0.3.0 — optional extras at booking time. June 13 · Docs — API reference, guides, and the in-browser playground. June 7 · Template — open-source salon booking site. June 5 · API — the public customer-facing booking API. Version history — the official TypeScript client, v0.1.0 → v0.3.2. # Add-Ons Source: https://opencals.com/docs/changelog/add-ons Optional extras attached to any bookable service, selected during the booking flow and paid in one transaction. Storefront SDK v0.3.0. ## Add-Ons — new feature Service businesses can now attach optional extras to any bookable service. Customers select add-ons during the booking flow — after picking a service and time slot, before checkout — and pay the combined total in one transaction. **What's in the release:** * `GET /storefront/add-ons` — list add-ons, filter by `productId` * `GET /storefront/products/:id/add-ons` — add-ons for a specific service * `POST /storefront/cart/add-ons` — add an add-on to a cart item * `PATCH /storefront/cart/add-ons/:id` — update quantity * `DELETE /storefront/cart/add-ons/:id` — remove from cart **Key behaviors:** * Add-ons are scoped to products. Only add-ons linked to the booked service appear. * Two pricing modes: **fixed** (customer picks quantity) and **duration-multiplied** (quantity auto-set to the number of duration units, useful for per-day or per-hour charges). * Prices are snapshotted at cart creation — later catalog changes don't affect existing orders. * Add-ons appear in order line items (`OrderAddOnLineItem`) and on appointment records (`AppointmentAddOn`). Partial quantity refunds are supported. * Works on both `platform='opencals'` and `platform='shopify'`. **SDK support** — available in `@opencals/storefront-sdk` [v0.3.0](/docs/changelog/storefront-sdk) and higher (via `AddonService`). Older versions do not include add-on methods or types. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} # List add-ons for a service curl -H "X-Api-Key: sfk_your_key" \ https://api.opencals.com/storefront/products/{productId}/add-ons # Add to cart curl -X POST -H "X-Api-Key: sfk_your_key" \ -H "X-Cart-Id: {cartId}" \ -H "Content-Type: application/json" \ -d '{"cartItemId": "...", "addOnId": "...", "quantity": 1}' \ https://api.opencals.com/storefront/cart/add-ons ``` See the [Add-Ons feature page](https://opencals.com/features/add-ons) for the full overview and use cases. # AI Agent Skills Source: https://opencals.com/docs/changelog/ai-agent-skills Opencals Agent Skills — ground-truth Skills that teach Claude Code, Cursor, Codex and Gemini CLI to build booking integrations on the Opencals API and SDK, plus a new AI Agents docs section and machine-readable llms.txt. ## Agent Skills for building on Opencals Most integrations are now written inside an AI coding agent. So we shipped **Agent Skills** that give those agents ground-truth knowledge of the Opencals API and SDK — real method names, argument shapes and flows, no guessing. They follow the open [Agent Skills](https://code.claude.com/docs/en/skills) standard and work in Claude Code, Cursor, OpenAI Codex, Gemini CLI and more. Two skills, in one public repo ([github.com/letsopencals/skills](https://github.com/letsopencals/skills)): * **`opencals-storefront-api`** — the ground-truth reference: availability (slots and ranges, custom durations), carts and expiry, appointments, checkout (addresses, marketing consent), customer accounts, guests and attendees, discounts (automatic, codes, balance credit), reschedule/cancel, and rate limiting. * **`opencals-build-booking-site`** — builds a whole booking website: pick an official template, scaffold, wire it up, rebrand, deploy. Install in Claude Code: ``` /plugin marketplace add letsopencals/skills /plugin install opencals@opencals ``` Also new: * A dedicated **[AI Agents](/docs/ai-agents/overview)** section in the docs, with per-tool install instructions (including v0 / Lovable / Bolt). * Machine-readable docs at **[llms.txt](https://opencals.com/docs/llms.txt)** and **[llms-full.txt](https://opencals.com/docs/llms-full.txt)** for agents that consume docs directly. [Skills repo ↗](https://github.com/letsopencals/skills) · [AI Agents docs](/docs/ai-agents/overview) # Lovable, v0 and Base44 Source: https://opencals.com/docs/changelog/ai-app-builders Setup guides for building Opencals booking sites in Lovable (custom connector), v0 (agent skills) and Base44 (backend function + workspace skills). ## Build booking sites in Lovable, v0 and Base44 Three new guides in the renamed **[AI & Builders](/docs/ai-agents/overview)** tab, each with every field and file ready to copy: * **[Lovable](/docs/ai-agents/lovable)**: add Opencals as a custom connector with Advanced auth (`X-Api-Key` + `X-Api-Version`) and two knowledge files. * **[v0](/docs/ai-agents/v0)**: attach `letsopencals/skills` from skills.sh and set `OPENCALS_API_KEY`. * **[Base44](/docs/ai-agents/base44)**: an `opencals` backend function, an app secret and two workspace skills. The knowledge files are also published as plain Markdown at [opencals.com/ai/opencals-storefront-api.md](https://opencals.com/ai/opencals-storefront-api.md), [lovable-booking-site-playbook.md](https://opencals.com/ai/lovable-booking-site-playbook.md) and [base44-booking-site-playbook.md](https://opencals.com/ai/base44-booking-site-playbook.md). # Bali Villa Rentals — villa booking template Source: https://opencals.com/docs/changelog/bali-villas-template Bali Villa Rentals, a free open-source villa and holiday-rental website template built on the Storefront API, plus a villa-resort seed dataset and server-side enforcement of fixed check-in and check-out times. ## Bali Villa Rentals — open-source villa booking template The seventh open-source Opencals template is live, and the first one built around **nightly stays**. **Bali Villa Rentals** is a villa-rental website for a demo resort, Lumbung Sidemen: ten villas in the rice fields of East Bali. The same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) as the other templates, with a warm, quiet design and every nightly rate on show. * **Booked by the night** — each villa is one staffless product with a one-night base duration, `allowCustomDuration`, and fixed check-in 15:00 / check-out 11:00, on a continuous 24/7 schedule * **Calendar on live availability** — reads `getCurrentAvailabilitiesMerged` and checks on the client that check-in → check-out fits inside one range * **Turnover days** — a check-out day can be the next guest's check-in day * **Guest details as custom attributes** — adults, children, infants, arrival time, flight number and occasion * Per-stay extras (airport transfer, floating breakfast, early check-in request), checkout questions and promo codes * Trip-aware customer accounts: dates in the resort timezone, night count, change dates (same number of nights) or cancel up to 14 days before check-in * Passwordless login and one-time email links * `LodgingBusiness`, `VacationRental` and `FAQPage` structured data, Open Graph, `robots.txt` and `sitemap.xml` configured * Stack: Next.js 15, TypeScript, Tailwind v4, Framer Motion, `@opencals/storefront-sdk` v0.3.17 * MIT licensed — clone, rebrand, deploy. One-click Vercel deploy. ### Villa-resort seed dataset New Dev Stores can choose the **Bali Villa Rentals** dataset (category Villa resort, `villa_resort`). It seeds ten villas on a 24/7 schedule with fixed check-in and check-out, four collections, per-stay extras, checkout questions and sample stays, so the template matches the demo out of the box. ### Fixed check-in and check-out are now enforced Products with **custom duration** and **fixed times** (`fixedTimes`, `fixedStartTime`, `fixedEndTime`) now have those times enforced by the server. A customer booking must start at `fixedStartTime` and end at `fixedEndTime` in the product's schedule timezone (the store timezone if the product has no schedule), or the API returns `400`. This applies to creating an appointment (`POST /storefront/appointments`, with or without a cart) and to customer reschedules (`PUT /storefront/appointments/{appointmentId}/reschedule`). Bookings and reschedules made by the merchant in the dashboard are exempt, so you can still give a guest an early check-in or a late check-out. A fixed-times stay is priced by whole nights: a 15:00 → 11:00 stay of N nights is shorter than N × 24 hours and is charged as N nights. No SDK changes. If your site already sends check-in → check-out slots built from the product's fixed times, nothing changes for you. [Live demo ↗](https://template-bali-villas.vercel.app) · [GitHub ↗](https://github.com/letsopencals/template-bali-villas) · [Template docs](/docs/open-source/bali-villas) # Checkout: existing customers and booking addresses Source: https://opencals.com/docs/changelog/checkout-customer-trust Storefront checkout no longer returns tokens for, or changes, an existing customer identified only by the email in the request. Addresses entered for such a booking are saved to the customer once the order is placed. ## Existing customers at checkout Storefront API keys are public, so an email or `customerId` in a checkout request is a claim, not proof of who is booking. A customer named in the request is now only treated as the caller when the request is signed in as that customer (customer `Authorization` token), or when this cart's checkout created them. When a guest enters the email of an existing customer and isn't signed in: * The booking is still attached to that customer, so it shows in their account after they sign in (for example with a login code). * Nothing on their profile is overwritten: name, phone, billing address and marketing consent stay as they were. * `checkout/submit` returns no `auth` or `customer`, and `checkout/save-customer` returns `{}` without a `customerId`. * Cart and appointment responses show `customer` and `customerId` as `null`. The `customerFirstName`, `customerEmail` and other snapshot fields echo what was entered. ### Addresses `checkout/save-customer` and `checkout/start` don't write to the customer. Until the order is placed, billing and delivery addresses entered for such a booking belong to the cart only, and their `customerId` is `null` (for example `cart.billingAddress.customerId`). When `checkout/submit` places the order, they're added to the customer's saved addresses. The order and invoice use them as entered. Treat an address's `customerId` as nullable. ### Billing at submit Required billing fields are checked against the `billingAddress` in the request, or, when the request has none, against the billing address already saved on the cart. You no longer need to send the billing address again at `checkout/start` or `checkout/submit`. A billing address sent for the first time with `checkout/submit` is linked to the order. A submit rejected for missing customer details leaves the cart's reservation in place until the cart expires as usual. **What to change in your integration:** always treat `auth` on the submit response as optional, and don't rely on `save-customer` returning a `customerId`. When it doesn't, either send the same customer details again in the next steps or leave `customer` out: the cart already holds the customer. # Clear Care — clinic template Source: https://opencals.com/docs/changelog/clarity-template Clear Care, a free open-source medical clinic booking website template built on the Storefront API — Next.js 15, department-first booking, medical intake, passwordless accounts, Stripe checkout. ## Clear Care — open-source clinic booking template The third open-source Opencals template is live. **Clear Care** is a medical clinic booking website — the clinical counterpart to [Haar](/docs/changelog/haar-template) and [Frisor](/docs/changelog/frisor-template): the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) underneath, a different vertical and a warm, organic clinical design. Every screen — departments, service picker, clinician and location selectors, time slots, medical intake, checkout, customer account — is built with the public API. * Department-first browsing (product collections) across nine specialties * Clinician & multi-location booking, real-time availability with live group capacity * Medical intake captured at checkout and saved to the appointment * Passwordless customer accounts — 6-digit email login with password fallback, one-time email links via `/link/[token]` * Stripe payments with deposit support * Stack: Next.js 15, TypeScript, Tailwind v4, `@opencals/storefront-sdk` * MIT licensed — clone, rebrand, deploy. One-click Vercel deploy. [Live demo ↗](https://template-clarity.vercel.app) · [GitHub ↗](https://github.com/letsopencals/template-clarity) · [Template docs](/docs/open-source/clarity) # Custom Attributes Source: https://opencals.com/docs/changelog/custom-attributes Attach arbitrary key-value data to appointments at booking time. Storefront SDK v0.3.11. ## Custom attributes on appointments Custom booking frontends can now attach arbitrary data to an appointment at creation time — a booking source, a party size, an external reference id, or small JSON strings. Custom attributes are a flat map of string keys to string values (the same idea as Shopify order note/custom attributes): no data types, no access rules, not searchable — just a simple bag of key-value pairs that travels with the appointment. **What's in the release:** * `POST /storefront/appointments` accepts an optional `customAttributes` object — a map of string keys to string values. * Appointment responses now echo `customAttributes`. * The merchant dashboard shows custom attributes on the appointment detail page. **Key behaviors:** * Keys and values are always strings (a value can itself be a JSON string — it is stored verbatim and never parsed). * Limits: up to **250** attributes per appointment, keys up to **255** characters, values up to **5000** characters. * Not searchable by design — attributes are stored inline with the appointment for fast reads, not indexed for querying. **SDK support** — available in `@opencals/storefront-sdk` [v0.3.11](/docs/changelog/storefront-sdk) and higher. `CreateAppointment` gains an optional `customAttributes` field. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} # Create an appointment with custom attributes curl -X POST -H "X-Api-Key: sfk_your_key" \ -H "Content-Type: application/json" \ -d '{ "slot": { "fromDate": "2026-08-15", "fromTime": "10:00:00", "toDate": "2026-08-15", "toTime": "11:00:00", "productId": "..." }, "customAttributes": { "source": "widget", "party_size": "4" } }' \ https://api.opencals.com/storefront/appointments ``` # Developer docs launched Source: https://opencals.com/docs/changelog/developer-docs The Opencals developer documentation is live — interactive API reference, quickstart, guides, and open-source templates. ## Developer docs launched The Opencals developer documentation is now live at **[opencals.com/docs](https://opencals.com/docs)**. * Interactive [API Reference](/docs/api-reference/introduction) with an in-browser playground — try every endpoint without leaving the docs * [Quickstart guide](/docs/quickstart) — first API call in under 5 minutes * [Build a booking page](/docs/guides/build-a-booking-page) — end-to-end tutorial (services → staff → availability → cart → checkout) * [TypeScript SDK guide](/docs/guides/storefront-sdk) — typed client with Zod schemas and formatting helpers * [Webhooks overview](/docs/guides/webhooks) — planned event catalog for appointment and order lifecycle events * [Open-source templates](/docs/open-source/templates) — production-ready booking site starters # Discounts Source: https://opencals.com/docs/changelog/discounts Automatic and promo-code discounts — target by product, staff, location, and schedule, with stacking and usage caps. Storefront SDK v0.3.2. ## Discounts — new feature Storefronts can now apply **automatic discounts** and **promo codes** to a cart before checkout. Automatic discounts apply to every qualifying booking without customer action; code discounts require the customer to enter a promo code. **What's in the release:** * `POST /storefront/cart/discount-code` — apply a promo code to the cart * `DELETE /storefront/cart/discount-code` — remove the applied code * Every cart-returning endpoint now includes an `appliedDiscounts[]` field and per-line-item `discounts[]`, so you can render a "you saved \$X" summary * Order line items carry `OrderLineItemDiscount` records for accurate receipts and refunds **Key behaviors:** * Two methods: **automatic** (applies silently) and **code** (customer enters a promo code). * Two value formats: **percentage** or **fixed amount**. * Two scopes: **line-item** (distributed across qualifying lines) and **order** (applied to the cart total). * Targeting by **product**, **staff member**, **location**, and an **include add-ons** toggle. Discounts re-evaluate on every cart mutation. * **Scheduling**: always-active, weekly-recurring windows, or fixed date ranges — evaluated in the store's timezone. * **Requirements**: minimum purchase amount and/or appointment duration range. * **Stacking**: combinable discounts stack; exclusive discounts apply the single best deal for the customer. * **Usage caps**: total redemption cap and per-customer cap. Usage is tracked per store on multi-store and Shopify setups. **SDK support** — available in `@opencals/storefront-sdk` [v0.3.2](/docs/changelog/storefront-sdk) and higher: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { CartService } from "@opencals/storefront-sdk"; // Apply a promo code const { data: cart } = await CartService.applyDiscountCode({ body: { code: "WELCOME10" }, }); // cart.appliedDiscounts now reflects the discount // Remove it await CartService.removeDiscountCode(); ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} # Apply a promo code over HTTP curl -X POST -H "X-Api-Key: sfk_your_key" \ -H "X-Cart-Id: {cartId}" \ -H "Content-Type: application/json" \ -d '{"code": "WELCOME10"}' \ https://api.opencals.com/storefront/cart/discount-code ``` See the [Discounts feature page](https://opencals.com/features/discounts) for the full overview, and the [Build a booking page guide](/docs/guides/build-a-booking-page) for where promo codes fit in the checkout flow. # Fluently — language school template Source: https://opencals.com/docs/changelog/fluently-template Fluently, a free open-source language school website template built on the Storefront API, plus a language-school seed dataset with online classroom links. ## Fluently — open-source language school template The sixth open-source Opencals template is live, and the first one built for **schools and courses**. **Fluently** is a language-school website set in New York with two campuses and an online classroom: the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) as the other templates, with a friendly, colorful design. * **Subjects are collections** and **levels are variants**: group classes with A1–B1 levels, private lessons by length, and a \$0 free trial * **Format from the data**: online vs campus groups come from location types, so the template works for any school without code changes * **This week timetable**: upcoming group sessions across every class with live seats left and one-click booking * **Teachers** from staff, with "Book with " deep links that preselect the teacher * **Join lesson** button on online bookings, using the online location's `link` * A level-finder quiz, placement questions at checkout, and extras such as a workbook and lesson recording * Passwordless student accounts, Stripe checkout and the no-payment path for the trial * `LanguageSchool` and `FAQPage` structured data, Open Graph, `robots.txt` and `sitemap.xml` configured * Stack: Next.js 15, TypeScript, Tailwind v4, Framer Motion, `@opencals/storefront-sdk` v0.3.14 * MIT licensed — clone, rebrand, deploy. One-click Vercel deploy. ### Language-school seed dataset New Dev Stores can choose the **Fluently** dataset (`language_school`). It seeds six languages, campus and online group classes with level variants, private lessons, the trial and conversation club, teachers, two campuses and an online classroom with its meeting link, so the template matches out of the box. Seeded online locations can now carry a meeting link. No API or SDK changes. [GitHub ↗](https://github.com/letsopencals/template-fluently) · [Template docs](/docs/open-source/fluently) # Frisor — barbershop template Source: https://opencals.com/docs/changelog/frisor-template Frisor, a free open-source barbershop booking website template built on the Storefront API — Next.js 15, Stripe checkout, add-ons, customer accounts. ## Frisor — open-source barbershop booking template The second open-source Opencals template is live. **Frisor** is the barbershop counterpart to [Haar](/docs/changelog/haar-template): same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) underneath, a different vertical and a dark editorial design. Every screen — services, barber picker, time slots, add-ons, cart, checkout, customer account — is built with the public API. * Service catalog, barber & multi-location booking, real-time availability * Add-ons at checkout, Stripe payments with deposit support * Customer accounts with reschedule and cancellation * Stack: Next.js 15, TypeScript, Tailwind v4, `@opencals/storefront-sdk` * MIT licensed — clone, rebrand, deploy. One-click Vercel deploy. [Live demo ↗](https://template-frisor-sage.vercel.app) · [GitHub ↗](https://github.com/letsopencals/template-frisor) · [Template docs](/docs/open-source/frisor) # Haar — salon template Source: https://opencals.com/docs/changelog/haar-template Haar, the first open-source Opencals template — a complete beauty-salon booking website built on the Storefront API. ## Haar — open-source salon booking template The first open-source Opencals template is live. **Haar** is a complete beauty-salon booking website built on the [Storefront API](/docs/api-reference/introduction): service catalog, staff and multi-location booking, real-time availability, Stripe checkout, and customer accounts. * Stack: Next.js 15, TypeScript, Tailwind, `@opencals/storefront-sdk` * MIT licensed — clone, rebrand, deploy for yourself or clients * One-click Vercel deploy [Live demo ↗](https://template-haar.vercel.app) · [GitHub ↗](https://github.com/letsopencals/template-haar) · [Template docs](/docs/open-source/haar) # NOIR Drive — car rental template Source: https://opencals.com/docs/changelog/noir-template NOIR Drive, a free open-source luxury car rental website template built on the Storefront API, plus a car-rental seed dataset and correct validation for multi-day bookings longer than 24 hours. ## NOIR Drive — open-source luxury car rental template The fifth open-source Opencals template is live, and the first one built around **multi-day bookings**. **NOIR Drive** is a luxury car-rental website set in Dubai: the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) as [Haar](/docs/changelog/haar-template), [Frisor](/docs/changelog/frisor-template), [Clear Care](/docs/changelog/clarity-template) and [VOLT](/docs/changelog/volt-template), with a black cinematic design and cars booked by the day. Two booking surfaces over one store: a multi-day rental flow for the fleet, and a classic staff flow for chauffeur packages. Every screen is built with the public API. * **Booked by the day** — each car is one staffless product with a 1-day base duration and `allowCustomDuration`, on a continuous 24/7 schedule * **Range calendar on live availability** — reads `getCurrentAvailabilitiesMerged` and checks on the client that pick-up → return fits inside one range * **Delivery or garage** — a `DELIVERY` location with the address captured at checkout, or a `PHYSICAL` garage * **Handover details as custom attributes** — handover and return windows, flight number, collection address * **Per-day extras** with `durationMultiplied`, plus one-off extras * Licence and ID uploads and a deposit-and-terms checkbox as checkout questions * **Chauffeur packages** with real drivers and a preferred-car request * Rental-aware customer accounts: dates in the store timezone, day count, change dates (same length) or cancel within policy * `AutoRental` and `Product` structured data, Open Graph, `robots.txt` and `sitemap.xml` configured * Stack: Next.js 15, TypeScript, Tailwind v4, Framer Motion, `@opencals/storefront-sdk` v0.3.14 * MIT licensed — clone, rebrand, deploy. One-click Vercel deploy. ### Car-rental seed dataset New Dev Stores can choose the **NOIR Drive** dataset (category Car rental, `car_rental`). It seeds twelve cars on a 24/7 schedule, a garage and a delivery location, per-day and fixed extras, the rental checkout questions, and four chauffeurs with their packages, so the template matches the demo out of the box. ### Fix: bookings longer than 24 hours Bookings with a custom duration longer than a day are now validated against **continuous availability**: the server checks that the whole span sits inside one free window. Before, validation used per-day slot generation and could reject a valid multi-day booking on a 24/7 schedule. No API or SDK changes. [Live demo ↗](https://template-noir.vercel.app) · [GitHub ↗](https://github.com/letsopencals/template-noir) · [Template docs](/docs/open-source/noir) # Storefront API — v1 Source: https://opencals.com/docs/changelog/storefront-api The Storefront API is publicly available — the full customer-facing booking flow over REST. ## Storefront API — v1 The Storefront API is now publicly available. It covers the full customer-facing booking flow: | Domain | Key endpoints | | - | - | | **Products** | List services, collections, variants | | **Staff members** | List staff, per-staff availability | | **Locations** | Multi-location support | | **Appointments** | Real-time slot availability | | **Cart** | Create and manage a booking cart | | **Checkout** | Initiate and confirm checkout | | **Payments** | Stripe-backed deposits and full payments | | **Customer self-service** | View, reschedule, and cancel bookings | | **Webhooks** | Signed event delivery *(coming soon)* | Base URL: `https://api.opencals.com` · Authentication: `Authorization: Bearer sfk_…` The API is versioned via the `X-Api-Version` header. See the [full endpoint catalog](/docs/api-reference/introduction). # Storefront SDK Source: https://opencals.com/docs/changelog/storefront-sdk Version history for @opencals/storefront-sdk — the official TypeScript client for the Storefront API. See which release each feature first shipped in. `@opencals/storefront-sdk` is the official TypeScript client for the Storefront API — generated from the OpenAPI spec, fully typed, with Zod runtime validation and formatting helpers for prices, durations, and timezones. MIT licensed. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm install @opencals/storefront-sdk ``` [npm ↗](https://www.npmjs.com/package/@opencals/storefront-sdk) · [SDK guide](/docs/guides/storefront-sdk) Each feature ships in a specific SDK version. To use a feature, install that version or higher — for example, [add-ons](/docs/changelog/add-ons) require **v0.3.0+** and [discounts](/docs/changelog/discounts) require **v0.3.2+**. Older versions don't include those methods or types. ## Version history **Staff contact details removed.** The Storefront API no longer returns staff `email`, `userId` or `refreshToken`, and staff detail no longer includes the linked `user`. Storefront keys ship in browsers, so these were readable by anyone. The fields are gone from every `*StaffMember` type, and the `StaffMemberDetailUser` type is removed. If you used `email` for avatar initials, use `firstName` / `lastName` instead. **Appointment `externalOrderId` / `externalOrderName` deprecated.** These fields predate orders. Read the platform order from `appointment.order` instead (`order.externalId`, `order.externalName`). * Both fields stay in appointment responses and are now filled from the linked order, so they're correct again for new bookings. They're marked `@deprecated` in the types. * `AppointmentService.findByExternalOrderName` is deprecated. It now only returns appointments from the store making the request, and it returns 404 instead of 400 when nothing matches. **Custom attributes on appointments.** Ships support for the new [custom attributes](/docs/changelog/custom-attributes) feature. * `CreateAppointment` accepts an optional `customAttributes` map (string keys → string values) to attach arbitrary data at booking time * Appointment responses now echo `customAttributes` **Discounts & passwordless auth.** Ships support for the new [discounts](/docs/changelog/discounts) feature and magic-link sign-in. * `CartService.applyDiscountCode()` and `CartService.removeDiscountCode()` * New discount types: `AppliedDiscount`, `ApplyCode`, `CartItemAppliedDiscount`, `OrderLineItemDiscount`, `OrderLineItemDiscountWritable` * Passwordless / magic-link auth: `AuthService.requestLoginCode()`, `AuthService.verifyLoginCode()`, `AuthService.resolveLink()`, with `RequestLoginCode`, `VerifyLoginCode`, `ResolveLinkResponse` types * Fixed the `AppointmentAddOn` type, which was missing fields due to a backend OpenAPI schema name collision (the DTO was renamed to `CreateAppointmentAddOnDto`) **Add-ons support.** First version with the [Add-Ons](/docs/changelog/add-ons) feature. * `AddonService`, with `CartAddOnItem`, `AppointmentAddOn`, and `OrderAddOnLineItem` types * `StoreService.getStorePublicSettings()` for storefront currency and public store settings **Service classes.** Released alongside the public [Storefront API v1](/docs/changelog/storefront-api). * Grouped SDK methods into `*Service` classes by tag (e.g. `ProductService`, `AuthService`, `CartService`) * Stripped the controller prefix from operation IDs (e.g. `ProductService.list()` instead of `productList()`) * Fixed `Auth` operations missing from the spec (tag collision with the admin controller) * Rewrote the README with updated examples and a service table Initial releases of the auto-generated TypeScript client. * **v0.1.0** — auto-generated client, Zod validation schemas, `setupOpencals()` one-line init, API-key/error/logging interceptors, and helpers (`formatPrice`, `formatDuration`, timezone conversion). Full support for products, cart, checkout, customer auth, appointments, orders, and feedback. * **v0.1.1** — renamed `external_domain` → `externalDomain` on `StorePublicSettings`. * **v0.1.2** — renamed `client_secret` → `clientSecret` on `CheckoutStartResponse`. # VOLT — padel & squash template Source: https://opencals.com/docs/changelog/volt-template VOLT, a free open-source padel and squash club booking website template built on the Storefront API — Next.js 15, a multi-court booking grid with custom durations, coach-led training, Stripe checkout, passwordless accounts. ## VOLT — open-source padel & squash club booking template The fourth open-source Opencals template is live, and the first one built around **staffless resources** instead of staff-led appointments. **VOLT** is a padel & squash club booking website: the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) as [Haar](/docs/changelog/haar-template), [Frisor](/docs/changelog/frisor-template) and [Clear Care](/docs/changelog/clarity-template), with a dark technical design and a different booking shape. Two booking surfaces over one store: an in-page court grid for rentals, and a classic coach-led flow for lessons and group classes. Every screen is built with the public API. * **Multi-court booking grid** — every court and every open slot for a day in one view, with a padel/squash toggle * **Custom durations** — 30-minute steps to two hours, composed from consecutive free slots and priced live * **Staffless resources** — courts use the `Self` rule with `maxAttendees: 1`, so each court is an independent, exclusively-booked unit * **Coach-led training** — filter by sport, level and format; "Any coach — next available" leads the picker; group classes show live "N left" capacity * Add-ons and custom checkout questions, Stripe payments with deposit support * Passwordless customer accounts — 6-digit email login with password fallback, auto-login after checkout, one-time email links via `/link/[token]` * `SportsActivityLocation` structured data, Open Graph, `robots.txt` and `sitemap.xml` configured * Stack: Next.js 15, TypeScript, Tailwind v4, Framer Motion, `@opencals/storefront-sdk` v0.3.14 * MIT licensed — clone, rebrand, deploy. One-click Vercel deploy. If you are booking courts, bays, lanes, studios or rooms rather than people, VOLT is the closest starting point of the four. [Live demo ↗](https://template-volt.vercel.app) · [GitHub ↗](https://github.com/letsopencals/template-volt) · [Template docs](/docs/open-source/volt) # Build a booking page Source: https://opencals.com/docs/guides/build-a-booking-page Step-by-step tutorial: use the Opencals Storefront API with Next.js and the TypeScript SDK to list services, pick a time slot, manage a cart, and complete checkout. This tutorial walks through building a custom booking page on the Opencals Storefront API. We'll use Next.js and the typed [@opencals/storefront-sdk](https://www.npmjs.com/package/@opencals/storefront-sdk), but every step maps 1:1 to plain REST calls — the flow works from any language. The complete booking flow is eight API calls: 1. List services — `GET /storefront/products` 2. Pick a staff member — `GET /storefront/staff-members` 3. Fetch available time slots — `GET /storefront/products/{productId}/current-availabilities` 4. Create an appointment — `POST /storefront/appointments` 5. Add it to a cart — `POST /storefront/cart` and `POST /storefront/cart/items` 6. (Optional) Show and attach add-ons — `GET /storefront/products/{productId}/add-ons` and `POST /storefront/cart/add-ons` 7. (Optional) Apply a promo code — `POST /storefront/cart/discount-code` and `DELETE /storefront/cart/discount-code` 8. Check out — `POST /storefront/cart/checkout/start` and `POST /storefront/cart/checkout/submit` ## Setup Install the SDK in a Next.js project and initialize it once, for example in a small module imported by your server code: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm install @opencals/storefront-sdk ``` ```ts lib/opencals.ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { setupOpencals } from "@opencals/storefront-sdk"; setupOpencals({ apiKey: process.env.OPENCALS_API_KEY, // sfk_… from Settings → API Keys }); ``` The API key must stay server-side, so the calls below belong in server components, route handlers, or server actions — not client components. `ProductService.list()` wraps `GET /storefront/products` and returns a paginated list of active services: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import "@/lib/opencals"; import { ProductService } from "@opencals/storefront-sdk"; const { data } = await ProductService.list({ query: { take: 50 } }); // data.data → Product[], data.meta → pagination info for (const product of data!.data) { console.log(product.title, product.price, product.duration); } ``` A few product fields drive your UI: | Field | What it means for your booking page | | - | - | | `duration` | Service length in seconds | | `price` | Price (0 = free); `minPrice`/`maxPrice` for variable pricing | | `maxAttendees` | Greater than 1 → show an attendee count selector | | `isCustomDuration` | `true` → show a date-range picker instead of single slots | | `staffMembers` | If populated, let the customer pick a staff member | | `locations` | If populated, let the customer pick a location | If customers choose who they book with, `StaffMemberService.list()` wraps `GET /storefront/staff-members`: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { StaffMemberService } from "@opencals/storefront-sdk"; const { data: staff } = await StaffMemberService.list(); ``` The selected staff member's `id` filters availability in the next step. The same pattern works for locations via `LocationService.list()` (`GET /storefront/locations`) on multi-location stores. `ProductService.getCurrentAvailabilities()` wraps `GET /storefront/products/{productId}/current-availabilities`. It takes a required `date` (`YYYY-MM-DD`) plus optional `staffMemberId`, `locationId`, and `timezone` filters: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { ProductService } from "@opencals/storefront-sdk"; const { data: slots } = await ProductService.getCurrentAvailabilities({ path: { productId: product.id }, query: { date: "2026-06-15", staffMemberId: selectedStaff?.id, timezone: "America/New_York", }, }); ``` Render the returned slots as your time picker. For custom-duration products (`isCustomDuration: true`, e.g. rentals or venue hire), use `GET /storefront/products/{productId}/current-availability-ranges` and show a date-range picker instead. There's also `GET /storefront/products/{productId}/nearest-availability` for "next available slot" badges on service cards. When the customer picks a slot, create an appointment with `POST /storefront/appointments`. The required `slot` object carries the product and the UTC date/time range; `staffMemberId` and `locationId` are optional: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { AppointmentService } from "@opencals/storefront-sdk"; const { data: appointment } = await AppointmentService.create({ body: { slot: { productId: product.id, fromDate: "2026-06-15", fromTime: "14:00:00", toDate: "2026-06-15", toTime: "15:00:00", staffMemberId: selectedStaff?.id ?? null, locationId: null, }, numberOfAttendees: 1, }, }); ``` All slot date/times are **UTC** — convert from the customer's timezone before sending. Carts hold appointments between slot selection and payment, and they expire after a few minutes so abandoned selections release their slots. Cart endpoints identify the cart with the `X-Cart-Id` header: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { CartService } from "@opencals/storefront-sdk"; // Create a cart (or fetch the existing one if you already have an ID) const { data: cart } = await CartService.createOrGet({ headers: { "X-Cart-Id": existingCartId ?? "" }, }); // Add the appointment to it const { data: updatedCart } = await CartService.addItem({ body: { appointmentId: appointment!.id }, headers: { "X-Cart-Id": cart!.id }, }); ``` Persist `cart.id` (for example in a cookie) so the customer keeps their cart across pages. If checkout takes a while, `POST /storefront/cart/extend` adds five minutes to the cart's expiration. If the service has add-ons (extras like equipment rentals, upgrades, or accessories), fetch them and let the customer choose before proceeding to payment. ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { AddOnService, CartService } from "@opencals/storefront-sdk"; // Fetch add-ons available for the booked service const { data: addOns } = await AddOnService.listForProduct({ path: { productId: product.id }, }); // addOns.data → AddOn[] with title, description, price, durationMultiplied, maxQuantity ``` When the customer selects an add-on, attach it to the cart item: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} await CartService.addAddOn({ body: { cartItemId: updatedCart!.items[0].id, addOnId: selectedAddOn.id, quantity: 1, }, headers: { "X-Cart-Id": cart!.id }, }); ``` Add-on pricing fields: | Field | What it means | | - | - | | `price` | Unit price. Multiply by quantity for the line total. | | `durationMultiplied` | If `true`, quantity is auto-set to the service duration units (e.g., 3 days = quantity 3). Don't ask the customer for quantity. | | `maxQuantity` | If non-null, cap your quantity input at this value. | Skip this step if `addOns.data` is empty — not every service has add-ons configured. If the store offers discount codes, let customers enter a promo code before checkout. `CartService.applyDiscountCode()` wraps `POST /storefront/cart/discount-code`: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { CartService } from "@opencals/storefront-sdk"; try { const { data: updatedCart } = await CartService.applyDiscountCode({ body: { code: "SUMMER15" }, headers: { "X-Cart-Id": cart!.id }, }); // updatedCart now includes applied discounts } catch (error) { // Handle typed errors: CODE_NOT_FOUND | CODE_EXPIRED | CODE_DISABLED | CODE_LIMIT_REACHED | CODE_CUSTOMER_INELIGIBLE | CODE_NO_MATCHING_ITEMS // Render inline below the input (Shopify-style red) } ``` Remove a code with `DELETE /storefront/cart/discount-code`: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} await CartService.removeDiscountCode({ headers: { "X-Cart-Id": cart!.id }, }); ``` ### Reading applied discounts Every cart-returning endpoint now includes `appliedDiscounts[]` and per-line-item `discounts[]` fields. Show a "You saved \$X" summary by subtracting the cart's `total` from the sum of original line prices: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} const originalTotal = updatedCart!.items.reduce( (sum, item) => sum + item.originalPrice * item.quantity, 0 ); const saved = originalTotal - updatedCart!.total; ``` Checkout is a three-call sequence against the same `X-Cart-Id`. **Start** validates the cart and initializes payment with your chosen provider (list configured providers with `GET /storefront/payment/providers`): ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { CheckoutService } from "@opencals/storefront-sdk"; const { data: checkout } = await CheckoutService.start({ body: { provider: "stripe" }, headers: { "X-Cart-Id": cart!.id }, }); // Stripe: checkout.clientSecret → confirm with Stripe Payment Element // Redirect providers: checkout.redirectUrl → send the customer there ``` **Save the customer** so the order and appointments have contact details (guests don't need an account): ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} await CheckoutService.saveCustomer({ body: { customer: { email: "jane@example.com", firstName: "Jane", lastName: "Doe", }, }, headers: { "X-Cart-Id": cart!.id }, }); ``` If the store has custom checkout questions, fetch them with `GET /storefront/cart/checkout/questions/{language}` and save answers via `POST /storefront/cart/checkout/save-answers`. **Submit** captures the payment and creates the order: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} const { data: result } = await CheckoutService.submit({ body: { stripePaymentIntentId: paymentIntentId }, // if confirmed client-side with Stripe headers: { "X-Cart-Id": cart!.id }, }); // result.order → the confirmed order // result.auth → tokens when this checkout created the customer or the request is signed in as them ``` The response includes the created `order` and, for new customers, an `auth` token pair you can use to sign them in automatically — see [Authentication](/docs/authentication). If the email belongs to an existing customer and the request isn't signed in as them, the booking is still added to that customer's account, but `auth` is omitted and their saved profile (name, phone, marketing consent) isn't changed. The billing and delivery addresses entered are added to the customer's saved addresses once the order is placed; `save-customer` and `checkout/start` don't write them. The customer sees the booking after signing in (for example with a login code). Always treat `auth` as optional. For pay-at-venue stores, start checkout with `provider: "cash"` and submit directly with no payment confirmation step. ## After the booking With a customer token you can power a full self-service portal: * `GET /storefront/appointments` — list the customer's appointments * `PUT /storefront/appointments/{appointmentId}/reschedule` — move a booking * `PUT /storefront/appointments/{appointmentId}/cancel` — cancel it * `GET /storefront/orders` — order history with payments and refunds ## Skip the boilerplate If you'd rather start from working code, Opencals publishes free open-source Next.js booking templates built on this exact flow — browse them at [open-source/templates](https://opencals.com/docs/open-source/templates) or clone the [Haar salon template](https://github.com/letsopencals/template-haar). For the full endpoint catalog, head to the [API reference](/docs/api-reference/introduction). # Customer email links (magic links) Source: https://opencals.com/docs/guides/customer-email-links Set up passwordless email links for customer actions: view/reschedule/cancel appointments, leave feedback, verify email, and reset passwords. No additional code — just configure one setting and implement one route. Customer email links let your customers interact with their bookings directly from email. When Opencals sends an appointment confirmation, feedback request, or password reset, the link in the email takes them directly to the right page — signed in automatically. This guide is for merchants building custom frontends with the Opencals Storefront SDK. If you use the multi-tenant Opencals storefront (salon.opencals.com), no setup is needed. ## Overview All customer email links follow the same pattern: * Every link is `{storefrontBaseUrl}/link/{token}` * Your app implements one route: `GET /link/{token}` * The route calls `Auth.resolveLink(token)` to get the customer's action and redirect target * You sign the customer in (if tokens are provided) and redirect them ## Setup ### Step 1: Set the `storefrontBaseUrl` Setting In the Opencals dashboard, go to **Settings → API → Storefront Base URL** and enter your app's base URL: ``` https://mybooking.example.com ``` This tells Opencals where to send customer email links. If you don't set it, Opencals will fall back to the store's domain, and links may not reach your custom frontend. ### Step 2: Implement the `/link/{token}` Route Your app must handle `GET /link/{token}`. Here's an example with Next.js: ```ts app/link/[token]/page.tsx theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { redirect } from "next/navigation"; import { setupOpencals, AuthService } from "@opencals/storefront-sdk"; // Ensure SDK is initialized (your server config should call setupOpencals once at startup) setupOpencals(); export default async function MagicLinkPage({ params }: { params: { token: string } }) { try { // Resolve the token: validates it, signs the customer in, and returns redirect info const { data: response } = await AuthService.resolveLink({ query: { token: params.token } }); if (!response) { return
Invalid or expired link
; } const { redirectPath, accessToken, refreshToken, requiresAction } = response; // If tokens are provided, save them for future requests if (accessToken && refreshToken) { // Option 1: Store in an httpOnly cookie (recommended) // You can set this via a server action or route handler // Option 2: Redirect to a handler that stores tokens and redirects // This avoids passing tokens in the URL } // Redirect the customer to their action redirect(redirectPath || "/account"); } catch (error) { return
This link has expired or is invalid
; } } ``` For production, store the tokens securely (httpOnly cookies) and redirect via a handler: ```ts app/api/auth/link/route.ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { cookies } from "next/headers"; import { setupOpencals, AuthService } from "@opencals/storefront-sdk"; setupOpencals(); export async function GET(req: Request) { const { searchParams } = new URL(req.url); const token = searchParams.get("token"); if (!token) { return new Response("Missing token", { status: 400 }); } try { const { data: response } = await AuthService.resolveLink({ query: { token } }); if (response?.accessToken) { const cookieStore = await cookies(); cookieStore.set("accessToken", response.accessToken, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 3600, // 1 hour }); if (response.refreshToken) { cookieStore.set("refreshToken", response.refreshToken, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 604800, // 7 days }); } } return Response.redirect(response?.redirectPath || "/account"); } catch (error) { console.error("Link resolution failed:", error); return new Response("Invalid or expired link", { status: 400 }); } } ``` ## Purpose → Redirect Table This table shows what each link type does and where the customer is redirected: | Purpose | Customer Receives | Redirect Path | Tokens Provided? | | - | - | - | - | | `view-appointment` | Link from confirmation email | `/account/appointments/{id}/view` | Yes (auto sign-in) | | `cancel-appointment` | Link to cancel or reschedule | `/account/appointments/{id}/view?action=cancel` | Yes (auto sign-in) | | `reschedule-appointment` | Link to reschedule | `/account/appointments/{id}/view?action=reschedule` | Yes (auto sign-in) | | `leave-feedback` | Link to leave a review | `/account/appointments/{id}/view?action=feedback` | Yes (auto sign-in) | | `verify-email` | Link to confirm email address | `/account` | Yes (auto sign-in) | | `reset-password` | Link to reset forgotten password | `/auth/reset-password?token={token}` | No (tokens passed separately) | ## Special Case: Password Reset Password reset links do **not** return customer tokens. Instead, the token is passed as a query parameter so your app can verify it when the customer submits a new password. ### Example: Password Reset Handler ```ts app/auth/reset-password/page.tsx theme={"theme":{"light":"github-light","dark":"github-dark-default"}} "use client"; import { useState } from "react"; import { useSearchParams } from "next/navigation"; import { AuthService } from "@opencals/storefront-sdk"; export default function ResetPasswordPage() { const searchParams = useSearchParams(); const token = searchParams.get("token"); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); if (!token) { return
Invalid reset link
; } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); try { // Call the API with the token from the URL await AuthService.resetPassword({ body: { token, newPassword: password } }); // Password reset successful — redirect to sign-in window.location.href = "/auth/sign-in?success=Password%20reset%20successful"; } catch (err: any) { setError(err.message || "Failed to reset password"); } finally { setLoading(false); } }; return (
setPassword(e.target.value)} placeholder="New password" required /> {error &&
{error}
}
); } ``` ## Passwordless Email Login (Optional) In addition to email links, you can offer passwordless login with 6-digit codes: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} // 1. Customer requests a code await AuthService.requestLoginCode({ body: { email: "customer@example.com" } }); // Email is sent with 6-digit code // 2. Customer enters the code const { data: tokens } = await AuthService.verifyLoginCode({ body: { email: "customer@example.com", code: "123456" } }); // 3. Tokens are returned — sign them in ``` The code expires in 5 minutes and allows 5 failed attempts before a new code must be requested. ## SDK Methods The following SDK methods are used for email links and passwordless auth: | Method | Purpose | | - | - | | `AuthService.resolveLink({ query: { token } })` | Validate and resolve a magic link token | | `AuthService.resetPassword({ body: { token, newPassword } })` | Reset password with token | | `AuthService.requestLoginCode({ body: { email } })` | Send 6-digit code to email | | `AuthService.verifyLoginCode({ body: { email, code } })` | Verify code and get tokens | See the [API reference](/docs/api-reference/introduction) for full request and response schemas. ## Security Checklist * Store API keys in environment variables — never in client code. * Call `resolveLink` on the server, not the client. * Store customer tokens in **httpOnly cookies** (not localStorage) when possible. * Redirect after storing tokens to avoid exposing them in the URL. * For password reset, store the token as a query parameter only (it does not grant authentication). * Tokens expire according to their purpose (1 hour for verify-email/reset-password, 7 days for leave-feedback, 24 hours default). ## Troubleshooting **"Invalid or expired link"** * The token may have been used already (one-time use only). * The token may have expired (check the purpose expiry in the earlier table). * The store context may not match the API key's store. **Customer not signed in after clicking the link** * Verify tokens were stored (httpOnly cookies, session, etc.). * Check that `AuthService.resolveLink` returned `accessToken` and `refreshToken`. * For `reset-password` links, no sign-in occurs — the customer must reset their password and sign in manually. **Password reset token not working** * The token is included in the URL query string only, not in the API call. * Pass it to `AuthService.resetPassword({ body: { token, newPassword } })` exactly as received. # Storefront SDK Source: https://opencals.com/docs/guides/storefront-sdk Install and use @opencals/storefront-sdk — the type-safe TypeScript client for the Opencals booking API, generated from the OpenAPI spec with Zod schemas and helpers included. [@opencals/storefront-sdk](https://www.npmjs.com/package/@opencals/storefront-sdk) is the official type-safe TypeScript client for the Storefront API. It's generated from the same OpenAPI specification as the [API reference](/docs/api-reference/introduction), so every request and response is fully typed, and it ships with Zod schemas for runtime validation plus formatting helpers for prices, durations, and timezones. MIT licensed. ## Installation ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm install @opencals/storefront-sdk ``` ## Setup Call `setupOpencals()` once at app startup — a root layout, an API client singleton, or a module you import from your server code: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { setupOpencals } from "@opencals/storefront-sdk"; setupOpencals({ apiKey: "sfk_…", // or set the OPENCALS_API_KEY env var baseUrl: "https://…", // optional, defaults to https://api.opencals.com logging: true, // optional request/response logging errorHandler: true, // throw OpencalsApiError on failures (default) customerToken: "eyJ…", // optional customer JWT for authenticated requests }); ``` With the `OPENCALS_API_KEY` environment variable set, `setupOpencals()` needs no arguments. The API key is attached to every request automatically — which also means SDK calls belong on the server, where the key is safe. ## Services The SDK mirrors the API's tags as static service classes: | Service | Methods | | - | - | | `ProductService` | `list`, `get`, `getBySlug`, `getByExternalId`, `getCurrentAvailabilities`, `getCurrentAvailabilitiesMerged`, `getNearestAvailability` | | `AuthService` | `signIn`, `signUp`, `oauth`, `refresh`, `requestPasswordReset`, `resetPassword`, `requestEmailVerification`, `verifyEmail` | | `CartService` | `get`, `createOrGet`, `addItem`, `removeItem`, `extendExpiration`, `applyDiscountCode`, `removeDiscountCode` | | `CheckoutService` | `start`, `submit`, `saveCustomer`, `saveAnswers`, `getCartQuestions` | | `AppointmentService` | `list`, `find`, `create`, `cancel`, `reschedule`, `feedback`, `getBookingPreferences` | | `OrderService` | `list`, `find` | | `SelfService` | `getProfile`, `updateProfile`, `changePassword` | | `LocationService` | `list`, `get`, `getBySlug` | | `StaffMemberService` | `list`, `getBySlug` | | `PaymentService` | `getAvailableProviders`, `getSettings` | | `StoreService` | `getStorePublicSettings` | | `ProductCollectionService` | `list`, `getBySlug` | | `ImageService` | `get` | | `CheckoutQuestionService` | `listTranslations` | | `FeedbackQuestionService` | `listTranslations` | Every method takes a single options object with typed `path`, `query`, `body`, and `headers` fields matching the underlying endpoint: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { ProductService } from "@opencals/storefront-sdk"; // GET /storefront/products/slug/{slug} const { data: product } = await ProductService.getBySlug({ path: { slug: "haircut" }, }); // GET /storefront/products/{productId}/current-availabilities const { data: slots } = await ProductService.getCurrentAvailabilities({ path: { productId: product!.id }, query: { date: "2026-06-15", timezone: "America/New_York" }, }); ``` Cart and checkout calls pass the cart ID through the typed `headers` field: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { CartService } from "@opencals/storefront-sdk"; const { data: cart } = await CartService.createOrGet({ headers: { "X-Cart-Id": cartId ?? "" }, }); await CartService.addItem({ body: { appointmentId }, headers: { "X-Cart-Id": cart!.id }, }); ``` ## Customer-authenticated requests For endpoints that act on behalf of a signed-in customer, either pass `customerToken` to `setupOpencals()` or set the header per call: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { AppointmentService } from "@opencals/storefront-sdk"; const { data: appointments } = await AppointmentService.list({ headers: { Authorization: `Bearer ${accessToken}` }, }); ``` See [Authentication](/docs/authentication) for how to obtain and refresh customer tokens. ## Error handling With the default error handler enabled, failed requests throw a typed `OpencalsApiError`: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { OpencalsApiError, ProductService } from "@opencals/storefront-sdk"; try { await ProductService.get({ path: { productId: "missing" } }); } catch (error) { if (error instanceof OpencalsApiError) { console.error(error.message); } } ``` ## Zod validation Response types come with auto-generated Zod schemas, useful when data crosses a trust boundary (for example, after passing through your own API routes): ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { zProductListResponse } from "@opencals/storefront-sdk"; const result = zProductListResponse.safeParse(apiResponse); ``` ## Helpers The SDK includes small utilities you'd otherwise rewrite in every booking UI: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { formatPrice, formatDuration, toLocalFromUtc, toUtcFromLocal, mergeTimeIntervals, } from "@opencals/storefront-sdk"; formatPrice(4500, "USD"); // "$45.00" formatDuration(3600); // "1h" toLocalFromUtc("2026-06-15T14:00:00Z", "America/New_York"); ``` `toLocalFromUtc` / `toUtcFromLocal` handle the UTC conversion the API expects for appointment slots, and `mergeTimeIntervals` is handy when rendering availability ranges. ## Examples The [Build a booking page](/docs/guides/build-a-booking-page) tutorial walks through a full implementation, and the open-source [booking templates](https://opencals.com/docs/open-source/templates) are complete Next.js apps you can fork. # Webhooks Source: https://opencals.com/docs/guides/webhooks The planned Opencals webhook event catalog for booking automations — and how to keep a headless booking site in sync today with polling and revalidation. **Webhooks are not yet available.** This page documents the planned design so you can prepare your integration architecture. Event names and payload shapes may change before release. Until webhooks ship, use the [polling and revalidation patterns](#keeping-data-fresh-today) below — they cover the same use cases reliably. Webhooks will let Opencals notify your application the moment something happens in your store — a booking is created, an order is paid, a customer cancels — so you can automate without polling. ## Planned events The first release focuses on the six events that power the most common merchant automations: | Event | Fires when | Common use cases | | - | - | - | | `appointment.created` | A new booking is made | CRM sync, welcome and confirmation flows | | `order.paid` | Payment is captured for an order | Fulfillment, accounting entries | | `appointment.canceled` | A booking is canceled | Waitlist backfill, freeing resources | | `appointment.rescheduled` | A booking is moved to a new slot | Calendar updates, staff notifications | | `appointment.completed` | A booking's end time passes | Review requests, follow-up sequences | | `order.refunded` | A refund is issued on an order | Accounting corrections, inventory release | ## How delivery will work A webhook is an HTTP `POST` from Opencals to an endpoint you host. Each delivery: * is sent as JSON to the URL you register, * identifies its event type so one endpoint can handle many events, * is **signed**, so you can verify the request really came from Opencals, * expects a `2xx` response. Non-2xx responses are treated as failures and retried with backoff, so make your handler idempotent — the same event may be delivered more than once. Respond quickly (acknowledge first, process asynchronously) to avoid timeouts on slow handlers. ### Verifying signatures Deliveries will follow the standard HMAC pattern: a signature header computed over the **raw request body** with your endpoint's secret. The verification you'd write today won't change: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}} import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyWebhookSignature( rawBody: string, signatureHeader: string, secret: string, ): boolean { const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signatureHeader); return a.length === b.length && timingSafeEqual(a, b); } ``` Verify against the raw, unparsed request body — re-serialized JSON can reorder keys and break the comparison. In Next.js route handlers, use `await request.text()` before parsing. ## Keeping data fresh today Until webhooks ship, two patterns keep an integration accurate: * **Poll customer-scoped endpoints on your own schedule.** `GET /storefront/appointments` and `GET /storefront/orders` return current state, so a periodic sync job covers CRM, accounting, and follow-up automations. Respect the [rate limits](/docs/quickstart#rate-limits) — a sync every few minutes is well within them. * **Time-based revalidation for catalog data.** Products, staff, and locations change rarely — Next.js ISR (`revalidate`) or a short CDN cache of a few minutes is usually enough for a headless booking site. * **Always fetch availability live.** Time slots change with every booking, so call the availability endpoints at request time rather than caching them. The booking flow is also self-correcting: `POST /storefront/cart/checkout/start` re-validates every cart item and reports invalid ones before any payment is taken. ## Related * [Build a booking page](/docs/guides/build-a-booking-page) — where cached catalog data and live availability fit in the flow. * [API reference](/docs/api-reference/introduction) — every endpoint the planned events map to. # Introduction Source: https://opencals.com/docs/introduction The Opencals Storefront API is booking infrastructure for builders — a REST API for services, real-time availability, carts, checkout, and payments, so you can build a fully custom booking experience on any stack. Opencals is a **standalone-first booking platform**: the dashboard runs your scheduling, staff, services, and payments, and the **Storefront API** exposes all of it over REST so you can build the customer-facing side yourself — your own booking page, your own stack, your own design. Think of it as booking infrastructure for builders. Opencals handles the scheduling engine, payments, and conflict prevention — you keep full control of the front end. ## What you can build * A custom booking page on your existing website, in any framework * A headless booking site generated statically and revalidated on demand * A customer self-service portal — appointments, rescheduling, cancellations, order history * Anything else that needs services, time slots, and checkout as an API If you can configure it in the dashboard, you can read it over the API. ## Start here Get an API key and make your first request in a few minutes. The full flow — services, availability, cart, and checkout — with Next.js and the TypeScript SDK. Every endpoint, parameter, and response shape, with an interactive playground. The typed TypeScript client, generated from the same OpenAPI spec as these docs. ## Skip the boilerplate If you'd rather start from working code, Opencals publishes free open-source booking templates built on this exact API — browse them at [opencals.com/docs/open-source/templates](https://opencals.com/docs/open-source/templates). # Bali Villa Rentals — villa booking template Source: https://opencals.com/docs/open-source/bali-villas Free, open-source villa and holiday-rental website template. Next.js 15, nightly stays with fixed check-in and check-out, a calendar on live availability, per-stay extras, guest details, Stripe checkout and passwordless accounts, built on the Opencals Storefront API. A complete villa-rental website you can clone and deploy today. Bali Villa Rentals is the first template built around **nightly stays**: each villa is booked by the night, from a check-in date to a check-out date, with a fixed **check-in at 15:00 and check-out at 11:00**. Start here if you rent villas, cabins, guest houses or rooms by the night. It runs on the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) as [Haar](/docs/open-source/haar), [Frisor](/docs/open-source/frisor), [Clear Care](/docs/open-source/clarity), [VOLT](/docs/open-source/volt), [NOIR Drive](/docs/open-source/noir) and [Fluently](/docs/open-source/fluently). The demo brand is **Lumbung Sidemen**, ten villas in the rice fields of Sidemen, East Bali (USD, `Asia/Makassar`). The design is warm and quiet: limestone paper, jungle green and a single terracotta accent, with every nightly rate shown. template-bali-villas.vercel.app letsopencals/template-bali-villas (MIT) One-click deploy rentals-multi-day reference Bali Villa Rentals website template on a laptop and a phone on a villa terrace above the rice fields Bali Villa Rentals template homepage hero, an aerial view of rice terraces with nightly price pins over the villas and a date and guest search ## Nightly stays Guests pick a villa, then a check-in and a check-out date on a calendar that reads the villa's live availability. Booked nights are struck through, and the calendar stops the check-out date at the next booking. Guests (adults, children, infants), arrival time, flight number and the occasion are collected in the same flow. Extras such as an airport transfer or a floating breakfast are added once per stay. The total is always **nights × nightly rate + extras**, the same maths the engine charges. Bali Villa Rentals template reserve page with check-in and check-out dates, guest counts, arrival time, extras and a stay summary ## The stay model This is the part worth copying even if you never ship this template. It uses existing Opencals features only. | Piece | How it's modelled | | - | - | | A villa | One staffless product in its own product pool, `duration: 86400` (one night), `allowCustomDuration: true`, `maxDuration` (21 nights in the demo), `maxAttendees: 1` | | Check-in and check-out | `fixedTimes: true`, `fixedStartTime: "15:00:00"`, `fixedEndTime: "11:00:00"`. The API rejects a customer booking that doesn't start and end at these times | | Availability across midnight | A continuous 24/7 schedule | | Date picker | `getCurrentAvailabilitiesMerged` **without** `duration`; the client checks that the stay fits inside one range | | The slot | Check-in date 15:00 → check-out date 11:00 in the schedule timezone, sent in UTC | | Turnover day | A check-out day can be the next guest's check-in day (out at 11:00, in at 15:00) | | Guests, arrival time, flight number, occasion | Appointment [custom attributes](/docs/changelog/custom-attributes) | | Minimum nights | Per-villa content in the template (`lib/villa-content.ts`), checked in the calendar | | Extras | Add-ons charged once per stay | | Cancellation | `allowCustomerCancel` with `cancelGap` of 14 days; customers can also move their dates | A stay at Lumbung Sidemen, check-in 14 Nov and check-out 17 Nov, becomes `{ fromDate: '2026-11-14', fromTime: '07:00:00', toDate: '2026-11-17', toTime: '03:00:00' }` (UTC+8): three nights, priced as three units, three × the nightly rate. Give each villa its own product rather than a variant: variants don't block each other, so the same villa could be booked twice. ## What's inside Bali Villa Rentals template villas page listing villas with nightly rates and collection filters Bali Villa Rentals template villa page for Joglo Agung with a photo gallery and a reservation card Bali Villa Rentals template illustrated site map of the resort with each villa marked on the rice terraces Bali Villa Rentals template My trips page showing the next stay at Pondok Bambu with check-in and check-out dates and night count Bali Villa Rentals template trip page with stay details, change dates and cancel buttons, payment summary and directions Bali Villa Rentals template villa page on a mobile screen | Feature | Notes | | - | - | | Home | Video hero with villa pins and a date and guest search, the valley, a day at the resort, experiences, testimonials and FAQ | | Villas | All ten villas with collection filters (rice barns, pool villas, family houses, signature), a search by dates and guests, and a map | | Villa pages | Gallery, room tour, sleeping arrangements, amenities, house rules, reviews and a cancellation timeline next to the booking calendar | | Reserve | Stay dates, guests and arrival details, per-stay extras, checkout questions, promo code, a hold countdown and Stripe payment | | Trip-aware accounts | Check-in → check-out dates in the resort timezone, night count and guest details; customers can change dates (same number of nights) or cancel within policy | | Passwordless accounts | 6-digit email login, one-time email links via `/link/[token]` | | Editorial pages | Experiences, journal, about and getting there | | Motion | Lenis smooth scrolling, a gate curtain on first visit, reveals and parallax; every effect has a reduced-motion fallback | | SEO | Per-page metadata, Open Graph, `LodgingBusiness`, `VacationRental` and `FAQPage` structured data, `robots.txt`, `sitemap.xml` | | Stack | Next.js 15, TypeScript, Tailwind v4, Framer Motion, `@opencals/storefront-sdk` v0.3.17 | ## Get started ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/template-bali-villas.git cd template-bali-villas npm install ``` Create a store in the [Opencals dashboard](https://opencals.com) and choose the **Bali Villa Rentals** dataset preset (category Villa resort, `villa_resort`). It seeds the ten villas on a 24/7 schedule with fixed check-in and check-out, collections, extras, checkout questions and sample stays. Then grab a Storefront API key (see [Authentication](/docs/authentication)) and set `OPENCALS_API_KEY` and `AUTH_SECRET` in `.env.local`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm run dev ``` Your villas, rates and availability render immediately. Push to your own repo and [deploy to Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fletsopencals%2Ftemplate-bali-villas\&env=OPENCALS_API_KEY,AUTH_SECRET\&envDescription=API%20key%20from%20your%20Opencals%20dashboard%20and%20a%20random%20secret%20for%20auth\&project-name=bali-villas\&repository-name=template-bali-villas), or host anywhere Next.js runs. Pages are server-rendered, so their API calls carry no browser `Origin`. Leave **allowed origins** empty on the key you give this template, or those calls are rejected. Set **Storefront Base URL** in your dashboard (Settings → API) to your deployed domain. Every customer link (view, reschedule, cancel, leave feedback) is built as `{storefrontBaseUrl}/link/{token}`, so if it still points at localhost those emailed links go nowhere. ## Make it yours MIT-licensed: rebrand it, restructure it, or use it for client work. Business settings live in `lib/site-config.ts`: name, currency and timezone, check-in and check-out times, the longest stay, the cancellation window, arrival times, occasions and contact details. Per-villa rooms, story, amenities and minimum nights are in `lib/villa-content.ts`. Every colour and font is a Tailwind v4 `@theme` token in `app/globals.css`: ```css theme={"theme":{"light":"github-light","dark":"github-dark-default"}} @theme { --color-paper: #F4EEE4; /* limestone page background */ --color-jungle: #1F3A2E; /* dark sections and footer */ --color-terracotta: #B4502E; /* the only accent */ } ``` Building a different stay or rental site with an AI agent? The [build-booking-site skill](/docs/ai-agents/build-booking-site-skill) has a `rentals-multi-day` reference with the full pattern, including nightly stays. # Clear Care — clinic booking template Source: https://opencals.com/docs/open-source/clarity Free, open-source medical clinic booking website template. Next.js 15, a guided Care Finder, a single-page booking composer with pre-visit notes and photo uploads, video visits, Stripe checkout and a family care record — built on the Opencals Storefront API. A complete medical clinic booking website you can clone and deploy today. Clear Care is the clinic counterpart to [Haar](/docs/open-source/haar) and [Frisor](/docs/open-source/frisor): the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) underneath, with a calm "Warm Practice" design: cream, honey oak, dusty steel-blue, brass and sage, set in Fraunces and Figtree. Patients start from what's going on rather than a department name, and every screen (Care Finder, departments, clinicians, video visits, the booking composer, checkout, the visit pass and the My Care portal) is built with the public API. template-clarity.vercel.app letsopencals/template-clarity (MIT) One-click deploy Clear Care medical clinic booking website template — storefront and booking flow Clear Care template Care Finder recommending a visit with next free times and the emergency notice Clear Care template booking composer with a day ribbon, time slots showing free clinicians and a live appointment card Clear Care template pre-visit notes with reason for visit, insurance provider and photo upload Clear Care template My Care portal with the next visit, a prep checklist and the care team ## What's inside | Feature | Notes | | - | - | | Care Finder | `/care` asks who the visit is for, what's going on and in clinic or by video, then recommends visits with the next free times. Needs and keywords live in `lib/site-config.ts`. Not a diagnosis tool; the emergency notice is on every booking surface | | Rooms of care | Product Collections endpoints — General Medicine, Cardiology, Dermatology, Pediatrics, Physiotherapy, Dental, Nutrition, Mental Health, Labs & Vaccinations, each with what it treats, its visits, clinicians and FAQs | | Clinicians | Staff Members endpoints — a directory and a profile per clinician with their bookable visits | | Video visits | The store's online location — chosen per visit in the booking flow, with a local camera/mic check on `/video-visits` | | Booking composer | One page: who the visit is for (Me / My child / Someone else), clinic or video, clinician or "first available", day and slots, add-ons, then pre-visit notes. A live appointment card is stamped "Held for you" on Reserve | | Pre-visit notes | Checkout questions mapped by `internalName` in `lib/intake.ts`, answered per visit (so a family cart keeps each patient's notes), with patient name/DOB when it's not for you and real photo uploads via presigned URLs | | Multi-location | Locations endpoints — three clinics out of the box, clinicians filtered by clinic | | Checkout | Cart + Checkout + Payment endpoints — two steps (contact, then payment), themed Stripe Elements or pay at the clinic, promo codes, a cart hold with Extend | | Visit pass | What to bring, add to calendar, and video join details on the thank-you page | | My Care | Self-Service endpoints — next visit with a prep checklist and join-video window, recalls due, care team, family filter, visit timeline and records with notes, photos and invoices, rate your care, reschedule/cancel within the store's policy, billing and communication preferences | | Passwordless sign-in | 6-digit email code first, password second, one-time email links via `/link/[token]` | | Stack | Next.js 15, TypeScript, Tailwind v4, framer-motion, Lenis, `@opencals/storefront-sdk` ^0.3.17 | ## Get started ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/template-clarity.git cd template-clarity npm install ``` Create a store in the [Opencals dashboard](https://opencals.com) — choose the **Medical / Clear Care Medical** dataset preset to seed the clinic's departments, services, clinicians and pre-visit questions — then grab a Storefront API key (see [Authentication](/docs/authentication)) and set it in `.env.local`. Stores seeded before the "Visit for" and patient questions were added still work, but book every visit for "Myself" until you reseed. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm run dev ``` Your clinic's real departments, clinicians, and availability render immediately. Push to your own repo and [deploy to Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fletsopencals%2Ftemplate-clarity), or host anywhere Next.js runs. ## Make it yours MIT-licensed: rebrand it, restructure it, or use it as a starting point for client work. Clinic copy and knowledge (Care Finder needs, department content, prep lists, recalls, clinician bios) live in `lib/site-config.ts`, intake mapping in `lib/intake.ts`, and every colour and font is a Tailwind v4 `@theme` token, with the full design contract in `DESIGN.md`. Agencies can run one codebase against multiple stores — each deployment points at a different API key. To go deeper than theming, follow the [booking page tutorial](/docs/guides/build-a-booking-page) to see the API calls behind each screen. # Fluently — language school template Source: https://opencals.com/docs/open-source/fluently Free, open-source language school website template. Next.js 15, group classes with level variants and live seats left, private lessons, a free trial, teachers, campuses plus an online classroom with a Join button, Stripe checkout and passwordless accounts, built on the Opencals Storefront API. A complete language-school website you can clone and deploy today. Fluently is built for **schools and courses**: group classes with levels, private 1:1 lessons, a free trial, teachers, campuses and a live **online classroom**. Start here for a language, music, coding, art or tutoring school. It runs on the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) as [Haar](/docs/open-source/haar), [Frisor](/docs/open-source/frisor), [Clear Care](/docs/open-source/clarity), [VOLT](/docs/open-source/volt) and [NOIR Drive](/docs/open-source/noir). It's set in New York (USD, `America/New_York`) with two campuses and an online classroom, teaching Spanish, French, German, Italian, Japanese and Mandarin. The design is friendly and colorful: chunky sticker buttons, speech-bubble chips and 3D mascots next to real photos. template-fluently.vercel.app letsopencals/template-fluently (MIT) One-click deploy classes-and-lessons reference Fluently language school website template on a laptop and two phones in a classroom Fluently template homepage hero reading Speak a new language by summer, with a waving mascot and greetings in six languages ## The school model This is the part worth copying even if you never ship Fluently itself. It uses existing Opencals features only, and the template reads everything from the data, so nothing about languages is hard-coded. | Piece | How it's modelled | | - | - | | A subject (a language) | A collection. A separate `start-here` collection holds the trial and conversation club | | A group class | A product with `maxAttendees > 1`, assigned to its teachers | | Levels (A1, A2, B1) | Variants of the group class, each with its own slug and price | | A private lesson | A 1-seat product whose variants are lengths (45 / 60 min) | | The free trial | A 1-seat product priced `0`, booked without a card | | Online vs campus | Derived from the class's location types: all `ONLINE` means an online group | | Teachers | Staff members; their languages and campuses come from their class and location assignments | | Seats left | `maxAttendees - attendees` on each availability slot | | The online classroom | The `ONLINE` location's `link`, returned on the booked appointment's location | | Colors | Each product's dashboard color picks its swatch | ## What's inside Fluently template class booking flow with campus choice, level variants, a date strip and a time slot showing seats left Fluently template weekly timetable with a day strip, language filters and a bookable class showing seats left | Feature | Notes | | - | - | | Home | Mascot hero with real teacher portraits, stats counted from your data, language tiles, a format switcher, a level-finder quiz, testimonials and FAQ | | This week timetable | Next seven days of group sessions across every class, with seats left, language filters and one-click booking (one cached availability call per level) | | Classes | Catalog filtered by language, format and level, with shareable URLs | | Language and teacher pages | Per-language classes, private lessons and teachers; teacher profiles with "Book with " | | Booking | Online or campus, level or length, teacher, extras, placement questions, details, payment. Deep links: `/booking/?date=&staff=&location=` | | Online lessons | A Join lesson button on online bookings, live from 15 minutes before the start | | Cart & Stripe checkout | Cart + Checkout + Payment endpoints, with the no-payment path for the free trial | | Passwordless accounts | 6-digit email login (password fallback), one-time email links via `/link/[token]` | | SEO | Per-page metadata, Open Graph, `LanguageSchool` and `FAQPage` structured data, `robots.txt`, `sitemap.xml` | | Stack | Next.js 15, TypeScript, Tailwind v4, Framer Motion, `@opencals/storefront-sdk` v0.3.14 | ## Get started ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/template-fluently.git cd template-fluently npm install ``` Create a store in the [Opencals dashboard](https://opencals.com) and choose the **Fluently** dataset preset (`language_school`). It seeds the six languages, campus and online group classes with level variants, private lessons, the free trial, teachers, both campuses, the online classroom with its link, extras and placement questions. Then grab a Storefront API key (see [Authentication](/docs/authentication)) and set `OPENCALS_API_KEY`, `AUTH_SECRET` and `NEXT_PUBLIC_SITE_URL` in `.env.local`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm run dev ``` Your classes, teachers and timetable render immediately. Push to your own repo and [deploy to Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fletsopencals%2Ftemplate-fluently\&env=OPENCALS_API_KEY,AUTH_SECRET\&envDescription=API%20key%20from%20your%20Opencals%20dashboard%20and%20a%20random%20secret%20for%20auth\&project-name=fluently\&repository-name=template-fluently), or host anywhere Next.js runs. Pages are server-rendered, so their API calls carry no browser `Origin`. Leave **allowed origins** empty on the key you give this template, or those calls are rejected. Set **Storefront Base URL** in your dashboard (Settings → API) to your deployed domain. Every customer link (view, reschedule, cancel, leave feedback) is built as `{storefrontBaseUrl}/link/{token}`, so if it still points at localhost those emailed links go nowhere. ## Make it yours MIT-licensed: rebrand it, restructure it, or use it for client work. All copy lives in `lib/site-config.ts`: hero, format descriptions, the level quiz, how it works, testimonials, FAQ and values. Every colour and font is a Tailwind v4 `@theme` token in `app/globals.css`: ```css theme={"theme":{"light":"github-light","dark":"github-dark-default"}} @theme { --color-bg: #FBF8F3; /* warm paper */ --color-ink: #16131F; /* ink + outlines */ --color-primary: #7C5CFF; /* grape */ --color-sun: #FFC93C; /* sunflower accent */ } ``` Building a different school site with an AI agent? The [build-booking-site skill](/docs/ai-agents/build-booking-site-skill) has a `classes-and-lessons` reference with the full pattern. # Frisor — barbershop booking template Source: https://opencals.com/docs/open-source/frisor Free, open-source barbershop booking website template. Next.js 15, Stripe checkout, staff and multi-location booking, add-ons, customer accounts — built on the Opencals Storefront API. A complete barbershop booking website you can clone and deploy today. Frisor is the barbershop counterpart to [Haar](/docs/open-source/haar): same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) underneath, a different vertical and a dark editorial design. Every screen — services, barber picker, time slots, add-ons, cart, checkout, customer account — is built with the public API. template-frisor-sage.vercel.app letsopencals/template-frisor (MIT) One-click deploy Frisor barbershop booking website template — storefront and booking flow Frisor template booking flow with services, barber, time slots and add-ons ## What's inside | Feature | Notes | | - | - | | Service catalog | Driven by the Products + Product Collections endpoints — Classic Cut, Beard Trim, Hot Towel Shave, Cut + Beard, Kids Cut | | Barber & multi-location booking | Staff Members + Locations endpoints — three shops out of the box | | Real-time availability | Appointments endpoints | | Add-ons at checkout | Add-On endpoints — hot towel finish, beard oil, eyebrow trim, in one transaction | | Cart & Stripe checkout | Cart + Checkout + Payment endpoints, deposits supported | | Customer accounts | Self-Service endpoints — bookings, reschedules, cancellations | | Stack | Next.js 15, TypeScript, Tailwind v4, `@opencals/storefront-sdk` | ## Get started ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/template-frisor.git cd template-frisor npm install ``` Create a store in the [Opencals dashboard](https://opencals.com), grab a Storefront API key (see [Authentication](/docs/authentication)), and set it in `.env.local`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm run dev ``` Your shop's real services, barbers, and availability render immediately. Push to your own repo and [deploy to Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fletsopencals%2Ftemplate-frisor), or host anywhere Next.js runs. ## Make it yours The template is MIT-licensed: rebrand it, restructure it, or use it as a starting point for client work. Every colour and font is a CSS variable — the deep-green-and-gold palette and the Fraunces display font are defined in one `@theme` block. Agencies can run one codebase against multiple stores — each deployment just points at a different API key. To go deeper than theming, follow the [booking page tutorial](/docs/guides/build-a-booking-page) to understand the API calls behind each screen. # Haar — salon booking template Source: https://opencals.com/docs/open-source/haar Free, open-source beauty salon booking website template. Next.js 15, Stripe checkout, staff and multi-location booking, customer accounts — built on the Opencals Storefront API. A complete salon booking website you can clone and deploy today. Haar is the reference implementation of the [Storefront API](/docs/api-reference/introduction): every screen — services, staff picker, time slots, cart, checkout, customer account — is built with the public API and the [TypeScript SDK](/docs/guides/storefront-sdk). template-haar.vercel.app letsopencals/template-haar (MIT) One-click deploy Haar salon booking website template — storefront and booking flow Haar template booking composer with day ribbon, time slots and live appointment card Haar template checkout with customer details, payment options and appointment cards ## What's inside | Feature | Notes | | - | - | | Service catalog | Driven by the Products + Product Collections endpoints | | Staff & multi-location booking | Staff Members + Locations endpoints | | Real-time availability | Appointments endpoints | | Cart & Stripe checkout | Cart + Checkout + Payment endpoints, deposits supported | | Customer accounts | Self-Service endpoints — bookings, reschedules, cancellations | | Stack | Next.js 15, TypeScript, Tailwind, `@opencals/storefront-sdk` | ## Get started ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/template-haar.git cd template-haar npm install ``` Create a store in the [Opencals dashboard](https://opencals.com), grab a Storefront API key (see [Authentication](/docs/authentication)), and set it in `.env.local`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm run dev ``` Your salon's real services, staff, and availability render immediately. Push to your own repo and [deploy to Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fletsopencals%2Ftemplate-haar), or host anywhere Next.js runs. ## Make it yours The template is MIT-licensed: rebrand it, restructure it, or use it as a starting point for client work. Agencies can run one codebase against multiple stores — each deployment just points at a different API key. To go deeper than theming, follow the [booking page tutorial](/docs/guides/build-a-booking-page) to understand the API calls behind each screen. # NOIR Drive — luxury car rental template Source: https://opencals.com/docs/open-source/noir Free, open-source luxury car rental website template. Next.js 15, multi-day booking by the day, delivery or garage collection, per-day extras, chauffeur packages, Stripe checkout and passwordless accounts, built on the Opencals Storefront API. A complete luxury car-rental website you can clone and deploy today. NOIR Drive is the first template built around **multi-day bookings**: each car is booked **by the day**, from a pick-up date to a return date, and blocked for the whole span. Start here if you rent cars, boats, equipment or villas by the day. It runs on the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) as [Haar](/docs/open-source/haar), [Frisor](/docs/open-source/frisor), [Clear Care](/docs/open-source/clarity) and [VOLT](/docs/open-source/volt). It's set in Dubai (AED, `Asia/Dubai`) with an all-black fleet of twelve cars. Two booking surfaces sit over one store: a **multi-day rental flow** for the fleet, and a classic **chauffeur flow** with real drivers. template-noir.vercel.app letsopencals/template-noir (MIT) One-click deploy NOIR Drive luxury car rental website template shown on a laptop in front of a black fleet in Dubai NOIR Drive template homepage hero reading Drive the night over a showroom of black cars ## Multi-day booking Customers pick a car, choose pick-up and return dates on a range calendar that reads the car's live availability, then choose *Deliver to my address* or *Collect at the garage* and a handover window. Per-day extras (excess waiver, additional driver) are priced × days, and fixed extras (child seat, prepaid fuel) once. Driving licence and ID uploads and a deposit-and-terms checkbox are regular checkout questions. The total is always **days × daily rate + extras**, the same maths the engine charges. NOIR Drive template booking flow with a two-month range calendar, handover windows, delivery or garage choice and a rental summary ## The rental model This is the part worth copying even if you never ship NOIR Drive itself. It uses existing Opencals features only. | Piece | How it's modelled | | - | - | | A car | One staffless product in its own product pool (so a booking blocks it at both locations), `duration: 86400`, `allowCustomDuration: true`, `maxDuration`, `maxAttendees: 1` | | Availability across midnight | A continuous 24/7 schedule. Bookings longer than 24 hours are validated against continuous availability | | Date picker | `getCurrentAvailabilitiesMerged` **without** `duration`; the client checks that pick-up → return fits inside one range | | The slot | Pick-up date 00:00 → return date 00:00 in the store timezone, sent in UTC | | Handover window, flight number, collection address | Appointment [custom attributes](/docs/changelog/custom-attributes) | | Delivery or collection | A `PHYSICAL` garage location and a `DELIVERY` location; the customer's address is captured at checkout | | Per-day extras | Add-ons with `durationMultiplied` | | Security deposit | Shown per car and accepted with a required checkbox question; taken at handover, never charged online | Dubai pick-up 10 Nov → return 13 Nov becomes `{ fromDate: '2026-11-09', fromTime: '20:00:00', toDate: '2026-11-12', toTime: '20:00:00' }`: three days, three units, three × the daily rate. Give each car its own product rather than a variant: variants don't block each other, so the same car could be booked twice. ## What's inside NOIR Drive template car page for a Rolls-Royce Cullinan priced per day NOIR Drive template rates table listing daily rate, deposit and kilometres per day for each car NOIR Drive template chauffeur page offering airport transfers, hourly and full-day packages | Feature | Notes | | - | - | | Fleet and car pages | Category filters from collections, "available for your dates", a two-month availability calendar with booked days struck through | | Rates | A full rates table (daily rate, deposit, km per day) | | Multi-day booking | Range calendar, delivery or garage, handover windows, per-day and fixed extras, checkout questions | | Chauffeur | Packages booked through the classic staff flow, with a preferred-car request | | Cart & Stripe checkout | Cart + Checkout + Payment endpoints | | Rental-aware accounts | Pick-up → return dates in the store timezone, day count, handover details; customers can change dates (same length) or cancel within policy | | Passwordless accounts | 6-digit email login (password fallback), one-time email links via `/link/[token]` | | Motion | Lenis smooth scrolling, text and image reveals, parallax, count-up specs; every effect has a reduced-motion fallback | | SEO | Per-page metadata, Open Graph, `AutoRental` and `Product` structured data, `robots.txt`, `sitemap.xml` | | Stack | Next.js 15, TypeScript, Tailwind v4, Framer Motion, `@opencals/storefront-sdk` v0.3.14 | ## Get started ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/template-noir.git cd template-noir npm install ``` Create a store in the [Opencals dashboard](https://opencals.com) and choose the **NOIR Drive** dataset preset (category Car rental). It seeds the fleet on a 24/7 schedule, the garage and delivery locations, extras, checkout questions, chauffeurs and packages. Then grab a Storefront API key (see [Authentication](/docs/authentication)) and set `OPENCALS_API_KEY` and `AUTH_SECRET` in `.env`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm run dev ``` Your cars, rates and availability render immediately. Push to your own repo and [deploy to Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fletsopencals%2Ftemplate-noir\&env=OPENCALS_API_KEY,AUTH_SECRET\&envDescription=API%20key%20from%20your%20Opencals%20dashboard%20and%20a%20random%20secret%20for%20auth\&project-name=noir-drive\&repository-name=template-noir), or host anywhere Next.js runs. Set **Storefront Base URL** in your dashboard (Settings → API) to your deployed domain. Every customer link (view, reschedule, cancel, leave feedback) is built as `{storefrontBaseUrl}/link/{token}`, so if it still points at localhost those emailed links go nowhere. ## Make it yours MIT-licensed: rebrand it, restructure it, or use it for client work. All business copy lives in `lib/site-config.ts`: name, currency and timezone, per-car specs and deposits, handover windows, delivery zones, FAQs and contact details. Every colour and font is a Tailwind v4 `@theme` token in `app/globals.css`: ```css theme={"theme":{"light":"github-light","dark":"github-dark-default"}} @theme { --color-bg: #050505; /* page black */ --color-ink: #F4F1EA; /* warm ivory text */ --color-primary: #C8A96B; /* champagne accent */ } ``` Building a different rental site with an AI agent? The [build-booking-site skill](/docs/ai-agents/build-booking-site-skill) has a `rentals-multi-day` reference with the full pattern. # Open-source templates Source: https://opencals.com/docs/open-source/templates Free, open-source booking website templates built on the Opencals Storefront API. Clone, customize, and deploy a production booking site in minutes. Every template is a complete, production-grade booking website built on the [Storefront API](/docs/api-reference/introduction) and the typed [TypeScript SDK](/docs/guides/storefront-sdk). MIT-licensed — clone it, rebrand it, ship it for yourself or your clients. ## How it works Each template is a standalone Next.js repository on [GitHub](https://github.com/letsopencals). Create a store in the [Opencals dashboard](https://opencals.com) and set your Storefront API key in the env vars. See [Authentication](/docs/authentication). One-click deploy to Vercel, or anywhere Next.js runs. Bookings, checkout, and payments work out of the box. ## Templates A free beauty-salon booking website: services, staff and multi-location booking, customer accounts, and Stripe checkout. Next.js 15 + TypeScript. **[Live demo ↗](https://template-haar.vercel.app)** · **[GitHub ↗](https://github.com/letsopencals/template-haar)** A free barbershop booking website: services and variants, barber and multi-location booking, add-ons at checkout, customer accounts, and Stripe checkout. Dark editorial design — Next.js 15 + TypeScript. **[Live demo ↗](https://template-frisor-sage.vercel.app)** · **[GitHub ↗](https://github.com/letsopencals/template-frisor)** A free medical-clinic booking website: a Care Finder that starts from what's going on, a single-page booking composer with pre-visit notes and photo uploads, clinic or video visits across three clinics, Stripe checkout, and a family care record in My Care. Calm, warm design — Next.js 15 + TypeScript. **[Live demo ↗](https://template-clarity.vercel.app)** · **[GitHub ↗](https://github.com/letsopencals/template-clarity)** A free padel & squash club booking website: a multi-court booking grid with custom durations, coach-led training with group capacity, add-ons and custom questions at checkout, passwordless customer accounts, and Stripe checkout. The first template built on staffless resources rather than staff-led appointments. Dark technical design — Next.js 15 + TypeScript. **[Live demo ↗](https://template-volt.vercel.app)** · **[GitHub ↗](https://github.com/letsopencals/template-volt)** A free luxury car-rental website: a fleet booked by the day with a range calendar on live availability, delivery or garage collection, per-day extras, licence and ID uploads at checkout, chauffeur packages with real drivers, rental-aware customer accounts, and Stripe checkout. The first template built on multi-day bookings. Black cinematic design — Next.js 15 + TypeScript. **[Live demo ↗](https://template-noir.vercel.app)** · **[GitHub ↗](https://github.com/letsopencals/template-noir)** A free language-school website: group classes with level variants and live seats left, private lessons, a free trial, a week-ahead timetable, teacher profiles, campuses plus an online classroom with a Join button, passwordless student accounts, and Stripe checkout. The first template built for schools and courses. Colorful, playful design — Next.js 15 + TypeScript. **[Live demo ↗](https://template-fluently.vercel.app)** · **[GitHub ↗](https://github.com/letsopencals/template-fluently)** A free villa-rental website: ten villas booked by the night with fixed check-in and check-out, a calendar on live availability, guest and arrival details, per-stay extras, trip-aware customer accounts with date changes and cancellation, passwordless login, and Stripe checkout. The first template built for nightly stays. Warm, quiet design — Next.js 15 + TypeScript. **[Live demo ↗](https://template-bali-villas.vercel.app)** · **[GitHub ↗](https://github.com/letsopencals/template-bali-villas)** Booking courts, bays, lanes or rooms rather than people? Start from [VOLT](/docs/open-source/volt), built on staffless resources. Renting by the day (cars, boats, equipment)? Start from [NOIR Drive](/docs/open-source/noir). Renting villas, cabins or rooms by the night? Start from [Bali Villa Rentals](/docs/open-source/bali-villas). Running a school or courses? Start from [Fluently](/docs/open-source/fluently). More are on the way: spa, fitness, and tattoo studio are next. Want one prioritized? [Tell us](https://opencals.com/contact). ## Quick start ```bash Clone Haar theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/template-haar.git cd template-haar npm install ``` ```bash Install the SDK only theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm install @opencals/storefront-sdk ``` Build something custom instead? Start from the [booking page tutorial](/docs/guides/build-a-booking-page). # VOLT — padel & squash club template Source: https://opencals.com/docs/open-source/volt Free, open-source padel and squash club booking website template. Next.js 15, a multi-court booking grid with custom durations, coach-led training, Stripe checkout, passwordless accounts — built on the Opencals Storefront API. A complete padel & squash club booking website you can clone and deploy today. VOLT is the first template built around **staffless resources** rather than staff-led appointments: courts are independent, exclusively-booked units, so this is the one to start from if you are booking courts, bays, lanes, studios or rooms rather than people. It runs on the same [Storefront API](/docs/api-reference/introduction) and typed [TypeScript SDK](/docs/guides/storefront-sdk) as [Haar](/docs/open-source/haar), [Frisor](/docs/open-source/frisor) and [Clear Care](/docs/open-source/clarity). Two booking surfaces sit over one store: an in-page **court booking grid** for rentals, and a classic **coach-led training flow** for lessons and group classes. template-volt.vercel.app letsopencals/template-volt (MIT) One-click deploy VOLT padel and squash club booking website template — storefront and booking flow VOLT template homepage hero with a floodlit padel court and a next-available-court chip ## The court booking grid The centrepiece. One grid shows **every court and every open slot** for a day at once — courts down the rows, time across the columns — with a Padel / Squash toggle that changes which courts fill the grid. Each court is its own resource, so booking one leaves every other court free. VOLT template court booking grid with a duration popover pricing 30, 60, 90 and 120 minutes Courts book in 30-minute steps up to two hours. The grid walks each court's row for runs of consecutive free slots and composes them into the 30 / 60 / 90 / 120-minute options actually available from the cell you tapped, pricing each one live. Hovering a duration highlights the spanned cells; a Confirm button commits. That logic lives in a `use-court-grid` hook rather than in the page, which is the first place to look if you price peak hours differently or cap rental length. ## Coach-led training A separate flow over the same store, for lessons and classes. Filter the catalog by sport, level (Starter → Advanced) and format (1-on-1 or group), pick a coach — or **"Any coach — next available"**, which leads the picker — then a date and time. Group classes show live **"N left"** capacity and disappear when they fill. VOLT template training booking flow with coach selection, date strip and time slots showing spots left ## Two resource models, one store This is the part worth copying even if you never ship VOLT itself. | Model | Rule | Capacity | Reads as | | - | - | - | - | | Courts (rentals) | `Self` — staffless resource | `maxAttendees: 1` | "Padel Court 3 — 90 min" | | Trainings (lessons) | `Staff` — assigned to a coach | `maxAttendees: 1` or more | "Intermediate Group — with Coach Marta" | Orders and appointment detail read naturally either way, so nothing downstream has to decode a court that was faked as a staff member. ## What's inside | Feature | Notes | | - | - | | Multi-court booking grid | Products + Appointments endpoints — every court and slot for a day in one view, padel/squash toggle | | Custom durations | 30-minute steps to two hours, composed from consecutive free slots and priced live | | Two sports, one store | Product Collections endpoints — a third sport is a data change, not a code change | | Coach-led training | Staff Members endpoints — filter by sport, level and format; "Any coach" first | | Group capacity | Live "N left" counts from `maxAttendees` | | Add-ons at checkout | Racket and ball rental out of the box | | Custom questions | Checkout questions configured in the dashboard, saved to the appointment | | Cart & Stripe checkout | Cart + Checkout + Payment endpoints, deposits supported | | Passwordless accounts | Self-Service endpoints — 6-digit email login (password fallback), auto-login after checkout, one-time email links via `/link/[token]` | | Video-ready hero | Upgrades to a looping video when `public/videos/hero.mp4` exists, still image otherwise | | SEO | Per-page metadata, Open Graph, `SportsActivityLocation` structured data, `robots.txt`, `sitemap.xml` | | Stack | Next.js 15, TypeScript, Tailwind v4, Framer Motion, `@opencals/storefront-sdk` | ## Get started ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} git clone https://github.com/letsopencals/template-volt.git cd template-volt npm install ``` Create a store in the [Opencals dashboard](https://opencals.com) — choose the **VOLT Padel & Squash** dataset preset to seed the club's courts, coaches, training classes, add-ons and venue — then grab a Storefront API key (see [Authentication](/docs/authentication)) and set `OPENCALS_API_KEY` and `AUTH_SECRET` in `.env`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} npm run dev ``` Your real courts, coaches and availability render immediately. Push to your own repo and [deploy to Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fletsopencals%2Ftemplate-volt\&env=OPENCALS_API_KEY,AUTH_SECRET\&envDescription=API%20key%20from%20your%20Opencals%20dashboard%20and%20a%20random%20secret%20for%20auth\&project-name=volt\&repository-name=template-volt), or host anywhere Next.js runs. Set **Storefront Base URL** in your dashboard (Settings → API) to your deployed domain. Every customer link — view, reschedule, cancel, leave feedback — is built as `{storefrontBaseUrl}/link/{token}`, so if it still points at localhost those emailed links go nowhere. ## Make it yours MIT-licensed: rebrand it, restructure it, or use it as a starting point for client work. All club-specific copy lives in `lib/site-config.ts` — club name, the two sports and their price hints, hero, coaches, gallery, stats, testimonials, FAQs, address and hours. Every colour and font is a Tailwind v4 `@theme` token in `app/globals.css`, so components keep working when you change values: ```css theme={"theme":{"light":"github-light","dark":"github-dark-default"}} @theme { --color-bg: #0A0A0A; /* near-black page background */ --color-primary: #FF4A1C; /* electric-orange accent */ --font-display: 'JetBrains Mono', ui-monospace, monospace; } ``` Keep the accent aligned with your store's storefront primary colour so booking emails match the site. Agencies can run one codebase against multiple stores — each deployment points at a different API key. To go deeper than theming, follow the [booking page tutorial](/docs/guides/build-a-booking-page) to see the API calls behind each screen. # Quickstart Source: https://opencals.com/docs/quickstart What the Opencals Storefront API is, how to get an API key, and how to make your first request. The Opencals Storefront API is a REST booking API for developers. It exposes everything a booking website needs — services, real-time availability, staff members, locations, carts, checkout, and payments — so you can build a fully custom booking experience on any stack while Opencals handles scheduling logic, conflict prevention, and order management. Everything you configure in the Opencals dashboard is available over the API. ## Base URL All Storefront API endpoints live under a single base URL: ```text theme={"theme":{"light":"github-light","dark":"github-dark-default"}} https://api.opencals.com ``` Endpoint paths are prefixed with `/storefront` — for example `GET /storefront/products` lists your bookable services. Responses are JSON with camelCase property names. ## Make your first request 1. Sign up at [opencals.com](https://opencals.com) and set up your store — services, staff members, locations, and schedules. 2. In the dashboard, go to **Settings → API Keys** and create a new **Storefront API Key**. 3. Each key is scoped to your store and starts with `sfk_`. Keep the key server-side (for example in an environment variable). It identifies your store and should never be shipped to the browser. List your store's active services with a single call: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} curl "https://api.opencals.com/storefront/products" \ -H "X-Api-Key: sfk_your_key_here" ``` The response is a paginated collection: ```json theme={"theme":{"light":"github-light","dark":"github-dark-default"}} { "data": [ { "id": "9c6f7c2e-…", "title": "Haircut", "duration": 3600, "price": 4500, "maxAttendees": 1 } ], "meta": { "page": 1, "take": 50, "itemCount": 12, "pageCount": 1, "hasPreviousPage": false, "hasNextPage": false } } ``` You can also try every endpoint directly in the [interactive API reference](/docs/api-reference/introduction). ## Pagination List endpoints accept `page` (1-indexed) and `take` (items per page, maximum 100) query parameters and return a `meta` object with `page`, `take`, `itemCount`, `pageCount`, `hasPreviousPage`, and `hasNextPage`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-default"}} curl "https://api.opencals.com/storefront/products" \ -H "X-Api-Key: sfk_your_key_here" \ --url-query "page=2" \ --url-query "take=20" ``` ## Rate limits Requests are rate-limited per API key: | Category | Limit | | - | - | | General | 100 requests / 60 seconds | | Authentication | 20 requests / 10 seconds | | Password reset | 5 requests / hour | | Registration | 10 requests / minute | When you exceed a limit the API responds with `429 Too Many Requests`. ## Timezones All date and time values in requests and responses are in **UTC**. Availability endpoints accept a `timezone` query parameter (for example `America/New_York`) to return slots in the user's local timezone. Convert user-selected times back to UTC before creating appointments. ## Next steps API keys, customer tokens, and error responses. The full flow from listing services to a confirmed booking. The typed TypeScript client for this API. Every endpoint, parameter, and response shape.