# Clientes del CRM

API externa /external/v1/clients: alta, listado, búsqueda por NIF, contactos vinculados, grupos de empresas, desactivación y avisos CLIENT_*.

Con `/external/v1/clients` tu aplicación lee y mantiene las fichas de
cliente del CRM de UTILIA OS de forma controlada y auditada: para
facturar desde fuera, sincronizar el CRM en los dos sentidos o dar
soporte con los datos de cada cliente a mano.

## Autenticación

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

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

Además del permiso de la API, el plan de tu organización tiene que
incluir el aplicativo **CRM**; si no, la respuesta es
`403 MODULE_NOT_ENABLED`. Lo que toca a los grupos de empresas
necesita además que el plan incluya **Grupos empresariales**: la
consulta del grupo de un cliente (`GET /external/v1/clients/:id/group`)
y cualquier `PATCH /external/v1/clients/:id` que lleve `parentClientId`.
Sin eso, esas peticiones responden `403 CAPABILITY_NOT_ENABLED` y el
resto sigue funcionando, incluido un `PATCH` sin ese campo.

## Permisos de la API (scopes)

| Scope | Qué permite |
| - | - |
| `crm:clients:read` | Lectura base: listar, buscar, detalle, sincronización por cambios, contactos vinculados (junto con `crm:contacts:read`) y grupo. |
| `crm:clients:write` | Crear, actualizar, desactivar y reactivar clientes. No permite eliminar ni cambiar el vínculo de grupo. Cambiar datos fiscales exige además `crm:clients:read:fiscal-data`. |
| `crm:clients:write:hierarchy` | Cambiar `parentClientId` con `PATCH /external/v1/clients/:id` (vincular o desvincular una filial de su matriz). Se exige además del de escritura: las claves existentes no lo reciben solas. Solo sirve si el plan incluye Grupos empresariales. |
| `crm:clients:read:fiscal-data` | Ver `taxId`, dirección fiscal, régimen fiscal y retenciones. Dato personal crítico. |
| `crm:clients:read:notes` | Ver `notes` cuando pides `?include=notes`. Dato personal crítico. |

Conceder `crm:clients:read:fiscal-data` o `crm:clients:read:notes` exige
el rol de Super Administrador y un motivo de al menos 20 caracteres,
que queda en el registro de auditoría.

> **Atención: Datos que esta API no expone nunca**
>
> - El **IBAN** no viaja por esta API: ni siquiera es un campo de la ficha de cliente.
> - Los **datos financieros agregados** (saldo, deuda, crédito disponible) no se publican.
> - El **`taxId` no aparece nunca en listados ni en la búsqueda libre**: solo en `GET /external/v1/clients/:id` o `/by-tax-id`, y solo si la aplicación tiene `crm:clients:read:fiscal-data`.

## Endpoints

La clave de API trabaja a nivel de **organización**: lee y cambia
cualquier cliente de tu organización, sin el alcance de filas que tenga
en el CRM la persona responsable de la aplicación. Cada lectura y cada
cambio queda en el registro de auditoría a nombre de la aplicación.

Forma de las respuestas:

- **Listados**: `{ data, pagination }`, con `page`, `limit`, `total`,
  `totalPages`, `hasNextPage` y `hasPreviousPage` en `pagination`.
- **Alta**: `{ client, recordedAt }`, con `201`.
- **Cambios** (actualización, desactivación, reactivación): `{ client, changed, recordedAt }`. `changed: false` si no había nada que cambiar.
- **Sincronización**: `{ data, nextCursor, hasMore }`.

Un cliente tiene esta forma (los campos fiscales y las notas solo
aparecen en el detalle y con su permiso):

```json
{
  "id": "cli-42",
  "organizationId": "org-1",
  "name": "Acme",
  "slug": "acme",
  "legalName": "Acme Soluciones SL",
  "status": "ACTIVE",
  "type": "EMPRESA",
  "size": "PEQUENA_EMPRESA",
  "industry": "Software",
  "email": "hola@acme.example",
  "phone": "+34 928 000 000",
  "city": "Las Palmas de Gran Canaria",
  "country": "España",
  "source": "external_app",
  "parentClientId": null,
  "createdAt": "2026-10-01T09:00:00.000Z",
  "updatedAt": "2026-10-20T11:30:00.000Z",
  "lastContactedAt": null
}
```

### Crear un cliente

**`POST /api/external/v1/clients`**

