Skip to content
For the first ten businesses: six months of Stippa free, in exchange for your feedback.Find out more
Stippa
FeaturesPricingDemoHelpContact
Log inStart free

Getting started

  • Introduction
  • Authentication
  • Permissions

Core concepts

  • Errors
  • Pagination
  • Idempotency
  • Rate limits

Resources

  • Appointments
  • Customers
  • Services
  • Staff
  • Availability
  • Products
  • Payments
  • Webhooks

Recipes

  • Recipe: book from your own website
  • Recipe: sync the diary to a spreadsheet
  • Recipe: a Slack message on every new booking

Reference

  • Changelog
  • OpenAPI 3.1 document

Recipe: book from your own website

The whole booking flow: services, dates, slots, create, and handling the slot that was taken while the customer was deciding.

Last updated 27 October 2026

The job: a booking form on your own site, in your own design, creating real appointments in a business's diary. This is the case that justifies the Pro tier, and it is four requests.

You need: services:read, availability:read, appointments:write. Writing needs Pro.

💡

If you want a booking form and not a booking integration, stop here. Stippa has an embeddable widget that needs no key, no server and no code, and it already handles everything below. Build this when you need the flow inside your own product.

The widget's language

The embed script takes two language options, as a JavaScript option or as a data attribute on the embed element:

OptionAttributeBehaviour
localedata-stippa-localeA language code such as "en", or "auto", the default. "auto" reads the host page's <html lang>, then the browser's languages. A language the business does not offer is redirected to its primary one
languagePickerdata-stippa-language-pickerfalse hides the in-widget language picker and locks the language, true forces it on. Leave it out and the business's own setting decides
<script>Stippa("inline", { salon: "kapsalon-anna", element: "#stippa-inline", locale: "en", languagePicker: false });</script>

The language a customer books in becomes their language for later messages, exactly as a locale on an API booking does.

The shape of it

1

Fetch the menu once, and cache it

Services change a few times a year.

2

Ask which days are open

One request for a whole month, so the calendar renders without a request per day.

3

Ask for the times on the day they picked

Fresh, every time. Never cached.

4

Create, with an idempotency key, and handle 409

The slot may have gone while they were typing. That is a normal outcome, not an error.

1. The menu

curl "https://stippa.nl/api/v1/services?isActive=true&limit=100" \
  -H "Authorization: Bearer $STIPPA_KEY"

isActive=true matters: without it you get retired services too, and a customer can pick one that cannot be booked. Sort your picker by the category's position, not alphabetically, so the order matches what the business arranged.

2. Which days are open

curl "https://stippa.nl/api/v1/availability/dates?serviceId=a425c960-1fbc-4ed6-8210-88d5cbd5e4c1&from=2026-09-07&to=2026-09-13" \
  -H "Authorization: Bearer $STIPPA_KEY"
{
  "from": "2026-09-07",
  "to": "2026-09-13",
  "serviceId": "a425c960-1fbc-4ed6-8210-88d5cbd5e4c1",
  "staffMemberId": null,
  "timezone": "Europe/Amsterdam",
  "durationMinutes": 30,
  "dates": [
    { "date": "2026-09-07", "slotCount": 26 },
    { "date": "2026-09-08", "slotCount": 26 },
    { "date": "2026-09-09", "slotCount": 26 },
    { "date": "2026-09-10", "slotCount": 26 },
    { "date": "2026-09-11", "slotCount": 26 },
    { "date": "2026-09-12", "slotCount": 31 }
  ]
}

Seven days requested, six returned: the Sunday is closed and is simply absent. Build a set from dates and treat everything else in your window as unavailable. Do not index by day offset.

3. The times on that day

curl "https://stippa.nl/api/v1/availability/slots?serviceId=a425c960-1fbc-4ed6-8210-88d5cbd5e4c1&date=2026-09-07" \
  -H "Authorization: Bearer $STIPPA_KEY"

Twenty-six slots, matching the slotCount above. Render them in the timezone the response gives you rather than in the browser's zone, or a customer booking from abroad sees times the salon does not recognise.

4. Create it

const idempotencyKey = crypto.randomUUID();  // Once, before the first attempt. Not in the loop.

