Reference
Errors and rate limits
Every REST error has the same small body. Branch on the HTTP status first, then on code, and quote the requestId when you ask us for help.
The error body#
{
"error": {
"code": "NOTFOUND",
"message": "Entry not found",
"requestId": "0f6c1d2e-3b4a-4c5d-8e9f-a0b1c2d3e4f5"
}
}| Field | |
|---|---|
code | A machine-readable code (below). |
message | A human-readable explanation, usually in English; some booking messages are in Bulgarian, as visitors see them. For request-body validation it is a list of messages. |
details | Present when there is more to say: per-field problems or the list of validation messages. |
requestId | Identifies the request in Citron’s logs. |
GraphQL reports query errors in its own errors array; see GraphQL errors.
Request ids#
Every error carries requestId, and so does meta.requestId on successful contact and booking answers. Send your own X-Request-Id header and the API uses it, which lets you match your logs with ours. Include it whenever you contact support.
# Send your own id and it comes back as requestId (and in the API's logs)
curl https://api.console.citronos.com/v1/content/nope \
-H "Authorization: Bearer $CITRON_API_KEY" \
-H "X-Request-Id: my-site-7f3a"
# {"error":{"code":"NOTFOUND","message":"Collection \"nope\" not found","requestId":"my-site-7f3a"}}Status codes#
| Status | Meaning | Retry? |
|---|---|---|
200, 201 | Success. Writes answer 201. | |
303 | A plain HTML form post was accepted; follow the redirect. | |
304 | Not modified: your If-None-Match ETag is current. | |
400 | Something in the request is wrong: a parameter, a filter, the body, a captcha, a booked slot. | No, fix the request. |
401 | The API key is missing, unknown or revoked. | No. |
403 | The key may not do this (status=draft with a ck_pub_ key), or the origin may not post this form. | No. |
404 | No such collection, entry, form or workspace, or GraphQL is off for the workspace. | No. |
413 | The request body is too large. | No. |
429 | A rate limit was hit. | Yes, later. |
500 | Our fault. The message is generic; the requestId finds the details. | Yes, with backoff. |
503 | Temporarily unavailable, e.g. form submissions paused. | Yes, later. |
Error codes#
Most errors use a generic code derived from the status. A few endpoints add a specific one. Match codes exactly as written: the generic ones have no underscore.
code | Status | Where |
|---|---|---|
BADREQUEST | 400 | Any bad request: an unknown filter field or operator, an unknown locale, a bad richtext value, more than 10 filters, a refused captcha, a body that fails validation, a slot that was just taken. |
UNAUTHORIZED | 401 | Content and GraphQL: no key, or an invalid or revoked one. |
FORBIDDEN | 403 | Drafts requested with a ck_pub_ key. |
NOTFOUND | 404 | Unknown collection or entry, unknown workspace slug. |
VALIDATION_FAILED | 400 | Forms: fields fail the form’s rules; details is [{ field, message }]. |
ORIGIN_NOT_ALLOWED | 403 | Forms: the Origin is not in the form’s allowed websites. |
FORM_UNAVAILABLE | 404 | Forms: the form is not live, or the Forms module is off. |
RATE_LIMITED | 429 | Forms: over the form’s per-IP or per-form hourly limit. |
FORMS_PAUSED | 503 | Forms: submissions are paused platform-wide for a moment. |
ENTITY_TOO_LARGE | 413 | The body is over the size limit. |
INTERNAL_SERVER_ERROR | 500 | An unexpected error. Retry later and quote the requestId to support. |
TOO_MANY_REQUESTS | 429 | A per-key or per-IP rate limit was hit. Wait and retry. (Deployments before 24 September 2026 sent INTERNAL_SERVER_ERROR with status 429, so branching on the status works everywhere.) |
Validation errors#
Forms explain each field:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Please check the highlighted fields",
"details": [{ "field": "email", "message": "Enter a valid email" }],
"requestId": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f"
}
}The contact and booking endpoints validate their JSON body strictly and list every problem, including fields they do not accept:
{
"error": {
"code": "BADREQUEST",
"message": ["mode must be one of the following values: online, in_office"],
"details": ["mode must be one of the following values: online, in_office"],
"requestId": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b"
}
}Rate limits#
| What | Limit | Counted per |
|---|---|---|
| Content API and GraphQL | 300 requests a minute by default | API key, in fixed one-minute windows. REST and GraphQL share it. |
| Contact submit, booking, form submit | 10 a minute and 60 an hour by default | Visitor IP address, per endpoint. |
| Form submissions | 30 per IP an hour and 500 per form an hour by default | Set per form in the console. |
- Over a limit, the answer is
429. The per-IP limits addRetry-After-short(the minute window) orRetry-After-long(the hour window) with the seconds to wait. The per-key limit resets at the start of the next minute. - Your own site’s server usually fetches content from one IP; the content API is limited per key, not per IP, so page rendering is never throttled by visitors.
- If you proxy submits through your server, forward
x-forwarded-forso each visitor has their own budget. - Cache on your side (ETags, Next.js revalidation, webhooks) rather than fetching on every page view.
In the SDK#
The SDK turns every error body into a CitronError and retries 429 and 5xx for you. See errors in the SDK.
import { CitronGraphQLError, isCitronError } from '@citron/sdk';
try {
await citron.collection('products').get('does-not-exist');
} catch (err) {
if (err instanceof CitronGraphQLError) {
console.error(err.errors, err.data); // GraphQL errors and partial data
} else if (isCitronError(err)) {
console.error(err.status, err.code, err.message, err.requestId, err.details);
if (err.isNotFound) { /* render your 404 */ }
if (err.isRetryable) { /* 0, 408, 429 or 5xx; err.retryAfter when the server said */ }
} else {
throw err;
}
}This reference covers the public API only and follows the API’s compatibility contract: public responses only ever gain fields. The OpenAPI description of these routes is at /developers/openapi.json.