Errors
The two error shapes the public API returns, and a table of every code you can branch on — site resolution, keys, bot checks, viewings, validation and rate limits.
There are two error shapes, and only one of them has a code.
Domain errors carry a code
{
"success": false,
"statusCode": 409,
"code": "VIEWING_SLOT_TAKEN",
"message": "That time has just been booked — please pick another.",
"details": { "propertyId": "…", "date": "2026-09-20", "slot": "11:00" },
"path": "/api/public/viewing-requests",
"method": "POST",
"timestamp": "2026-09-14T09:30:27.019Z"
}code is at the top level, not nested. details is optional and differs per
code. Branch on code; never on message, which is written for humans and is
translated over time.
Validation errors carry none
A body or query string the API refuses is a 400 with an array of strings in
message and no code at all:
{
"success": false,
"statusCode": 400,
"message": ["property type should not exist"],
"error": "Bad Request",
"path": "/api/public/properties",
"method": "GET"
}One helper in your HTTP layer covers both:
function apiError(body: any) {
if (body?.code) return { code: body.code as string, message: body.message };
const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message;
return { code: 'VALIDATION_FAILED', message };
}Without that check, half your forms render [object Object].
Site resolution
| Code | Status | When |
|---|---|---|
SITE_NOT_FOUND | 404 | The site is a draft, or the organization runs no site at all. Deliberately the same answer for both. |
SITE_PAUSED | 503 | The site is paused in the CRM. Retry later; nothing on your side is wrong. |
SITE_ORIGIN_MISMATCH | 403 | X-Site names a site that does not belong to the domain (or organization) the request came from. |
PUBLIC_TENANT_REQUIRED | 400 | Nothing identified a site: no verified domain, no X-Site, no X-Subdomain. |
See Identify your site.
API keys
| Code | Status | When |
|---|---|---|
SITE_KEY_REQUIRED | 401 | A write arrived with no X-Api-Key, or with a key the CRM does not know. |
SITE_KEY_REVOKED | 401 | The key was revoked or rotated away. |
SITE_KEY_ORIGIN_MISMATCH | 403 | A publishable key used from an origin that is not one of the site's domains, or a key belonging to a different site. |
While the rollout is in monitor mode these are logged, not returned. See Keys and CORS.
Bot check
| Code | Status | When |
|---|---|---|
BOT_CHECK_REQUIRED | 400 | A protected write with a publishable key and no X-Turnstile-Token. |
BOT_CHECK_FAILED | 403 | Cloudflare rejected the token — expired, reused or forged. |
See Bot protection.
Viewing requests
| Code | Status | What the UI should do |
|---|---|---|
VIEWING_SLOT_INVALID | 400 | The time is not one of the site's configured slots. Re-fetch the slots. |
VIEWING_DAY_CLOSED | 400 | The site takes no viewings that weekday. Re-fetch the slots. |
VIEWING_SLOT_TOO_SOON | 400 | Inside the site's minimum notice period. Offer a later time. |
VIEWING_SLOT_TAKEN | 409 | Somebody booked it between the page load and the submit. Re-fetch the slots and clear the chosen time. |
All four mean the same thing to a visitor — "that time is gone, pick another" —
so map them to one message, invalidate your cached
GET /public/properties/{id}/viewing-slots, and clear the selected time. The
slots endpoint already marks taken times available: false; the 409 only closes
the race between two visitors.
Visitor accounts
VISITOR_TOKEN_REQUIRED, VISITOR_TOKEN_INVALID, VISITOR_EMAIL_TAKEN,
VISITOR_CREDENTIALS_INVALID, VISITOR_EMAIL_NOT_VERIFIED, OTP_INVALID,
OTP_EXPIRED, OTP_LOCKED, RESET_TOKEN_INVALID and friends are listed with
their handling on Visitor accounts.
Not found, and caps
404s from the catalogue are uniform: PROPERTY_NOT_FOUND,
PROJECT_NOT_FOUND, LOCATION_NOT_FOUND, VIEWING_REQUEST_NOT_FOUND,
VISITOR_SAVED_SEARCH_NOT_FOUND, OWNER_SUBMISSION_NOT_FOUND, each with
details: { id }. FAVORITES_CAP (409) is the eleventh favourite of a type.
Rate limits
A 429 carries no code — treat the status itself as the signal. Stop, back
off, and tell the visitor to try again shortly; never retry in a tight loop, and
never retry a write automatically at all.
See Rate limits for the ceilings.
Errors you will only meet in the CRM
These come from the staff API, not from /api/public/*. They are here because
they are what you see while setting a site up.
| Code | Status | Surface |
|---|---|---|
DOMAIN_INVALID | 400 | Adding a domain that is not a hostname (localhost, a bare word, a URL with a path). |
DOMAIN_TAKEN | 409 | The host is already attached to a site — possibly another organization's. The message never says whose. |
DOMAIN_NOT_FOUND | 404 | Acting on a domain that is not on this site. |
DOMAIN_NOT_VERIFIED | 400 | Making a domain primary when it was left pending by the retired TXT-record flow. Remove it and add it again. |
RICH_TEXT_TOO_LONG | 400 | Saving an About or legal text over its limit (5 000 plain characters for About, 50 000 for legal). details carries { field, locale, max, actual }. |
SITE_TYPE_NOT_ALLOWED | 400 | Creating or retyping a site whose type needs a module the organization does not have enabled. |
SITE_LIMIT | 400 | Creating a second site without the multi-site feature. |