Skip to content
StippaStippa
FeaturesPricingDemoFAQContact
Log inGet started

Getting started

  • Introduction
  • Authentication
  • Permissions

Core concepts

  • Errors
  • Pagination
  • Idempotency
  • Rate limits

Resources

  • Appointments
  • Customers
  • Services
  • Staff
  • Availability
  • Products
  • Payments
  • Webhooks

Recipes

  • Recipe: book from your own website
  • Recipe: sync the diary into a spreadsheet or BI tool
  • Recipe: a Slack message on every new booking

Reference

  • Changelog
  • OpenAPI 3.1 document

Recipe: sync the diary into a spreadsheet or BI tool

Walk every appointment once, then keep up with updatedSince. Read-only, so it works on Groei.

Last updated 1 August 2026

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.

PhaseParametersRuns
Backfilllimit=100 and nothing elseOnce
SweepupdatedSince=<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.

  • updatedSince catches 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.
  • from and to bound 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.

On this page

  • The two phases
  • The backfill
  • The sweep
  • Upsert, and expect the shape to change
  • What to denormalise, and what not to
  • Scheduling it
StippaStippa

Appointment scheduling, without the hassle.

For whom

  • For salons
  • Beauty salons
  • Physiotherapists
  • Coaches
  • Personal trainers

Features

  • Booking widget
  • Online payments
  • Reminders
  • No-show prevention
  • Online calendar
  • Client management

Product

  • Features
  • Pricing
  • FAQ
  • Contact
  • System status

Legal

  • Privacy policy
  • Terms of service
  • Data processing agreement

© 2026 Stippa. All rights reserved.

De Rechter Software · Molenwater 20, 4511 BN Breskens · KvK 98466402 · btw NL005332100B80

······