# 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
