A customer is a person on the books of one business. Email is the natural key: there is at most one customer per email address per business, and everything about this resource follows from that.
| Method | Endpoint | Scope | Plan |
|---|---|---|---|
| GET | /api/v1/customersList customers Query: | customers:read | Groei |
| POST | /api/v1/customersCreate a customer Requires an | customers:write | Pro |
| GET | /api/v1/customers/{id}Retrieve a customer | customers:read | Groei |
| PATCH | /api/v1/customers/{id}Update a customer | customers:write | Pro |
What you get back
{
"id": "1fbe8438-e37a-497b-85f3-38d7e168a369",
"firstName": "Renee",
"lastName": "de Vries",
"email": "renee@voorbeeld.nl",
"phone": "0612345678",
"marketingOptIn": false,
"totalAppointments": 12,
"totalNoShows": 0,
"totalCancellations": 1,
"lastBookingAt": "2026-07-14T08:30:00.000Z",
"anonymizedAt": null,
"createdAt": "2026-07-29T20:32:31.337Z",
"updatedAt": "2026-07-29T20:32:31.337Z"
}
You usually do not need customers:write
The most common reason to reach for this resource is to create somebody before booking them, and
that is not how it works here. POST /api/v1/appointments takes a customer object and
deduplicates on the email itself: an existing person is reused, a new one is created. A booking flow
needs appointments:write and no customer scope at all.
Reach for customers:write when the customer record is the thing you are managing rather than a
side effect of a booking. Importing a list from a previous system, or syncing a CRM where somebody
edits a phone number in your tool and it should land here too.
Creating: read the status code, not just the body
POST /api/v1/customers deduplicates on email too, and the status code is the only thing that
tells you which happened:
| Status | Means |
|---|---|
201 | A new customer was inserted |
200 | That email was already on file. The body is the existing record |
A duplicate is not an error, because for the ordinary case it is not one: a CRM sync that pushes a
hundred people every night is telling you about ninety-nine you already know. A 409 there would
make the normal path look like a failure and push every caller into a lookup-then-create dance with
a race in the middle.
On a 200, the fields you sent were discarded. The response is the stored record, not a merge
of it with your request, so a name or phone number that differs from what is on file is silently
dropped and the body you get back will not match the body you sent.
This is the one thing that will quietly cost you an import. Branch on the status code: treat 200
as "already exists, decide whether to PATCH it", not as "saved". A loop that posts a CSV and
checks only for a 2xx will report a hundred successes and change nothing.
Idempotency-Key is still required. The dedupe protects you against a repeated address; the key
protects you against a repeated request, and only the key can tell a retry apart from a genuinely
second submission.
Finding somebody
| Parameter | Behaviour |
|---|---|
email | Exact match, case-insensitive. Returns zero or one |
search | Partial match across name and email, accent-insensitive |
updatedSince | Last modification. The one to sync on |
email and search are different tools rather than a strict and a loose version of one. email is
the identity lookup: you hold an address and you want the record, and one result or none is the
answer. search is what you put behind a text box, and it is accent-folded in both directions, so
Renee finds Renée and the reverse. Do not use search to resolve an identity: jan@ matches
every address starting that way.
An email with no customer is a normal 200 with data: [], never a 404. The list endpoint answers
"how many match", and zero is a valid answer.
Fields this API will not write
POST and PATCH take exactly four fields: firstName, lastName, email, phone. Everything
else on the response is refused, and each refusal is a decision.
The counters (totalAppointments, totalNoShows, totalCancellations, lastBookingAt) are
derived from the appointments table. A writable no-show count is a number that disagrees with the
appointments it is supposed to summarise, and the business makes decisions about people from it.
marketingOptIn and the notification preferences are consent. Consent has to be traceable to
the moment a person gave it, and an API key cannot testify to that. The customer sets these through
their own preference link, or the business records them from a conversation.
Custom fields are not on this API in any form. They are business-defined and typed at runtime, so they cannot be a stable v1 contract.
Deletion has no endpoint and no scope. Deleting a customer erases appointment history and is legally consequential, and it is not something a leaked key should be able to do to a business's whole book. It stays behind a logged-in human. See permissions.
Anonymised customers
anonymizedAt is a timestamp when the person has exercised their right to erasure. The row survives
because the appointments referencing it have to, and it stays in list responses for the same reason:
omitting it would make a mirrored customer list disagree with a mirrored appointment list.
The identifying fields are overwritten with placeholders rather than nulled, which matters because a null is something your code probably already handles and a plausible-looking wrong name is not:
{
"firstName": "Geanonimiseerde",
"lastName": "klant",
"email": "anonymized+89775ffe-f1c1-45a6-84cd-aae76a766e31@noreply.stippa.app",
"phone": "",
"anonymizedAt": "2026-07-30T09:12:44.000Z"
}
Branch on anonymizedAt, not on the shape of the values. Those two Dutch words are the only Dutch
in any response on this API and they are not a contract; the timestamp is. Treat a non-null
anonymizedAt as "do not contact, do not display": that address is deliverable nowhere, and a
PATCH writing real details back onto one is undoing an erasure request somebody made.