La API v1 está en borrador y ya responde fichas por EAN, búsquedas y contribuciones. Ver qué funciona hoy

bbdd moda

Documentación de la API

Fichas por EAN, búsqueda, contribuciones y webhooks, en JSON.

Base
https://api.example.com/v1
Autenticación
Cabecera X-Api-Key, desde tu backend
Formato
JSON, fechas en ISO 8601 UTC
Versión
v1, borrador
Contenido de la documentación
El contrato es un borrador: dentro de la versión 1 solo se añadirán cosas, nunca se quitarán. La usan igual la app oficial y cualquier tercero; cambian el plan y si el cliente puede enviar contribuciones.

Primera petición#

  1. Consigue una clave

    Se crean y se revocan en el área de desarrolladores del portal. Cada cliente puede tener varias, para rotarlas.

  2. Pide una ficha por su EAN

    Desde tu backend, nunca desde la app ni la web: la clave no puede viajar al móvil.

  3. Recibe la talla exacta

    Modelo, color, talla, composición por partes y la confianza de cada dato.

Petición
curl https://api.example.com/v1/skus/08445678123453 \
  -H "X-Api-Key: $MODA_API_KEY"
Respuesta200 OK
{
  "sku": { "ean": "08445678123453", "talla": "M", "confianza": "verificada" },
  "modelo": { "nombre": "Sobrecamisa de pana", "temporada": "PV", "anio": 2027 },
  "composicion": [
    { "parte": "principal", "material": "algodon", "porcentaje": 98 },
    { "parte": "principal", "material": "elastano", "porcentaje": 2 }
  ],
  …
}

Qué funciona hoy#

Todo el contrato está publicado abajo. Lo marcado como próximamente aún no responde: llega en las siguientes entregas de la v1.

MétodoRutaEstado
GET/v1/skus/{ean}Disponible
GET/v1/buscarDisponible
GET/v1/atributos?categoria=<slug>Próximamente
GET/v1/variantes/{id}Próximamente
GET/v1/modelos/{id}Próximamente
GET/v1/grupos/{slug}Próximamente
GET/v1/marcasDisponible
GET/v1/marcas/{slug}Disponible
GET/v1/marcas/{slug}/novedades?desde=<fecha>Próximamente
POST/v1/contribucionesDisponible
GET/v1/contribuciones/{id}Disponible
POST/v1/verificacionesPróximamente
Webhookcontribucion.actualizadaDisponible
Webhookficha.actualizadaPróximamente

Autenticación#

CabeceraObligatoriaQué es
X-Api-KeySiempreClave del cliente. Solo se guarda su hash. Nunca va en una app móvil ni en una web pública: la usa el backend del cliente. Cada cliente puede tener varias claves activas para rotarlas sin cortes; se crean y revocan en el área de desarrolladores del portal.
X-ContribuidorAl contribuir o verificarId pseudónimo del usuario final que genera el cliente (p. ej. HMAC-SHA256 de su id interno con una clave secreta del cliente). La plataforma no recibe emails ni nombres.

Errores: 401 clave inválida · 403 el plan no permite la operación · 404 no existe o no está publicado · 422 datos no válidos · 429 límite superado (cabecera Retry-After).

Todos los errores tienen el mismo formato. codigo es estable y se puede programar contra él; mensaje es para personas y puede cambiar:

JSON
{ "error": { "codigo": "clave_invalida", "mensaje": "La clave no existe, está revocada o ha caducado" } }
CódigoEstadoCuándo
falta_clave401No hay cabecera X-Api-Key
clave_invalida401La clave no existe, está revocada o ha caducado, o el cliente está desactivado
ean_invalido422El EAN no tiene una longitud válida o no pasa el dígito de control
idioma_invalido422idioma no es un código como es, en o pt-BR
parametros_invalidos422Otro parámetro no válido (p. ej. pais que no son 2 letras)
no_encontrado404No existe, no está publicado o la ruta no existe
contribucion_no_permitida403El cliente no tiene puede_contribuir
falta_contribuidor422Contribución sin cabecera X-Contribuidor
contribuidor_invalido422X-Contribuidor no tiene el formato pedido (o parece un email)
foto_invalida422Falta foto_prenda, o una foto no es una imagen admitida, es muy pequeña o muy grande
almacen_no_disponible503No se pueden guardar las fotos en este momento; se puede reintentar