const response = await fetch('https://stippa.nl/api/v1/appointments', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.STIPPA_KEY}`,
    'Idempotency-Key': idempotencyKey,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    serviceId: 'a425c960-1fbc-4ed6-8210-88d5cbd5e4c1',
    startTime: '2026-09-07T11:15:00.000Z',
    customer: {
      firstName: 'Recipe',
      lastName: 'One',
      email: 'renee@voorbeeld.nl',
      phone: '0612345678',
    },
  }),
});
{
  "appointment": {
    "id": "a194f6ca-7122-40de-824f-c61b7cdf2ff2",
    "status": "confirmed",
    "startTime": "2026-09-07T11:15:00.000Z",
    "endTime": "2026-09-07T11:45:00.000Z",
    "bookingReference": "260907-0001",
    "customer": { "id": "f568da3f-193d-4800-93ba-c21021277faa", "firstName": "Recipe" },
    "service": { "id": "a425c960-1fbc-4ed6-8210-88d5cbd5e4c1", "name": "Watergolf" },
    "staffMember": { "id": "08b56e75-b5f8-41bf-bca2-81b73ccea92c", "name": "Youssef El Amrani" },
    "basePriceCents": 3000,
    "totalPriceCents": 3000
  },
  "requiresPayment": false
}

Note what you did not send: no endTime, derived from the service. No staffMemberId, so the business's own rules picked somebody. No customer id, because customer deduplicates on the email. And you got back a bookingReference to show the customer, which is friendlier than a uuid.

Handling the slot that went

This is the part a booking flow lives or dies on. Between step 3 and step 4 somebody else can book the same time, and there is nothing you can do to prevent it: there is no hold, and no endpoint that creates one. The create is what makes it yours.

{
  "error": "The requested time slot is already taken",
  "code": "SLOT_TAKEN"
}

That is a 409, and it is a normal outcome rather than a failure of your integration. Fetch step 3 again, show the fresh list, and let the customer pick another time. Do not retry the same start time, and do not retry with a new idempotency key hoping for a different answer.

if (response.status === 409) {
  const { code } = await response.json();
  // Three codes mean the same thing to your flow: fetch the slots again. `SLOT_HELD` is a time
  // held for somebody else, such as a waitlist offer. `NO_STAFF_AVAILABLE` comes only when we were
  // choosing the staff member and found nobody free, so there is nothing to drop.
  if (code === 'SLOT_TAKEN' || code === 'SLOT_HELD' || code === 'NO_STAFF_AVAILABLE') {
    return { retry: 'pick-another-slot', slots: await fetchSlots(serviceId, date) };
  }
}

Retrying safely

Three things happen when you send the same key again, and this is what the header buys you:

What you sendWhat you get
Same key, same body, first time201 and the appointment
Same key, same body, again201, the same appointment, plus Idempotency-Replayed: true
Same key, different body409 IDEMPOTENCY_KEY_REUSED. A bug in your code, reported rather than hidden

The middle row is the one that saves you: a connection that drops after we created the appointment and before you read the response is indistinguishable, from your side, from a request that never arrived. Retry with the same key and you get the original result rather than a second booking.

Mint the key once, before the first attempt, and hold it for the whole retry sequence. A key generated inside the retry loop gives every attempt a fresh key and protects nothing.

If the business takes prepayment

Then requiresPayment is true, the appointment is in pending_payment, and the 201 carries a payment object. Send the customer to checkoutUrl on a Mollie business, or use clientSecret with Stripe Elements on a Stripe one; exactly one is present.

The appointment becomes confirmed when the money arrives, which you hear about from the payment.succeeded and appointment.confirmed webhooks rather than by polling.

Booking on the business's behalf

Everything above is a customer booking themselves, so the business's own rules apply: notice periods, how far ahead it accepts bookings, whether it takes online bookings from new customers at all. Each refusal is a 422 with its own code, listed on the errors page.

If you are building the business's own tool, where a receptionist takes a booking by phone, send enforceBookingRules: false and none of those apply. A receptionist is not subject to the rules the public widget is.

On this page

  • The widget's language
  • The shape of it
  • 1. The menu
  • 2. Which days are open
  • 3. The times on that day
  • 4. Create it
  • Handling the slot that went
  • Retrying safely
  • If the business takes prepayment
  • Booking on the business's behalf
Stippa

Appointment scheduling, without the hassle.

Try free for 1 month

Product

  • Features
  • Pricing
  • Demo
  • FAQ
  • Contact
  • System status

Features

  • Booking widget
  • Online payments
  • Reminders
  • No-show prevention
  • Online calendar
  • Your own website
  • Client management

For whom

  • For salons
  • Beauty salons
  • Physiotherapists
  • Coaches
  • Personal trainers
  • Barbershops
  • Nail salons
  • Pedicurists
  • Massage salons

Legal

  • Privacy policy
  • Terms of service
  • Data processing agreement

© 2026 Stippa. All rights reserved.

De Rechter Software · Molenwater 20, 4511 BN Breskens · KvK 98466402· VAT NL005332100B80 · +31 6 15745447

······
Try free for 1 month

1 month free. No card needed.

Cookies

We use cookies to measure how visitors use this website. With your consent we also record your visit, so we can see where something goes wrong.

You can change your choice at any time via 'Cookie preferences' at the bottom of every page.

· Learn more