API REST
Las operaciones de la API local expuestas por HTTP. Esta capa no decide nada: traduce una petición a una llamada de la API local y su resultado a una respuesta. Todo lo que decide qué documentos salen o qué escrituras valen está documentado en la otra página.
La integración de Astro monta todas estas rutas por ti. La firma del final de la página solo importa si montas el CMS a mano.
Endpoints
Sección titulada «Endpoints»Prefijo por defecto /api/cms, configurable con api.path.
| Método | Ruta | Operación | Éxito |
|---|---|---|---|
GET |
/api/cms/:collection |
find |
200 |
POST |
/api/cms/:collection |
create |
201 |
GET |
/api/cms/:collection/count |
count |
200 |
GET |
/api/cms/:collection/:id |
findByID |
200 |
PATCH |
/api/cms/:collection/:id |
update |
200 |
DELETE |
/api/cms/:collection/:id |
delete |
200 |
count se resuelve antes que :id, así que nunca se interpreta como el id de un documento.
Las actualizaciones son PATCH y no PUT: son parciales, y PUT prometería un reemplazo total que no
ocurre. findOne no tiene endpoint —?limit=1 sobre el listado da lo mismo— y la autenticación se sirve
aparte, en /api/auth/*.
Escrituras condicionales
Sección titulada «Escrituras condicionales»Las respuestas de un documento único —GET /:id, POST y PATCH— llevan una cabecera ETag con el
updatedAt del documento entre comillas, el mismo valor que viaja en el cuerpo (salvo un GET /:id con
un ?select= que no pida updatedAt: sin ese campo no hay de dónde sacar la cabecera):
ETag: "2026-01-01T00:00:00.001Z"Un PATCH puede mandarlo de vuelta en If-Match para escribir solo si nadie ha tocado el documento
desde que lo leíste:
PATCH /api/cms/posts/01J8… HTTP/1.1If-Match: "2026-01-01T00:00:00.001Z"Content-Type: application/json
{ "title": "Hola de nuevo" }Si el updatedAt de la fila ya no coincide, la respuesta es 412 con código PRECONDITION_FAILED y no
se escribe nada: vuelve a leer el documento, aplica tu cambio sobre la copia nueva y reintenta con el
ETag que te devolvió. Sólo se compara un entity-tag fuerte, entre comillas dobles, contra el
updatedAt que el documento tiene ahora mismo: cualquier otra forma —la fecha sin comillas, *,
W/"…", una lista de varios tags o basura— nunca coincide y también es 412; un id que no existe
sigue siendo 404, se mande lo que se mande.
La cabecera es opcional: sin If-Match, PATCH escribe como siempre. Los listados, count,
DELETE, la subida de ficheros y las dos rutas de un global no llevan
ETag. Desde un navegador y otro origen también funciona, siempre que el origen esté
en la lista de CORS: el preflight autoriza If-Match y la respuesta expone ETag.
Ficheros
Sección titulada «Ficheros»Las colecciones que declaran upload añaden dos rutas más. Están documentadas en
Ficheros:
| Método | Ruta | Qué hace | Éxito |
|---|---|---|---|
POST |
/api/cms/:collection/upload |
Sube un fichero con multipart/form-data |
201 |
POST |
/api/cms/:collection/upload?action=create |
Abre una subida por trozos | 201 |
PUT |
/api/cms/:collection/upload?action=part |
Manda un trozo | 200 |
POST |
/api/cms/:collection/upload?action=complete |
Cierra la subida y crea el documento | 201 |
DELETE |
/api/cms/:collection/upload?action=abort |
Cancela la subida | 200 |
GET |
/api/cms/cdn/:collection/:id/:filename |
Devuelve el fichero | 200 |
GET |
/api/cms/cdn/:collection/:id |
Redirige a la ruta canónica | 302 |
upload se resuelve antes que :id, igual que count. El segmento cdn no colisiona con ninguna
colección: las rutas de colección tienen uno o dos segmentos y las de fichero tres o cuatro, así que una
colección llamada cdn sigue respondiendo con normalidad en /api/cms/cdn.
Las cuatro acciones viajan como parámetro de consulta y no como rutas nuevas porque una ruta de
colección de tres segmentos es un 404. Un ?action= desconocido, o ausente donde hace falta —un
PUT sin acción, por ejemplo—, responde 400 con código INVALID_ACTION. El POST sin action es
la subida de un solo golpe de toda la vida y no cambia.
Subir es una escritura y exige sesión, las cinco formas. Servir es una lectura y respeta el
access.read de su colección.
Las carpetas reservadas de ssd
Sección titulada «Las carpetas reservadas de ssd»La biblioteca del CMS, ssd, guarda en carpetas que empiezan por . sus propios ficheros —.system
es donde Ajustes pone el favicon y el logo— y REST no deja tocarlas desde fuera de esa pantalla:
| Petición | Qué contesta |
|---|---|
GET /api/cms/ssd y /ssd/count sin sesión |
401, salvo que el config declare media: { access: { list: 'public' } } — ver Sesión |
GET /api/cms/ssd y /ssd/count con sesión |
Las omiten. Un ?where[folder][equals]=.system no vuelve a ensancharlo: las dos condiciones se suman |
GET /api/cms/ssd/:id de una fila reservada |
404, lo mismo que un id que no existe. También con ?select=, y también sin sesión |
PATCH y DELETE sobre una |
400 con un issue de código RESERVED_FOLDER |
POST /api/cms/ssd/upload con folder reservado |
400 RESERVED_FOLDER, y no se escribe nada, tampoco en ?action=create |
GET /api/cms/cdn/ssd/<id>/<fichero> de una |
200, sin sesión. Sus bytes sí se sirven: de eso vive el <link rel="icon"> del panel |
Lo que se cierra es el listado —por carpeta y por sesión—, no el fichero. La API local las sigue leyendo —es por donde la
pantalla de Ajustes encuentra su almacén—, pero escribir en ellas tampoco es libre ahí: ssd.update
lanza el mismo RESERVED_FOLDER sin excepción, y las únicas escrituras que pasan son las de Ajustes,
que sube y borra por la vía explícita { system: true } de upload y delete.
Un folder que sea el slug de una colección upload tuya también se rechaza al subir, con código
FIXED_FOLDER: esa carpeta tiene su propia tabla y se sube a ella por su propia ruta.
La subida por trozos
Sección titulada «La subida por trozos»Para ficheros que no caben en una sola petición. El troceado lo hace el cliente; el servidor no
guarda nada entre una petición y la siguiente: le devuelves lo que te dio create —id,
uploadId, key y el filename— y él vuelve a deducir la clave, que es determinista. Ver
Ficheros para los límites y
Limitaciones para el techo.
-
Abrir. Aquí se valida todo lo que se puede validar sin bytes: el tamaño declarado contra
maxFileSize, y los campos de tu colección. Un campo obligatorio que falte falla ahora, no después de subir un giga.Ventana de terminal curl -X POST "https://mi-sitio.com/api/cms/ssd/upload?action=create" \-H "Cookie: <tu sesión>" \-H "Content-Type: application/json" \-d '{"filename": "video.mp4","size": 734003200,"fields": { "folder": "charlas", "alt": "La charla" }}'{"id": "0190d4c2-…","uploadId": "ABPnzm…","key": "charlas/0190d4c2-…/video.mp4","partSize": 26214400,"maxParts": 10000,"maxSize": 5368709120}La
keyes<carpeta>/<id>/<fichero>, y<id>/<fichero>cuando no hay carpeta. Enssdla carpeta sale delfolderque mandes enfields; en una colecciónuploadtuya es siempre su slug, y no se elige. -
Mandar cada trozo. El cuerpo son los bytes crudos, no
multipart/form-data. Trocea el fichero porpartSizey numera desde 1; puedes mandar varios a la vez. Guarda cadaetag. Cada petición tiene que declarar suContent-Length, o responde411: sin la cabecera no hay forma de acotar el trozo antes de leerlo.Ventana de terminal curl -X PUT "https://mi-sitio.com/api/cms/ssd/upload?action=part\&id=0190d4c2-…&uploadId=ABPnzm…&filename=video.mp4&key=charlas/0190d4c2-…/video.mp4&partNumber=1" \-H "Cookie: <tu sesión>" \--data-binary @trozo-1.bin{ "partNumber": 1, "etag": "5d41402a…" } -
Cerrar. Con la lista de partes; el orden da igual, se ordenan por
partNumber. Aquí es donde se olfatea el tipo, se leen las dimensiones y se inserta la fila.Ventana de terminal curl -X POST "https://mi-sitio.com/api/cms/ssd/upload?action=complete" \-H "Cookie: <tu sesión>" \-H "Content-Type: application/json" \-d '{"id": "0190d4c2-…","uploadId": "ABPnzm…","filename": "video.mp4","key": "charlas/0190d4c2-…/video.mp4","parts": [{ "partNumber": 1, "etag": "5d41402a…" }],"fields": { "alt": "La charla" }}'Responde
201con el documento creado, igual que la ruta de un solo golpe. -
Cancelar, si algo se tuerce. Cancelar una subida que ya no existe no es un error: responde lo mismo, para que un cliente que reintenta no se quede atascado.
Ventana de terminal curl -X DELETE "https://mi-sitio.com/api/cms/ssd/upload?action=abort\&id=0190d4c2-…&uploadId=ABPnzm…&filename=video.mp4&key=charlas/0190d4c2-…/video.mp4" \-H "Cookie: <tu sesión>"{ "aborted": true }
Los tamaños que devuelve create:
| Campo | Qué es |
|---|---|
partSize |
El tamaño de trozo que toca usar. 25 MiB salvo que el fichero necesite más |
maxParts |
10000, el máximo de R2 |
maxSize |
El fichero más grande que admite esta colección |
Los errores propios de estas cuatro:
| Situación | Status | code del issue |
|---|---|---|
action desconocida o ausente |
400 | INVALID_ACTION |
id que no es un UUID |
400 | INVALID_ID |
uploadId que no existe, o partes que no cuadran |
400 | INVALID_UPLOAD |
partNumber fuera de 1..10000 |
400 | OUT_OF_RANGE |
| Trozos desiguales o por debajo de 5 MiB, al cerrar | 400 | INVALID_UPLOAD |
Trozo sin Content-Length |
411 | — |
Trozo con Content-Length sobre 95 MiB |
413 | — |
size declarado sobre maxFileSize o sobre el techo |
413 | — |
Tamaño real sobre maxFileSize, al cerrar |
413 | — |
Tipo fuera de mimeTypes, al cerrar |
415 | — |
Globals
Sección titulada «Globals»Un global es un documento único sin listado, así que tiene dos rutas y no seis:
| Método | Ruta | Operación | Sesión | Éxito |
|---|---|---|---|---|
GET |
/api/cms/globals/:slug |
get |
Salvo access: { read: 'public' } |
200 |
PATCH |
/api/cms/globals/:slug |
update |
Siempre | 200 |
Un global que nadie ha guardado todavía responde 200 con sus valores por defecto, nunca 404:
no hay fila que pueda faltar. El primer guardado también es 200, no 201.
depth y select funcionan como en un documento. Los otros cuatro se descartan, pero la query entera
pasa antes por el mismo parser: un limit, un page o un depth mal formado o fuera de rango sigue
siendo 400 con QUERY_ERROR —un ?limit=abc lo es igual que en un listado—. De where y sort
solo se comprueba la forma, y su contenido no llega a mirarse: un ?sort=noexiste o un
?where[noexiste][eq]=1 responden 200, donde un listado daría 400.
| Petición | Respuesta |
|---|---|
GET /api/cms/globals a secas |
404 — Los globals no se listan: pide uno por su slug, /api/cms/globals/<slug> |
| Un slug que el config no declara | 404 — El global "<slug>" no existe, con sesión y sin ella |
Un tercer segmento (/globals/site/x) |
404, el mismo genérico de cualquier ruta que no existe |
POST, PUT, DELETE o cualquier otro verbo |
405 — "<verbo>" no se puede usar sobre un global |
PATCH sin namespace de KV declarado |
500 INTERNAL_ERROR. Es un error de despliegue, no una petición mala: el detalle (MISSING_KV) va a los logs del Worker |
El slug se resuelve antes que el método y que la sesión, así que uno que no existe es 404 para
todo el mundo; y el método antes que la sesión, así que un PUT es 405 lleve o no credenciales.
globals es, por eso, un slug reservado para colecciones: /api/cms/globals/:slug tiene la
misma forma que /api/cms/:coleccion/:id, así que una colección con ese nombre quedaría tapada sin
avisar y defineConfig la rechaza al arrancar con RESERVED_GLOBALS_SLUG. Al revés la ruta no se
tapa, pero el label derivado choca con la interfaz Globals del codegen y hace falta declarar uno:
ver Declararlo.
Ajustes
Sección titulada «Ajustes»Dos rutas más, sobre los ajustes del sitio guardados en KV:
| Método | Ruta | Qué hace | Éxito |
|---|---|---|---|
GET |
/api/cms/settings |
Los siete campos efectivos | 200 |
PATCH |
/api/cms/settings |
Fusión superficial, devuelve el resultado | 200 |
Las dos exigen sesión siempre, sin el matiz de access.read que tienen las colecciones. Un
PATCH con una clave desconocida, un valor que no es cadena o un siteUrl que no es una URL
absoluta responde 400 con su array issues.
A diferencia de cdn, este segmento sí colisionaría con una colección: /api/cms/settings tiene un
solo segmento, igual que un listado. Por eso settings es un slug reservado y defineConfig
rechaza al arrancar una colección que lo declare.
ssd es el otro slug reservado, por una razón distinta: es la biblioteca de
medios que el paquete inyecta él mismo, así que declararla sería declararla
dos veces. defineConfig también lo rechaza al arrancar, con código RESERVED_MEDIA_SLUG.
Cambiar el prefijo mueve los seis, y también las de ficheros, las de globals y la de ajustes:
export default defineConfig({ collections: [posts], api: { path: '/api/contenido' },})Parámetros de consulta
Sección titulada «Parámetros de consulta»GET /api/cms/posts ?where[status][equals]=published &where[publishedAt][less_than]=2026-01-01 &sort=-publishedAt &limit=10 &page=2 &depth=1 &select=title,slug| Parámetro | Por defecto | Rango |
|---|---|---|
limit |
10 |
entero de 1 a 1000 |
page |
1 |
entero ≥ 1 |
depth |
1 |
entero de 0 a 10 |
sort |
-createdAt |
campos separados por comas, - para descendente |
select |
todas las columnas | nombres de campo separados por comas |
where |
— | ver abajo |
Cualquiera fuera de rango responde 400 sin haber lanzado una sola consulta.
select y depth valen también al leer un documento por id. count solo mira where.
Se codifica con la sintaxis de corchetes de qs, la misma que usa Payload:
| Consulta | Query string |
|---|---|
{ status: { equals: 'published' } } |
where[status][equals]=published |
{ readingTime: { greater_than: 5 } } |
where[readingTime][greater_than]=5 |
{ slug: { in: ['a', 'b'] } } |
where[slug][in][0]=a&where[slug][in][1]=b |
{ or: [{ … }, { … }] } |
where[or][0][slug][equals]=a&where[or][1][slug][equals]=b |
Los once operadores y sus tipos admitidos son los de la API local, sin
recorte. Los valores viajan como cadenas y los convierte el campo, así que una fecha se escribe en ISO y un
checkbox como true:
where[publishedAt][less_than]=2026-01-01where[featured][equals]=truein y not_in aceptan como mucho 100 valores, que es el tope de parámetros que D1 liga por consulta.
and y or aceptan como mucho 100 condiciones por lista, en cualquier nivel de anidamiento. Pasarse
de cualquiera de los dos responde 400/QUERY_ERROR con el motivo; y una consulta que D1 rechace por tamaño
aunque ninguna lista supere el tope responde el mismo 400, no un 500.
Un operador que no existe, un campo que no existe, un operador que no aplica al tipo del campo o un valor que
el campo no sabe convertir responden 400. Ninguno se ignora en silencio: un filtro descartado devuelve filas
que no deberían estar ahí y el fallo aparece tarde.
Respuestas
Sección titulada «Respuestas»Listado — 200:
{ "docs": [{ "id": "01J8…", "title": "Hola" }], "pagination": { "totalDocs": 42, "limit": 10, "page": 1, "totalPages": 5, "hasNextPage": true, "hasPrevPage": false }}Conteo — 200:
{ "totalDocs": 42 }Documento único — 200 con el documento. Creación — 201 con el documento. Borrado — 200 con el documento
borrado. Las tres respuestas con un documento escrito o leído por id —GET /:id, POST y PATCH— llevan
además la cabecera ETag, salvo un GET /:id con un ?select= que deje fuera
updatedAt.
En las lecturas —GET /:coleccion y GET /:coleccion/:id— lo que sale es lo que dejó el
afterRead de la colección, si declara alguno: los hooks corren dentro de la
lectura, así que la respuesta es la que ellos devolvieron y no la fila cruda, y el ETag de un
GET /:id se calcula sobre ese cuerpo —del updatedAt que lleve—, de modo que un hook que quite
updatedAt deja ese GET sin cabecera y uno que lo cambie cambia el ETag. Las respuestas de
POST, PATCH y DELETE no pasan por afterRead —una escritura corre afterChange, no la
fase de lectura—, así que devuelven la fila tal como se escribió, con su ETag intacto.
Errores
Sección titulada «Errores»El cuerpo lleva siempre error y message, más issues cuando es de validación:
{ "error": "VALIDATION_ERROR", "message": "El documento no es válido", "issues": [{ "path": "title", "code": "REQUIRED", "message": "title es obligatorio" }]}| Situación | Status | error |
|---|---|---|
| Colección o documento inexistente | 404 | NOT_FOUND |
| Cuerpo inválido | 400 | VALIDATION_ERROR |
| Consulta inválida | 400 | QUERY_ERROR |
| Sin sesión donde hace falta | 401 | UNAUTHORIZED |
| Con sesión pero sin permiso | 403 | FORBIDDEN |
| Método no soportado en la ruta | 405 | METHOD_NOT_ALLOWED |
Subida sin Content-Length |
411 | LENGTH_REQUIRED |
If-Match que ya no coincide con el updatedAt, o cualquier If-Match sobre un global |
412 | PRECONDITION_FAILED |
| Cuerpo mayor de 1 MB | 413 | PAYLOAD_TOO_LARGE |
Fichero cuyo tipo detectado queda fuera de mimeTypes |
415 | UNSUPPORTED_MEDIA_TYPE |
| Fallo de D1 | 500 | INTERNAL_ERROR |
Los issues de una validación son los mismos { path, code, message }
de la API local.
El 403 lo emite hoy un POST sobre la colección de sesión —la que declara auth: true—: las cuentas
se crean invitando desde el panel, no escribiendo en la colección. Sin sesión la respuesta sigue siendo
401, porque la autorización se resuelve antes. Lo que no existe todavía es el control de acceso por
rol: eso llega después.
Un hook before* que lanza un CMSError con status menor que 500 manda
sobre la respuesta: el código y el status que le pusiste salen tal cual, así que
throw new CMSError('SIN_STOCK', 'No hay', 409) en un beforeChange contesta 409 con
"error": "SIN_STOCK". Vale para POST, PATCH y DELETE, y también para el PATCH de un
global. Un CMSError de 5xx —y cualquier otra excepción: un TypeError, un fetch que
falla— sale como el 500 genérico de arriba: un error interno nunca cuenta lo que pasó, aunque lo
haya compuesto tu hook.
Cuerpos de escritura
Sección titulada «Cuerpos de escritura»El Content-Type no se mira: lo que decide es si el cuerpo parsea como objeto JSON. Si no parsea, es 400.
Un POST con {} responde 400 con un issue por cada campo obligatorio que falta.
El límite es 1 MB. Por encima es 413, y se rechaza sin llegar a parsearlo. La única excepción es
?action=complete, que admite 4 MB: su cuerpo lleva la lista de partes —10.000 caben en unos
620 KB— y ahí un 413 llegaría con el fichero ya subido, dejando una subida que no se puede cerrar.
El handler recibe una función getSession(request), que la ruta inyectada cablea a la
autenticación. La sesión viaja en una cookie, así que desde el navegador basta
con haber iniciado sesión.
| Operación | Regla |
|---|---|
GET |
Pública si la colección —o el global— declara access: { read: 'public' }; si no, requiere sesión |
GET /ssd y GET /ssd/count |
Requieren sesión, salvo que el config declare media: { access: { list: 'public' } }. Es la única palanca sobre la biblioteca, que no se declara: GET /ssd/:id y las rutas de /cdn/ssd/... siguen públicas |
POST, PATCH, DELETE |
Requieren sesión siempre, el PATCH de un global incluido |
Cualquiera sobre /settings |
Requiere sesión siempre, también el GET |
defineCollection({ slug: 'posts', access: { read: 'public' }, fields: [{ name: 'title', type: 'text', required: true }],})El método se resuelve antes que la sesión, así que un PUT responde 405 lleve o no credenciales: pedir
autenticación para una ruta que no existe es confirmar que existe.
Desactivado por defecto: en el caso normal la API la consume el propio sitio, y el mismo origen no necesita CORS. Se activa listando orígenes:
cms(config, { cors: ['https://app.example.com'] })Con la lista puesta, Access-Control-Allow-Origin se emite solo para los orígenes que estén en ella, siempre
acompañado de Vary: Origin, y el preflight OPTIONS responde 204. Sin lista no se emite ninguna cabecera
de CORS y un OPTIONS responde 405, porque no hay preflight que atender.
El preflight responde Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS —el PUT es
el de ?action=part, el único paso del troceado que mueve bytes— y
Access-Control-Allow-Headers: Content-Type, If-Match, ninguna de las dos safelisted para el navegador.
Las respuestas de verdad llevan Access-Control-Expose-Headers: ETag, que tampoco lo es. Un origen de la
lista puede, por tanto, trocear un fichero y hacer escrituras condicionales
igual que uno del mismo sitio. El valor del ETag también viaja en updatedAt dentro del cuerpo, por si
prefieres no tocar cabeceras.
Access-Control-Allow-Credentials no se emite nunca, que es lo que hace inofensivo un * en la lista.
Lo que enruta la petición. La integración de Astro lo llama por ti desde la ruta que inyecta; esta firma solo importa si montas el CMS a mano.
function handle( request: Request, cms: { collections: Record<string, unknown>; globals?: Record<string, unknown>; bucket?: R2Bucket }, config: ResolvedConfig, options: { getSession(request: Request): Promise<unknown> | unknown settings: SettingsAPI cors?: string[] },): Promise<Response>globals es opcional para que un CMS montado a mano sin ellos siga arrancando; sin él, las dos rutas
de un global responden 404. bucket lo es por la misma razón y solo hace falta si alguna
colección guarda ficheros: sin él, /cdn/... responde 404. settings, en cambio, es
obligatorio: los ajustes no salen de buildAPI(), así que se inyectan por la misma puerta
que getSession.
El cliente de D1 y el objeto de operaciones se le pasan: son los que devuelve
buildAPI. El handler no construye ninguno de los dos.