Una clave revocada puede seguir funcionando hasta 30 segundos, porque la API recuerda en memoria las claves válidas.

Versionado y compatibilidad#

La versión mayor va en la ruta (/v1). Lo que puede cambiar dentro de una versión y lo que obliga a sacar otra:

Dentro de v1 (sin aviso previo)Solo en una versión nueva (v2)
Campos nuevos en las respuestasQuitar o renombrar un campo
Endpoints nuevosCambiar el tipo o el significado de un campo
Parámetros nuevos opcionalesHacer obligatorio un parámetro, o quitar uno
Valores nuevos en listas cerradas (confianza, temporada, estado…), atributos y opciones nuevasCambiar los códigos de error o de estado HTTP
Eventos de webhook nuevosCambiar el formato de un evento existente

Obligaciones de cada cliente, para que lo de la columna izquierda no le rompa:

  • ignorar los campos que no conozca;
  • aceptar valores desconocidos en las listas cerradas (tratarlos como "otro", no fallar);
  • ignorar los eventos de webhook que no conozca.

Cuando sale una versión nueva:

  • v1 y v2 conviven al menos 6 meses (objetivo: 12).
  • Durante ese tiempo, las respuestas de v1 llevan las cabeceras Deprecation (RFC 9745) y Sunset (RFC 8594, con la fecha de cierre) y un Link a la guía de migración.
  • Como cada cliente usa su propia clave, se sabe quién sigue en v1 y se le avisa directamente antes del cierre.
  • Cada cambio, compatible o no, se anota en el registro de cambios de este documento.

La especificación OpenAPI que genera FastAPI es el contrato ejecutable: un test en CI la compara con la publicada y falla si un cambio quita o altera algo dentro de v1. El servidor MCP para agentes de IA se monta sobre la API y sigue su misma versión.

Catálogo (lectura)#

Idioma y país (todas las lecturas)#

ParámetroQué hace
idioma (o cabecera Accept-Language)Idioma de los textos de la marca (nombre del modelo, descripción, nombre del color). Se acepta en cualquier mayúscula (pt_br → pt-BR). Si un texto no existe en ese idioma, se busca en su idioma base (pt-BR → pt), después en inglés y, si tampoco, en el primero disponible. Por defecto, es. idioma tiene prioridad sobre Accept-Language.
paisCódigo ISO de 2 letras (en cualquier mayúscula). Filtra las ofertas y elige el precio_lanzamiento de ese país. Sin él, se devuelven las ofertas de todos los países y ningún precio de lanzamiento.

Las categorías, los materiales y los atributos se devuelven siempre como clave (camisetas, algodon, largo_manga: "corta"): son un vocabulario propio y cada cliente los traduce en su interfaz.

GET/v1/skus/{ean}Disponible#

Ficha completa de una talla concreta. El EAN puede llegar como EAN-8/12/13/14; se normaliza a GTIN-14. Solo devuelve fichas publicadas.

JSON
{
  "sku":      { "id": "…", "ean": "04006381333931", "talla": "M", "confianza": "verificada" },
  "variante": { "id": "…", "codigo_color_marca": "800", "nombre_color_marca": "Azul marino",
                "color_normalizado": "azul_marino", "atributos": {},
                "confianza": "verificada" },
  "modelo":   { "id": "…", "referencia_marca": "1234", "nombre": "Camiseta manga corta",
                "descripcion": "…", "categoria": "camisetas",
                "linea": { "nombre": "TRF", "slug": "trf" },
                "temporada": "OI", "anio": 2026, "pais_fabricacion": "PT",
                "atributos": { "largo_manga": "corta", "cuello": "redondo", "ajuste": "regular" },
                "precio_lanzamiento": { "precio": 19.95, "moneda": "EUR" },
                "confianza": "verificada" },
  "marca":    { "id": "…", "nombre": "Marca", "slug": "marca",
                "grupo": { "nombre": "Grupo", "slug": "grupo" } },
  "composicion": [ { "parte": "principal", "material": "algodon", "porcentaje": 100 } ],
  "imagenes": [ { "url": "…", "tipo": "frontal", "licencia": "…" } ],
  "ofertas":  [ { "tienda": { "nombre": "Tienda", "slug": "tienda" }, "pais": "ES",
                  "precio": 17.95, "moneda": "EUR", "url": "…", "disponible": true,
                  "visto_en": "2026-10-26T08:00:00Z" } ],
  "idioma": "es",
  "actualizado_en": "2026-10-26T10:00:00Z"
}

