Graphdocs

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

StatusMeaningTypical messages
200Success.
400The 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
401No API key, or one that isn't valid.Unauthorized - Invalid or missing API key
403The key is valid but lacks a permission.Forbidden - this API key needs the "content.edit" permission
402Your 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.
404The 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.
500Something 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 });
5
6 if (response.ok) return response.json();
7
8 const { error } = await response.json().catch(() => ({ error: response.statusText }));
9
10 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-types and GET /v1/content-types/:slug
  • GET /v1/content/:type and GET /v1/content/:type/:slug
  • POST /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:

LimitDefault
Per API keySet by your plan (600 requests a minute if it doesn't set one)
Per IP address1,200 requests a minute

Your plan's limit is shown in Settings → Billing in the dashboard.

Each response tells you where you stand:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the current window
X-RateLimit-RemainingRequests left in it
X-RateLimit-ResetWhen 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 limit and offset instead of fetching everything on every request.
  • Back off and retry on 429 and 5xx responses: wait Retry-After seconds on a 429, 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}