Every error the Content API can send you, in one place: what the body looks like, why it usually happens and how to fix it. The API is read-only and fairly small, so the list is short — and every error comes with a machine-readable message, so your code never has to guess.
All errors at a glance
| Status | message | In one sentence |
|---|---|---|
| 400 | invalid_marker | The marker in the URL is not a valid marker. |
| 400 | invalid_lang | A language code in lang is malformed. |
| 400 | unknown_lang | A language code is fine, but the site has no such active language. |
| 400 | too_many_langs | More than 50 codes in lang. |
| 401 | invalid_key | The site key is missing, malformed or revoked. |
| 404 | page_not_found | No page with this marker. |
| 404 | section_not_found | No section with this marker. |
| 404 | block_not_found | No block with this marker (in this section). |
| 404 | post_not_found | No visible blog post with this slug. |
| 404 | Cannot GET /v1/… | The URL itself does not exist in the API. |
| 405 | method_not_allowed | Anything other than GET or HEAD. |
| 429 | rate_limit_exceeded | Too many requests this minute. |
| 5xx | — | Something broke on our side. |
What an error looks like
Every error body is JSON with a message field. Some errors add a few helpful fields — for example, unknown_lang tells you which languages the site actually has.
HTTP/1.1 404 Not Found
Content-Type: application/json
Cache-Control: no-store
{"message":"page_not_found"}Errors are always sent with Cache-Control: no-store, so a 404 for a page that an editor is creating right now will not get stuck in a browser or CDN cache. Fix the cause, and the next request gets a normal answer.
400 Bad Request
The request itself is wrong. Retrying will not help — fix the URL.
invalid_marker
{"message":"invalid_marker"}Markers of pages, sections and blocks must match ^[A-Za-z0-9_]{2,40}$: Latin letters, digits and underscores, 2 to 40 characters.
Typical causes:
- A hyphen or a dot:
/v1/pages/about-us— markers use underscores,about_us. - A path instead of a marker: the page's
href(/about) is not its marker (about). - A single character, more than 40 characters, spaces or non-Latin letters.
- A URL segment from a visitor passed straight to the API — someone typed
/über-unsin the address bar.
How to fix: copy the marker from the CRM. If your routes use hyphens, map them to markers in your router. And when a marker comes from a visitor's URL, treat invalid_marker exactly like a 404: that page simply does not exist.
invalid_lang
{"message":"invalid_lang","lang":"EN"}Each code in lang must look like ^[a-z]{2}(-[A-Za-z]{2,4})?$: two lowercase letters, optionally followed by a region like pt-BR. The check is case-sensitive. The lang field echoes the value that did not pass.
Typical causes:
- Uppercase:
ENinstead ofen. - An underscore locale from a server or an i18n library:
en_US. - Three-letter codes like
eng, or a full name likeenglish.
How to fix: use the exact codes the site has — they come from GET /v1/langs. Normalising whatever your framework gives you to one of those codes is the most reliable approach.
unknown_lang
{
"message": "unknown_lang",
"lang": "fr",
"unknown": ["fr"],
"available": ["en", "de"]
}The code is well-formed, but it is not an active language of this site. The body is genuinely useful: unknown lists every requested code that failed, available lists the ones you can use.
Typical causes:
- An editor switched a language off in the CRM, and your code still asks for it by name.
- The browser locale passed as is:
en-USis a valid format, but the site hasen. - A neighbour's code:
uawhere the site usesuk,czwhere it usescs. - The key belongs to a different site with a different set of languages.
How to fix: build your language list from GET /v1/langs instead of hard-coding it, or fall back to the first code from available. If you need every language anyway, just leave lang out. More in Languages & fallbacks.
too_many_langs
{"message":"too_many_langs","max":50}You can ask for at most 50 codes in one request. Hitting this usually means the list is built from something unbounded — a header, user input, a loop that keeps appending.
How to fix: to get all active languages, omit lang entirely — that is exactly what it does by default.
401 invalid_key
{"message":"invalid_key"}The API did not accept the site key. A valid key looks like pk_ followed by 32 lowercase hex characters and is sent in the x-crm-key header (or as ?key=).
Typical causes:
- The header is missing — most often because the environment variable is empty in this environment.
- In the browser, the variable never made it into the bundle (in Next.js only
NEXT_PUBLIC_*variables do), so the header literally saysundefined. - Quotes, spaces or a trailing newline copied into
.envalong with the key. - The key was revoked in the CRM.
- The key was created a moment ago. Usually it works instantly; in the worst case it can take a few minutes to be recognised. The same goes for revocation.
How to fix: check the key in the CRM under Settings → Content API keys and test it with curl. Details on the Site keys page.
curl -i "https://back.sitecog.com/content/v1/langs" -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"404 Not Found: no such content
The URL is fine, the key is fine, but there is nothing at that address. The message tells you what exactly is missing:
| message | Endpoint | Usual suspects |
|---|---|---|
page_not_found | /v1/pages/:marker | A typo or wrong case (Home ≠ home), a page renamed or deleted in the CRM, a key of another site. |
section_not_found | /v1/sections/:marker | Same as above. Note that hidden sections (show: false) are still returned — a 404 really means the section is not there. |
block_not_found | /v1/blocks/:marker | A typo, or the block lives in a different section than the one you passed in ?section=. |
post_not_found | /v1/blog/:slug | The post is a draft, is scheduled for the future, or the slug is invalid (only lowercase a–z, digits and hyphens, up to 120 characters — an invalid slug gives 404, not 400). |
{"message":"page_not_found"}How to fix: if the marker is hard-coded in your templates, check it against the CRM. If it comes from a visitor's URL, this is not a bug at all — it is a regular 404, so show your “page not found” page (in Next.js, call notFound()).
Unknown route
If the URL does not match any endpoint, you get the framework's standard 404, which looks different from the content 404s above:
{
"message": "Cannot GET /v1/page/home",
"error": "Not Found",
"statusCode": 404
}You can tell it apart by the error and statusCode fields and by the Cannot GET … message. It is always a bug in your code, never missing content.
Typical causes:
- A singular instead of a plural:
/v1/page/home,/v1/block/phone. - A forgotten
/v1in the path. - Extra segments:
/v1/pages/home/hero— sections have their own endpoint,/v1/sections/hero.
How to fix: compare the URL with the API reference. Keeping the base URL in one constant (https://back.sitecog.com/content) and building paths in one helper prevents most of these.
405 method_not_allowed
{"message":"method_not_allowed"}The Content API is read-only: it answers GET and HEAD. POST, PUT, PATCH and DELETE get a 405.
Typical causes: an HTTP client defaulting to POST, a form whose action points at the API, or the hope of saving content through it. Content is edited in the CRM or right on the site in Live mode — the API only reads.
429 rate_limit_exceeded
{"message":"rate_limit_exceeded"}Too many requests in the current minute — from your IP (300), with your key (600), or too many bad-key attempts from your IP (20). There is no Retry-After header: wait for the next minute, with a little jitter. The limits, the maths and a ready-made retry helper are on the Rate limits page.
5xx: our side
A 500, 502, 503 or 504 means something went wrong on our side, not in your code. These are the errors worth retrying: a short exponential backoff with jitter (example), and if it still fails, show the last good copy you have cached. A site that keeps serving yesterday's text during a hiccup is much better than a site that shows a stack trace.
Network errors and CORS
Sometimes there is no response at all — fetch rejects with a TypeError (“Failed to fetch”, “fetch failed”). Causes:
- DNS trouble, no connection, a firewall on your server, a request that hangs. Set a timeout:
AbortSignal.timeout(10_000). - CORS in the browser. The API allows any origin, methods
GETandOPTIONS, and request headersx-crm-key,content-typeandif-none-match. Add any other custom header (anAuthorization, a tracing header from your HTTP client) and the browser blocks the request before it is sent — your code sees it as a network error. Check the console: the browser tells you which header it did not like.
A robust error handler in TypeScript
Put all of the above into one small module: every non-2xx response and every network failure becomes a typed ContentApiError with the status, the machine-readable code and the extra fields. The rest of your code just checks err.code.
const API = 'https://back.sitecog.com/content';
export type ContentErrorCode =
| 'invalid_marker' | 'invalid_lang' | 'unknown_lang' | 'too_many_langs'
| 'invalid_key'
| 'page_not_found' | 'section_not_found' | 'block_not_found' | 'post_not_found'
| 'method_not_allowed' | 'rate_limit_exceeded'
| 'unknown_route' // 404 "Cannot GET …" — the URL is wrong
| 'server_error' // 5xx
| 'network_error' // no response at all
| 'unexpected'; // anything we have not seen before
const KNOWN = new Set<string>([
'invalid_marker', 'invalid_lang', 'unknown_lang', 'too_many_langs', 'invalid_key',
'page_not_found', 'section_not_found', 'block_not_found', 'post_not_found',
'method_not_allowed', 'rate_limit_exceeded',
]);
type ErrorBody = {
message?: string;
lang?: string;
unknown?: string[];
available?: string[];
max?: number;
};
export class ContentApiError extends Error {
readonly status: number; // HTTP status, 0 when there was no response
readonly code: ContentErrorCode;
readonly lang?: string; // invalid_lang, unknown_lang
readonly unknown?: string[]; // unknown_lang: the codes that failed
readonly available?: string[]; // unknown_lang: the codes you can use
readonly max?: number; // too_many_langs
constructor(status: number, code: ContentErrorCode, body: ErrorBody = {}, cause?: unknown) {
super(`Content API ${status || 'network'}: ${body.message ?? code}`, { cause });
this.name = 'ContentApiError';
this.status = status;
this.code = code;
this.lang = body.lang;
this.unknown = body.unknown;
this.available = body.available;
this.max = body.max;
}
/** Worth trying again later: network trouble, rate limit, our 5xx */
get retryable() {
return this.status === 0 || this.status === 429 || this.status >= 500;
}
/** The content does not exist — show your 404 page */
get notFound() {
return this.code.endsWith('_not_found') || this.code === 'invalid_marker';
}
}
function toCode(status: number, message?: string): ContentErrorCode {
if (status >= 500) return 'server_error';
if (message && KNOWN.has(message)) return message as ContentErrorCode;
if (status === 404 && message?.startsWith('Cannot ')) return 'unknown_route';
return 'unexpected';
}
export async function contentFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let res: Response;
try {
res = await fetch(API + path, {
...init,
headers: { ...(init.headers as Record<string, string>), 'x-crm-key': process.env.CRM_KEY! },
signal: init.signal ?? AbortSignal.timeout(10_000),
});
} catch (cause) {
throw new ContentApiError(0, 'network_error', {}, cause);
}
if (res.ok) return (await res.json()) as T;
// Error bodies are JSON, but a proxy on the way might answer with HTML — be careful
const body: ErrorBody = await res.json().catch(() => ({}));
throw new ContentApiError(res.status, toCode(res.status, body.message), body);
}And here is how it reads in real code:
// app/[lang]/[marker]/page.tsx
import { notFound, redirect } from 'next/navigation';
import { contentFetch, ContentApiError } from '@/lib/content-api';
type Props = { params: Promise<{ lang: string; marker: string }> };
export default async function Page({ params }: Props) {
const { lang, marker } = await params;
try {
const page = await contentFetch<PageData>(`/v1/pages/${marker}?lang=${lang}`, {
next: { revalidate: 60 },
});
return <PageView page={page} lang={lang} />;
} catch (err) {
if (err instanceof ContentApiError) {
if (err.notFound) notFound();
if (err.code === 'unknown_lang' && err.available?.length) {
redirect(`/${err.available[0]}/${marker}`);
}
console.error(err.code, err.status, err.message); // invalid_key, unknown_route… — a bug to fix
}
throw err; // error.tsx shows "something went wrong"
}
}import { contentFetch, ContentApiError } from './content-api';
let lastGood: unknown = null;
export async function loadHome() {
try {
lastGood = await contentFetch('/v1/pages/home?lang=en');
} catch (err) {
if (err instanceof ContentApiError && err.retryable && lastGood) {
// 429, 5xx or no network: keep showing what we already have
console.warn('Content API is having a moment:', err.code);
} else {
throw err;
}
}
return lastGood;
}