linea, grupo y precio_lanzamiento pueden ser null. atributos solo incluye los que se conocen (puede ser {}). idioma es el idioma pedido (cada texto puede venir en uno de reserva). Todos los id son UUIDv7. Un EAN que no pasa el dígito de control da 422 ean_invalido; uno válido que no existe o no está publicado, 404 no_encontrado.

Implementado (services/api). También GET /v1/buscar, GET /v1/marcas, GET /v1/marcas/{slug}, POST /v1/contribuciones y GET /v1/contribuciones/{id}; el resto de endpoints de este documento, todavía no.

GET/v1/buscarDisponible#

Parámetros: q (texto), grupo (slug), marca (slug), linea (slug), categoria (slug), color, temporada, anio, atributo[<clave>] (p. ej. atributo[largo_manga]=corta; se pueden combinar varios), pagina, por_pagina (máx. 50, por defecto 20), idioma. Devuelve variantes publicadas (modelo + color) con su modelo, su marca y sus tallas:

JSON
{
  "resultados": [
    { "variante": { "id": "…", "codigo_color_marca": "800", "nombre_color_marca": "Azul marino",
                    "color_normalizado": "azul_marino", "atributos": {}, "confianza": "verificada" },
      "modelo":   { "id": "…", "referencia_marca": "1234", "nombre": "Camiseta manga corta",
                    "categoria": "camisetas", "linea": { "nombre": "TRF", "slug": "trf" },
                    "temporada": "OI", "anio": 2026, "atributos": { "largo_manga": "corta" },
                    "confianza": "verificada" },
      "marca":    { "id": "…", "nombre": "Marca", "slug": "marca",
                    "grupo": { "nombre": "Grupo", "slug": "grupo" } },
      "tallas":   [ { "id": "…", "talla": "S", "ean": "04006381333924" },
                    { "id": "…", "talla": "M", "ean": "04006381333931" } ],
      "imagen":   { "url": "…", "tipo": "frontal" } }
  ],
  "pagina": 1, "por_pagina": 20, "total": 134, "total_exacto": true, "idioma": "es"
}
  • Los bloques variante, modelo y marca son los de la ficha, sin lo que solo tiene sentido para una talla (descripción, país de fabricación, precio). tallas va en orden de tallaje (XS…XXL, 36…46) y su ean puede ser null. imagen, la frontal si la hay, o null.
  • q: cada palabra (como mucho 8) tiene que aparecer, sin distinguir mayúsculas ni tildes, en el nombre del modelo (en cualquier idioma), la marca, la referencia, el nombre o la clave del color o la categoría.
  • categoria de primer nivel (partes-de-arriba) incluye sus subcategorías. En atributo[<clave>] manda el valor de la variante sobre el del modelo, y en los atributos de varias opciones basta con que la contenga.
  • Orden: lo último publicado primero.
  • total se cuenta hasta 10.000; si hay más, total es 10.000 y total_exacto es false. Solo se pueden recorrer los 10.000 primeros resultados (pagina × por_pagina ≤ 10.000; si no, 422 parametros_invalidos): para ir más allá, se afina el filtro.

GET/v1/atributos?categoria=<slug>Próximamente#

Definiciones de los atributos que se aplican a una categoría (incluidos los heredados de sus categorías superiores): clave, tipo, nivel (modelo o variante), unidad y rango, opciones posibles y si es obligatorio. Sin categoria, todos.

GET/v1/variantes/{id}GET/v1/modelos/{id}Próximamente#

