> ## Documentation Index
> Fetch the complete documentation index at: https://opencals.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 — an API for booking systems that handles services, real-time availability, carts, checkout, and payments. 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, so the same 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
});
```

<Warning>
  The API key must stay server-side, so the calls below belong in server components, route handlers, or server actions — not client components.
</Warning>

<Steps>
  <Step title="List services">
    `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               |
  </Step>

  <Step title="Let the customer pick a staff member">
    For salons and similar businesses, customers often 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.
  </Step>

  <Step title="Fetch available time slots">
    `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.

    <Note>
      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.
    </Note>
  </Step>

  <Step title="Create the appointment">
    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,
      },
    });
    ```

    <Warning>
      All slot date/times are **UTC** — convert from the customer's timezone before sending.
    </Warning>
  </Step>

  <Step title="Cart">
    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.
  </Step>

  <Step title="Show and attach add-ons (optional)">
    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 },
    });
    ```

    A few things to know about add-on pricing:

    | 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.                                                                             |

    <Note>
      Skip this step if `addOns.data` is empty — not every service has add-ons configured.
    </Note>
  </Step>

  <Step title="Apply a promo code (optional)">
    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;
    ```
  </Step>

  <Step title="Checkout">
    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 for new/passwordless customers (auto sign-in)
    ```

    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).

    <Note>
      For pay-at-venue stores, start checkout with `provider: "cash"` and submit directly with no payment confirmation step.
    </Note>
  </Step>
</Steps>

## 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).
