Skip to content
StippaStippa
FeaturesPricingDemoFAQContact
Log inStart 14 days free

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

Errors

The error envelope, every status code this API returns, and what to do about each.

Last updated 1 August 2026

Every error, on every endpoint, has the same shape:

{
  "error": "Invalid request parameters",
  "code": "VALIDATION_ERROR",
  "fields": [
    {
      "path": "statuss",
      "message": "Not a supported parameter for this endpoint",
      "reason": "unknown_field"
    }
  ]
}

error is a string, not an object. code sits beside it at the top level rather than inside it. That is worth reading twice, because the other arrangement is the more common convention and a client written against it will find undefined where it expected a code.

code is a stable identifier and is what your code branches on. error is a sentence written for a human reading a log: it may be reworded at any time, and it never echoes what you sent.

Three members appear only on the codes that carry them:

MemberAppears on
fieldsVALIDATION_ERROR. One entry per rejected parameter or body field, keyed path and not field
scopeINSUFFICIENT_SCOPE and PLAN_UPGRADE_REQUIRED. The scope that was needed
requiredPlanPLAN_UPGRADE_REQUIRED. The cheapest plan slug that entitles it
ℹ️

Do not parse this shape strictly. A code we add later may bring a member with it, and that is an additive change that ships without notice. Read the members you know and ignore the rest.

Statuses

StatusWhenRetry?
400The request is malformed or a value is invalidNo. Fix the request
401Missing, unknown, revoked or expired keyNo. See authentication
403The key lacks the scope, the plan does not entitle it, or the business lacks the moduleNo
404No such record for this businessNo
405Wrong method for this pathNo
409The current state conflicts with the requestOnly after resolving the conflict
422The request was fine and a business rule refused itOnly if the rule stops applying
429Rate limitedYes, after Retry-After
500Our faultYes, with backoff, if the request is safe to repeat
503The module is temporarily unavailableYes, with backoff

Codes

codeStatusWhat it means and what to do
VALIDATION_ERROR400Read fields. It names every rejected parameter or body field
IDEMPOTENCY_KEY_REQUIRED400A creating POST needs an Idempotency-Key header. See idempotency
UNAUTHORIZED401The credential is missing, malformed or unknown
KEY_REVOKED401Somebody revoked this key. Stop and alert a human
KEY_EXPIRED401The key passed its expiry date
INSUFFICIENT_SCOPE403The key was not granted this scope. The owner can edit the key
PLAN_UPGRADE_REQUIRED403The plan does not entitle this scope. The owner must upgrade
FORBIDDEN403The business does not have the module this endpoint belongs to
PAYMENTS_UNAVAILABLE403The business has no payment provider connected
NOT_FOUND404No such record for this business
METHOD_NOT_ALLOWED405Including OPTIONS, which this API answers with 405 by design
SLOT_TAKEN409The time asked for is occupied by another booking
NO_STAFF_AVAILABLE409Nobody is free for the time asked for. Try another time, or drop staffMemberId and let us assign
CONFLICT409A value that must be unique is not. A taken slot has its own code above
IDEMPOTENCY_IN_PROGRESS409The same key is being processed right now. Wait and poll
IDEMPOTENCY_KEY_REUSED409Reused with a different body. A client bug, reported rather than hidden
RATE_LIMITED429Over quota. Wait for Retry-After seconds. See rate limits
INVALID_STATUS_TRANSITION422The appointment cannot go from its current status to that one
STAFF_NOT_BOOKABLE422That staff member is not available for online booking
PAYMENTS_UNAVAILABLE422Prepayment is required and the provider is not reachable
PAYMENT_ERROR500Prepayment is required and creating the checkout failed
MODULE_UNAVAILABLE503Temporarily switched off platform-wide. Back off and retry
INTERNAL_ERROR500Ours. Retry an idempotent request with backoff

The business's own booking rules

A business configures rules the widget enforces, and POST /api/v1/appointments enforces the same ones - as does PATCH /api/v1/appointments/{id} whenever the edit moves the appointment. Each refusal is a 422 carrying the rule's own code, so you can tell them apart:

