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 shape of it
Fetch the menu once, and cache it
Services change a few times a year.
Ask which days are open
One request for a whole month, so the calendar renders without a request per day.
Ask for the times on the day they picked
Fresh, every time. Never cached.
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, no reservation, and no endpoint that creates one. The create is what makes it yours.
{
"error": "The requested time slot is already taken",
"code": "CONFLICT"
}
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();
if (code === 'CONFLICT') {
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 send | What you get |
|---|---|
| Same key, same body, first time | 201 and the appointment |
| Same key, same body, again | 201, the same appointment, plus Idempotency-Replayed: true |
| Same key, different body | 409 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.