# LicitaIA API v1

> Versión 1.0. Servidor: https://api.licitaia.org. Este documento se genera del mismo OpenAPI que sirve `https://api.licitaia.org/api/v1/openapi.json`; si difieren, manda el JSON.

API pública de LicitaIA (piloto). Licitaciones españolas y europeas,
recomendaciones por empresa y análisis de pliegos con IA, integrables en tus
propias aplicaciones.

## Autenticación

Cabecera `x-api-key` con una clave con prefijo `lic_live_`. Sin ella, o con
una clave inválida: 401 sin `code`; revocada: 401 `API_KEY_REVOCADA`;
caducada: 401 `API_KEY_CADUCADA` (rótala o crea otra). La clave se crea desde la app
(`POST /api-keys`, solo owner/admin de la organización) y se consulta el
consumo del mes con `GET /api-keys/uso`.

## Quién tiene API

Planes **Pro**, **Team** y **AAPP** (plan efectivo). La prueba de Pro de 14
días al alta cuenta como Pro mientras dura. Al terminar la prueba o el plan
sin renovar: 403 `PLAN_REQUIRED`. El plan **Free no tiene acceso a la API**.

## Permisos (scopes)

Cada clave lleva uno o varios: `licitaciones:read`, `recomendaciones:read`,
`analisis:read`, `analisis:write`, `veredictos:read`. Una llamada a una
ruta sin el scope necesario responde 403 `SCOPE_REQUIRED`.

## Veredicto GO/NO_GO

`GET /v1/licitaciones/{id}/veredicto` devuelve el último veredicto guardado
de la empresa del dueño de la clave (`meta.stale` avisa si el perfil cambió
desde entonces); `POST` al mismo recurso lo (re)calcula. Es el mismo motor
determinista que la ficha web: compara los requisitos de solvencia del pliego
con el perfil de empresa, **no llama al modelo y no cobra créditos**. Si la
licitación no tiene requisitos estructurados todavía, el veredicto llega con
0 checks y un `siguientePaso` que dice cómo desbloquearlo (lanzar
`POST /v1/analisis`, que sí cobra, y volver a pedir el veredicto).
Errores propios: 404 `SIN_VEREDICTO` (nunca calculado; el cuerpo trae el
`siguientePaso`) y 404 `EMPRESA_REQUERIDA` (la organización no tiene
perfil de empresa: sin perfil no hay nada que comparar).

## Cuotas

| Plan | Peticiones/mes | Peticiones/minuto | Claves activas |
|---|---|---|---|
| Pro | 5.000 | 60 | 2 |
| Team / AAPP | 50.000 | 300 | 10 |

La cuota **mensual es por ORGANIZACIÓN**: rotar o añadir claves no la
multiplica, todas las claves de la organización comparten el mismo contador.
El límite **por minuto es por CLAVE**.

Toda respuesta autenticada lleva `X-RateLimit-Limit-Month`,
`X-RateLimit-Remaining-Month` y `X-RateLimit-Reset-Month` (epoch en
segundos). Un rechazo por 429 lleva además `Retry-After` y el cuerpo
`{ code: 'RATE_LIMIT' | 'QUOTA_EXCEEDED', message, retryAfter, docs_url }`.
En un 429 `RATE_LIMIT` (ráfaga por minuto) el contador mensual no se ha
leído, así que **`X-RateLimit-Remaining-Month` se omite a propósito** en esa
respuesta concreta. Una petición rechazada con 400 por validación **sí
consume cuota mensual**: los guards de clave/permiso/cuota corren antes que
los pipes de validación del cuerpo.

Las rutas v1 están fuera del limitador global por IP de la plataforma, así
que no llevan las cabeceras `X-RateLimit-*` genéricas: los únicos límites son
la cuota mensual (cabeceras `-Month`) y la ráfaga por minuto. Un
`x-api-key` sin la forma de una clave (`lic_live_` + 32 hex) da 401 sin
tocar la BD. Si el dueño de la clave deja de ser miembro activo de la
organización, 401 `CLAVE_SIN_DUENO`: rótala.

## Idempotencia

