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»).

Legacyv1
Prefijo/api/…, /auth/token/api/v1/…
TokenClave pública del portal o pk_ de una API keySolo la pk_ de una API key
Éxito{success, data}{success, data, meta}
Errores{success: false, error: {code, message}}application/problem+json
Paginaciónpage + limit, en data.paginationpage + per_page, en meta.pagination
Altas200201 con Location
ScopesLos de la API key, salvo portal:admin (nunca se emite)Un scope por endpoint; portal:admin solo si se pide
LímiteSolo token y proxies600 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

Legacyv1Scope
Assets
GET /api/assetsGET /api/v1/assetsasset:read
POST /api/assetsPOST /api/v1/assetsasset: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}/restorePOST /api/v1/assets/{id}/restoreasset:write
GET /api/assets/n/{filename}Sin equivalente (no lleva Deprecation)—
Variantes
GET /api/assets/{id}/variantsGET /api/v1/assets/{id}/variants (paginado)variant:read
POST /api/assets/{id}/variantsPOST /api/v1/assets/{id}/variants (202 por defecto)variant:write
POST /api/assets/{id}/variants/uploadPOST /api/v1/assets/{id}/variants/uploadvariant: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-sourcePOST /api/v1/variants/from-sourceasset:write
GET /api/image-convertGET /api/v1/image-convertPú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}/assetsGET /api/v1/folders/{id}/assetsfolder:read o asset:read
POST /api/foldersPOST /api/v1/foldersfolder: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/tagsGET /api/v1/tagsasset:read
Autenticación, portal y presets
POST /auth/tokenPOST /api/v1/auth/tokenPública. La legacy no emite portal:admin; para ese scope, v1 con api_key
GET /api/portals/me/cross-portal-accessGET /api/v1/portals/me/cross-portal-accessasset:read
GET /portals/me/presetsGET /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 /sizePOST /api/v1/portals/me/presets/aspect y /sizeportal: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/endpointsGET /api/v1/metrics/usage, GET /api/v1/metrics/endpointsportal:read
IA, vídeo y stock
GET /api/ai/servicesGET /api/v1/ai/servicesai:read
GET y POST /api/ai/paramsGET /api/v1/ai/params (solo GET)ai:read
POST /api/ai/generatePOST /api/v1/ai/generateai:generate
GET /api/ai/status/{jobId}GET /api/v1/ai/jobs/{id}ai:read
GET /api/ai/video/services, /params, /capabilitiesGET /api/v1/ai/video/services, /params, /capabilitiesai:read
POST /api/ai/video/generatePOST /api/v1/ai/video/generateai:generate
GET /api/ai/video/status/{jobUuid}GET /api/v1/ai/video/jobs/{id}ai:read
GET /api/stock/servicesGET /api/v1/stock/servicesstock:search
POST /api/stock/searchGET /api/v1/stock/search (ahora con query)stock:search
POST /api/stock/downloadPOST /api/v1/stock/downloadstock:download
Contenido externo
GET /api/external/portals, /sources y GET /api/portalsGET /api/v1/external/portalsexternal:read
GET /api/external/portals/{slug}GET /api/v1/external/portals/{slug}external:read
POST /api/external/portals/{slug}/refreshPOST /api/v1/external/portals/{slug}/refreshportal:admin
GET /api/external/mediaGET /api/v1/external/mediaexternal:read
GET /api/external/articles, POST /api/external/searchGET /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}/imagesGET /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 legacyexternal:read
Sin v1
POST /api/pipeline/uploadUsa POST /api/v1/assetsasset:write
POST /api/pipeline/{id}/variantsUsa POST /api/v1/assets/{id}/variantsvariant:write
POST /api/pipeline/{id}/generate-master-webpSin 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/proxyPOST /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 401 invalid_credentials en v1 (igual que una pk_ desconocida, una key o un portal inactivos o un sk_ incorrecto: no se distinguen). Si hoy usas la del portal, crea o localiza una API key y usa su pk_.
  • El sk_ va en el campo api_key del cuerpo y solo desde el servidor. Con una cabecera Origin (un navegador) da 403 sk_not_allowed_from_browser sin siquiera mirar la clave: si se vio en un navegador, considérala expuesta.
  • Scopes: scopes es opcional y subconjunto de los de la key. Sin él, el token lleva todos salvo portal:admin, que solo se emite si lo pides y envías un api_key válido (sin él, 403 portal_admin_requires_api_key).
  • portal:admin y la legacy. POST /auth/token emite los scopes de la key sin portal:admin, siempre (si es el único, el token sale con scopes: []). Con ese token dan 403 POST /api/portals/{portalId}/images, POST /portals/me/presets/aspect, PUT /portals/me/presets/aspect/{id} y DELETE /portals/me/presets/aspect/{id} (y los equivalentes de /size) y cualquier escritura bajo /api/portal*. Salidas: GET /api/portals/{portalId}/images con los mismos parámetros en la query, o los presets por /api/v1/portals/me/presets/** con un token v1 pedido con api_key (sk_) y scopes: ["portal:admin"]. Un token v1 con portal:admin sí se acepta en las rutas legacy.
  • Respuesta: token, token_type, expires_in, scopes, portal y policies, con Cache-Control: no-store. Exige Content-Type: application/json (415 si no).
curl
# 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 porQué hacer
sk_not_allowed_from_browserEstá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_allowedEl 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..

Antes (legacy, 404)
{ "success": false, "error": { "code": "asset_not_found", "message": "Asset not found" } }
Ahora (v1, 404)
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

Antes (legacy): GET /api/assets?page=2&limit=20
{ "success": true,
  "data": { "data": [ … ], "pagination": { "page": 2, "limit": 20, "pages": 3, "total": 45 } } }
Ahora (v1): GET /api/v1/assets?page=2&per_page=20
{ "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.

  1. POST /api/v1/proxy/sign con {"source": "external" | "atenea", "urls": [...]} (1 a 50 URLs absolutas http(s), hasta 2048 caracteres, sin usuario@). Responde data: [{url, proxy_url, expires_at}] en el mismo orden. Exige uno de external:read, stock:search, ai:read o ai:generate, para ambos source. Firma una vez por página, no por imagen: cuenta para el límite de 600 por minuto y portal.
  2. GET {proxy_url}, público y sin JWT. Responde la imagen con Access-Control-Allow-Origin: * (sin credenciales), X-Content-Type-Options: nosniff y Content-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/proxyCausa
400 invalid_requestFalta source, url, portal, expires o signature
403 invalid_signatureFirma alterada, o url, source, portal o expires distintos de los firmados. Pide una firma nueva; no edites la URL
403 expired_signaturePasó expires_at: vuelve a firmar
429Límite por IP; respeta Retry-After
502El 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 (no includePortals); sin filtro portal_id. Un asset borrado da 404 en GET y PATCH; borrar dos veces o restaurar uno no borrado da 409. delete y restore devuelven id (no asset_id). Renombrar es solo por name.
  • Escritura ajena. Un asset que no puedes leer da 404, idéntico a uno inexistente; uno legible de otro portal da 403 write_access_denied sin 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-Type incorrecto, 415. Generar es asíncrono por defecto (202) y el PATCH de una variante solo acepta name. En la legacy ocurre lo mismo: cambiar width, height, output_format, quality o crop_params da 400 REGENERATION_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. PATCH en vez de PUT; borrar responde 200 {id, status} (no 204); el id lo genera el servidor; máximo 100 por tipo.
  • Carpetas. Solo PATCH. Borrar una con assets da 409 folder_not_empty salvo con ?force=true; el borrado correcto es 204.
  • IA y vídeo. generate espera internamente y puede responder 202 con job_id; async y query dan 422. Los jobs creados desde la legacy no se ven en v1 (su jobs/{id} da 404).
  • Stock. La búsqueda pasa de POST con cuerpo a GET con query (5–200 caracteres) y paginación.
  • Externo. portal es obligatorio en articles (1 a 10 slugs); un portal desconocido o inactivo es 404. external:read lo concede un administrador con limbo:grant-scope.
  • Image-convert. Sin json=true (400 invalid_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

  1. Inventaría las rutas legacy que usas (cabecera Deprecation o tu log) y localiza cada una en la tabla de equivalencias.
  2. Crea una API key con los scopes que necesitas y cambia la petición de token a POST /api/v1/auth/token con su pk_. Haz que lo pida tu backend.
  3. Cambia el prefijo y los métodos que difieren (PUT a PATCH, POST a GET en la búsqueda de stock).
  4. Cambia el parseo de errores a problem+json y decide por estado y código de detail.
  5. Cambia el parseo de listados: per_page, meta.pagination y data como array.
  6. Añade Idempotency-Key a los reintentos de creación y respeta Retry-After en el 429.
  7. Pide a un administrador external:read y los accesos cross-portal que te falten.
  8. Si cargas imágenes por /api/atenea/proxy o /api/external/proxy, firma las URLs con POST /api/v1/proxy/sign y usa GET /api/v1/proxy.
  9. Prueba en PRE ruta a ruta y confirma que ya no recibes Deprecation en las que migraste.

Los schemas de cada respuesta están en la referencia OpenAPI.