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.
1
Create a storefront key
In the Opencals dashboard open Settings → API Keys and create a key
(
sfk_…) with no allowed origins. The backend function sends no
Origin header.2
Add two workspace skills
Workspace name (bottom left) → Settings → Plugins → Skills →
Add skill → Start from scratch.Skill 1 · name
Skill 2 · name
Raw files: opencals-storefront-api.md ·
base44-booking-site-playbook.md
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.Instructions: Opencals Storefront API (~36k chars)
Instructions: Opencals Storefront API (~36k chars)
opencals-storefront-api.md
# Opencals Storefront API — complete reference
The Opencals connector is a hosted booking API for service businesses (salons, clinics, studios, courts, rooms, classes). It owns availability maths, timezones, double-booking prevention, pricing, discounts, tax and payments. The app's job is to call the endpoints below in the right order and render the results. Never implement any of that logic in the app.
Everything here is copied from the live API. **Never invent endpoints, fields or enum values.** If something isn't listed here, it doesn't exist.
## How to call the API
- All paths are relative to the connector's base URL. The connector adds the store's API key and the `X-Api-Version: 1` header on every request. **Do not add `X-Api-Key` or `X-Api-Version` yourself.**
- Call Opencals from server-side code only, through the connector. The browser must never call Opencals directly or hold the key.
- Request and response bodies are JSON. Send `Content-Type: application/json` on requests with a body.
- The connector's key identifies **one store**. Every endpoint is scoped to that store automatically. There is no store id to pass.
- Two per-request headers the app must pass through itself:
- `X-Cart-Id: <cartId>` on cart and checkout calls (see "Cart").
- `Authorization: Bearer <accessToken>` for a signed-in customer (see "Customer accounts"). Optional on cart, checkout and booking calls; required on account calls.
## Key endpoints
### Catalogue (read-only)
| Method | Path | Purpose |
|---|---|---|
| GET | `/storefront/stores/public-settings` | Store name, currency, timezone, time/date format, checkout field rules, marketing opt-in display, contact info |
| GET | `/storefront/products` | Paginated list of services (products), each with `variants[]` |
| GET | `/storefront/products/{productId}` | One product with staff, locations, add-ons, variants, checkout questions |
| GET | `/storefront/products/slug/{slug}` | Same, by slug |
| GET | `/storefront/products/{productId}/add-ons` | Add-ons bookable with this product (query: `locationId`, `staffMemberId`) |
| GET | `/storefront/product-collections` | Collections/categories (query: `isVisible`) |
| GET | `/storefront/product-collections/slug/{slug}` | One collection with its products |
| GET | `/storefront/locations` | Locations |
| GET | `/storefront/locations/{locationId}` · `/storefront/locations/slug/{slug}` | One location |
| GET | `/storefront/staff-members` | Staff members |
| GET | `/storefront/staff-members/slug/{slug}` | One staff member |
| GET | `/storefront/add-ons` · `/storefront/add-ons/{addOnId}` · `/storefront/add-ons/slug/{slug}` | Add-ons |
| GET | `/storefront/images/{imageId}` | Image record (URLs) |
| GET | `/storefront/checkout-questions/{productId}/translations/{language}` | Checkout questions for a product |
| GET | `/storefront/feedback-questions/{productId}/translations/{language}` | Post-appointment feedback questions |
### Availability
| Method | Path | Purpose |
|---|---|---|
| GET | `/storefront/products/{productId}/current-availabilities` | Exact bookable slots for one date |
| GET | `/storefront/products/{productId}/current-availability-ranges` | Merged availability windows across the booking horizon (for calendar highlighting) |
| GET | `/storefront/products/{productId}/nearest-availability` | The next available slot (204 when none) |
### Cart
| Method | Path | Purpose |
|---|---|---|
| POST | `/storefront/cart` | Create a cart, or return the existing one named by `X-Cart-Id` |
| GET | `/storefront/cart` | Read the cart (`X-Cart-Id`) |
| POST | `/storefront/cart/items` | Add an existing appointment to the cart |
| DELETE | `/storefront/cart/items/{itemId}` | Remove a cart item (and release its held slot) |
| POST | `/storefront/cart/extend` | Push the cart's `expiresAt` further out |
| POST | `/storefront/cart/items/{cartItemId}/add-ons` | Attach an add-on to a cart item |
| PATCH | `/storefront/cart/add-ons/{cartAddOnItemId}` | Change a fixed add-on's quantity |
| DELETE | `/storefront/cart/add-ons/{cartAddOnItemId}` | Remove an add-on |
| POST | `/storefront/cart/discount-code` | Apply a promo code |
| DELETE | `/storefront/cart/discount-code` | Remove the promo code |
### Appointments
| Method | Path | Purpose |
|---|---|---|
| POST | `/storefront/appointments` | Create (hold) an appointment for a slot, attached to a cart |
| GET | `/storefront/appointments` | The signed-in customer's appointments (paginated) |
| GET | `/storefront/appointments/{appointmentId}` | One appointment |
| PUT | `/storefront/appointments/{appointmentId}/reschedule` | Move to a new slot |
| PUT | `/storefront/appointments/{appointmentId}/cancel` | Cancel |
| POST | `/storefront/appointments/{appointmentId}/guests` | Invite a guest by email |
| DELETE | `/storefront/appointments/{appointmentId}/guests/{guestId}?notify=true` | Remove a guest (`notify` query is required) |
| POST | `/storefront/appointments/{appointmentId}/feedback` | Submit feedback answers |
| GET | `/storefront/appointments/preferences/booking-history` | The customer's usual product/staff/location, for pre-filling |
### Checkout and payment
| Method | Path | Purpose |
|---|---|---|
| GET | `/storefront/cart/checkout/questions/{language}` | Checkout questions for everything in the cart |
| POST | `/storefront/cart/checkout/save-customer` | Attach customer details, billing/delivery address and marketing consent to the cart |
| POST | `/storefront/cart/checkout/save-answers` | Save checkout-question answers |
| GET | `/storefront/payment/providers` | Payment options for this cart (`X-Cart-Id`) |
| POST | `/storefront/cart/checkout/start` | Start payment with the chosen provider; returns a Stripe client secret or redirect |
| POST | `/storefront/cart/checkout/submit` | Finalise: creates the order and confirms the appointments |
| POST | `/storefront/uploads/presign` | Get an upload URL for a file-upload checkout question |
### Orders
| Method | Path | Purpose |
|---|---|---|
| GET | `/storefront/orders` | The signed-in customer's orders |
| GET | `/storefront/orders/{orderId}` | One order |
| GET | `/storefront/orders/{orderId}/invoices` | Invoices/receipts for an order |
### Customer accounts
| Method | Path | Purpose |
|---|---|---|
| POST | `/storefront/auth/sign-up` | Register with email + password (204) |
| POST | `/storefront/auth/sign-in` | Email + password → tokens |
| POST | `/storefront/auth/request-login-code` | Email a 6-digit login code (204) |
| POST | `/storefront/auth/verify-login-code` | Email + code → tokens |
| GET | `/storefront/auth/resolve-link?token=…` | Resolve a magic link from an email → tokens + where to go |
| GET | `/storefront/auth/refresh` | Refresh token (as `Authorization: Bearer <refreshToken>`) → new tokens |
| POST | `/storefront/auth/request-password-reset` | Email a reset link (204) |
| POST | `/storefront/auth/reset-password` | Token + new password (204) |
| POST | `/storefront/auth/request-email-verification` | Resend verification email (204) |
| POST | `/storefront/auth/verify-email` | Token → tokens |
| GET / PUT | `/storefront/self-service/profile` | Read / update name and phone |
| PUT | `/storefront/self-service/password` | Set or change password |
| GET / PUT | `/storefront/self-service/marketing-consent` | Read / update per-channel marketing consent |
## Core concepts
### IDs and products vs variants
- A **product** is a bookable service. `GET /storefront/products` returns items with a `variants[]` array (e.g. "Haircut" → "Short hair", "Long hair"). Each variant is a full product row with its own `id`, `price`, `duration`, `staffMembers` and `locations`.
- **The bookable id is always a variant's `id`.** Use it in `/storefront/products/{productId}/…` paths and as `slot.productId`. Don't use the `productId` field on a product row: that's a grouping key shared by all its variants. A product with one variant has `variants[0].id === id`.
- IDs are UUID strings.
### Time and money
- **All slot dates and times are UTC.** Slots use `fromDate`/`toDate` (`YYYY-MM-DD`) and `fromTime`/`toTime` (`HH:MM:SS`). Appointment `from`/`to` are ISO 8601 UTC timestamps.
- Convert to the store's timezone (`settings.timezone` in public settings) or the visitor's timezone **for display only**. Always send back the exact UTC values the API gave you.
- Durations and gaps are in **seconds** (`duration: 3600` = 1 hour).
- Prices are decimal numbers in the store currency (`currency` in public settings, `paymentCurrencyCode` on carts and orders), e.g. `45` or `12.5`. Not cents. `taxesIncluded` says whether prices already include tax.
### Pagination
List endpoints take `page` (1-based, default 1) and `take` (default 50, max 100), and optionally `q` (search), `orderBy`, `order` (`ASC`/`DESC`). They return:
```json
{
"data": [ ... ],
"meta": { "page": 1, "take": 50, "itemCount": 12, "pageCount": 1, "hasPreviousPage": false, "hasNextPage": false }
}
```
Single-resource endpoints return the object directly (no wrapper).
## Store settings
`GET /storefront/stores/public-settings` → `{ name, domain, currency, settings, storefrontSettings, contactInfo, checkoutSettings, features }`.
- `settings.timezone`, `settings.timeFormat` (`12H` | `24H`), `settings.dateFormat`, `settings.defaultAdvanceScheduleDays` (-1 = unlimited).
- `checkoutSettings` controls the customer form:
- `customerFirstNameRequired: boolean`. Last name and email are always required.
- `customerPhoneRequirement`, `customerCompanyNameRequirement`, `customerCompanyVatNumberRequirement`, `customerBillingAddressRequirement`: each `hidden` | `optional` | `required`.
- `emailMarketingOptIn`, `smsMarketingOptIn`, `whatsappMarketingOptIn`: each `dont_show` | `checkout_only` | `checkout_and_account`.
- Fetch this once and cache it. Build the checkout form from it rather than hard-coding fields.
## Products
Useful fields on a product/variant:
| Field | Meaning |
|---|---|
| `id`, `slug`, `title`, `variantTitle`, `description` | Identity and copy |
| `price`, `taxable`, `duration` (s) | Pricing and length |
| `image`, `images[]` | Images; use `image.url` (can be null) |
| `staffMembers[]`, `locations[]` | Who can perform it, and where |
| `maxAttendees` | Capacity per slot (1 = private; >1 = group class) |
| `allowCustomDuration`, `maxDuration` (s, -1 = unlimited) | Variable-length bookings (see below) |
| `allowGuests`, `maxGuests` (null = unlimited) | Whether named guests can be invited |
| `allowCustomerReschedule`, `rescheduleGap` (s) | Self-service reschedule, and the minimum notice |
| `allowCustomerCancel`, `cancelGap` (s) | Self-service cancel, and the minimum notice |
| `advanceScheduleThreshold` | How many days ahead it can be booked |
| `status` | `active` \| `inactive`. Show active only |
| `collections[]`, `addOns[]`, `checkoutQuestions[]` | On the detail endpoint |
`GET /storefront/products?status=active&take=100` is the usual catalogue call. Filter by location with `locationId`.
Locations have `type`: `physical` (has an address; use `displayAddress` if set, else `address`), `online` (has a `link`), or `delivery` (the business comes to the customer, so a customer address is needed at checkout).
## Availability
### Exact slots for a day
```
GET /storefront/products/{productId}/current-availabilities?date=2026-10-14&timezone=Europe/London&locationId=…&staffMemberId=…
```
Query: `date` (required, `YYYY-MM-DD`), `timezone` (IANA name; aligns the "day" to local time, so always send it), `locationId`, `staffMemberId`, `duration` (seconds as a string, for variable-length bookings), `excludeAppointmentId` (for rescheduling).
Response: an array of slots:
```json
[
{
"productId": "b2c1…",
"fromDate": "2026-10-14", "fromTime": "09:00:00",
"toDate": "2026-10-14", "toTime": "09:45:00",
"staffMemberIds": ["5f0e…", "91aa…"],
"locationIds": ["c3d4…"],
"attendees": 0,
"maxAttendees": 1
}
]
```
- `staffMemberIds` lists who is free for that slot. If the customer picked "anyone", you may omit `staffMemberId` when booking. If they picked a person, filter slots by it (or pass `staffMemberId` in the query) and send it in the slot.
- For group products, spots left = `maxAttendees - attendees`.
- An empty array means no availability that day.
### Calendar highlighting
```
GET /storefront/products/{productId}/current-availability-ranges?timezone=Europe/London&locationId=…&staffMemberId=…&duration=…
```
Returns the same slot shape, but each entry is a merged **window** of availability across the horizon (`date` is optional; omit it for the full horizon). Use it to enable or disable days in a date picker, then call `current-availabilities` for the day the customer picks. This costs one request per product instead of one per day.
### Next available
`GET /storefront/products/{productId}/nearest-availability` → one slot (`productId, locationId, staffMemberId, fromDate, fromTime, toDate, toTime`), or HTTP 204 when nothing is bookable. Use it to open the picker on the first bookable day.
### Many units in a grid (courts × time, rooms × time)
There is no multi-product availability endpoint. Call `current-availabilities` once per unit for the selected date, cache each result by product + date + duration, and mark a cell available if that unit has a slot starting at that time. Watch the rate limit (see Notes).
## Booking flow (the happy path)
1. `POST /storefront/cart` with header `X-Cart-Id: ""` (empty on the first call). Store `cart.id` (e.g. in `localStorage`) and send it as `X-Cart-Id` on every later cart/checkout call.
2. `GET …/current-availabilities`, and the customer picks a slot.
3. `POST /storefront/appointments` with the slot and `cartId`. The appointment is held in the cart; you don't need to call `/cart/items`.
4. `GET /storefront/cart` to render the summary (items, add-ons, discounts, totals, `expiresAt`).
5. Optional: `POST /storefront/cart/discount-code`.
6. `POST /storefront/cart/checkout/save-customer`.
7. If `GET /storefront/cart/checkout/questions/{language}` returns questions: `POST /storefront/cart/checkout/save-answers`.
8. `GET /storefront/payment/providers`, then the customer picks one, then `POST /storefront/cart/checkout/start`.
9. Pay (Stripe in the browser) if needed, then `POST /storefront/cart/checkout/submit`.
10. Show the confirmation from the returned `order`. Clear the stored cart id.
### Create an appointment
```
POST /storefront/appointments
```
```json
{
"slot": {
"productId": "b2c1…",
"fromDate": "2026-10-14", "fromTime": "09:00:00",
"toDate": "2026-10-14", "toTime": "09:45:00",
"staffMemberId": "5f0e…",
"locationId": "c3d4…"
},
"cartId": "8e7d…",
"numberOfAttendees": 1,
"addOns": [{ "addOnId": "a1b2…", "quantity": 1 }],
"guests": [{ "email": "friend@example.com" }],
"customAttributes": { "source": "lovable-site" }
}
```
- `slot` is required. Copy the date/time fields exactly from a returned availability slot. `staffMemberId` and `locationId` are optional (nullable).
- Optional: `cartId` (always send it on the booking path), `numberOfAttendees` (default 1, capped by remaining capacity), `addOns[]`, `guests[]` (only if the product has `allowGuests`), `checkoutQuestionAnswers[]`, `address` (service address for a `delivery` location), `customAttributes` (string→string map, max 250 keys; keys ≤255 chars, values ≤5000 chars).
- Response (201): the appointment (`id`, `name` (a human number like `1001`), `status: "pending"`, `from`, `to`, `product`, `staffMember`, `location`, `addOns`, `guests`, …). It stays `pending` until checkout is submitted.
- 4xx here usually means the slot was just taken, is outside the booking window, or capacity was exceeded. Re-fetch availability and ask the customer to choose again.
To remove a booking from the cart: `DELETE /storefront/cart/items/{itemId}` using the cart item's `id` (not the appointment id).
## Cart
`POST /storefront/cart` (header `X-Cart-Id`, empty or an existing id) → `CartResponse`:
| Field | Meaning |
|---|---|
| `id` | Cart id, sent as `X-Cart-Id` |
| `status` | `active` \| `converted` \| `abandoned`. If it's not `active`, start a new cart |
| `expiresAt` | When held slots are released (ISO UTC, a few minutes out) |
| `paymentCurrencyCode`, `taxesIncluded` | Currency and tax mode |
| `subtotal`, `totalTax`, `total` | **Authoritative totals.** Display these; never recompute |
| `appliedDiscounts[]` | `{ id, title, totalAmount, code? }` |
| `appliedDiscountCode` | The promo code currently applied, or null |
| `creditCovered`, `creditRemaining`, `creditValueType` | Customer balance used (`time` = minutes, `fixed_amount` = money) |
| `items[]` | One per appointment (below) |
Each `items[]` entry: `id` (cart item id), `appointmentId`, `appointment` (with `from`, `to`, `product`, `staffMember`, `location`), `quantity`, `originalUnitPrice`, `originalSubtotal`, `totalDiscount`, `discountedSubtotal`, `totalTax`, `discountedTotal`, `discounts[]`, `addOnItems[]`, `addOnSubtotal`, `addOnTotalTax`. Use the line totals (`discountedSubtotal`, `discountedTotal`) for exact per-line amounts. `discountedUnitPrice` and `unitTaxAmount` are approximate.
### Expiry
- Show a countdown from `expiresAt`, and recompute it when the tab regains focus.
- While the customer is actively on checkout, call `POST /storefront/cart/extend` (with `X-Cart-Id`) to push `expiresAt` out.
- If the cart has expired, or a call fails because of it, start a new cart (`X-Cart-Id: ""`). Tell the customer their held time was released and let them pick again. Don't retry the old id.
### Add-ons
- Product add-ons: `GET /storefront/products/{productId}/add-ons`. Each has `price`, `durationMultiplied` and `maxQuantity`.
- `durationMultiplied: true`: charged per base-duration unit of the appointment. Quantity is set automatically; don't send one.
- `durationMultiplied: false`: a fixed add-on. The customer picks a quantity from 1 to `maxQuantity`.
- Attach at creation (`addOns` on the appointment) or later with `POST /storefront/cart/items/{cartItemId}/add-ons` and body `{ "addOnId": "…", "quantity": 2 }`. Change the quantity with `PATCH /storefront/cart/add-ons/{cartAddOnItemId}` and body `{ "quantity": 3 }`. Each returns the updated cart.
## Discounts
Three kinds, all resolved server-side and reflected in the cart. Never calculate discounts in the app.
1. **Automatic**: configured by the merchant and applied when the cart qualifies. No call needed; they appear in `appliedDiscounts` and `items[].discounts`.
2. **Promo codes**: `POST /storefront/cart/discount-code` with body `{ "code": "SUMMER10" }` returns the updated cart. `DELETE /storefront/cart/discount-code` removes it. On failure you get HTTP 400:
```json
{ "message": "This code has expired", "code": "CODE_EXPIRED", "statusCode": 400 }
```
`code` is one of `CODE_NOT_FOUND`, `CODE_EXPIRED`, `CODE_DISABLED`, `CODE_LIMIT_REACHED`, `CODE_CUSTOMER_INELIGIBLE`, `CODE_NO_MATCHING_ITEMS`, `CODE_NO_CREDIT`, `minimum_requirement_not_met`. Show a friendly message next to the code field and keep the customer in checkout.
3. **Balance / credit**: a customer can hold a money or time balance (e.g. "10 free hours"). It's used up automatically for a signed-in customer and shows as `creditCovered` / `creditRemaining` / `creditValueType` on the cart.
Per-line discount rows (`items[].discounts[]`, `addOnItems[].discounts[]`): `{ discountId, title, amountPerUnit, valueType: "percentage" | "fixed_amount" | "time", value, targetType: "base" | "add_on" }`.
If `checkout/start` returns `discountCodeCleared: true`, the code stopped applying (e.g. the cart changed). Tell the customer.
## Checkout
### Customer object: the `kind` field is required
Every `customer` object in `save-customer`, `checkout/start`, `checkout/submit` and `POST /storefront/appointments` **must** include `kind`:
- New customer: `{ "kind": "new", "email": "…", "firstName": "…", "lastName": "…", "phone": "…" }`
- Existing customer: `{ "kind": "existing", "customerId": "…" }`, e.g. a signed-in customer, or one whose `customerId` came back from `save-customer`.
If you leave out `kind`, the API can't tell which shape you sent, and the request is rejected or the customer isn't saved.
### Save the customer
```
POST /storefront/cart/checkout/save-customer (X-Cart-Id)
```
```json
{
"customer": {
"kind": "new",
"email": "jo@example.com",
"firstName": "Jo",
"lastName": "Smith",
"phone": "+447700900000",
"billingAddress": {
"companyName": "Smith Ltd",
"taxRegistrationId": "GB123456789",
"addressLine1": "1 High St", "city": "London", "postalCode": "N1 1AA", "country": "GB"
},
"deliveryAddress": {
"firstName": "Jo", "lastName": "Smith",
"addressLine1": "2 Park Rd", "city": "London", "postalCode": "N2 2BB", "country": "GB"
},
"marketingConsent": { "email": true, "sms": false, "whatsapp": false }
}
}
```
Response: `{ "customerId": "…" }`. Keep it and use `{ "kind": "existing", "customerId }` in the next steps.
- `billingAddress` (optional): `firstName`, `lastName`, `companyName`, `taxRegistrationId` (VAT/GST), `addressLine1`, `addressLine2`, `city`, `state`, `postalCode`, `country`, `email`, `phone`. Company name and VAT id go **here**, not on the customer. Show or require it according to `checkoutSettings`.
- `deliveryAddress` (optional): same fields without `taxRegistrationId`. Needed **only** when the cart has an appointment at a `delivery`-type location. It applies to every delivery appointment in the order and overrides any per-appointment `address`.
- `marketingConsent` (optional): per-channel booleans. Only show the channels whose `…MarketingOptIn` setting isn't `dont_show`. Leave them unticked by default.
- `country` is always a 2-letter uppercase ISO code (`GB`, `US`, `DE`).
- A signed-in customer sends `{ "kind": "existing", "customerId": "<profile id>", "firstName"?, "lastName"?, "phone"?, "billingAddress"?, "deliveryAddress"?, "marketingConsent"? }`.
### Checkout questions
`GET /storefront/cart/checkout/questions/en` (use the site language code) returns an array of `{ id, type, required, order, translations: [{ title, description, options }], existingAnswer?, fileConfig? }`. Use `translations[0].title` as the label (and as `question` when saving). `type` is one of `single-line-text-field`, `multi-line-text-field`, `dropdown`, `rating`, `checkbox`, `file-upload`. Render them in `order`, enforce `required`, and pre-fill from `existingAnswer`.
```
POST /storefront/cart/checkout/save-answers (X-Cart-Id)
```
```json
{
"answers": [
{ "questionId": "q1…", "question": "Any allergies?", "answer": "None" },
{ "questionId": "q2…", "question": "Upload your referral", "answer": "", "fileIds": ["f9…"] }
]
}
```
`questionId`, `question` (the question text as shown) and `answer` (a string, even for ratings and checkboxes) are all required. For `file-upload` questions (limits in `fileConfig`):
1. `POST /storefront/uploads/presign` with `{ "questionId": "…", "questionKind": "checkout", "filename": "referral.pdf", "mime": "application/pdf", "size": 48213 }` → `{ fileId, presignedUrl, expiresIn }`.
2. From the browser, `PUT` the file bytes to `presignedUrl` with a matching `Content-Type`. This is a direct storage upload, not through the connector.
3. Send `fileIds: [fileId]` in the answer.
### Payment providers
`GET /storefront/payment/providers` (with `X-Cart-Id`) → `[{ name, displayName, description?, checkoutMode, icon?, mode? }]`
- `name`: `stripe` | `cash` | `bank_transfer` | `shopify` | `no_payment_required`
- `checkoutMode`: `internal` (card form in the app, i.e. Stripe), `redirect` (send the browser to `redirectUrl`), `manual` (pay later: cash on arrival or bank transfer), `none` (nothing to pay)
- `mode`: `test` | `live` for Stripe. Show a "test mode" badge when it's `test`.
Show only what's returned. If there's exactly one option, preselect it.
### Start checkout
```
POST /storefront/cart/checkout/start (X-Cart-Id)
{ "provider": "stripe", "customer": { "kind": "existing", "customerId": "…" } }
```
Response:
```json
{
"provider": "stripe",
"clientSecret": "pi_…_secret_…",
"publishableKey": "pk_live_…",
"stripeAccountId": "acct_…",
"redirectUrl": null,
"paymentId": "…",
"cart": { "...": "updated cart" },
"invalidItemIds": [],
"revivedItemIds": [],
"discountCodeCleared": false
}
```
**Branch on the `provider` in the response, not the one you requested.** When nothing is due (a £0 total, or below the minimum card charge), the API switches the provider to `no_payment_required`.
- `no_payment_required`, `cash`, `bank_transfer`: call `submit` straight away (no payment step).
- `stripe`: in the browser, load Stripe.js with `publishableKey` and `{ stripeAccount: stripeAccountId }`, mount the Payment Element with `clientSecret`, then call `stripe.confirmPayment({ elements, redirect: 'if_required' })`. On success, call `submit` with the PaymentIntent id. The app needs no Stripe secret key. The merchant's Stripe account is connected in Opencals.
- `redirectUrl` present: send the browser there.
- `invalidItemIds` not empty: those items were no longer bookable and were removed. Tell the customer and show the updated cart.
### Submit
```
POST /storefront/cart/checkout/submit (X-Cart-Id)
{ "stripePaymentIntentId": "pi_…", "appointmentsSettings": { "markAsScheduled": true } }
```
- `stripePaymentIntentId`: only for Stripe.
- `appointmentsSettings.markAsScheduled: true` confirms the appointments (sends the confirmation email). Send it on a normal booking flow. `notifyCustomer` (optional) controls the confirmation email when marking as scheduled.
- Optional `trackingData` (e.g. `{ "gclid": "…" }`) for ad attribution.
Response: `{ order, auth?, customer? }`.
- `order`: `id`, `name` (e.g. `1042`), `total`, `subtotal`, `totalTax`, `paidTotal`, `dueToPay`, `paymentStatus` (`unpaid` | `paid` | `partially-paid`), `lineItems[]`, `paymentCurrencyCode`.
- `auth`: `{ accessToken, refreshToken }` for new or passwordless customers. Store them to sign the customer in automatically so they can see their booking.
- After a successful submit, the cart is `converted`. Discard the stored cart id.
## Customer accounts
Storefront reads and bookings work anonymously. Account features (my appointments, reschedule, cancel, profile, consent) need the customer's access token, sent as `Authorization: Bearer <accessToken>`.
### Sign in options
- **Passwordless (recommended)**:
1. `POST /storefront/auth/request-login-code` with `{ "email" }` → 204, and the customer receives a 6-digit code.
2. `POST /storefront/auth/verify-login-code` with `{ "email", "code" }` → `{ accessToken, refreshToken }`.
- **Password**: `POST /storefront/auth/sign-up` with `{ email, password (min 8), firstName?, lastName? }` → 204 (a verification email is sent). Then `POST /storefront/auth/sign-in` with `{ email, password }` → tokens.
- **Magic links** from Opencals emails: the link carries a `token`. Call `GET /storefront/auth/resolve-link?token=…` → `{ purpose, redirectPath, accessToken?, refreshToken?, requiresAction?, metadata? }`. Store the tokens if present, then route to `redirectPath`.
- **Password reset**: `request-password-reset` with `{ email }`, then `reset-password` with `{ token, newPassword }`.
- **Email verification**: `verify-email` with `{ token }` → tokens. `request-email-verification` with `{ email }` resends it.
### Tokens
- The access token is short-lived. When a call returns 401, call `GET /storefront/auth/refresh` with `Authorization: Bearer <refreshToken>`, store the new pair, and retry once. If refresh also fails, sign the customer out.
- Store tokens for the session and forward the access token to the server function that calls Opencals. Never log tokens.
### Self-service
- `GET /storefront/self-service/profile` → `{ id, email, firstName, lastName, phone, isEmailVerified, isPasswordSet, language, … }`. Use `id` as the `customerId` for `{ "kind": "existing" }` at checkout. Display only name, email and phone.
- `PUT /storefront/self-service/profile` with `{ firstName?, lastName?, phone? }`.
- `PUT /storefront/self-service/password` with `{ currentPassword?, newPassword }`. `currentPassword` is required only if `isPasswordSet`.
- `GET /storefront/self-service/marketing-consent` → `{ emailState, smsState, whatsappState }`, each `not_subscribed` | `subscribed` | `unsubscribed`. It can be `null` if never set.
- `PUT /storefront/self-service/marketing-consent` with `{ email?, sms?, whatsapp? }` (booleans). Only show channels whose opt-in setting is `checkout_and_account`.
### My appointments and orders
- `GET /storefront/appointments?status=scheduled&orderBy=from&order=ASC&take=20` is the customer's own appointments. `status` values: `pending`, `scheduled`, `completed`, `canceled`. Other filters: `date` (`YYYY-MM-DD`), `products`, `staffMembers`, `locations` (arrays).
- `GET /storefront/appointments/{appointmentId}` returns the detail, including product (with the reschedule/cancel rules), staff, location and guests.
- `GET /storefront/orders?take=20` and `GET /storefront/orders/{orderId}` return orders. `GET /storefront/orders/{orderId}/invoices` returns receipts/invoices.
- `GET /storefront/appointments/preferences/booking-history` returns the customer's usual choices, for a "book again" shortcut.
## Reschedule and cancel
Show these actions only when the product allows them (`allowCustomerReschedule` / `allowCustomerCancel`) **and** the appointment starts more than `rescheduleGap` / `cancelGap` seconds from now. Otherwise disable the button and explain the notice period.
### Reschedule
1. Fetch availability **excluding the appointment being moved**, so its current slot doesn't show as busy:
```
GET /storefront/products/{productId}/current-availabilities?date=2026-10-15&timezone=Europe/London&excludeAppointmentId={appointmentId}&staffMemberId=…&locationId=…
```
2. Move it:
```
PUT /storefront/appointments/{appointmentId}/reschedule
{ "slot": { "productId": "…", "fromDate": "2026-10-15", "fromTime": "10:00:00", "toDate": "2026-10-15", "toTime": "10:45:00", "staffMemberId": "…", "locationId": "…" }, "notifyCustomer": true }
```
`address` is optional (for delivery locations only). The response is the updated appointment.
### Cancel
```
PUT /storefront/appointments/{appointmentId}/cancel
{ "notifyCustomer": true }
```
The API applies refund and cancellation policy. Never calculate refunds in the app. Ask the customer to confirm before cancelling.
## Attendees and guests
- **Attendees** is how many people take up capacity: `numberOfAttendees` on create. For group products (`maxAttendees > 1`), show "X spots left" from the slot (`maxAttendees - attendees`) and cap the input at that number.
- **Guests** are named people (by email) who get copies of the appointment emails. They don't use up capacity. Add them at creation (`guests: [{ email }]`) or later with `POST /storefront/appointments/{id}/guests` and body `{ "email": "…", "notify": true }`. Remove one with `DELETE /storefront/appointments/{id}/guests/{guestId}?notify=true`. Only offer guests when `allowGuests` is true, and respect `maxGuests`.
## Custom duration (variable-length bookings)
When a product has `allowCustomDuration: true`, the customer books a whole multiple of the base `duration`, up to `maxDuration` (-1 = unlimited). Example: base 1800 s (30 min) at £15, `maxDuration` 5400, so the options are 30/60/90 min for £15/£30/£45.
1. Offer the lengths `duration × n` for n = 1, 2, 3 … while the total is ≤ `maxDuration`.
2. Fetch slots for the chosen length: `…/current-availabilities?date=…&timezone=…&duration=5400`. Each returned slot already spans the full length.
3. Book that slot as-is. The API prices it (`ceil(booked / base) × price`). Never send a price.
If `fixedTimes` is true, bookings must start at `fixedStartTime` and end at `fixedEndTime` (e.g. check-in/check-out style). Just use the slots the API returns.
Don't model "60 min" and "90 min" as separate products for the same physical resource. They wouldn't block each other and the resource could be double-booked.
## Errors
Error responses are JSON:
```json
{ "message": "Invalid input. Please check your form fields.", "statusCode": 400,
"errors": [{ "property": "email", "messages": ["Email must be an email"] }] }
```
- `errors[]` appears on validation failures. Map `property` to the form field and show `messages`.
- Always show the real `message` and keep the status. Never replace it with a generic "Something went wrong".
| Status / symptom | Likely cause | Fix |
|---|---|---|
| 404 `Cannot GET /storefront/...` | `X-Api-Version` header missing | The connector sends it. Check the connector's auth config |
| 401 `X-Api-Key header is required` / `Invalid or inactive storefront API key` | Key missing, wrong or revoked | Reconnect with a valid `sfk_…` key |
| 401 `Origin not allowed for this API key` | Key has an allowed-origins list; server calls send no Origin | Use a key with no allowed-origins restriction for the connector |
| 401 on account endpoints | Missing or expired customer token | Refresh, then sign in again |
| 400 on save-customer/start/submit | Missing `kind` on `customer`, or a required field | Add `kind`; show `errors[]` |
| 400 with a discount `code` | Promo code rejected | Show the mapped message |
| 4xx on create/reschedule | Slot taken, outside the window, over capacity, or inside the notice gap | Re-fetch availability; explain |
| Cart calls act on a new/empty cart | `X-Cart-Id` not forwarded | Forward the stored id on every cart/checkout call |
| Slots at the wrong hour | UTC shown as local | Convert for display only; send `timezone` |
| Customer can't pick their own current slot when rescheduling | `excludeAppointmentId` not sent | Add it |
| 429 | Rate limit | Back off and retry (below) |
## Notes
- **Rate limits** apply per API key plus client IP: 20 requests/second, 60 per 10 seconds and 120 per minute. Through the connector, all visitors share the gateway's IP, so treat these as **app-wide** limits:
- Cache the catalogue (products, collections, locations, staff, public settings) for a few minutes server-side instead of fetching on every page view.
- Use `current-availability-ranges` for the calendar, then `current-availabilities` only for the chosen day. Cache results by product + date + duration for ~30–60 s.
- Don't poll. Don't prefetch availability for every product on the home page.
- On 429, retry with exponential backoff and jitter (500 ms, 1 s, 2 s, 4 s, each plus up to 30%, max 4 attempts). The `Retry-After` header isn't reliable, so don't depend on it.
- **Versioning** uses the `X-Api-Version` header (the connector sends `1`). Paths don't carry a version.
- **Dates**: `YYYY-MM-DD` dates and `HH:MM:SS` times in UTC for slots; ISO 8601 UTC for timestamps.
- **Languages**: question endpoints take a language code (`en`, `de`, `fr`, `es`, `pt_br`, …). Use the site's language, falling back to `en`.
- **What the API doesn't do**: there's no merchant/admin access, no creating services or staff, and no editing prices. That's all done by the merchant in the Opencals dashboard (app.opencals.com). If a service is missing or has no slots, it's a dashboard configuration issue, not a code issue.
- **Don't store or display** internal fields you don't need (`storeId`, `externalId`, `scheduleId`, `internalNote`, staff emails). Show customers only names, images, descriptions, prices, durations and times.
- Human-readable docs: https://opencals.com/docs
opencals-booking-site · 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.Instructions: booking site playbook for Base44 (~16k chars)
Instructions: booking site playbook for Base44 (~16k chars)
base44-booking-site-playbook.md
# 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: <OPENCALS_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: <cartId>` when `cartId` is given
- `Authorization: Bearer <accessToken>` 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=<seconds>` to confirm the block fits, then book the returned slot.
- Each court is one product. Never model the 60-min and 90-min options as separate products; they wouldn't block each other.
### Variable length
Show the length picker before the date/time. Pass `duration` (seconds) to both availability calls, and show the price for each length as `ceil(length / base) × price`, clearly marked as an estimate. The cart total is authoritative.
## Step 4: cart and checkout
- Persist `cart.id` in `localStorage`. 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.
3
Store the key as an app secret
In the app editor: Dashboard → Secrets → Add Secret. Name
OPENCALS_API_KEY, value your sfk_… key.4
Build the function, then the site
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.
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.