`POST /v1/analisis` acepta la cabecera `Idempotency-Key` (máx. 128
caracteres), válida 24 h. Un reintento con la misma clave debe llevar el
mismo `licitacionId` y el mismo `modo`; si no, 422
`IDEMPOTENCY_KEY_REUSED`. Sin Redis disponible la idempotencia por cabecera
no funciona, pero el servicio sigue devolviendo el análisis existente de tu
organización para esa licitación (mismo resultado práctico, sin la garantía
de la cabecera).

## Recomendaciones

`GET /v1/recomendaciones` devuelve lo mismo que ve el panel web
(`matchForCompany`) para la empresa ACTIVA del dueño de la clave (o la por
defecto de su organización) — no la empresa asociada a la clave.
`meta.companyId` dice qué empresa se usó; `meta.companyIdClave` aparece
solo si la clave tiene otra empresa asociada. `score` es la afinidad 0-100
normalizada sobre los criterios efectivos del perfil (CPV, provincia,
presupuesto, tipo) y las preferencias de alerta del dueño de la clave — el
mismo cálculo que usa el digest de alertas por correo; `reasons` son sus
motivos. El digest de alertas por correo aplica su propia ventana de tiempo,
su propia selección de candidatas y un máximo de 25 resultados, así que la
LISTA puede diferir de esta respuesta aunque el cálculo del `score` por
licitación sea idéntico.

## Webhook `alert.digest`

Team y AAPP con una integración activa reciben el evento `alert.digest` tras
cada digest, con identificadores únicamente (`companyId`,
`licitacionIds`, ventana, frecuencia) — el contenido se resuelve llamando a
`GET /v1/licitaciones/{id}`. Llega un evento por digest de cada miembro con
alertas por correo; `destinatario` (hash opaco, sin datos personales) los
distingue.

## Para agentes (capa 0, 2026-09-29)

Cada operación lleva la extensión `x-licitaia` con `scope`, `cobra`
(true solo en `POST /v1/analisis`, con `creditos`) y `soloLectura`, y un
`example` de respuesta con ids sintéticos. Esta misma referencia existe en
Markdown en `/api/v1/docs.md` (o en `/api/v1/docs` con
`Accept: text/markdown`), el índice para agentes en `/llms.txt` y el
catálogo RFC 9727 en `/.well-known/api-catalog`. Las fichas públicas del
sitio también se sirven en Markdown: `https://licitaia.org/licitaciones/{id}.md`.
Para agentes de programación (Claude Code, Codex, Copilot, Cursor…) hay una
**skill oficial** en el estándar Agent Skills:
`https://licitaia.org/skills/licitaia-api/SKILL.md` (carpeta
`frontend/public/skills/licitaia-api/` del repositorio), con los flujos en curl, los errores y las reglas de coste.

## Errores

400 (`IDEMPOTENCY_KEY_INVALID`), 401 (sin clave / clave inválida /
`API_KEY_REVOCADA` / `API_KEY_CADUCADA` / `CLAVE_SIN_DUENO`), 403 (`PLAN_REQUIRED` / `SCOPE_REQUIRED` / créditos
insuficientes en `POST /v1/analisis`),
404 (recurso no existe o es de otra organización), 409 (conflicto de
estado), 422 (petición semánticamente inválida, p. ej.
`IDEMPOTENCY_KEY_REUSED` o licitación sin pliegos), 429 (`RATE_LIMIT` /
`QUOTA_EXCEEDED`).

## Versionado

v1 es estable. Los cambios incompatibles llegarán en `/v2`; toda
deprecación de v1 se anuncia en esta misma documentación con **al menos 90
días** de antelación.

## Límites del piloto

Sin subida de ficheros por API (el análisis parte siempre de una licitación
ya ingerida). Hay **servidor MCP** (Streamable HTTP sin estado) en
`POST https://api.licitaia.org/api/mcp` con la misma clave
(`x-api-key` o `Authorization: Bearer lic_live_…`), mismos scopes y misma
cuota; solo con clave, sin OAuth (no listado en directorios).

## Ejemplos

Todos los ejemplos de esta documentación usan identificadores sintéticos
(`00000000-0000-4000-8000-000000000001` y variantes) y ninguna clave real.

