# Leads del CRM

La API externa de leads para prospección y sincronización con otro CRM: listado, cambios, cualificar, descartar, convertir y avisos LEAD_*.

Con `/external/v1/leads` tu aplicación lee y trabaja los leads del CRM
de UTILIA OS de forma controlada y auditada. Sirve a herramientas de
prospección (Apollo, Cognism, Lusha), a plataformas que sincronizan
con otro CRM (HubSpot, Pipedrive, Salesforce) y a herramientas de
soporte que necesitan ver la captación.

## Autenticación

Todas las peticiones llevan la clave de API de la aplicación:

```
X-Api-Key: <api_key_de_la_app>
```

La clave se obtiene al dar de alta la aplicación en
[Ajustes › Creadas por tu empresa](https://os.utilia.ai/dashboard/settings/custom-apps) y
puedes rotarla desde su ficha sin reescribir tu aplicación.

## Permisos de la API (scopes)

| Scope | Qué permite |
| - | - |
| `crm:leads:read` | Lectura base: listar, buscar, ver el detalle y sincronizar por cambios. |
| `crm:leads:write` | Crear (`POST /external/v1/leads`), actualizar (`PATCH /external/v1/leads/:id`), cualificar, descartar y ajustar la puntuación (`/score`). |
| `crm:leads:convert` | Convertir el lead: crea un contacto y, si lo pides, una oportunidad. No se puede deshacer. |
| `crm:leads:read:scoring` | Ver `score` y `temperature` cuando pides `?include=scoring`. |
| `crm:leads:read:notes` | Ver `notes` cuando pides `?include=notes`. Dato personal crítico. |

Sin el permiso necesario, la respuesta es `403` con
`errorCode: INSUFFICIENT_SCOPE`.

`crm:leads:convert` y `crm:leads:read:notes` son permisos críticos:
concederlos a una aplicación exige el rol de Super Administrador y un
motivo de al menos 20 caracteres. Además, cada llamada a `/convert`
exige su propio `reason` con esa misma longitud mínima.

## Endpoints

La clave de API trabaja a nivel de **organización**: lee y cambia
cualquier lead de tu organización, sin el alcance de filas que tenga en
el CRM la persona responsable de la aplicación. Lo que la API crea
(leads, contactos, clientes, oportunidades) queda a nombre de esa persona
responsable; sin una responsable activa, la respuesta es
`403 APP_RESPONSIBLE_INACTIVE`.

Forma de las respuestas:

- **Listados**: `{ data, pagination: { page, limit, total, totalPages, hasNextPage, hasPreviousPage } }`.
- **Alta**: `{ lead, recordedAt }`, con `201`.
- **Cambios** (actualización, puntuación, cualificar, descartar): `{ lead, changed, recordedAt }`.
- **Conversión**: `{ lead, contactId, opportunityId, clientId, changed, recordedAt }`.
- **Sincronización**: `{ data, nextCursor, hasMore }`.

Un lead tiene esta forma (`score` y `temperature` solo con
`?include=scoring` y `crm:leads:read:scoring`; `notes`, con
`?include=notes` y `crm:leads:read:notes`):

```json
{
  "id": "ld-42",
  "organizationId": "org-1",
  "firstName": "Luis",
  "lastName": "Santana",
  "fullName": "Luis Santana",
  "companyName": "Talleres Santana",
  "email": "luis@talleres.example",
  "phone": "+34 928 000 002",
  "position": "Gerente",
  "source": "Apollo",
  "status": "NEW",
  "stage": "INITIAL_CONTACT",
  "priority": "MEDIUM",
  "isConverted": false,
  "convertedToContactId": null,
  "createdAt": "2026-10-19T08:00:00.000Z",
  "updatedAt": "2026-10-19T08:00:00.000Z",
  "lastContactedAt": null,
  "convertedAt": null
}
```

### Crear un lead

**`POST /api/external/v1/leads`**

Crea un lead en estado NEW. Permiso crm:leads:write. Hace falta al menos email, phone o mobile (si no, 400 VALIDATION_ERROR). Responde 201 con { lead, recordedAt } y emite LEAD_CREATED.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `firstName / lastName` | `string` | No | Nombre y apellidos, hasta 100 caracteres cada uno. fullName se compone solo. |
| `email` | `string` | No | Correo válido, hasta 255 caracteres. |
| `phone / mobile` | `string` | No | Teléfonos, hasta 50 caracteres. |
| `company` | `string` | No | Empresa, hasta 200 caracteres. En la respuesta sale como companyName. |
| `position` | `string` | No | Cargo, hasta 150 caracteres. |
| `source` | `string` | No | Origen (campaña, canal, fuente), hasta 100 caracteres. |
| `notes` | `string` | No | Notas, hasta 5.000 caracteres. Solo se guardan con crm:leads:read:notes; sin él se descartan sin error. |
| `score` | `number` | No | Puntuación inicial, de 0 a 100. Solo se guarda con crm:leads:read:scoring; sin él se descarta sin error. |
| `assignedToUserId` | `string` | No | Persona de tu organización a la que asignarlo. Si no existe o no está activa, 422 USER_NOT_FOUND. |

### Listar y buscar

**`GET /api/external/v1/leads`**

Listado paginado. Por defecto, leads sin convertir en estado NEW, CONTACTED, QUALIFIED o NEGOTIATING, 25 por página (hasta 100). Permiso crm:leads:read.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `page / limit` | `number` | No | Página desde 1 y leads por página, de 1 a 100 (25 por defecto). |
| `status` | `enum[]` | No | NEW, CONTACTED, QUALIFIED, NEGOTIATING, WON, LOST o ARCHIVED, separados por comas. |
| `stage` | `enum[]` | No | INITIAL_CONTACT, NEEDS_ASSESSMENT, PROPOSAL_SENT, NEGOTIATION o CLOSING. |
| `priority` | `enum[]` | No | LOW, MEDIUM, HIGH o URGENT. |
| `leadType` | `enum[]` | No | LEAD_MAGNET, DEMO_REQUEST, CONTACT_FORM, REFERRAL, EVENT, COLD_OUTREACH, INBOUND_CALL u OTHER. |
| `temperature` | `enum[]` | No | COLD, WARM o HOT. |
| `updatedSince` | `string` | No | Solo los cambiados desde esa fecha (ISO 8601). |
| `sortBy / sortOrder` | `enum` | No | updatedAt (por defecto), createdAt, fullName o score; desc (por defecto) o asc. |
| `include` | `string` | No | scoring o notes, separados por comas. Cada uno exige su permiso. |

**`GET /api/external/v1/leads/search`**

Búsqueda libre (q, hasta 200 caracteres) por nombre, apellidos, empresa y correo, hasta 100 resultados (25 por defecto). Permiso crm:leads:read. Admite include.

**`GET /api/external/v1/leads/by-email`**

Búsqueda exacta por correo (email, hasta 320 caracteres), con tiempo de respuesta constante. Permiso crm:leads:read. 404 EMAIL_NOT_INDEXED_LEAD si ningún lead activo lo tiene.

### Consultar un lead

**`GET /api/external/v1/leads/:id`**

Detalle de un lead. Permiso crm:leads:read; ?include=scoring o notes exige su permiso. 404 LEAD_NOT_FOUND si no existe o es de otra organización.

Parámetro de ruta: `id`.

### Sincronizar por cambios

**`GET /api/external/v1/leads/sync`**

Lo que ha cambiado, de lo más antiguo a lo más reciente, con cursor compuesto (updatedAt, id). Permiso crm:leads:read. Parámetros: updatedAfter (ISO 8601), cursor, limit (de 1 a 200, 100 por defecto) e include.

Funciona igual que la de clientes: lo explica
[Sincronizar por cambios](https://os.utilia.ai/docs/developers/api/clients#sincronizar-por-cambios).

### Actualizar un lead

**`PATCH /api/external/v1/leads/:id`**

Actualiza los campos que mandes, con bloqueo optimista (expectedUpdatedAt: si el lead cambió después, 409 LEAD_UPDATE_CONFLICT). Permiso crm:leads:write. notes y score solo se aplican con su permiso de lectura. Responde { lead, changed, recordedAt }.

Parámetro de ruta: `id`.

Admite los mismos campos que el alta, todos opcionales, y
`expectedUpdatedAt`. Un campo que omites no se toca.

**`POST /api/external/v1/leads/:id/score`**

Atajo para cambiar la puntuación (score de 0 a 100, reason opcional de hasta 500 caracteres y expectedUpdatedAt). Exige crm:leads:write y crm:leads:read:scoring. Emite LEAD_SCORE_UPDATED.

Parámetro de ruta: `id`.

### Qué puede cambiar tu aplicación

Desde la API, un lead se cualifica, se descarta o se convierte. Los
estados Contactado y Negociando los pone tu equipo desde el CRM.

**Estados de un lead desde la API**

1. **Nuevo** (`NEW`). Recién creado.
   - **Perdido** (`LOST`). Se llega desde Nuevo cuando: disqualify, desde cualquier estado sin convertir. Es la única baja posible.
2. **Calificado** (`QUALIFIED`). Se llega desde Nuevo cuando: qualify, solo desde Nuevo. Desde otro estado, qualify responde 409.
3. **Ganado** (`WON`, estado final). Se llega desde Calificado cuando: convert crea el contacto y, si lo pides, la oportunidad. isConverted pasa a true. No se deshace.

Otras transiciones:

- Calificado → Perdido: disqualify también lo descarta.
- Nuevo → Ganado: convert funciona desde cualquier estado que no sea Ganado.
- Perdido → Ganado: convert también acepta un lead perdido.

_Los rótulos son los del CRM; el valor de status es el que devuelve la API._

**`POST /api/external/v1/leads/:id/qualify`**

Pasa el lead de NEW a QUALIFIED. Permiso crm:leads:write. Desde otro estado, 409 (LEAD_ALREADY_QUALIFIED también si estaba en CONTACTED o NEGOTIATING). Emite LEAD_QUALIFIED.

Parámetro de ruta: `id`.

**`POST /api/external/v1/leads/:id/disqualify`**

Pasa el lead a LOST: es la única baja posible por la API. Permiso crm:leads:write. 409 LEAD_ALREADY_DISQUALIFIED o LEAD_ALREADY_LOST si ya lo estaba. Emite LEAD_DISQUALIFIED.

Parámetro de ruta: `id`.

**`POST /api/external/v1/leads/:id/convert`**

Convierte el lead: crea el contacto, lo vincula a un cliente (el de targetClientId o uno nuevo) y, si lo pides, una oportunidad. No se deshace. Exige crm:leads:convert y reason de 20 a 1.000 caracteres. Emite LEAD_CONVERTED.

Parámetro de ruta: `id`.

### Cuerpo de las acciones

- **`qualify`**: `reason` (hasta 500 caracteres) y `source`, que admite `external_app`, `crm_sync`, `manual` o `automation`.
- **`disqualify`**: `reason` (hasta 500 caracteres) y `source`, con los mismos valores más `bounce`.
- **`convert`**: `reason` (de 20 a 1.000 caracteres), `createOpportunity` (opcional, `false` por defecto) y `targetClientId` (opcional): la ficha de cliente existente a la que vincular el contacto (si no existe, `404 CLIENT_NOT_FOUND`). Sin `targetClientId`, se crea un cliente nuevo con los datos del lead. Con `createOpportunity: true`, el plan tiene que incluir las oportunidades (`crm.opportunities`); si no, `403 CAPABILITY_NOT_ENABLED` y no se convierte nada.

Los campos personalizados del CRM no salen por esta API. Puedes
leerlos y escribirlos por MCP: lo explica el apartado «Campos
personalizados del CRM» de la [referencia MCP](https://os.utilia.ai/docs/developers/mcp/reference).

## Ejemplos

```bash
# Listar leads activos
curl -H "X-Api-Key: $KEY" \
  "https://os.utilia.ai/api/external/v1/leads?limit=25"

# Cualificar un lead
curl -X POST \
  -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"reason":"Pidió una demostración y encaja en el perfil","source":"external_app"}' \
  "https://os.utilia.ai/api/external/v1/leads/ld-42/qualify"

# Convertir (no se puede deshacer)
curl -X POST \
  -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"reason":"Aceptó la propuesta económica y empezamos la implantación","createOpportunity":true}' \
  "https://os.utilia.ai/api/external/v1/leads/ld-42/convert"
```

```typescript
// SDK JavaScript
import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({ baseURL: 'https://os.utilia.ai/api', apiKey: KEY });

const page = await sdk.externalLeads.list({ limit: 25 });

await sdk.externalLeads.qualify('ld-42', {
  reason: 'Pidió una demostración y encaja en el perfil',
  source: 'external_app',
});

const conv = await sdk.externalLeads.convert('ld-42', {
  reason: 'Aceptó la propuesta económica y empezamos la implantación',
  createOpportunity: true,
});
```

```python
# SDK Python
from utilia_sdk import (
    ConvertLeadInput,
    ExternalLeadListFilters,
    QualifyLeadInput,
    UtiliaSDK,
)

async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key=KEY) as sdk:
    page = await sdk.external_leads.list(ExternalLeadListFilters(limit=25))

    await sdk.external_leads.qualify(
        "ld-42",
        QualifyLeadInput(
            reason="Pidió una demostración y encaja en el perfil",
            source="external_app",
        ),
    )

    conv = await sdk.external_leads.convert(
        "ld-42",
        ConvertLeadInput(
            reason="Aceptó la propuesta económica y empezamos la implantación",
            create_opportunity=True,
        ),
    )
```

## Avisos a tu servidor de lead

Seis eventos a los que puedes suscribir la aplicación desde su ficha.
Si el cambio lo hizo tu propia aplicación, no le llega el aviso; al
resto de aplicaciones suscritas, sí.

| Evento | Cuándo se envía |
| - | - |
| `LEAD_CREATED` | Se ha creado un lead en el CRM. |
| `LEAD_UPDATED` | Cambian el correo, el estado, la etapa, la puntuación o la persona asignada. Incluye `changes[]`. |
| `LEAD_QUALIFIED` | El lead pasa a `QUALIFIED`, con el motivo si la aplicación lo aporta. |
| `LEAD_DISQUALIFIED` | El lead pasa a `LOST`, con el motivo registrado. |
| `LEAD_CONVERTED` | El lead queda convertido (`isConverted=true`, `status=WON`). Incluye `contactId` y `opportunityId`. |
| `LEAD_SCORE_UPDATED` | Cambian `score` o `temperature`. Solo llega a aplicaciones con `crm:leads:read:scoring`. |

## Errores propios de estos endpoints

| Código | HTTP | Significado |
| - | - | - |
| `INSUFFICIENT_SCOPE` | 403 | A la aplicación le falta el permiso de la API necesario. |
| `MODULE_NOT_ENABLED` | 403 | Tu organización no tiene contratado el aplicativo CRM. Ninguna operación de esta API funcionará. |
| `CAPABILITY_NOT_ENABLED` | 403 | El CRM está contratado, pero la capacidad de leads (`crm.leads`) no entra en el plan. |
| `LEAD_NOT_FOUND` | 404 | El lead no existe o es de otra organización. |
| `EMAIL_NOT_INDEXED_LEAD` | 404 | No hay ningún lead activo con ese correo. |
| `LEAD_ALREADY_QUALIFIED` | 409 | El lead ya estaba cualificado. |
| `LEAD_ALREADY_DISQUALIFIED` | 409 | El lead ya estaba descartado. |
| `LEAD_ALREADY_LOST` | 409 | Variante del anterior cuando el estado es `LOST`. |
| `LEAD_ALREADY_CONVERTED` | 409 | El lead ya se había convertido. |
| `LEAD_UPDATE_CONFLICT` | 409 | Bloqueo optimista: el lead cambió después de la lectura que indica `expectedUpdatedAt`. |
| `LEAD_CONVERSION_REQUIRES_REASON` | 422 | Falta `reason` o tiene menos de 20 caracteres. |
| `USER_NOT_FOUND` | 422 | Al crear o actualizar: la persona de `assignedToUserId` no existe en tu organización o no está activa. |
| `CLIENT_NOT_FOUND` | 404 | Al convertir: el cliente de `targetClientId` no existe o es de otra organización. |
| `APP_RESPONSIBLE_INACTIVE` | 403 | Al crear, actualizar o convertir: la aplicación no tiene una persona responsable activa. |

> **Atención: Tres 403 distintos, y solo uno se arregla con permisos**
>
> Delante de `/external/v1/leads` hay dos puertas comerciales además de
> la de permisos: el aplicativo **CRM** tiene que estar contratado y la
> capacidad **`crm.leads`** tiene que entrar en el plan de tu
> organización. Las tres responden `403`; solo las distingue el campo
> `errorCode` del cuerpo.
>
> Conceder permisos a la aplicación no desbloquea un `MODULE_NOT_ENABLED`
> ni un `CAPABILITY_NOT_ENABLED`: eso solo cambia cuando tu organización
> amplía su plan, y reintentar tampoco sirve. El cuerpo exacto de cada
> uno y cómo detectarlos desde los SDK están en
> [Errores de plan](https://os.utilia.ai/docs/developers/sdk/plan-errors).

## Límites de uso

| Endpoint | Límite por defecto |
| - | - |
| Lectura general | 600 peticiones por hora |
| `GET /external/v1/leads/sync` | 60 peticiones por hora |
| `GET /external/v1/leads/by-email` | 300 peticiones por hora |

Al superar un límite, la API responde `429` con la cabecera
`Retry-After`, que indica los segundos que faltan para que se renueve.

## Con los SDK oficiales

- **JavaScript** (`@utilia-os/sdk-js`): `sdk.externalLeads` con once
  métodos: `list`, `get`, `search`, `byEmail`, `sync`, `qualify`,
  `disqualify`, `convert`, `create`, `update` y `updateScore`.
- **Python** (`utilia-sdk`): `sdk.external_leads` con los mismos once
  métodos, en versión asíncrona y síncrona.

Las versiones de los dos SDK están en un único sitio:
[Versiones y compatibilidad](https://os.utilia.ai/docs/developers/sdk/versions).
Consúltalo antes de fijar la dependencia de tu proyecto.

## Decisiones de producto

- No hay `DELETE` sobre leads desde fuera: la única baja es `/disqualify`, que pasa el lead a `LOST`.
- `/convert` no se puede deshacer: crea un contacto y, si lo pides, una oportunidad.
- Las notas internas del lead solo salen con su permiso propio (`crm:leads:read:notes`).

Cada cambio queda en el registro de auditoría con la dirección IP y el
cliente que lo hizo, y cada organización solo ve sus propios leads.

## Siguiente paso

Tras integrar la lectura, suscribe la aplicación a los eventos `LEAD_*`
desde su ficha para mantener la captación sincronizada sin consultar la
API cada poco. Para trabajar también con la ficha de cliente vinculada,
consulta [Clientes del CRM](https://os.utilia.ai/docs/developers/api/clients); el
catálogo completo de eventos está en
[Contenido de los avisos](https://os.utilia.ai/docs/developers/webhooks/events).