Crea un cliente en el CRM. Permiso crm:clients:write. Responde 201 con { client, recordedAt } y emite CLIENT_CREATED.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `name` | `string` | Sí | Nombre comercial, de 1 a 200 caracteres. |
| `slug` | `string` | No | Identificador legible, único en tu organización: minúsculas, números y guiones, hasta 250 caracteres. Sin él, se genera a partir del nombre. Si lo mandas y ya existe, 409 CLIENT_SLUG_GENERATION_FAILED. |
| `type` | `enum` | No | EMPRESA, AUTONOMO o PARTICULAR. |
| `country` | `string` | No | País, hasta 100 caracteres. |
| `sector` | `string` | No | Sector, hasta 100 caracteres. |
| `description` | `string` | No | Descripción corta, hasta 200 caracteres. |
| `email` | `string` | No | Correo válido, hasta 255 caracteres. |
| `phone` | `string` | No | Teléfono, hasta 50 caracteres. |
| `notes` | `string` | No | Notas, hasta 5.000 caracteres. Solo se guardan si la aplicación tiene crm:clients:read:notes. |
| `taxId` | `string` | No | NIF o CIF, hasta 50 caracteres. Solo se guarda si la aplicación tiene crm:clients:read:fiscal-data; sin ese permiso se descarta sin error. |

El cliente nace activo y a nombre de la persona responsable de la
aplicación: sin una responsable activa, el alta responde
`403 APP_RESPONSIBLE_INACTIVE` antes de escribir nada.

### Listar y buscar

**`GET /api/external/v1/clients`**

Listado paginado, por defecto solo clientes activos y 25 por página (hasta 100). Permiso crm:clients:read. Nunca incluye taxId.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `page` | `number` | No | Página, desde 1. |
| `limit` | `number` | No | Clientes por página, de 1 a 100. Por defecto, 25. |
| `status` | `enum[]` | No | ACTIVE, INACTIVE, SUSPENDED o BLOCKED, separados por comas. Por defecto, solo ACTIVE. |
| `type` | `enum[]` | No | EMPRESA, AUTONOMO o PARTICULAR. |
| `size` | `enum[]` | No | Tamaño de la empresa: MICRO, PEQUENA_EMPRESA, MEDIANA o GRANDE. |
| `industry` | `string` | No | Sector exacto, hasta 100 caracteres. |
| `country` | `string` | No | País exacto, hasta 100 caracteres. |
| `updatedSince` | `string` | No | Solo los cambiados desde esa fecha (ISO 8601). |
| `sortBy` | `enum` | No | updatedAt (por defecto), createdAt o name. |
| `sortOrder` | `enum` | No | desc (por defecto) o asc. |
| `include` | `string` | No | notes, con crm:clients:read:notes. Los datos fiscales nunca salen en un listado. |

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

Búsqueda libre (q, hasta 200 caracteres) por nombre comercial, razón social, slug, correo y web, hasta 100 resultados (25 por defecto). Permiso crm:clients:read. No busca ni devuelve taxId.

**`GET /api/external/v1/clients/by-tax-id`**

Búsqueda exacta por NIF o CIF (taxId, hasta 50 caracteres), con tiempo de respuesta constante. Permiso crm:clients:read. 404 TAX_ID_NOT_INDEXED si ningún cliente activo lo tiene.

**`GET /api/external/v1/clients/by-slug`**

Búsqueda exacta por slug (hasta 250 caracteres), con tiempo de respuesta constante. Permiso crm:clients:read. 404 SLUG_NOT_INDEXED si ningún cliente activo lo tiene.

`by-tax-id` y `by-slug` devuelven el cliente con la forma del detalle;
`taxId` solo viaja si la aplicación tiene `crm:clients:read:fiscal-data`.

### Consultar un cliente

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

Detalle del cliente, con parent si es filial. Permiso crm:clients:read; ?include=fiscalData exige crm:clients:read:fiscal-data y ?include=notes, crm:clients:read:notes. 404 CLIENT_NOT_FOUND si no existe o es de otra organización.

Parámetro de ruta: `id`.

**`GET /api/external/v1/clients/:id/contacts`**

Contactos vinculados al cliente, paginados (page y limit, de 1 a 100, 25 por defecto). Exige crm:clients:read y crm:contacts:read. Responde { data, pagination } con la forma de los contactos.

Parámetro de ruta: `id`.

### Sincronizar por cambios

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

Lo que ha cambiado, de lo más antiguo a lo más reciente, con cursor compuesto (updatedAt, id). Permiso crm:clients:read. Parámetros: updatedAfter (ISO 8601), cursor (el nextCursor anterior) y limit (de 1 a 200, 100 por defecto). Sin taxId.

