Ir al contenido

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.

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/*.

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.1
If-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.

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.

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.

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 createid, 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.

  1. 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 key es <carpeta>/<id>/<fichero>, y <id>/<fichero> cuando no hay carpeta. En ssd la carpeta sale del folder que mandes en fields; en una colección upload tuya es siempre su slug, y no se elige.

  2. Mandar cada trozo. El cuerpo son los bytes crudos, no multipart/form-data. Trocea el fichero por partSize y numera desde 1; puedes mandar varios a la vez. Guarda cada etag. Cada petición tiene que declarar su Content-Length, o responde 411: 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…" }
  3. 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 201 con el documento creado, igual que la ruta de un solo golpe.

  4. 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

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 404Los globals no se listan: pide uno por su slug, /api/cms/globals/<slug>
Un slug que el config no declara 404El 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.

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:

cms.config.ts
export default defineConfig({
collections: [posts],
api: { path: '/api/contenido' },
})
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-01
where[featured][equals]=true

in 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.

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 lecturasGET /: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.

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.

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.