Content API (REST)
Errors & rate limits
Status codes, error bodies, CORS and rate limits.
The REST API uses ordinary HTTP status codes and returns errors as JSON with a single error message. GraphQL reports errors inside the response body instead; see GraphQL errors.
Error format
{ "error": "Content type not found" }The message is written for a developer to read. Branch on the status code, not on the text.
Status codes
| Status | Meaning | Typical messages |
|---|---|---|
200 | Success. | |
400 | The request is malformed. | Unknown environment "prod". Use "production" or "staging"., Unsupported locale "fr". Use a code like en-US or fr-FR., Missing required fields: contentTypeSlug and title, Request body must be valid JSON |
401 | No API key, or one that isn't valid. | Unauthorized - Invalid or missing API key |
403 | The key is valid but lacks a permission. | Forbidden - this API key needs the "content.edit" permission |
402 | Your workspace's plan doesn't allow it: a limit is reached (entries, content types) or the feature isn't included (staging). Nothing is removed when you're over a limit; you just can't add more until you upgrade or delete something. In GraphQL, the mutation returns success: false with the same message. | The Starter plan includes 2 entries. Upgrade your plan in Settings → Billing., The staging environment isn’t included in this team’s plan. |
404 | The thing asked for doesn't exist, or staging isn't set up. | Content type not found, Content entry not found, This team has no staging environment yet. Set it up from the dashboard. |
500 | Something failed on our side. Safe to retry. | Internal server error |
On the content endpoints, checks happen in this order: the key (401), its permission (403), the environment (400 or 404), then the request itself. A request with a bad key and a bad environment gets 401.
Handling errors
1async function graph(path) {2 const response = await fetch(`https://api.graphcms.io/v1${path}`, {3 headers: { Authorization: `Bearer ${process.env.GRAPH_API_KEY}` },4 });56 if (response.ok) return response.json();78 const { error } = await response.json().catch(() => ({ error: response.statusText }));910 switch (response.status) {11 case 401:12 throw new Error(`Check GRAPH_API_KEY: ${error}`);13 case 403:14 throw new Error(`Give the key more permissions in Settings → API keys: ${error}`);15 case 404:16 return null;17 default:18 throw new Error(`Graph ${response.status}: ${error}`);19 }20}CORS
These endpoints allow requests from any origin, and accept the Authorization, X-API-Key, X-Environment and Content-Type headers:
GET /v1/content-typesandGET /v1/content-types/:slugGET /v1/content/:typeandGET /v1/content/:type/:slugPOST /graphql
GET /v1/content, POST /v1/content and the Marketing API don't send CORS headers, so a browser on another origin can't call them. Call those from a server.
Being able to call the API from a browser doesn't make it a good idea: any key you ship to the browser is public. Keep keys server-side unless the key only has content.view on content you are happy for anyone to read.
Rate limits
Every REST and GraphQL request counts towards two limits:
| Limit | Default |
|---|---|
| Per API key | Set by your plan (600 requests a minute if it doesn't set one) |
| Per IP address | 1,200 requests a minute |
Your plan's limit is shown in Settings → Billing in the dashboard.
Each response tells you where you stand:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window |
X-RateLimit-Remaining | Requests left in it |
X-RateLimit-Reset | When the window resets, in Unix seconds |
Over the limit, the API answers 429 Too Many Requests with a Retry-After header (seconds) and:
{ "error": "Rate limit exceeded. Try again in 39 seconds." }Build clients that stay well under it:
- Cache responses. Content changes far less often than pages are viewed. Fetch at build time, or cache with a revalidation window (for example Next.js
next: { revalidate: 60 }). - Page through lists with
limitandoffsetinstead of fetching everything on every request. - Back off and retry on
429and5xxresponses: waitRetry-Afterseconds on a429, and use exponential delays otherwise. - Use one key per service, so usage is easy to attribute and a misbehaving client can be cut off on its own.
1async function withRetry(request, attempts = 4) {2 for (let attempt = 0; ; attempt++) {3 const response = await request();4 const retryable = response.status === 429 || response.status >= 500;5 if (!retryable || attempt === attempts - 1) return response;6 const retryAfter = Number(response.headers.get('retry-after'));7 const delay = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250;8 await new Promise((resolve) => setTimeout(resolve, delay));9 }10}