`GET /external/v1/clients/sync` trae solo lo que ha cambiado, de lo más
antiguo a lo más reciente, en páginas de hasta 200 clientes (100 por
defecto). Sirve igual para contactos y leads, cada uno con su ruta.

**Sincronizar los clientes por cambios**

Participantes: Tu servidor y UTILIA OS.

1. Tu servidor → UTILIA OS: `GET /api/external/v1/clients/sync?updatedAfter=…`. La fecha de tu última sincronización. Sin ella, se empieza desde el principio.
2. UTILIA OS → Tu servidor (respuesta): data, nextCursor y hasMore: true. Hasta limit clientes, ordenados por updatedAt e id.
3. Tu servidor → UTILIA OS: `GET /api/external/v1/clients/sync?cursor=…`. Con el nextCursor recibido. Sigue justo donde lo dejaste.
4. UTILIA OS → Tu servidor (respuesta): hasMore: false y nextCursor: null. No queda nada pendiente en esta ronda.
5. Tu servidor (por su cuenta): Guarda el updatedAt más reciente para la próxima ronda.

_Si el cursor llega dañado, la respuesta es 400 VALIDATION_ERROR: vuelve a empezar con updatedAfter._

### Actualizar un cliente

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

Actualiza los campos que mandes, con bloqueo optimista (expectedUpdatedAt). Permiso crm:clients:write; taxId y fiscalAddress exigen además crm:clients:read:fiscal-data, y parentClientId, crm:clients:write:hierarchy y Grupos empresariales en el plan. Responde { client, changed, recordedAt }.

Parámetro de ruta: `id`.

Admite los mismos campos que el alta, todos opcionales, y además:

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `fiscalAddress` | `string` | No | Dirección fiscal, hasta 5.000 caracteres. Exige crm:clients:read:fiscal-data, como taxId. |
| `parentClientId` | `string \| null` | No | Identificador de la matriz para vincular el cliente como filial, o null para desvincularlo. Ver Grupos de empresas. |
| `expectedUpdatedAt` | `string` | No | El updatedAt (ISO 8601) de tu última lectura. Si la ficha cambió después, 409 CLIENT_UPDATE_CONFLICT y no se guarda nada. |

Cambiar `taxId` o `fiscalAddress` emite, además de `CLIENT_UPDATED`,
`CLIENT_FISCAL_DATA_UPDATED`.

### Desactivar y reactivar

**`POST /api/external/v1/clients/:id/deactivate`**

Pasa el cliente a INACTIVE: es la única baja posible por la API. Permiso crm:clients:write. 409 CLIENT_ALREADY_INACTIVE si ya lo estaba. Emite CLIENT_DEACTIVATED.

Parámetro de ruta: `id`.

**`POST /api/external/v1/clients/:id/reactivate`**

Devuelve a ACTIVE un cliente inactivo. Permiso crm:clients:write. 409 CLIENT_ALREADY_ACTIVE si ya está activo o en un estado que no se reactiva. Emite CLIENT_REACTIVATED.

Parámetro de ruta: `id`.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `reason` | `string` | Sí | Desactivar: motivo libre, hasta 500 caracteres. Reactivar: motivo razonado de 20 a 1.000 caracteres. Queda en el registro de auditoría y viaja en el aviso. |
| `source` | `enum` | Sí | Solo al desactivar: external_app, crm_sync, manual o automation. |

## Grupos de empresas: matriz y filiales

El CRM permite agrupar empresas de tu organización bajo una empresa
matriz, con dos niveles: matriz y filiales, sin más niveles por debajo.
La API externa refleja esa jerarquía así:

- **`parentClientId`** viaja en todas las respuestas: listados,
  búsqueda, sincronización y detalle. Cuando el vínculo cambia, el
  `updatedAt` de la filial se actualiza y la fila vuelve a salir en
  `GET /external/v1/clients/sync`.
- **`parent { id, name, slug }`** solo aparece en las respuestas de
  detalle (`GET /external/v1/clients/:id`, `/by-tax-id`, `/by-slug` y
  las de cambios).
- **`GET /external/v1/clients/:id/group`** devuelve `hierarchyRole`
  (`PARENT`, `SUBSIDIARY` o `STANDALONE`), la referencia a la matriz,
  las filiales con la misma forma que el listado (sin `taxId` ni
  `notes`) y `totalSubsidiaries`. La clave de API trabaja a nivel de
  organización, así que lista todas las filiales. No expone datos
  financieros por filial.