Igual que la ficha de SKU, al nivel indicado (un modelo incluye sus variantes y tallas).

GET/v1/grupos/{slug}Próximamente#

El grupo con sus marcas.

GET/v1/marcasGET/v1/marcas/{slug}Disponible#

La marca incluye su grupo y sus líneas:

JSON
{ "id": "…", "nombre": "Marca", "slug": "marca", "pais_origen": "ES", "web": "https://…",
  "grupo": { "nombre": "Grupo", "slug": "grupo" }, "lineas": [ { "nombre": "TRF", "slug": "trf" } ] }

El listado, por orden alfabético, admite q (parte del nombre o del slug), grupo (slug), pagina y por_pagina (máx. 200, por defecto 50), y devuelve { "resultados": [ … ], "pagina", "por_pagina", "total" }. Un slug que no existe da 404.

GET/v1/marcas/{slug}/novedades?desde=<fecha>Próximamente#

Variantes publicadas desde una fecha. Es lo que alimenta el feed de "marcas que sigues" en la app.

Contribuciones (requiere puede_contribuir)#

POST/v1/contribucionesDisponible#

multipart/form-data, con la cabecera X-Contribuidor:

CampoTipoNotas
eantextoOpcional si hay referencia en la etiqueta. Si llega, tiene que pasar el dígito de control (422 ean_invalido); se guarda como GTIN-14
texto_ocrtextoObligatorio. Texto leído en el móvil de ambas etiquetas (máx. 20.000 caracteres)
foto_etiqueta_cartonimagenOpcional
foto_etiqueta_cosidaimagenRecomendada: siempre está en la prenda
foto_prendaimagenObligatoria
  • Fotos: JPEG, PNG o WebP; máx. 10 MB y entre 200 px de lado y 40 megapíxeles. Se guardan como JPEG sin metadatos (la API quita el EXIF, que puede llevar la ubicación del móvil) y reducidas a 4096 px de lado como mucho. No se publican tal cual.
  • X-Contribuidor: id pseudónimo del usuario final, de 8 a 128 caracteres (A-Z a-z 0-9 . _ ~ : + / = -), p. ej. un HMAC-SHA256 en hexadecimal. Un valor con @ se rechaza: nunca un email. El primer envío de cada id crea su contribuidor.
  • Un campo opcional enviado vacío cuenta como no enviado.

Respuesta 202 Accepted, con la cabecera Location: /v1/contribuciones/{id}:

JSON
{ "id": "…", "estado": "recibida" }

La contribución se procesa en segundo plano; el resultado se consulta con el GET de abajo o llega por el webhook contribucion.actualizada.

Errores propios: 403 contribucion_no_permitida (se comprueba antes que el formulario), 422 falta_contribuidor, contribuidor_invalido, ean_invalido, foto_invalida, parametros_invalidos (falta texto_ocr o está vacío), 503 almacen_no_disponible.

GET/v1/contribuciones/{id}Disponible#

JSON
{ "id": "…", "estado": "aceptada", "sku_id": "…", "problemas": [],
  "creado_en": "2026-10-31T19:05:36Z", "actualizado_en": "2026-10-31T19:07:02Z" }

Cada cliente solo ve sus contribuciones: la de otro cliente, o un id que no existe, da 404 no_encontrado. sku_id es null hasta que hay una talla publicada o encontrada. Estados: recibida → procesada → aceptada | en_revision | rechazada | duplicada. aceptada significa que la ficha se ha publicado con confianza comunidad; sube a verificada cuando coincide con una fuente oficial o con verificaciones de otros contribuidores. En en_revision y rechazada, problemas explica el motivo (p. ej. "la parte 'principal' suma 90 %, no 100 %").

POST/v1/verificacionesPróximamente#

Confirmación de un usuario final de que una ficha coincide con su prenda.

JSON
{ "entidad": "variante", "entidad_id": "…", "resultado": true }

Una por contribuidor y ficha. Con N confirmaciones independientes, la ficha pasa de comunidad a verificada.

Webhooks (al webhook_url del cliente)#

