The job: get the diary out of Stippa and into something else, and keep it current. A dashboard, a spreadsheet, a warehouse, an accountant's export.
You need: appointments:read, and customers:read or services:read if you want to denormalise.
Every one of those is entitled by Groei, so this recipe needs no write access and no upgrade. It is
also the cheapest thing to build against this API and a good first integration.
The two phases
A sync is a backfill once and a sweep forever. They use the same endpoint with different parameters, and confusing them is the usual bug.
| Phase | Parameters | Runs |
|---|---|---|
| Backfill | limit=100 and nothing else | Once |
| Sweep | updatedSince=<your last successful run> | On a schedule |
The backfill
One generator, and it is the whole of the pagination contract:
async function* everyAppointment(key, params = {}) {
let cursor = null;
do {
const query = new URLSearchParams({ ...params, limit: '100' });
if (cursor) query.set('cursor', cursor);
const response = await fetch(`https://stippa.nl/api/v1/appointments?${query}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const page = await response.json();
yield* page.data;
cursor = page.nextCursor;
} while (cursor);
}
Three details, each of which is a bug if you get it wrong:
params is outside the loop. Filters must be repeated on every request in the walk, including the
ones carrying a cursor. A filter you drop after page one silently widens the rest of the walk.
Loop on nextCursor, not on hasMore. They agree, but the cursor is the value you need anyway,
and a loop that reads one field cannot get two out of step.
limit=100 rather than the default 50. Halves your request count for the same data.
On the business this was written against, that generator walked 8,910 appointments across 90 requests in 6.5 seconds, with zero duplicate ids. The zero is the interesting number: appointments collide on start time constantly, because two staff members are busy at 09:00 by design and a recurring series is written in a single statement. The cursor carries the row's id alongside its sort value and orders on the pair, so there is exactly one row after any given row. A pager built on the timestamp alone would have served some rows twice and skipped others.
Do not build this on a page number. There is no ?page=2 and there will not be. An appointment list
changes while you are reading it, and with an offset a booking inserted before your position shifts
everything down by one: page 2 skips a record and page 3 shows one twice. You get a corrupt export and
no error. See pagination.
The sweep
Store the timestamp of your last successful run. Pass it as updatedSince and you get only what
changed:
const since = lastRun.toISOString();
for await (const appointment of everyAppointment(key, { updatedSince: since })) {
await upsert(appointment); // Keyed on appointment.id
}
await recordSuccessfulRun(startedAt); // The time the run STARTED, not when it finished.
Record the time the run started, not the time it finished. A row modified while you were paging would otherwise fall in the gap between the two, and you would never see it again.
updatedSince is the right filter here and from/to are not, and the difference is the point of
this recipe. Measured on the same business: updatedSince over the last hour returned 1 row, and
from/to bounding start time across the whole of September 2026 returned 1 row. They are
different questions.
updatedSincecatches a booking made in March and cancelled this morning. Its start time is in the past and it changed five minutes ago, so a sync must pick it up.fromandtobound the start time, which is what you want for "show me next week", and which would never have found that cancellation.
Use updatedSince to stay current. Use from and to to answer a question about a period.
Upsert, and expect the shape to change
Key your destination on appointment.id and upsert. A sweep returns rows you already have whenever
something about them changed, which is the whole point, and an insert-only sync produces duplicates on
the second run.
Tolerate fields you do not recognise. A new optional field is an additive change and ships without
notice, so a strict schema in your warehouse loader will break on a change we are not obliged to
announce. The same goes for enum values: handle a status you have not seen by passing it through, not
by throwing.
Cancellations are not deletions. A cancelled appointment stays in the list with
status: "cancelled", so your destination needs a status column rather than a delete path. Nothing
disappears from this API.
What to denormalise, and what not to
Every appointment already embeds its customer, service and staff member, so the common report needs no
joins and no extra requests. Take the embedded copies rather than resolving ids against your own
tables: a service the business has since deleted is not in
GET /api/v1/services any more, and the appointment still carries its name, duration and price.
Resolve against the catalogue endpoints only for things the appointment does not carry, like a service's category. Fetch those once per run and cache them; they change a few times a year.
Scheduling it
Every fifteen minutes is generous for a salon, and it costs one request per run in the ordinary case
where nothing changed. Watch X-RateLimit-Remaining on the responses you are already reading.
If you find yourself sweeping every thirty seconds to feel current, you want
webhooks instead: the event arrives in seconds and costs no quota at all.
That needs Pro. Polling with updatedSince on a generous interval is the correct answer on Groei, and
polling aggressively is the fastest way to exhaust an hourly quota.