- **`PATCH /external/v1/clients/:id` con `parentClientId`** vincula el
  cliente como filial de la matriz indicada, o lo desvincula con `null`.
  Pedir el vínculo que ya existe no cambia nada (`changed: false`). Si
  el plan no incluye Grupos empresariales, la petición responde
  `403 CAPABILITY_NOT_ENABLED` aunque el vínculo pedido sea el vigente:
  quita `parentClientId` del cuerpo para seguir actualizando el resto.

**`GET /api/external/v1/clients/:id/group`**

Grupo del cliente: hierarchyRole, parentClientId, parent, subsidiaries (con la forma del listado) y totalSubsidiaries. Permiso crm:clients:read y Grupos empresariales en el plan (si no, 403 CAPABILITY_NOT_ENABLED).

Parámetro de ruta: `id`.

```json
{
  "client": { "id": "cli-matriz", "name": "Grupo Acme", "slug": "grupo-acme" },
  "hierarchyRole": "PARENT",
  "parentClientId": null,
  "parent": null,
  "subsidiaries": [],
  "totalSubsidiaries": 0
}
```

La API valida las reglas del grupo:

- La matriz tiene que ser de tipo `EMPRESA` y no puede ser filial de otro grupo.
- La filial no puede ser `PARTICULAR`, no puede pertenecer ya a otro
  grupo (para moverla, desvincúlala y vincúlala de nuevo) ni tener
  filiales propias.

Vincular o desvincular una filial emite los avisos
`CLIENT_LINKED_TO_GROUP` y `CLIENT_UNLINKED_FROM_GROUP`, solo con
identificadores, y no emite `CLIENT_UPDATED`. Si necesitas los datos de
los clientes afectados, pide su detalle.

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
# Buscar por NIF o CIF
curl -H "X-Api-Key: $KEY" \
  "https://os.utilia.ai/api/external/v1/clients/by-tax-id?taxId=B12345678"

# Detalle con datos fiscales y notas
# (requiere crm:clients:read:fiscal-data y crm:clients:read:notes)
curl -H "X-Api-Key: $KEY" \
  "https://os.utilia.ai/api/external/v1/clients/cli-42?include=fiscalData,notes"

# Desactivar
curl -X POST \
  -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"reason":"Cliente sin actividad desde 2025","source":"external_app"}' \
  "https://os.utilia.ai/api/external/v1/clients/cli-42/deactivate"
```

```typescript
// SDK JavaScript
const page = await sdk.externalClients.list({ limit: 25 });

const client = await sdk.externalClients.get('cli-42', {
  include: ['fiscalData', 'notes'],
});

const matched = await sdk.externalClients.byTaxId('B12345678');

const contacts = await sdk.externalClients.listContacts('cli-42', { limit: 50 });

await sdk.externalClients.deactivate('cli-42', {
  reason: 'Cliente sin actividad desde 2025',
  source: 'external_app',
});

// Grupo de empresas
const group = await sdk.externalClients.getGroup('cli-matriz');

// Vincular una filial (requiere crm:clients:write:hierarchy
// y que el plan incluya Grupos empresariales)
await sdk.externalClients.update('cli-filial', {
  parentClientId: 'cli-matriz',
});

// Desvincular del grupo
await sdk.externalClients.update('cli-filial', { parentClientId: null });
```

```python
# SDK Python
from utilia_sdk import (
    DeactivateClientInput,
    ExternalClientContactsFilters,
    ExternalClientListFilters,
    UpdateClientInput,
    UtiliaSDK,
)

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

    client = await sdk.external_clients.get(
        "cli-42",
        include=["fiscalData", "notes"],
    )

    matched = await sdk.external_clients.by_tax_id("B12345678")

    contacts = await sdk.external_clients.list_contacts(
        "cli-42", ExternalClientContactsFilters(limit=50)
    )

    await sdk.external_clients.deactivate(
        "cli-42",
        DeactivateClientInput(
            reason="Cliente sin actividad desde 2025",
            source="external_app",
        ),
    )

    # Grupo de empresas
    group = await sdk.external_clients.get_group("cli-matriz")

    # Vincular una filial (requiere crm:clients:write:hierarchy
    # y que el plan incluya Grupos empresariales)
    await sdk.external_clients.update(
        "cli-filial",
        UpdateClientInput(parent_client_id="cli-matriz"),
    )

    # Desvincular del grupo (None explícito)
    await sdk.external_clients.update(
        "cli-filial",
        UpdateClientInput(parent_client_id=None),
    )
