An appointment is one scheduled slot: a customer, a service, a start time, and usually a staff member. It is the resource everything else on this API exists to support, and the only one you can both read and write.
| Method | Endpoint | Scope | Plan |
|---|---|---|---|
| GET | /api/v1/appointmentsList appointments Query: | appointments:read | Groei |
| POST | /api/v1/appointmentsCreate an appointment Requires an | appointments:write | Pro |
| GET | /api/v1/appointments/{id}Retrieve an appointment | appointments:read | Groei |
| PATCH | /api/v1/appointments/{id}Update an appointment | appointments:write | Pro |
| POST | /api/v1/appointments/{id}/statusChange an appointment status | appointments:write | Pro |
What you get back
{
"id": "98bb2277-c304-4b27-a63f-aa18fce6173a",
"status": "confirmed",
"startTime": "2026-08-11T11:31:00.000Z",
"endTime": "2026-08-11T11:46:00.000Z",
"notes": null,
"bookingReference": null,
"createdAt": "2026-07-29T17:57:46.643Z",
"updatedAt": "2026-07-29T17:57:46.643Z",
"customer": {
"id": "75af1e75-0222-4560-a39f-02424c207f2b",
"firstName": "Christiaan",
"lastName": "Veenstra",
"email": "nola16-693@example.nl",
"phone": "+3160000694"
},
"service": {
"id": "b50661da-7649-4c14-b748-91bbee8e9643",
"name": "Baard trimmen",
"durationMinutes": 15,
"priceCents": 1200
},
"staffMember": { "id": "db8914de-a84e-49f9-8625-f44b4abcf4d6", "name": "Sanne Jansen" },
"basePriceCents": null,
"totalPriceCents": null,
"depositCents": null,
"currency": "EUR",
"recurringSeriesId": null,
"isRecurringOccurrence": false,
"segments": [
{
"id": "0a57c90f-d295-45b1-8d43-f4d61568e69e",
"position": 0,
"kind": "active",
"startTime": "2026-08-11T11:31:00.000Z",
"endTime": "2026-08-11T11:46:00.000Z",
"staffMemberId": "db8914de-a84e-49f9-8625-f44b4abcf4d6",
"label": null
}
]
}
The customer, service and staff member are always embedded, so listing a day's diary is one request rather than one plus three lookups per row. They are deliberately smaller than the standalone resources: enough to render and to contact somebody, not the whole record.
Every field is documented with its type in the OpenAPI document. This page covers what the types do not say.
Filtering the list
| Parameter | Filters on |
|---|---|
status | Exact match on one status |
from, to | Start time, not creation time. Both inclusive, both ISO 8601 |
customerId, staffMemberId, serviceId | Exact match. Combine freely; they intersect |
updatedSince | Last modification. The one to sync on |
from and to filtering on start time is the choice worth knowing, because the other reading is
plausible and wrong for the job. Asking "what is on for next week" is a question about when the
appointments happen. Asking "what changed since my last sync" is a question about modification
time, and that is updatedSince, which catches a booking made months ago and cancelled this
morning. from and to never would.
Sorting is fixed: startTime descending, then id descending. There is no sort parameter in v1.
Creating one
POST /api/v1/appointments goes through the same service the public booking widget uses. The
overlap pre-check, the customer deduplication, the price snapshot, the confirmation email and the
appointment.created event all happen exactly as they do for a widget booking. There is no
back door that skips them.
curl -X POST https://stippa.nl/api/v1/appointments \
-H "Authorization: Bearer $STIPPA_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"serviceId": "b50661da-7649-4c14-b748-91bbee8e9643",
"startTime": "2026-08-20T09:00:00.000Z",
"customer": {
"firstName": "Renee",
"lastName": "de Vries",
"email": "renee@voorbeeld.nl",
"phone": "0612345678"
}
}'
Three things about the body:
There is no endTime. The footprint is derived from the service, every time, including when a
PATCH changes the start time or the service. A client that computed an end time would be
computing it from a durationMinutes that can change between your read and your write.
customer is an object, not an id. Pass the person's details and the API deduplicates by email
within the business: an existing customer is reused, a new one is created. That is the same rule
the widget follows, and it is why a booking flow does not need customers:write at all. If you
already hold a customer id, you still pass the email; the id is not accepted here.
staffMemberId is optional. Omit it and the business's own assignment rules pick somebody.
Pass staffLocked: true alongside it when the customer specifically asked for that person, so a
later reschedule does not quietly reassign them.
Idempotency-Key is required on this endpoint. A create without one is a 400. See
idempotency for why, and for how to pick a key that actually
protects you.
When the business takes prepayment
The 201 body is not bare an appointment. It is:
{
"appointment": { "...": "as above, with status pending_payment" },
"requiresPayment": true,
"payment": {
"amountCents": 1200,
"currency": "EUR",
"provider": "mollie",
"amountKind": "full",
"checkoutUrl": "https://..."
}
}
requiresPayment is false and payment is absent for most businesses. When it is true, the
appointment exists but is not confirmed, and the customer has to settle it: send them to
checkoutUrl on a Mollie business, or use clientSecret with Stripe Elements on a Stripe one.
Exactly one of the two is present. Nothing else about your integration changes.
The rules a create can fail
The business configures rules the widget enforces, and this endpoint enforces the same ones. Each
refusal is a 422 carrying the rule's own code, listed on the errors
page.
If you are creating on the business's behalf rather than a customer's, for example from their
own back-office tool or a phone booking, send enforceBookingRules: false and none of them apply.
A receptionist taking a booking by phone is not subject to the rules the public widget is. Leave it
at its default in every other case.
A slot that is already taken is a 409, from a pre-check before the insert and from a database
constraint behind it. Both exist deliberately: the check gives you a clean error, and the
constraint means a race cannot double-book anybody even if the check passes twice at once.
Changing one
PATCH /api/v1/appointments/{id} takes serviceId, staffMemberId, startTime and notes, and
takes no Idempotency-Key, because a PATCH is naturally idempotent.
status is not a field on the PATCH. It has its own endpoint, and the split is a real
distinction rather than tidiness: POST /api/v1/appointments/{id}/status sends notifications and
fires webhooks, and a field edit does not. Renaming a note should not email the customer.
curl -X POST https://stippa.nl/api/v1/appointments/98bb2277-c304-4b27-a63f-aa18fce6173a/status \
-H "Authorization: Bearer $STIPPA_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "completed" }'
Four destinations are offered: confirmed, cancelled, completed and no_show. The others are
lifecycle states the product moves rows into rather than places you send one. A transition the
appointment cannot make is a 422 INVALID_STATUS_TRANSITION. Reinstating a cancelled or no-show
appointment re-checks the slot, so it can also be a 409 if somebody else has taken the time in
the meantime.
A confirmation really does send mail. Transitioning an appointment on a live business notifies that customer, so do your first experiments on an appointment you created yourself.
Two shapes that surprise people
The segments array is the real block layout
Most appointments have one segment covering the whole booking, and you can ignore the array. Some have several, and then the appointment is not a solid block of time.
A colour treatment is the standard case: apply, wait thirty minutes, then cut. The waiting time is
a gap segment, the staff member is free during it, and the business books somebody else into it.
startTime and endTime on the appointment still span the whole thing, so a calendar drawn from
those two fields is correct about when the customer arrives and leaves, and wrong about when the
chair is occupied. If you are mirroring the diary rather than reading it, draw the segments.
Recurring series
recurringSeriesId names the series a row belongs to and isRecurringOccurrence says whether it
is one. Both are read-only here: this API creates one-off appointments, and a series is configured
in the product.
Only materialized occurrences appear in the list. A weekly series that has been expanded eight
weeks ahead returns eight rows; the ninth week exists as a rule rather than as an appointment and
is not in any response. So a full read is not a complete forecast of the diary, and there is no
endpoint that makes it one. Fetching a series template id directly is a 404, because a template
is a rule and not an appointment.