A product is a physical item sold alongside an appointment: shampoo at the till after a haircut. It is the only resource here that a business may not have at all.
Read-only. Products are added to an appointment through the appointment's own products field, not
by writing to this resource.
| Method | Endpoint | Scope | Plan |
|---|---|---|---|
| GET | /api/v1/productsList products Query: | products:read | Groei |
| GET | /api/v1/product-categoriesList product categories Query: | products:read | Groei |
The module gate
Products come from the product add-ons module, which is a feature a business switches on rather
than something every tenant has. If it is off, both endpoints answer 403 FORBIDDEN for a key whose
scopes are otherwise perfectly correct.
403 FORBIDDEN on this resource is not a scope problem or a plan problem, so do not send anybody to
edit a key or upgrade. It means the business has not enabled the module. Check
GET /api/v1/me, whose activeModules array tells you before you make the request.
That is a different failure from the other two 403s, and the code field is what distinguishes them:
INSUFFICIENT_SCOPE is fixed by editing the key, PLAN_UPGRADE_REQUIRED by changing plan, and
FORBIDDEN by the owner switching a module on. See errors.
If your integration is generic rather than built for one salon, treat products as optional and
degrade rather than failing: read activeModules once at startup and skip the whole feature when the
module is absent.
What you get back
{
"id": "c3ad47aa-7ddc-4cc4-8097-cf8b3f4e9d45",
"name": "Shampoo",
"description": "Milde dagelijkse shampoo",
"priceCents": 1200,
"categoryId": "6182b9e0-6686-420c-8411-b88e791bef52",
"isActive": true,
"position": 0,
"createdAt": "2026-07-29T17:57:46.241Z"
}
Shorter than a service in one telling way: there is no durationMinutes and no updatedAt. A
product occupies no time, which is the whole difference between the two resources, and it carries no
modification timestamp, so there is no updatedSince on this endpoint and no way to sync
incrementally. Re-read the list; it is small.
position is the order the business arranged the catalogue in and is what you sort by. priceCents
is an integer in cents like every amount on this API.
Categories work exactly like service categories
GET /api/v1/product-categories returns the tree in one page, with the same parentId and
position shape as service categories. It is a separate tree from the
service one: "Verzorging" as a service category and "Verzorging" as a product category are two
different rows with two different ids, and a product's categoryId never resolves against the
service tree.
They are separate endpoints under separate scopes for that reason. Resolving a product's category
needs products:read, not services:read.
Selling one with an appointment
Products reach an appointment through the create:
{
"serviceId": "b50661da-7649-4c14-b748-91bbee8e9643",
"startTime": "2026-08-20T09:00:00.000Z",
"customer": { "email": "renee@voorbeeld.nl", "firstName": "Renee", "lastName": "de Vries" },
"products": [{ "productId": "c3ad47aa-7ddc-4cc4-8097-cf8b3f4e9d45", "quantity": 1 }]
}
The prices are snapshotted at booking time and roll into the appointment's totalPriceCents, which
is why that field can exceed the service's own priceCents. A product repriced afterwards does not
change what an existing appointment charges.
Sending products on a business without the module is refused rather than ignored, so the same
activeModules check applies to the create path and not only to reading the catalogue.