Skip to main content
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, but every step maps 1:1 to plain REST calls, so the same flow works from any language. The complete booking flow is seven 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. 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:
npm install @opencals/storefront-sdk
lib/opencals.ts
import { setupOpencals } from "@opencals/storefront-sdk";

setupOpencals({
  apiKey: process.env.OPENCALS_API_KEY, // sfk_… from Settings → API Keys
});
The API key must stay server-side, so the calls below belong in server components, route handlers, or server actions — not client components.
1

List services

ProductService.list() wraps GET /storefront/products and returns a paginated list of active services:
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:
FieldWhat it means for your booking page
durationService length in seconds
pricePrice (0 = free); minPrice/maxPrice for variable pricing
maxAttendeesGreater than 1 → show an attendee count selector
isCustomDurationtrue → show a date-range picker instead of single slots
staffMembersIf populated, let the customer pick a staff member
locationsIf populated, let the customer pick a location
2

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

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:
import { ProductService } from "@opencals/storefront-sdk";

const { data: slots } = await ProductService.getCurrentAvailabilities({
  path: { productId: product.id },
  query: {
    date: "2026-06-15",
    staffMemberId: selectedStaff?.id,
    timezone: "America/New_York",
  },
});
Render the returned slots as your time picker.
For custom-duration products (isCustomDuration: true, e.g. rentals or venue hire), use GET /storefront/products/{productId}/current-availability-ranges and show a date-range picker instead. There’s also GET /storefront/products/{productId}/nearest-availability for “next available slot” badges on service cards.
4

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:
import { AppointmentService } from "@opencals/storefront-sdk";

const { data: appointment } = await AppointmentService.create({
  body: {
    slot: {
      productId: product.id,
      fromDate: "2026-06-15",
      fromTime: "14:00:00",
      toDate: "2026-06-15",
      toTime: "15:00:00",
      staffMemberId: selectedStaff?.id ?? null,
      locationId: null,
    },
    numberOfAttendees: 1,
  },
});
All slot date/times are UTC — convert from the customer’s timezone before sending.
5

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

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.
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:
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:
FieldWhat it means
priceUnit price. Multiply by quantity for the line total.
durationMultipliedIf true, quantity is auto-set to the service duration units (e.g., 3 days = quantity 3). Don’t ask the customer for quantity.
maxQuantityIf non-null, cap your quantity input at this value.
Skip this step if addOns.data is empty — not every service has add-ons configured.
7

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):
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):
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:
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.
For pay-at-venue stores, start checkout with provider: "cash" and submit directly with no payment confirmation step.

After the booking

With a customer token you can power a full self-service portal:
  • GET /storefront/appointments — list the customer’s appointments
  • PUT /storefront/appointments/{appointmentId}/reschedule — move a booking
  • PUT /storefront/appointments/{appointmentId}/cancel — cancel it
  • GET /storefront/orders — order history with payments and refunds

Skip the boilerplate

If you’d rather start from working code, Opencals publishes free open-source Next.js booking templates built on this exact flow — browse them at opencals.com/templates or clone the Haar salon template. For the full endpoint catalog, head to the API reference.
Last modified on June 13, 2026