EventoCuándo
contribucion.actualizadaCambia el estado de una contribución del cliente
ficha.actualizadaCambia una ficha publicada (para refrescar cachés)

Cada envío es un POST con Content-Type: application/json y la cabecera X-Firma: sha256=<hex>: el HMAC-SHA256 del cuerpo exacto recibido (los bytes, antes de interpretar el JSON) con el secreto del webhook del cliente, en hexadecimal.

Todos los eventos comparten el mismo sobre, con la versión del formato:

JSON
{ "id": "…", "tipo": "ficha.actualizada", "version": "v1",
  "creado_en": "2026-10-26T10:00:00.000000Z", "datos": { … } }

Los cambios en datos siguen las mismas reglas que la API: añadir campos es compatible; cambiar o quitar campos exige una versión nueva del evento, que se envía en paralelo a la anterior durante la transición.

contribucion.actualizada: datos tiene los mismos campos que GET /v1/contribuciones/{id}, tal como estaban al cambiar el estado:

JSON
{ "id": "…", "tipo": "contribucion.actualizada", "version": "v1", "creado_en": "2026-10-31T23:08:14.271877Z",
  "datos": { "id": "…", "estado": "aceptada", "sku_id": "…", "problemas": [],
             "creado_en": "2026-10-31T23:08:13.289008Z", "actualizado_en": "2026-10-31T23:08:14.271877Z" } }

Entrega:

  • Solo a URLs https que resuelvan a direcciones públicas.
  • Cuenta como entregado si responde 2xx en menos de 10 s. Las redirecciones no se siguen (cuentan como fallo).
  • Si falla, se reintenta pasados 1 min, 5 min, 30 min, 2 h, 6 h, 12 h y 24 h (8 intentos en unas 45 h); después se da por fallido. Lo pendiente de enviar usa la URL y el secreto vigentes en cada intento.
  • Al menos una vez: puede llegar repetido (descártalo por id) y sin orden garantizado (usa datos.actualizado_en para quedarte con el más reciente).
  • Responde rápido y procesa después: un receptor lento retrasa sus propios eventos.

Comprobar la firma (en el backend del cliente; el secreto nunca va en el móvil):

Python
import hashlib, hmac
esperada = "sha256=" + hmac.new(secreto.encode(), cuerpo_crudo, hashlib.sha256).hexdigest()
if not hmac.compare_digest(esperada, cabeceras["X-Firma"]):
    ...  # responder 401 y descartar

Como id y creado_en van dentro del cuerpo firmado, reenviar un evento antiguo no cuela: se descarta por id repetido o por antigüedad.

La URL y el secreto se configuran por cliente (hoy, desde la terminal; más adelante, en el área de desarrolladores del portal). Generar un secreto nuevo invalida el anterior al momento.

Registro de cambios#

FechaCambioCompatible
2026-11-01Implementado el webhook contribucion.actualizada: formato de X-Firma (sha256=<hex>), datos como GET /v1/contribuciones/{id}, condiciones de entrega y calendario de reintentosSí (borrador)
2026-10-31Implementados GET /v1/buscar (forma de la respuesta, total_exacto, máximo de 10.000 resultados recorribles) y GET /v1/marcas · GET /v1/marcas/{slug} (pais_origen, web, paginación)Sí (borrador)
2026-10-31Implementados POST /v1/contribuciones y GET /v1/contribuciones/{id}: reglas de X-Contribuidor y de las fotos, cabecera Location, creado_en y actualizado_en, códigos de error de contribucionesSí (borrador)
2026-10-26Implementado GET /v1/skus/{ean}; formato único de errores con codigo estableSí (borrador)
2026-10-26Varias claves X-Api-Key por cliente (rotación sin cortes)Sí (borrador)
2026-10-26Atributos por categoría: atributos en modelo y variante, filtro atributo[<clave>], GET /v1/atributosSí (borrador)
2026-10-26Grupo, línea, ofertas, precio de lanzamiento, parámetros idioma y paisSí (borrador)

Mientras el contrato sea un borrador sin clientes en producción puede cambiar libremente; a partir de la primera versión publicada, se aplican las reglas de versionado.