```

`getGroup` (`get_group` en Python) y `parentClientId` en `update` están
en los SDK desde la versión 2.18.0.

## Avisos a tu servidor de cliente

Siete eventos, que se envían a todas las aplicaciones suscritas salvo a
la que provocó el cambio. **No existe `CLIENT_DELETED`**: la API
externa nunca elimina, solo desactiva.

| Evento | Cuándo se envía |
| - | - |
| `CLIENT_CREATED` | Se ha creado un cliente en el CRM. |
| `CLIENT_UPDATED` | Cambian el nombre, el sector, la dirección o el estado. Incluye `changes[]`. El cambio de grupo no lo dispara. |
| `CLIENT_DEACTIVATED` | El cliente pasa a `INACTIVE`, con el motivo declarado. |
| `CLIENT_REACTIVATED` | El cliente vuelve a estar activo, con el motivo declarado. |
| `CLIENT_FISCAL_DATA_UPDATED` | Cambian los datos fiscales. Solo llega a aplicaciones con `crm:clients:read:fiscal-data`. |
| `CLIENT_LINKED_TO_GROUP` | Una empresa se vincula como filial de su matriz. Solo identificadores: `parentClientId`, `childClientId`, `organizationId`. |
| `CLIENT_UNLINKED_FROM_GROUP` | Una filial se desvincula de su matriz, sola (`reason=UNLINK`) o porque se disuelve el grupo (`reason=DISSOLVE`, un aviso por filial). Solo identificadores. |

## Errores propios de estos endpoints

| Código | HTTP | Significado |
| - | - | - |
| `INSUFFICIENT_SCOPE` | 403 | A la aplicación le falta el permiso de la API, incluido `crm:clients:read:fiscal-data` al pedir `fiscalData` y `crm:clients:write:hierarchy` al cambiar `parentClientId`. |
| `CAPABILITY_NOT_ENABLED` | 403 | En `GET /external/v1/clients/:id/group` y en un `PATCH` que lleve `parentClientId`: el plan de tu organización no incluye Grupos empresariales. No se resuelve dando permisos a la aplicación, sino ampliando el plan. |
| `CLIENT_NOT_FOUND` | 404 | El cliente no existe o es de otra organización. |
| `TAX_ID_NOT_INDEXED` | 404 | No hay ningún cliente activo con ese NIF o CIF. |
| `SLUG_NOT_INDEXED` | 404 | No hay ningún cliente activo con ese `slug`. |
| `CLIENT_ALREADY_INACTIVE` | 409 | El cliente ya estaba inactivo. |
| `CLIENT_ALREADY_ACTIVE` | 409 | El cliente ya estaba activo, o en un estado que no se puede reactivar. |
| `CLIENT_UPDATE_CONFLICT` | 409 | Bloqueo optimista: la ficha cambió después de la lectura que indica `expectedUpdatedAt`. |
| `CLIENT_SLUG_GENERATION_FAILED` | 409 | El `slug` choca con otro y no se ha podido resolver (manual o tras cinco intentos automáticos). |
| `CLIENT_HIERARCHY_CONFLICT` | 409 | Se incumple una regla del grupo: la matriz ya es filial, el cliente ya está en otro grupo, tiene filiales propias o hay otra operación simultánea. |
| `CLIENT_HIERARCHY_INVALID_TYPE` | 422 | Tipos incompatibles: la matriz tiene que ser `EMPRESA` y la filial no puede ser `PARTICULAR`. |
| `EXTERNAL_APP_CREATOR_MISSING` | 422 | Al crear: la aplicación no tiene una organización vinculada en UTILIA OS. Es un problema de configuración, no de tu petición. |

## Límites de uso

| Endpoint | Límite por defecto |
| - | - |
| Lectura general y cambios | 500 peticiones por hora |
| `GET /external/v1/clients/sync` | 60 peticiones por hora |
| `GET /external/v1/clients/by-tax-id` | 100 peticiones por hora |
| `GET /external/v1/clients/by-slug` | Sin límite propio: solo el cupo por hora de la clave |

El límite general es más estricto que el de contactos y leads (600 por
hora) porque hay menos clientes y el dato es más sensible. El de
`by-tax-id` lo es todavía más por tratarse del NIF: dificulta recorrer
la base de clientes aunque una clave se filtre.

## Decisiones de producto

- No hay `DELETE` desde fuera: solo `/deactivate`.
- No se exponen el IBAN ni los datos financieros agregados.
- `taxId` nunca sale en listados ni en la búsqueda libre.

## Siguiente paso

Combina este recurso con los [endpoints de leads](https://os.utilia.ai/docs/developers/api/leads)
y los [endpoints de contactos](https://os.utilia.ai/docs/developers/api/contacts)
para cubrir el recorrido lead, contacto, cliente y factura. El catálogo
completo de eventos está en
[Contenido de los avisos](https://os.utilia.ai/docs/developers/webhooks/events).