codeMeans
SLOT_NOT_OFFEREDThe business is not open for online bookings then: nobody is rostered, the day is closed, or it falls inside a break
BOOKING_TOO_SOONInside the notice period this business requires
BOOKING_TOO_FAR_AHEADFurther ahead than this business accepts bookings
BOOKING_REQUIRES_EXISTING_CUSTOMERThis business only takes online bookings from customers on file
BOOKING_PERIOD_RESTRICTEDNot accepting online bookings for that period
BOOKING_SLOT_NOT_CONNECTEDThat start time would leave an unbookable gap in the day

SLOT_NOT_OFFERED is the one to expect most, and it is not the same as SLOT_TAKEN: that one means somebody else has the time and another will do, this one means the business is shut. Ask GET /api/v1/availability/slots which start times it offers rather than guessing one.

If you are creating or moving an appointment on the business's behalf rather than on a customer's, for example from their own back-office tool, send enforceBookingRules: false in the body and none of these apply. That flag exists because a receptionist taking a booking by phone is not subject to the rules the public widget is.

The rule set is extensible, so a seventh code can appear. When it does, the message field carries a readable sentence naming it.

ℹ️

This list can grow. A new code is an additive change and ships without notice, so branch on the codes you handle and fall through to a generic branch for anything else. Do not write a switch that throws on an unknown code.

Two behaviours worth knowing about

A cross-tenant id is a 404, never a 403

Passing an id that belongs to another business returns 404 NOT_FOUND, exactly as a nonexistent id would. It is deliberate: a 403 would confirm that the record exists somewhere, which is a membership oracle over other businesses' data. You cannot tell the two cases apart, and neither can anyone probing.

Each entry has a path, a reason and a message

path is where the problem is, dotted into the request: customer.email, products[0].productId, or (body) / (query) / (path) when the request as a whole was rejected.

reason is what is wrong, as a stable identifier. Branch on this, not on message — the same rule as code at the top level. It is one of:

reasonMeans
requiredAbsent, null, or empty where a value is needed
invalid_email / invalid_url / invalid_dateNot a valid address, URL, or date/time
invalid_formatSome other shape: a uuid, a pattern, a prefix
too_short / too_longOutside the length allowed. limit carries the bound, in characters
too_small / too_largeA number outside its range. limit carries the bound
invalid_optionNot one of the values this field accepts
unknown_fieldA parameter or body key this endpoint does not accept
invalidAnything else, including a rule that spans more than one field

message is English written for a person reading a log. It may be reworded at any time and it never echoes what you sent, so treat it as a hint during development rather than as something to show a user or match on.

Query parsing is strict. A parameter this endpoint does not accept is a 400 naming it, rather than being ignored:

{
  "error": "Invalid request parameters",
  "code": "VALIDATION_ERROR",
  "fields": [
    {
      "path": "statuss",
      "message": "Not a supported parameter for this endpoint",
      "reason": "unknown_field"
    }
  ]
}

A typo in a filter that is silently dropped gives you a full unfiltered list and no signal, and that is a worse failure than a 400 during development. Request bodies behave the same way.

An out-of-range value carries the bound it crossed, in limit as well as in the message:

{
  "error": "Invalid request parameters",
  "code": "VALIDATION_ERROR",
  "fields": [
    { "path": "limit", "message": "Must be at most 100", "reason": "too_large", "limit": 100 }
  ]
}

Retrying safely

GET requests are safe to retry freely. Use exponential backoff and honour Retry-After on a 429.

Creating POST requests are safe to retry because they require an Idempotency-Key. Retry with the same key and you either get the original result or a clear conflict, never a duplicate appointment. Retrying with a new key on a request that may have succeeded is how you double-book somebody.

PATCH and the status transition are naturally idempotent: setting a status to cancelled twice is one cancellation.

When you need to see what happened

The business owner has a Logboek screen in the product listing every request made with their keys: method, path, status, error code, duration and timestamp. If your integration is failing and the error is not telling you enough, that log is the shared view of reality between you and them. Webhook deliveries have an equivalent log with the full payload and every retry attempt.

On this page

  • Statuses
  • Codes
  • The business's own booking rules
  • Two behaviours worth knowing about
  • A cross-tenant id is a 404, never a 403
  • Each entry has a path, a reason and a message
  • Retrying safely
  • When you need to see what happened
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

······