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" }
]
}
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:
| Member | Appears on |
|---|---|
fields | VALIDATION_ERROR. One entry per rejected parameter or body field, keyed path and not field |
scope | INSUFFICIENT_SCOPE and PLAN_UPGRADE_REQUIRED. The scope that was needed |
requiredPlan | PLAN_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
| Status | When | Retry? |
|---|---|---|
400 | The request is malformed or a value is invalid | No. Fix the request |
401 | Missing, unknown, revoked or expired key | No. See authentication |
403 | The key lacks the scope, the plan does not entitle it, or the business lacks the module | No |
404 | No such record for this business | No |
405 | Wrong method for this path | No |
409 | The current state conflicts with the request | Only after resolving the conflict |
422 | The request was fine and a business rule refused it | Only if the rule stops applying |
429 | Rate limited | Yes, after Retry-After |
500 | Our fault | Yes, with backoff, if the request is safe to repeat |
503 | The module is temporarily unavailable | Yes, with backoff |
Codes
code | Status | What it means and what to do |
|---|---|---|
VALIDATION_ERROR | 400 | Read fields. It names every rejected parameter or body field |
IDEMPOTENCY_KEY_REQUIRED | 400 | A creating POST needs an Idempotency-Key header. See idempotency |
UNAUTHORIZED | 401 | The credential is missing, malformed or unknown |
KEY_REVOKED | 401 | Somebody revoked this key. Stop and alert a human |
KEY_EXPIRED | 401 | The key passed its expiry date |
INSUFFICIENT_SCOPE | 403 | The key was not granted this scope. The owner can edit the key |
PLAN_UPGRADE_REQUIRED | 403 | The plan does not entitle this scope. The owner must upgrade |
FORBIDDEN | 403 | The business does not have the module this endpoint belongs to |
PAYMENTS_UNAVAILABLE | 403 | The business has no payment provider connected |
NOT_FOUND | 404 | No such record for this business |
METHOD_NOT_ALLOWED | 405 | Including OPTIONS, which this API answers with 405 by design |
CONFLICT | 409 | The slot is taken, or a value that must be unique is not |
IDEMPOTENCY_IN_PROGRESS | 409 | The same key is being processed right now. Wait and poll |
IDEMPOTENCY_KEY_REUSED | 409 | Reused with a different body. A client bug, reported rather than hidden |
RATE_LIMITED | 429 | Over quota. Wait for Retry-After seconds. See rate limits |
INVALID_STATUS_TRANSITION | 422 | The appointment cannot go from its current status to that one |
STAFF_NOT_BOOKABLE | 422 | That staff member is not available for online booking |
PAYMENTS_UNAVAILABLE | 422 | Prepayment is required and the provider is not reachable |
PAYMENT_ERROR | 500 | Prepayment is required and creating the checkout failed |
MODULE_UNAVAILABLE | 503 | Temporarily switched off platform-wide. Back off and retry |
INTERNAL_ERROR | 500 | Ours. 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. Each refusal is a 422 carrying the rule's own code, so you can tell them apart:
code | Means |
|---|---|
BOOKING_TOO_SOON | Inside the notice period this business requires |
BOOKING_TOO_FAR_AHEAD | Further ahead than this business accepts bookings |
BOOKING_REQUIRES_EXISTING_CUSTOMER | This business only takes online bookings from customers on file |
BOOKING_PERIOD_RESTRICTED | Not accepting online bookings for that period |
BOOKING_SLOT_NOT_CONNECTED | That start time would leave an unbookable gap in the day |
If you are creating 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 sixth 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.
fields names parameters you did not send
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" }
]
}
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 reads the same way, with the bound in the message:
{
"error": "Invalid request parameters",
"code": "VALIDATION_ERROR",
"fields": [{ "path": "limit", "message": "Must be at most 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.