Todos los errores que te puede devolver la Content API, en un solo sitio: qué pinta tiene el cuerpo, por qué suele pasar y cómo arreglarlo. La API es de solo lectura y bastante pequeña, así que la lista es corta, y cada error trae un message legible por máquina: tu código nunca tiene que adivinar.
Todos los errores de un vistazo
| Estado | message | En una frase |
|---|---|---|
| 400 | invalid_marker | El marcador de la URL no es un marcador válido. |
| 400 | invalid_lang | Un código de idioma en lang está mal formado. |
| 400 | unknown_lang | El código está bien, pero el sitio no tiene ese idioma activo. |
| 400 | too_many_langs | Más de 50 códigos en lang. |
| 401 | invalid_key | La clave de sitio falta, está mal formada o revocada. |
| 404 | page_not_found | No hay ninguna página con este marcador. |
| 404 | section_not_found | No hay ninguna sección con este marcador. |
| 404 | block_not_found | No hay ningún bloque con este marcador (en esta sección). |
| 404 | post_not_found | No hay ninguna entrada visible del blog con este slug. |
| 404 | Cannot GET /v1/… | La propia URL no existe en la API. |
| 405 | method_not_allowed | Cualquier método que no sea GET o HEAD. |
| 429 | rate_limit_exceeded | Demasiadas peticiones en este minuto. |
| 5xx | — | Algo se ha roto en nuestro lado. |
Qué pinta tiene un error
El cuerpo de cualquier error es un JSON con el campo message. Algunos errores añaden un par de campos útiles: por ejemplo, unknown_lang te dice qué idiomas tiene realmente el sitio.
HTTP/1.1 404 Not Found
Content-Type: application/json
Cache-Control: no-store
{"message":"page_not_found"}Los errores siempre se envían con Cache-Control: no-store, así que un 404 de una página que un editor está creando justo ahora no se quedará atascado en la caché del navegador o de la CDN. Arregla la causa y la siguiente petición recibirá una respuesta normal.
400 Bad Request
La petición en sí está mal. Reintentar no sirve de nada: arregla la URL.
invalid_marker
{"message":"invalid_marker"}Los marcadores de páginas, secciones y bloques deben cumplir ^[A-Za-z0-9_]{2,40}$: letras latinas, dígitos y guiones bajos, de 2 a 40 caracteres.
Causas típicas:
- Un guion o un punto:
/v1/pages/about-us; los marcadores usan guiones bajos,about_us. - Una ruta en lugar de un marcador: el
hrefde la página (/about) no es su marcador (about). - Un solo carácter, más de 40 caracteres, espacios o letras no latinas.
- Un segmento de la URL del visitante pasado tal cual a la API: alguien escribió
/über-unsen la barra de direcciones.
Cómo arreglarlo: copia el marcador desde el CRM. Si tus rutas usan guiones, tradúcelas a marcadores en tu router. Y cuando el marcador viene de la URL de un visitante, trata invalid_marker exactamente como un 404: esa página simplemente no existe.
invalid_lang
{"message":"invalid_lang","lang":"EN"}Cada código en lang debe tener la forma ^[a-z]{2}(-[A-Za-z]{2,4})?$: dos letras minúsculas, opcionalmente seguidas de una región como pt-BR. La comprobación distingue mayúsculas y minúsculas. El campo lang te devuelve el valor que no ha pasado.
Causas típicas:
- Mayúsculas:
ENen lugar deen. - Un locale con guion bajo sacado del servidor o de una librería de i18n:
en_US. - Códigos de tres letras como
eng, o el nombre completo comoenglish.
Cómo arreglarlo: usa exactamente los códigos que tiene el sitio; salen de GET /v1/langs. Lo más fiable es normalizar lo que te dé tu framework a uno de esos códigos.
unknown_lang
{
"message": "unknown_lang",
"lang": "fr",
"unknown": ["fr"],
"available": ["en", "de"]
}El código está bien formado, pero no es un idioma activo de este sitio. Aquí el cuerpo es útil de verdad: unknown lista todos los códigos pedidos que han fallado y available, los que sí puedes usar.
Causas típicas:
- Un editor desactivó un idioma en el CRM y tu código lo sigue pidiendo por su nombre.
- El locale del navegador pasado tal cual:
en-UStiene un formato válido, pero el sitio tieneen. - El código del vecino:
uadonde el sitio usauk,czdonde usacs. - La clave pertenece a otro sitio con otro conjunto de idiomas.
Cómo arreglarlo: construye tu lista de idiomas a partir de GET /v1/langs en lugar de escribirla a mano, o recurre al primer código de available. Si de todas formas necesitas todos los idiomas, simplemente omite lang. Más en Idiomas y respaldo.
too_many_langs
{"message":"too_many_langs","max":50}Puedes pedir como máximo 50 códigos en una petición. Si te topas con esto, normalmente la lista se construye a partir de algo sin límite: una cabecera, lo que escribe el usuario, un bucle que no para de añadir.
Cómo arreglarlo: para obtener todos los idiomas activos, omite lang por completo: eso es justo lo que hace por defecto.
401 invalid_key
{"message":"invalid_key"}La API no ha aceptado la clave de sitio. Una clave válida es pk_ seguido de 32 caracteres hexadecimales en minúscula y se envía en la cabecera x-crm-key (o como ?key=).
Causas típicas:
- Falta la cabecera, casi siempre porque la variable de entorno está vacía en este entorno.
- En el navegador, la variable nunca llegó al bundle (en Next.js solo entran las variables
NEXT_PUBLIC_*), así que la cabecera dice literalmenteundefined. - Comillas, espacios o un salto de línea final copiados en
.envjunto con la clave. - La clave se revocó en el CRM.
- La clave se creó hace un momento. Normalmente funciona al instante; en el peor de los casos puede tardar unos minutos en reconocerse. Lo mismo pasa con la revocación.
Cómo arreglarlo: revisa la clave en el CRM, en Ajustes → Claves de Content API, y pruébala con curl. Los detalles, en la página Claves de sitio.
curl -i "https://back.sitecog.com/content/v1/langs" -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"404 Not Found: ese contenido no existe
La URL está bien, la clave está bien, pero en esa dirección no hay nada. El message te dice qué es exactamente lo que falta:
| message | Endpoint | Sospechosos habituales |
|---|---|---|
page_not_found | /v1/pages/:marker | Una errata o mayúsculas equivocadas (Home ≠ home), una página renombrada o borrada en el CRM, una clave de otro sitio. |
section_not_found | /v1/sections/:marker | Lo mismo que arriba. Ojo: las secciones ocultas (show: false) se devuelven igualmente, así que un 404 significa de verdad que la sección no está. |
block_not_found | /v1/blocks/:marker | Una errata, o el bloque vive en otra sección distinta de la que pasaste en ?section=. |
post_not_found | /v1/blog/:slug | La entrada es un borrador, está programada para el futuro o el slug no es válido (solo a–z en minúscula, dígitos y guiones, hasta 120 caracteres; un slug no válido da 404, no 400). |
{"message":"page_not_found"}Cómo arreglarlo: si el marcador está escrito a mano en tus plantillas, compáralo con el CRM. Si viene de la URL de un visitante, no es ningún bug: es un 404 normal y corriente, así que muestra tu página de «página no encontrada» (en Next.js, llama a notFound()).
Ruta desconocida
Si la URL no coincide con ningún endpoint, recibes el 404 estándar del framework, que tiene una pinta distinta de los 404 de contenido de arriba:
{
"message": "Cannot GET /v1/page/home",
"error": "Not Found",
"statusCode": 404
}Lo distingues por los campos error y statusCode y por el mensaje Cannot GET …. Siempre es un bug de tu código, nunca contenido que falta.
Causas típicas:
- Singular en lugar de plural:
/v1/page/home,/v1/block/phone. - Se te ha olvidado
/v1en la ruta. - Segmentos de más:
/v1/pages/home/hero; las secciones tienen su propio endpoint,/v1/sections/hero.
Cómo arreglarlo: compara la URL con la referencia de la API. Si guardas la URL base en una sola constante (https://back.sitecog.com/content) y construyes las rutas en un único helper, te ahorras la mayoría de estos fallos.
405 method_not_allowed
{"message":"method_not_allowed"}La Content API es de solo lectura: responde a GET y HEAD. POST, PUT, PATCH y DELETE reciben un 405.
Causas típicas: un cliente HTTP que usa POST por defecto, un formulario cuyo action apunta a la API, o la esperanza de guardar contenido a través de ella. El contenido se edita en el CRM o directamente en el sitio en modo Live; la API solo lee.
429 rate_limit_exceeded
{"message":"rate_limit_exceeded"}Demasiadas peticiones en el minuto actual: desde tu IP (300), con tu clave (600), o demasiados intentos con claves incorrectas desde tu IP (20). No hay cabecera Retry-After: espera al siguiente minuto, con un poco de jitter. Los límites, las cuentas y un helper de reintentos listo para usar están en la página Límites.
5xx: cosa nuestra
Un 500, 502, 503 o 504 significa que algo ha fallado en nuestro lado, no en tu código. Estos son los errores que sí vale la pena reintentar: un backoff exponencial corto con jitter (ejemplo) y, si sigue fallando, muestra la última copia buena que tengas en caché. Un sitio que sigue sirviendo el texto de ayer durante un tropiezo es muchísimo mejor que uno que enseña un stack trace.
Errores de red y CORS
A veces no hay respuesta en absoluto: fetch se rechaza con un TypeError («Failed to fetch», «fetch failed»). Causas:
- Problemas de DNS, sin conexión, un firewall en tu servidor, una petición que se queda colgada. Pon un timeout:
AbortSignal.timeout(10_000). - CORS en el navegador. La API admite cualquier origen, los métodos
GETyOPTIONSy las cabeceras de peticiónx-crm-key,content-typeeif-none-match. Añade cualquier otra cabecera personalizada (unAuthorization, una cabecera de trazas de tu cliente HTTP) y el navegador bloqueará la petición antes de enviarla: tu código lo verá como un error de red. Mira la consola: el navegador te dice qué cabecera no le ha gustado.
Un manejador de errores robusto en TypeScript
Mete todo lo anterior en un pequeño módulo: cada respuesta que no sea 2xx y cada fallo de red se convierte en un ContentApiError tipado con el estado, el código legible por máquina y los campos extra. El resto de tu código solo tiene que mirar 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 …": la URL está mal
| 'server_error' // 5xx
| 'network_error' // ninguna respuesta
| 'unexpected'; // cualquier cosa que no hayamos visto antes
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; // estado HTTP, 0 si no hubo respuesta
readonly code: ContentErrorCode;
readonly lang?: string; // invalid_lang, unknown_lang
readonly unknown?: string[]; // unknown_lang: los códigos que han fallado
readonly available?: string[]; // unknown_lang: los códigos que puedes usar
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;
}
/** Vale la pena reintentar más tarde: problemas de red, límite de peticiones, nuestros 5xx */
get retryable() {
return this.status === 0 || this.status === 429 || this.status >= 500;
}
/** El contenido no existe: muestra tu página 404 */
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;
// Los cuerpos de error son JSON, pero un proxy por el camino podría responder con HTML: cuidado
const body: ErrorBody = await res.json().catch(() => ({}));
throw new ContentApiError(res.status, toCode(res.status, body.message), body);
}Y así se lee en código real:
// 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…: un bug que arreglar
}
throw err; // error.tsx muestra «algo ha ido mal»
}
}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 o sin red: seguimos mostrando lo que ya tenemos
console.warn('Content API is having a moment:', err.code);
} else {
throw err;
}
}
return lastGood;
}