Migrar a v1
Qué cambia al pasar de la API legacy a /api/v1 y cómo hacerlo paso a paso.
Qué cambia, en una tabla
La API legacy (rutas /api/… sin v1/ y /auth/token) sigue funcionando igual:
mismos cuerpos y códigos, y solo añade las cabeceras Deprecation, Sunset y Link. Con la fecha de retirada publicada (Sunset: 2027-05-01),
puedes migrar endpoint a endpoint. Una excepción: el token legacy ya no lleva portal:admin (ver «Pedir el token»).
| Legacy | v1 | |
|---|---|---|
| Prefijo | /api/…, /auth/token | /api/v1/… |
| Token | Clave pública del portal o pk_ de una API key | Solo la pk_ de una API key |
| Éxito | {success, data} | {success, data, meta} |
| Errores | {success: false, error: {code, message}} | application/problem+json |
| Paginación | page + limit, en data.pagination | page + per_page, en meta.pagination |
| Altas | 200 | 201 con Location |
| Scopes | Los de la API key, salvo portal:admin (nunca se emite) | Un scope por endpoint; portal:admin solo si se pide |
| Límite | Solo token y proxies | 600 por minuto y portal, con X-RateLimit-* (image-convert, 600 por minuto e IP, con presupuesto propio) |
| Reintentos | — | Idempotency-Key en los POST de creación |
Equivalencias: legacy a v1
| Legacy | v1 | Scope |
|---|---|---|
| Assets | ||
GET /api/assets | GET /api/v1/assets | asset:read |
POST /api/assets | POST /api/v1/assets | asset:write |
GET /api/assets/{id} | GET /api/v1/assets/{id} | asset:read |
GET /api/assets/f/{folderSlug}/{filename} | GET /api/v1/assets/f/{folderSlug}/{filename} | asset:read |
PATCH /api/assets/{id} | PATCH /api/v1/assets/{id} | asset:write |
DELETE /api/assets/{id} | DELETE /api/v1/assets/{id} | asset:delete |
POST /api/assets/{id}/restore | POST /api/v1/assets/{id}/restore | asset:write |
GET /api/assets/n/{filename} | Sin equivalente (no lleva Deprecation) | — |
| Variantes | ||
GET /api/assets/{id}/variants | GET /api/v1/assets/{id}/variants (paginado) | variant:read |
POST /api/assets/{id}/variants | POST /api/v1/assets/{id}/variants (202 por defecto) | variant:write |
POST /api/assets/{id}/variants/upload | POST /api/v1/assets/{id}/variants/upload | variant:write |
PATCH /api/assets/{assetId}/variants/{variantId} | PATCH /api/v1/assets/{id}/variants/{variantId} | variant:write |
DELETE /api/assets/{assetId}/variants/{variantId} | DELETE /api/v1/assets/{id}/variants/{variantId} | variant:delete |
POST /api/variants/from-source | POST /api/v1/variants/from-source | asset:write |
GET /api/image-convert | GET /api/v1/image-convert | Pública |
| Carpetas y tags | ||
GET /api/folders, GET /api/folders/{id} | GET /api/v1/folders, GET /api/v1/folders/{id} | folder:read o asset:read |
GET /api/folders/{id}/assets | GET /api/v1/folders/{id}/assets | folder:read o asset:read |
POST /api/folders | POST /api/v1/folders | folder:write |
PUT y PATCH /api/folders/{id} | PATCH /api/v1/folders/{id} (no hay PUT) | folder:write |
DELETE /api/folders/{id} | DELETE /api/v1/folders/{id} | folder:write |
GET /api/tags | GET /api/v1/tags | asset:read |
| Autenticación, portal y presets | ||
POST /auth/token | POST /api/v1/auth/token | Pública. La legacy no emite portal:admin; para ese scope, v1 con api_key |
GET /api/portals/me/cross-portal-access | GET /api/v1/portals/me/cross-portal-access | asset:read |
GET /portals/me/presets | GET /api/v1/portals/me/presets; el perfil y las políticas, en GET /api/v1/portals/me (nuevo) | portal:read |
POST /portals/me/presets/aspect y /size | POST /api/v1/portals/me/presets/aspect y /size | portal:admin (token v1 pedido con sk_; con el token legacy da 403) |
PUT /portals/me/presets/aspect/{id} y /size/{id} | PATCH /api/v1/portals/me/presets/aspect/{id} y /size/{id} | portal:admin |
DELETE /portals/me/presets/aspect/{id} y /size/{id} | DELETE /api/v1/portals/me/presets/aspect/{id} y /size/{id} | portal:admin |
GET /api/metrics/usage, GET /api/metrics/endpoints | GET /api/v1/metrics/usage, GET /api/v1/metrics/endpoints | portal:read |
| IA, vídeo y stock | ||
GET /api/ai/services | GET /api/v1/ai/services | ai:read |
GET y POST /api/ai/params | GET /api/v1/ai/params (solo GET) | ai:read |
POST /api/ai/generate | POST /api/v1/ai/generate | ai:generate |
GET /api/ai/status/{jobId} | GET /api/v1/ai/jobs/{id} | ai:read |
GET /api/ai/video/services, /params, /capabilities | GET /api/v1/ai/video/services, /params, /capabilities | ai:read |
POST /api/ai/video/generate | POST /api/v1/ai/video/generate | ai:generate |
GET /api/ai/video/status/{jobUuid} | GET /api/v1/ai/video/jobs/{id} | ai:read |
GET /api/stock/services | GET /api/v1/stock/services | stock:search |
POST /api/stock/search | GET /api/v1/stock/search (ahora con query) | stock:search |
POST /api/stock/download | POST /api/v1/stock/download | stock:download |
| Contenido externo | ||
GET /api/external/portals, /sources y GET /api/portals | GET /api/v1/external/portals | external:read |
GET /api/external/portals/{slug} | GET /api/v1/external/portals/{slug} | external:read |
POST /api/external/portals/{slug}/refresh | POST /api/v1/external/portals/{slug}/refresh | portal:admin |
GET /api/external/media | GET /api/v1/external/media | external:read |
GET /api/external/articles, POST /api/external/search | GET /api/v1/external/articles (portal obligatorio) | external:read |
GET /api/external/articles/{id} | GET /api/v1/external/articles/{id} | external:read |
GET y POST /api/portals/{portalId}/images | GET /api/v1/external/articles?portal={portalId}. Mientras migras, usa GET /api/portals/{portalId}/images con los mismos parámetros en la query: el POST exige portal:admin y da 403 con el token legacy | external:read |
| Sin v1 | ||
POST /api/pipeline/upload | Usa POST /api/v1/assets | asset:write |
POST /api/pipeline/{id}/variants | Usa POST /api/v1/assets/{id}/variants | variant:write |
POST /api/pipeline/{id}/generate-master-webp | Sin sucesor: el master WebP lo genera la subida | — |
/api/atenea/* | Sin v1: usa los endpoints de IA, stock y contenido externo de las secciones anteriores | — |
GET /api/atenea/proxy, GET /api/external/proxy | POST /api/v1/proxy/sign y GET /api/v1/proxy con la URL firmada (ver más abajo) | external:read, stock:search, ai:read o ai:generate para firmar; el GET es público |
En cada ruta legacy con sucesor, Link: <…>; rel="successor-version" apunta a la ruta v1 equivalente.
Pedir el token
Es el cambio que más rompe, porque cambian las credenciales válidas:
- Solo vale la
pk_de una API key. La clave pública del portal, que la legacy aceptaba, da 401invalid_credentialsen v1 (igual que unapk_desconocida, una key o un portal inactivos o unsk_incorrecto: no se distinguen). Si hoy usas la del portal, crea o localiza una API key y usa supk_. - El
sk_va en el campoapi_keydel cuerpo y solo desde el servidor. Con una cabeceraOrigin(un navegador) da 403sk_not_allowed_from_browsersin siquiera mirar la clave: si se vio en un navegador, considérala expuesta. - Scopes:
scopeses opcional y subconjunto de los de la key. Sin él, el token lleva todos salvoportal:admin, que solo se emite si lo pides y envías unapi_keyválido (sin él, 403portal_admin_requires_api_key). portal:adminy la legacy.POST /auth/tokenemite los scopes de la key sinportal:admin, siempre (si es el único, el token sale conscopes: []). Con ese token dan 403POST /api/portals/{portalId}/images,POST /portals/me/presets/aspect,PUT /portals/me/presets/aspect/{id}yDELETE /portals/me/presets/aspect/{id}(y los equivalentes de/size) y cualquier escritura bajo/api/portal*. Salidas:GET /api/portals/{portalId}/imagescon los mismos parámetros en la query, o los presets por/api/v1/portals/me/presets/**con un token v1 pedido conapi_key(sk_) yscopes: ["portal:admin"]. Un token v1 conportal:adminsí se acepta en las rutas legacy.- Respuesta:
token,token_type,expires_in,scopes,portalypolicies, conCache-Control: no-store. ExigeContent-Type: application/json(415 si no).
# Antes (legacy) # Ahora (v1)
POST /auth/token POST /api/v1/auth/token
{"public_key": "pk_portal…"} {"public_key": "pk_de_la_api_key…"}
403 al pedir el token desde el navegador
Si tu frontend pide el token directamente y recibe un 403, el detail del problema empieza por un código que dice cuál es el caso:
detail empieza por | Qué hacer |
|---|---|
sk_not_allowed_from_browser | Estás enviando el sk_ desde el navegador. Que lo pida tu backend y el navegador reciba solo el JWT (tokenProvider en el componente), y rota esa clave. |
origin_not_allowed | El Origin no está en los allowedOrigins del portal: añádelo (esquema, host y puerto exactos). |
Más detalle en Autenticación.
Errores
Decide por el estado HTTP y por el código de máquina que abre detail (write_access_denied, folder_not_empty…),
no por el texto, que es fijo y no repite datos internos. Los 5xx llevan siempre An unexpected error occurred..
{ "success": false, "error": { "code": "asset_not_found", "message": "Asset not found" } }
Content-Type: application/problem+json
{
"type": "https://limbo.lefebvre.es/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "Asset not found.",
"instance": "/api/v1/assets/3f1c9a52-7d0e-4b8a-9c11-0a52e6d4b7f1"
}
La validación (422) añade errors[] con field y message. En v1 un fichero ausente o inválido es 422 (la legacy daba 400).
Paginación
{ "success": true,
"data": { "data": [ … ], "pagination": { "page": 2, "limit": 20, "pages": 3, "total": 45 } } }
{ "success": true,
"data": [ … ],
"meta": { "pagination": { "current_page": 2, "total_pages": 3, "total_items": 45,
"items_per_page": 20, "has_next_page": true, "has_prev_page": true } } }
limit pasa a per_page (máximo 100) y limit da 422. El array ya no está anidado en data.data.
Límite de peticiones
La v1 permite 600 peticiones por minuto y portal (ventana deslizante), y el token 120 por minuto e IP. Las respuestas llevan
X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset. Este último es el
timestamp Unix del primer instante en que se aceptará la siguiente petición; mientras te queden peticiones es «ahora», no el fin de la ventana.
El 429 añade Retry-After en segundos reales (de 1 a 60): úsalo en vez de esperar un minuto fijo.
En la legacy esto solo existía en el token y los proxies, y el 429 decía siempre 60 s.
Reintentos con Idempotency-Key
Es nuevo y opcional en los POST de creación (assets, subida y generación de variantes, carpetas, presets, generación de IA y descarga de stock). Al migrar los reintentos automáticos, añádelo. Dos reglas que sorprenden:
- El cuerpo se compara byte a byte (en multipart, el nombre y el hash de cada fichero). El mismo JSON re-serializado con otro orden de claves o espaciado da 422: guarda y reenvía los bytes originales.
- Misma clave para reintentar la misma operación (también tras un 429, un corte de red o un 5xx). Clave nueva para cualquier operación distinta, incluso con el mismo fichero. 409 significa que la primera sigue en curso.
Ejemplos en Recetas.
Proxies de imagen firmados
Los proxies legacy servían <img src> sin JWT y se protegían por Origin/Referer. En v1 la autorización es una firma:
pides las URLs firmadas con tu JWT y el navegador las carga sin credenciales.
POST /api/v1/proxy/signcon{"source": "external" | "atenea", "urls": [...]}(1 a 50 URLs absolutashttp(s), hasta 2048 caracteres, sinusuario@). Respondedata: [{url, proxy_url, expires_at}]en el mismo orden. Exige uno deexternal:read,stock:search,ai:readoai:generate, para ambossource. Firma una vez por página, no por imagen: cuenta para el límite de 600 por minuto y portal.GET {proxy_url}, público y sin JWT. Responde la imagen conAccess-Control-Allow-Origin: *(sin credenciales),X-Content-Type-Options: nosniffyContent-Security-Policy: sandbox.
expires_at (segundos Unix) está alineado a la hora: la URL vale entre 1 y 2 horas y es la misma durante la hora, así que el navegador la cachea (Cache-Control: public, hasta lo que quede de validez). Renueva la firma antes de que caduque. Quien vea la URL puede usarla hasta expires_at, igual que las URLs firmadas de /files/; el límite por IP lo acota.
Estado en GET /api/v1/proxy | Causa |
|---|---|
400 invalid_request | Falta source, url, portal, expires o signature |
403 invalid_signature | Firma alterada, o url, source, portal o expires distintos de los firmados. Pide una firma nueva; no edites la URL |
403 expired_signature | Pasó expires_at: vuelve a firmar |
| 429 | Límite por IP; respeta Retry-After |
| 502 | El origen no respondió o no devolvió contenido (el detail se enmascara en todos los 5xx) |
El fetch de una URL firmada debe ir sin credentials ni Authorization: con Access-Control-Allow-Origin: * el navegador rechaza las peticiones con credenciales. Los proxies legacy siguen funcionando hasta la retirada de la legacy; cada uno lleva Deprecation, Sunset y Link.
Cabeceras de obsolescencia
Las rutas legacy con sucesor envían Deprecation: @1793318400 (deprecadas desde 2026-10-30) y
Link: <ruta-v1>; rel="successor-version", y todas llevan Sunset: 2027-05-01. Es la fecha a partir de la cual la legacy puede retirarse, pero la retirada no es automática: la anuncia el equipo. No llevan Sunset las rutas sin Deprecation: /api/atenea/* (salvo su /proxy),GET /api/assets/n/{filename}, POST /auth/validate y /component-auth/*. Los dos /proxy legacy llevan Deprecation desde 2026-11-05, con el mismo Sunset y Link a /api/v1/proxy.POST /api/pipeline/{id}/generate-master-webp lleva Sunset pero no Link (no tiene sucesor). Se escriben también en los 4xx y 5xx del controlador, pero no en las respuestas cortadas antes
(401 del JWT y 429 del límite). Puedes usarlas para inventariar qué rutas legacy te quedan en el log de tu cliente.
Diferencias de comportamiento
- Assets.
include_portals(noincludePortals); sin filtroportal_id. Un asset borrado da 404 en GET y PATCH; borrar dos veces o restaurar uno no borrado da 409. delete y restore devuelvenid(noasset_id). Renombrar es solo porname. - Escritura ajena. Un asset que no puedes leer da 404, idéntico a uno inexistente; uno legible de otro portal da 403
write_access_deniedsin el slug del propietario. Aplica también a generar y subir variantes. - Subida. Solo JPEG, PNG, WebP y GIF. Una URL pasa por la guarda anti-SSRF (IPs privadas rechazadas, 200 MB máximo, mensaje de error fijo).
- Variantes. Nombre
[A-Za-z0-9][A-Za-z0-9_-]{0,63}sin transformar; formatos webp, jpeg y png; máximo 20 por asset; claves desconocidas dan 422;Content-Typeincorrecto, 415. Generar es asíncrono por defecto (202) y elPATCHde una variante solo aceptaname. En la legacy ocurre lo mismo: cambiarwidth,height,output_format,qualityocrop_paramsda 400REGENERATION_NOT_SUPPORTED(antes era un 500); para otra medida, crea una variante nueva. - Cross-portal. Respuesta en snake_case (
self_portal,read_access,asset_count); la legacy usa camelCase. - Presets.
PATCHen vez dePUT; borrar responde 200{id, status}(no 204); elidlo genera el servidor; máximo 100 por tipo. - Carpetas. Solo
PATCH. Borrar una con assets da 409folder_not_emptysalvo con?force=true; el borrado correcto es 204. - IA y vídeo.
generateespera internamente y puede responder 202 conjob_id;asyncyquerydan 422. Los jobs creados desde la legacy no se ven en v1 (sujobs/{id}da 404). - Stock. La búsqueda pasa de
POSTcon cuerpo aGETcon query (5–200 caracteres) y paginación. - Externo.
portales obligatorio enarticles(1 a 10 slugs); un portal desconocido o inactivo es 404.external:readlo concede un administrador conlimbo:grant-scope. - Image-convert. Sin
json=true(400invalid_request); límite propio de 600 por minuto e IP, separado del de los proxies. La legacy no tiene límite. - Métricas. Un rango de fechas inválido es 422 y no se recorta en silencio (máximo 365 días).
Cambios que también notará quien no migre, porque comparten código: la legacy ya no repite el texto de las excepciones en sus errores
(decide por error.code), bloquea IPs privadas al descargar por URL, comprueba propiedad al generar y subir variantes,
borra de verdad las variantes y rechaza nombres con .., / o \ y formatos como avif.
Lista de pasos
- Inventaría las rutas legacy que usas (cabecera
Deprecationo tu log) y localiza cada una en la tabla de equivalencias. - Crea una API key con los scopes que necesitas y cambia la petición de token a
POST /api/v1/auth/tokencon supk_. Haz que lo pida tu backend. - Cambia el prefijo y los métodos que difieren (
PUTaPATCH,POSTaGETen la búsqueda de stock). - Cambia el parseo de errores a
problem+jsony decide por estado y código dedetail. - Cambia el parseo de listados:
per_page,meta.paginationydatacomo array. - Añade
Idempotency-Keya los reintentos de creación y respetaRetry-Afteren el 429. - Pide a un administrador
external:ready los accesos cross-portal que te falten. - Si cargas imágenes por
/api/atenea/proxyo/api/external/proxy, firma las URLs conPOST /api/v1/proxy/signy usaGET /api/v1/proxy. - Prueba en PRE ruta a ruta y confirma que ya no recibes
Deprecationen las que migraste.
Los schemas de cada respuesta están en la referencia OpenAPI.