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#

JSON
{
  "error": {
    "code": "NOTFOUND",
    "message": "Entry not found",
    "requestId": "0f6c1d2e-3b4a-4c5d-8e9f-a0b1c2d3e4f5"
  }
}
Field
codeA machine-readable code (below).
messageA 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.
detailsPresent when there is more to say: per-field problems or the list of validation messages.
requestIdIdentifies 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.

curl
# 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#

StatusMeaningRetry?
200, 201Success. Writes answer 201.
303A plain HTML form post was accepted; follow the redirect.
304Not modified: your If-None-Match ETag is current.
400Something in the request is wrong: a parameter, a filter, the body, a captcha, a booked slot.No, fix the request.
401The API key is missing, unknown or revoked.No.
403The key may not do this (status=draft with a ck_pub_ key), or the origin may not post this form.No.
404No such collection, entry, form or workspace, or GraphQL is off for the workspace.No.
413The request body is too large.No.
429A rate limit was hit.Yes, later.
500Our fault. The message is generic; the requestId finds the details.Yes, with backoff.
503Temporarily 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.

codeStatusWhere
BADREQUEST400Any 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.
UNAUTHORIZED401Content and GraphQL: no key, or an invalid or revoked one.
FORBIDDEN403Drafts requested with a ck_pub_ key.
NOTFOUND404Unknown collection or entry, unknown workspace slug.
VALIDATION_FAILED400Forms: fields fail the form’s rules; details is [{ field, message }].
ORIGIN_NOT_ALLOWED403Forms: the Origin is not in the form’s allowed websites.
FORM_UNAVAILABLE404Forms: the form is not live, or the Forms module is off.
RATE_LIMITED429Forms: over the form’s per-IP or per-form hourly limit.
FORMS_PAUSED503Forms: submissions are paused platform-wide for a moment.
ENTITY_TOO_LARGE413The body is over the size limit.
INTERNAL_SERVER_ERROR500An unexpected error. Retry later and quote the requestId to support.
TOO_MANY_REQUESTS429A 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:

JSON
{
  "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:

JSON
{
  "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#

WhatLimitCounted per
Content API and GraphQL300 requests a minute by defaultAPI key, in fixed one-minute windows. REST and GraphQL share it.
Contact submit, booking, form submit10 a minute and 60 an hour by defaultVisitor IP address, per endpoint.
Form submissions30 per IP an hour and 500 per form an hour by defaultSet per form in the console.
  • Over a limit, the answer is 429. The per-IP limits add Retry-After-short (the minute window) or Retry-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-for so 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.

TypeScript 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.