```bash
curl https://api.licitaia.org/api/v1/licitaciones \
  -H "x-api-key: lic_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

## Operaciones

### GET /api/v1/licitaciones

**Buscar licitaciones**

Devuelve una lista paginada de licitaciones filtradas por los parámetros indicados.

- Scope: `licitaciones:read` · Cobra: no · Solo lectura: sí
- Parámetros:
  - `limit` (query, opcional): Resultados por página (20 por defecto, máx 100)
  - `page` (query, opcional): Página (1-based)
  - `conPliego` (query, opcional): true = solo licitaciones con pliego descargable (las únicas analizables con POST /v1/analisis)
  - `organismo` (query, opcional): Órgano o entidad contratante (texto)
  - `provincia` (query, opcional): Nombre de la provincia
  - `presupuestoMax` (query, opcional): Euros sin IVA, sobre presupuestoBase
  - `presupuestoMin` (query, opcional): Euros sin IVA, sobre presupuestoBase
  - `estado` (query, opcional): Estado; para «abiertas» usa en_plazo
  - `tipoContrato` (query, opcional): Tipo de contrato
  - `cpv` (query, opcional): Código CPV (8 dígitos)
  - `query` (query, opcional): Texto libre
- Respuestas:
  - `200`: Lista de licitaciones
  - `400`: Parámetros de búsqueda inválidos
  - `401`: API key inválida o ausente
  - `403`: SCOPE_REQUIRED (sin licitaciones:read) o PLAN_REQUIRED
  - `429`: RATE_LIMIT o QUOTA_EXCEEDED (ver Retry-After)

### GET /api/v1/licitaciones/{id}

**Obtener licitación por ID**

Devuelve el detalle completo de una licitación, incluyendo la clasificación de umbral LCSP.

- Scope: `licitaciones:read` · Cobra: no · Solo lectura: sí
- Parámetros:
  - `id` (path, obligatorio): UUID de la licitación
- Respuestas:
  - `200`: Detalle de la licitación con clasificación de umbral
  - `401`: API key inválida o ausente
  - `403`: SCOPE_REQUIRED (sin licitaciones:read) o PLAN_REQUIRED
  - `404`: Licitación no encontrada
  - `429`: RATE_LIMIT o QUOTA_EXCEEDED (ver Retry-After)

### GET /api/v1/recomendaciones

**Licitaciones recomendadas para tu empresa**

Las mismas recomendadas que el panel y el copilot (hasta 50, ordenadas por afinidad, con sus motivos). La empresa es la activa del dueño de la clave (o la por defecto de la organización), no la asociada a la clave: meta.companyId dice cuál se usó y meta.companyIdClave aparece si la clave tiene otra. El digest por correo usa reglas propias (preferencias de alerta, ventana, máximo 25), así que no tiene por qué coincidir. La lista no trae documentos ni requisitos: la ficha completa está en /v1/licitaciones/{id}.

- Scope: `recomendaciones:read` · Cobra: no · Solo lectura: sí
- Parámetros:
  - `limit` (query, opcional): Máximo de recomendaciones a devolver (1-50). Sin él, todas las que da el panel (hasta 50).
- Respuestas:
  - `200`: Recomendadas con score y motivos
  - `400`: limit fuera de 1-50
  - `401`: API key inválida, revocada o ausente
  - `403`: SCOPE_REQUIRED (sin recomendaciones:read) o PLAN_REQUIRED
  - `404`: La organización no tiene perfil de empresa
  - `429`: RATE_LIMIT o QUOTA_EXCEEDED (ver Retry-After)

### POST /api/v1/analisis

**Analizar los pliegos de una licitación**

Lanza (o recupera) el análisis de los pliegos oficiales de la licitación, con el mismo cobro que la ficha (2 créditos; la caché compartida y los análisis que tu organización ya tiene no se vuelven a cobrar). Sin Idempotency-Key, pedir otra vez la misma licitación devuelve el análisis existente. Con Idempotency-Key, un reintento en 24 h devuelve el mismo análisis con idempotente: true.

- Scope: `analisis:write` · Cobra: sí, 2 créditos · Solo lectura: no
- Parámetros:
  - `Idempotency-Key` (header, opcional): Clave del cliente para reintentos seguros (máx. 128 caracteres, 24 h).
- Cuerpo JSON obligatorio (esquema en el OpenAPI)
- Respuestas:
  - `202`: Análisis en cola o ya disponible
  - `400`: Cuerpo inválido o Idempotency-Key de más de 128 caracteres
  - `401`: API key inválida, revocada o ausente
  - `403`: SCOPE_REQUIRED (sin analisis:write), PLAN_REQUIRED o créditos insuficientes
  - `404`: Licitación no encontrada
  - `422`: La licitación no publica pliegos descargables, o IDEMPOTENCY_KEY_REUSED (la clave ya se usó con otra licitación u otro modo)
  - `429`: RATE_LIMIT o QUOTA_EXCEEDED (ver Retry-After)

### GET /api/v1/analisis/{id}

**Leer un análisis**

El análisis de tu organización, con la misma restricción por plan o derecho de evaluación que la web. No cobra.

- Scope: `analisis:read` · Cobra: no · Solo lectura: sí
- Parámetros:
  - `id` (path, obligatorio)
- Respuestas:
  - `200`: Análisis
  - `400`: id no es un UUID
  - `401`: API key inválida, revocada o ausente
  - `403`: SCOPE_REQUIRED o PLAN_REQUIRED
  - `404`: No existe o es de otra organización
  - `429`: RATE_LIMIT o QUOTA_EXCEEDED (ver Retry-After)

### GET /api/v1/analisis/{id}/status

**Estado de un análisis**

Estado y etapa real del worker (descargando, leyendo, analizando) mientras corre. Para sondear. No cobra.

- Scope: `analisis:read` · Cobra: no · Solo lectura: sí
- Parámetros:
  - `id` (path, obligatorio)
- Respuestas:
  - `200`: { id, status, etapa, iniciadoEn, errorMessage? }
  - `400`: id no es un UUID
  - `401`: API key inválida, revocada o ausente
  - `403`: SCOPE_REQUIRED o PLAN_REQUIRED
  - `404`: No existe o es de otra organización
  - `429`: RATE_LIMIT o QUOTA_EXCEEDED (ver Retry-After)

### GET /api/v1/licitaciones/{id}/veredicto

**Último veredicto GO/NO_GO guardado**

El veredicto más reciente de la empresa del dueño de la clave para esta licitación, tal y como lo ve la ficha web. meta.stale = true significa que el perfil de empresa cambió desde entonces en algo que el motor consulta: recalcula con POST. No cobra ni llama al modelo. Si nunca se calculó, 404 SIN_VEREDICTO con el siguiente paso en el cuerpo.

- Scope: `veredictos:read` · Cobra: no · Solo lectura: sí
- Parámetros:
  - `id` (path, obligatorio): Id de la licitación
- Respuestas:
  - `200`: Veredicto guardado
  - `400`: id no es un UUID
  - `401`: API key inválida, revocada o ausente
  - `403`: SCOPE_REQUIRED (sin veredictos:read) o PLAN_REQUIRED
  - `404`: SIN_VEREDICTO (nunca calculado para esta empresa; el cuerpo trae siguientePaso) o EMPRESA_REQUERIDA (la organización no tiene perfil de empresa)
  - `429`: RATE_LIMIT o QUOTA_EXCEEDED (ver Retry-After)

### POST /api/v1/licitaciones/{id}/veredicto

**Calcular (o recalcular) el veredicto GO/NO_GO**

Compara los requisitos de solvencia del pliego con el perfil de la empresa del dueño de la clave y guarda el resultado. Determinista: no llama al modelo y no cobra créditos. A diferencia de la web, NO lanza la lectura del pliego oficial cuando la licitación aún no tiene requisitos estructurados: en ese caso el veredicto llega con 0 checks y siguientePaso indica que hay que lanzar POST /v1/analisis (scope analisis:write, 2 créditos) y volver a pedir el veredicto cuando termine. Respuesta 200 aunque recalcule: el recurso es el veredicto, no un job.

- Scope: `veredictos:read` · Cobra: no · Solo lectura: no
- Parámetros:
  - `id` (path, obligatorio): Id de la licitación
- Respuestas:
  - `200`: Veredicto recién calculado
  - `400`: id no es un UUID
  - `401`: API key inválida, revocada o ausente
  - `403`: SCOPE_REQUIRED (sin veredictos:read) o PLAN_REQUIRED
  - `404`: Licitación no encontrada o EMPRESA_REQUERIDA (la organización no tiene perfil de empresa)
  - `429`: RATE_LIMIT o QUOTA_EXCEEDED (ver Retry-After)
