Primera petición#
Consigue una clave
Se crean y se revocan en el área de desarrolladores del portal. Cada cliente puede tener varias, para rotarlas.
Pide una ficha por su EAN
Desde tu backend, nunca desde la app ni la web: la clave no puede viajar al móvil.
Recibe la talla exacta
Modelo, color, talla, composición por partes y la confianza de cada dato.
curl https://api.example.com/v1/skus/08445678123453 \ -H "X-Api-Key: $MODA_API_KEY"
{
"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étodo | Ruta | Estado |
|---|---|---|
| GET | /v1/skus/{ean} | Disponible |
| GET | /v1/buscar | Disponible |
| 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/marcas | Disponible |
| GET | /v1/marcas/{slug} | Disponible |
| GET | /v1/marcas/{slug}/novedades?desde=<fecha> | Próximamente |
| POST | /v1/contribuciones | Disponible |
| GET | /v1/contribuciones/{id} | Disponible |
| POST | /v1/verificaciones | Próximamente |
| Webhook | contribucion.actualizada | Disponible |
| Webhook | ficha.actualizada | Próximamente |
Autenticación#
| Cabecera | Obligatoria | Qué es |
|---|---|---|
X-Api-Key | Siempre | Clave 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-Contribuidor | Al contribuir o verificar | Id 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:
{ "error": { "codigo": "clave_invalida", "mensaje": "La clave no existe, está revocada o ha caducado" } }| Código | Estado | Cuándo |
|---|---|---|
falta_clave | 401 | No hay cabecera X-Api-Key |
clave_invalida | 401 | La clave no existe, está revocada o ha caducado, o el cliente está desactivado |
ean_invalido | 422 | El EAN no tiene una longitud válida o no pasa el dígito de control |
idioma_invalido | 422 | idioma no es un código como es, en o pt-BR |
parametros_invalidos | 422 | Otro parámetro no válido (p. ej. pais que no son 2 letras) |
no_encontrado | 404 | No existe, no está publicado o la ruta no existe |
contribucion_no_permitida | 403 | El cliente no tiene puede_contribuir |
falta_contribuidor | 422 | Contribución sin cabecera X-Contribuidor |
contribuidor_invalido | 422 | X-Contribuidor no tiene el formato pedido (o parece un email) |
foto_invalida | 422 | Falta foto_prenda, o una foto no es una imagen admitida, es muy pequeña o muy grande |
almacen_no_disponible | 503 | No 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 respuestas | Quitar o renombrar un campo |
| Endpoints nuevos | Cambiar el tipo o el significado de un campo |
| Parámetros nuevos opcionales | Hacer obligatorio un parámetro, o quitar uno |
Valores nuevos en listas cerradas (confianza, temporada, estado…), atributos y opciones nuevas | Cambiar los códigos de error o de estado HTTP |
| Eventos de webhook nuevos | Cambiar 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:
v1yv2conviven al menos 6 meses (objetivo: 12).- Durante ese tiempo, las respuestas de
v1llevan las cabecerasDeprecation(RFC 9745) ySunset(RFC 8594, con la fecha de cierre) y unLinka la guía de migración. - Como cada cliente usa su propia clave, se sabe quién sigue en
v1y 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ámetro | Qué 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. |
pais | Có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.
{
"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:
{
"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,modeloymarcason los de la ficha, sin lo que solo tiene sentido para una talla (descripción, país de fabricación, precio).tallasva en orden de tallaje (XS…XXL, 36…46) y sueanpuede sernull.imagen, la frontal si la hay, onull. 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.categoriade primer nivel (partes-de-arriba) incluye sus subcategorías. Enatributo[<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.
totalse cuenta hasta 10.000; si hay más,totales 10.000 ytotal_exactoesfalse. 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:
{ "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:
| Campo | Tipo | Notas |
|---|---|---|
ean | texto | Opcional 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_ocr | texto | Obligatorio. Texto leído en el móvil de ambas etiquetas (máx. 20.000 caracteres) |
foto_etiqueta_carton | imagen | Opcional |
foto_etiqueta_cosida | imagen | Recomendada: siempre está en la prenda |
foto_prenda | imagen | Obligatoria |
- 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}:
{ "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#
{ "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.
{ "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)#
| Evento | Cuándo |
|---|---|
contribucion.actualizada | Cambia el estado de una contribución del cliente |
ficha.actualizada | Cambia 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:
{ "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:
{ "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
httpsque resuelvan a direcciones públicas. - Cuenta como entregado si responde
2xxen 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 (usadatos.actualizado_enpara 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):
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 descartarComo 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#
| Fecha | Cambio | Compatible |
|---|---|---|
| 2026-11-01 | Implementado el webhook contribucion.actualizada: formato de X-Firma (sha256=<hex>), datos como GET /v1/contribuciones/{id}, condiciones de entrega y calendario de reintentos | Sí (borrador) |
| 2026-10-31 | Implementados 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-31 | Implementados 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 contribuciones | Sí (borrador) |
| 2026-10-26 | Implementado GET /v1/skus/{ean}; formato único de errores con codigo estable | Sí (borrador) |
| 2026-10-26 | Varias claves X-Api-Key por cliente (rotación sin cortes) | Sí (borrador) |
| 2026-10-26 | Atributos por categoría: atributos en modelo y variante, filtro atributo[<clave>], GET /v1/atributos | Sí (borrador) |
| 2026-10-26 | Grupo, línea, ofertas, precio de lanzamiento, parámetros idioma y pais | Sí (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.