Platform
Errors
Every status this API returns, what causes it, and what your site should do about it.
Errors carry a machine-readable code alongside the message. Branch on the code, log the message, and show your visitor neither.
{
"success": false,
"error": {
"message": "Missing required permission: read_content",
"code": "PERMISSION_DENIED"
}
}By status
| Status | Means | Do |
|---|---|---|
400 | The request is malformed, or a rule of the venue refuses it — a closed day, a full slot, a party too large. | Branch on the code. Which field failed is in the message or in details; see below. |
401 | No credentials, wrong credentials, or expired ones. | Fix the key. Never retry on a loop. |
403 | Valid credentials without the scope, or a dashboard-only endpoint called with a key. The checkout and a booking also answer it while the merchant has switched online ordering or booking requests off (ORDERING_DISABLED, BOOKINGS_DISABLED); most endpoints do not check whether a module is on. | Hide the feature. This is configuration, not failure. |
404 | No such record, or it exists but is not published. | Render an empty state, not an error page. |
409 | The record does not allow this in the state it is in — an order with a payment behind it, which nobody may delete. | Do not retry. The message says what to do instead. |
429 | Over the rate limit for this key or IP. | Back off, then retry once. |
500 | Something broke on our side. | Retry once, then degrade. |
502 | An upstream provider is unavailable. | Fall back. Never block on it. |
503 | A subsystem is temporarily unavailable, usually payments. | Say "briefly unavailable" rather than showing a raw error. |
A rule that refuses a request answers 400 with its own code, not 422. The one 422 is a back-office call: a reply to a contact submission that has no email address, NO_REPLY_ADDRESS.
Codes you will actually meet
| Code | Status | Cause |
|---|---|---|
API_KEY_MISSING | 401 | No X-API-Key header and no api_key parameter. |
INVALID_API_KEY | 401 | No active key matches the one sent. |
API_KEY_EXPIRED | 401 | The key is past its expiry date. |
UNAUTHORIZED | 401 | A session endpoint was called without a signed-in user. |
PERMISSION_DENIED | 403 | The key lacks the scope this call needs. The message names it: Missing required permission: read_content. |
INSUFFICIENT_PERMISSIONS | 403 | A signed-in user whose role lacks the right. Not something a key meets. |
DASHBOARD_ONLY | 403 | The endpoint is dashboard work and refuses keys, whatever scope they carry. |
ORGANIZATION_ACCESS_DENIED | 403 | The caller does not belong to the organization requested. |
VALIDATION_ERROR | 400 | A field is missing or malformed. The ordering endpoints name the path in the message; the others answer Validation failed and list the fields in details, except a content item edit, which names the field in the message. See Reading a validation error. |
INVALID_BODY | 400 | The body is not JSON, so no field was looked at: Invalid JSON body. |
INVALID_ID | 400 | A value in the path or a filter that its column cannot read — nearly always an id that is not a uuid: Invalid identifier or value. |
INVALID_DATE | 400 | A date that does not exist, or cannot be read: 2026-02-30, 2026-13-45. Invalid date or time, or on availability, the bookings hours and the reservations occupancy A date in YYYY-MM-DD format is required. |
NOT_FOUND | 404 | No such record for this organization, from an endpoint that has no code of its own for it: Not found, or on a content item Content item not found. |
FULFILLMENT_TYPE_UNAVAILABLE | 400 | Pickup, delivery or dine-in was requested while switched off. |
ADDRESS_REQUIRED | 400 | A delivery order arrived without an address. |
RATE_LIMIT_EXCEEDED | 429 | Over the key's configured limit. |
RATE_LIMITED | 429 | Too many checkout attempts from one IP. |
PROVIDER_UNAVAILABLE | 502 | The address lookup provider did not answer. |
QUOTA_EXCEEDED | 429 | The address lookup quota for this organization is spent. |
An id that is not a uuid is not always INVALID_ID. Where an endpoint has its own answer for a missing record, a malformed id gets that answer instead: it names no record either.
Content
| Code | Status | Cause |
|---|---|---|
NOT_FOUND | 404 | No content item with that id in this organization — or, for a key, one that is not published: Content item not found. On GET, and on PUT and DELETE from the dashboard; deleting an item that is not there is not a success. |
INVALID_ID | 400 | The id in the path is not a uuid, on every verb of the item. |
VALIDATION_ERROR | 400 | An edit writes a value its field cannot hold. The message names the field by its label — Field 'Price' must be a number, Required field 'Menu' is missing or empty — and there are no details. |
SLUG_EXISTS | 409 | An edit gives the item a slug another item of the same type already has. Creating never refuses a slug; it appends a counter. |
Only reading is open to a key. Editing and deleting are dashboard work: a key meets 403 PERMISSION_DENIED there before anything is looked up.
Reservations and availability
| Code | Status | Cause |
|---|---|---|
RESERVATION_TYPE_NOT_FOUND | 404 | The reservation_type_slug or id names no type of this organization — on availability, on the create, and as a filter on the list. |
RESERVATION_NOT_FOUND | 404 | No reservation with that id in this organization. Also for an id that is not a uuid, and on the comments of a reservation that is not there. |
CUSTOM_FIELD_INVALID | 400 | A field of the reservation type is missing or breaks its rule: Validation errors: Party Size is required, and per field a details entry whose path is ["custom_data", "<field_key>"]. |
START_TIME_IN_PAST | 400 | A reservation for a time that is now or earlier — or, from a key, a booking whose date and time are. Fetch availability again and let the guest pick. |
START_NOT_ON_FULL_MINUTE | 400 | The start_datetime carries seconds. |
DATE_CLOSED | 400 | The venue does not take reservations — or, on Bookings, bookings — on that day. |
OUTSIDE_OPENING_HOURS | 400 | The day is open, the time is not. The message names that day's hours: Start time must be between 09:00 and 22:00 (2026-10-03). The ordering refusal of the same name is further down. |
SLOT_MISALIGNED | 400 | The time is not on the slot grid, which steps from the day's opening time. |
OPENING_HOURS_MISCONFIGURED | 400 | The venue's hours for that day, or its slot interval, cannot be booked at all. Nothing in the request; the merchant has to correct them. |
BOOKING_TOO_SOON | 400 | Closer to now than the type's min_advance_hours. Also for a key that moves a reservation with PATCH. |
BOOKING_TOO_FAR_AHEAD | 400 | Further ahead than the type's max_advance_days, in calendar days in the venue's zone. Also on PATCH for a key. |
PARTY_SIZE_TOO_LARGE | 400 | More people than max_guests, on the create and on availability alike. Offer the phone number instead of the form. |
INVALID_PARTY_SIZE | 400 | Availability was sent a party_size that is not a whole number above zero. Leaving it out is allowed. |
SLOT_FULL | 400 | The slot has no room left for this party. Fetch availability again. |
Bookings
| Code | Status | Cause |
|---|---|---|
BOOKINGS_DISABLED | 403 | The venue has switched booking requests off. accepting on the hours endpoint says so in advance. |
BOOKING_NOT_FOUND | 404 | No booking with that id in this organization. Also for an id that is not a uuid. |
INVALID_DAYS | 400 | The hours or the free times were sent a days that is not a whole number of at least one. Above the ceiling — 31 for the hours, 62 for the free times — is capped, not refused. |
HOURS_UNAVAILABLE | 503 | The venue's hours could not be read. Show the form and let the booking decide. |
INVALID_DURATION | 400 | The free times were asked without a duration, or with one that is not a whole number of minutes from 1 to 720. A booking sent with a duration_minutes outside those bounds is a VALIDATION_ERROR instead, like every other field of the create. |
INVALID_STEP | 400 | The free times were sent a step that is not a whole number of minutes from 5 to 240. |
INVALID_BUFFER | 400 | The free times were sent a buffer that is not a whole number of minutes from 0 to 240. Refused rather than capped: a shorter buffer than asked for is two appointments too close together. |
INVALID_LEAD | 400 | The free times were sent a lead that is not a whole number of minutes from 0 to 10080. |
AVAILABILITY_UNAVAILABLE | 503 | The free times could not be worked out. Never an empty calendar instead: offer no times and show the phone number until it answers again. |
Dashboard work: reminders and reservation comments
None of these is a call for a website: they answer a signed-in member of staff, and a key never meets them. They carry a code of their own so the dashboard, or a log, can tell them apart instead of reading them all as INTERNAL_ERROR.
| Code | Status | Cause |
|---|---|---|
INVALID_REMINDER_TYPE | 400 | The reminder run was asked for a type other than today, tomorrow or hourly: Invalid reminder type. |
REMINDERS_FAILED | 500 | The run finished with errors and nothing was reported as processed. The message lists them: Failed to process tomorrow reminders: .... |
COMMENT_NOT_FOUND | 404 | No comment with that id in this organization: Comment not found. |
COMMENT_DELETED | 400 | An edit to a comment that was already removed: Deleted comments cannot be edited. |
COMMENT_AUTHOR_ONLY | 403 | An edit by someone other than the comment's author: Only the comment author can edit this comment. |
COMMENT_REMOVE_FORBIDDEN | 403 | A removal by someone who is neither the author nor allowed to moderate: You do not have permission to remove this comment. |
A booking also meets DATE_CLOSED, OUTSIDE_OPENING_HOURS, OPENING_HOURS_MISCONFIGURED and START_TIME_IN_PAST from the table above — the same checks, with the booking's own sentences.
Spam screening
On bookings, reservations and the contact form alike, and only once the venue has switched screening on.
| Code | Status | Cause |
|---|---|---|
SUBMISSION_REJECTED | 400 | The address, its domain or the phone number is on the venue's block list. Deliberately without a reason. |
DAILY_LIMIT_REACHED | 400 | This address — or on a booking or reservation this number — has sent the venue's daily maximum in the last 24 hours. Tell the visitor to call. |
Contact form and email
| Code | Status | Cause |
|---|---|---|
ORGANIZATION_MISMATCH | 403 | The body names an organization that is not the credential's own: an organization_id on a contact form submission, or a booking of another organization sent to /api/bookings/cancel-email. Leave it out; the credential decides. |
CONTACT_SUBMISSION_INVALID | 400 | The submission passed validation and the table refused a value anyway. Nothing was stored. |
CONTACT_SUBMISSION_FAILED | 500 | Storing the submission failed on our side. Nothing was stored. |
NO_REPLY_ADDRESS | 422 | A reply to a submission that has no email address. Nothing was sent or stored. |
REPLY_SEND_FAILED | 502 | The mail server refused a reply. It is stored with status failed; the submission stays as it was. |
TEMPLATE_NOT_FOUND | 404 | A notification setting named a template that is not one of this organization's own, on /api/email-notifications or /api/organization-notifications. Another organization's template and one that does not exist get the same answer. |
Orders
| Code | Status | Cause |
|---|---|---|
CUSTOM_PAYMENT_METHOD_NOT_FOUND | 404 | A pay-in-person checkout named a custom_payment_method_id that is not an active method of this organization. |
NOTHING_TO_UPDATE | 400 | A PUT to /api/orders/{id} carrying none of the fields it changes: status, fulfillment_status, prep_minutes, delivery_minutes or payment_info. Anything else in the body is ignored. |
VALIDATION_ERROR | 400 | That same PUT with a field of the wrong shape — a status other than pending, completed or cancelled, for one. A POST to /api/orders from a key with a status other than those three, too: refunded and disputed are Stripe's. Validation failed, with the field in details. |
UNKNOWN_FULFILLMENT_STATUS | 400 | That same PUT with a fulfillment_status that is not one of this organization's own order statuses: Unknown fulfillment status "<key>". Nothing is written. |
PAYMENT_NOT_MANUAL | 400 | Marking an order paid whose payment is not manual — online, refunded, or none. Online payments are confirmed by Stripe. |
UNKNOWN_CATALOG_ITEM | 400 | A line of a POST to /api/orders names a content_item_id that is not an item of this organization — another organization's, or nobody's: Unknown catalog item: <id>. Nothing is created. The checkout refuses the same line with the same code. |
ORDER_NOT_FOUND | 404 | A GET, PUT or DELETE to /api/orders/{id} whose id names no order of this organization, or is not a uuid: Order not found. Nothing is changed or deleted. A 500 from the same path is a read that failed on our side. |
ORDER_HAS_PAYMENT | 409 | That DELETE on an order money was recorded on: completed, refunded or disputed, paid through Stripe, paid out, or paid in person. For a key and for the dashboard alike. Nothing is deleted. Cancel it with PUT instead; a refund is the dashboard's. |
A refusal is not an error
One family does not appear above, because it does not arrive as a failure. Ask the quote what a basket costs and a basket that cannot be ordered yet answers 200 with orderable: false and a refusal — a code and a message. Nothing broke, nothing was rejected: the answer to "what does this cost" is "this cannot be bought right now, and here is why".
| Code | Cause |
|---|---|
ORDERING_DISABLED | Online ordering is switched off for this organization. |
VENUE_CLOSED | No time was chosen — "as soon as possible" — and the venue is shut right now. opening.reopens_at says when it is next open. |
OUTSIDE_OPENING_HOURS | A fulfillment.requested_at was sent and the venue is closed at that moment. Ordering ahead is fine; the time has to be one the venue is open. |
BELOW_MINIMUM_ORDER | Under the zone minimum or the pickup minimum. On delivery, delivery.zone.shortfall is the gap. |
DELIVERY_OUT_OF_AREA | No zone accepted the address. delivery.reason separates outside-every-zone from inside-an-exclusion. |
DELIVERY_ADDRESS_INCOMPLETE | Checkout only. A zone drawn on the map needs the street and house number to decide, and one of them is empty. The quote says delivery.state: needs_address for the same address. |
FULFILLMENT_TYPE_UNAVAILABLE | That fulfillment type is switched off. |
INVALID_DISCOUNT_CODE | A code was entered and does not apply. The only refusal that still carries the amounts: the basket is priced as though no code had been entered. |
UNKNOWN_CATALOG_ITEM / UNPRICED_CATALOG_ITEM / CATALOG_UNAVAILABLE | A line points at something gone, unpriced, or unreadable. Your cached menu is behind. |
MODIFIER_* | Ten codes for the option rules on a line: a required group skipped, too many choices, a sold-out option. |
Reading a validation error
A VALIDATION_ERROR comes in two shapes, depending on the endpoint.
The ordering endpoints — the quote, the checkout and the promotion preview — name the failed path in the message, and the path is literal. Read it as a typo report before reading it as a bug report.
fulfillment.address: unknown field(s) housenumber
^ house_numberThis is the single most common integration failure, and it is nearly always a form field mapped across by hand. When you copy an address out of the suggestion endpoint, copy it wholesale. The keys already match.
Everywhere else — bookings, reservations, the contact form, an order update — the message is always the same two words, and the fields are in details: one entry per problem, and every entry has exactly three keys, path, message and code.
{
"success": false,
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"details": [
{ "path": ["name"], "message": "Name is required", "code": "too_small" },
{ "path": ["date"], "message": "Required", "code": "invalid_type" }
]
}
}Read details[].path to find the field, and put your own sentence next to it. The message in each entry is written for your logs, like every message here. The code is the validation library's name for the kind of problem — invalid_type for a missing or mistyped value, too_small and too_big for a length, invalid_string for an email that is not one, invalid_enum_value for a value outside a fixed set, custom for a rule of ours — and is there for your logs as well.
Through @cnter/client the same body arrives on the thrown CnterError as status, code, apiMessage and details. details is kept from client 1.0.4. Up to 1.0.3 it was dropped, and the hosted cnter.js serves 1.0.3 until its pointer moves to 1.0.4: until then a page that loads it sees code and apiMessage but not the fields. Install @cnter/client 1.0.4 or newer on the server for field errors, and treat a missing details as "say something general", not as "no field was wrong".
A field that may take more than one shape is reported by what went wrong inside it, not as a bare Invalid input: a booking without a date gets the single Required above. A value of a type none of the shapes takes gets one entry per shape, each saying what it expected — a number sent as a booking's date is Expected string, received number and Expected date, received number, both on ["date"].
One exception: editing a content item. There the message is a sentence naming the field by the label the merchant gave it — Field 'Price' must be a number — and there are no details.
What to do in your site
- 01
Content failures degrade
Return an empty array from your fetch helper and render the page without that section. Log the status. Do not surface it.
- 02
Payment failures stop early
If payment methods come back empty or the checkout answers 503, hide the checkout button. Failing at submit, after a customer typed an address, is the worst place to fail.
- 03
Convenience failures are invisible
Address autocomplete returning 502 or an empty list means the plain input fields carry on working. Never let it block a checkout.
- 04
Configuration failures are silent
A 403 is configuration: a scope the key lacks, a dashboard-only endpoint, or on the checkout and a booking a switch the merchant turned off. Render nothing. Which modules the customer runs is in the manifest, not in a 403.