Clientes del CRM
API externa /external/v1/clients: alta, listado, búsqueda por NIF, contactos vinculados, grupos de empresas, desactivación y avisos CLIENT_*.
Actualizado el . 8 min de lectura
Incluido en todos los planes. Ver precios
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.
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
taxIdno aparece nunca en listados ni en la búsqueda libre: solo enGET /external/v1/clients/:ido/by-tax-id, y solo si la aplicación tienecrm:clients:read:fiscal-data.
Endpoints
| Método | Ruta | Para qué sirve |
|---|---|---|
| POST | /external/v1/clients | Crear un cliente. Requiere crm:clients:write. slug es opcional (se genera solo). |
| GET | /external/v1/clients | Listado paginado (25 por página por defecto). Por defecto solo clientes activos. Nunca incluye taxId. |
| GET | /external/v1/clients/:id | Detalle. 404 CLIENT_NOT_FOUND si no existe o es de otra organización. Admite ?include=fiscalData,notes. |
| GET | /external/v1/clients/search?q= | Búsqueda libre por nombre comercial, razón social, slug, correo y web. No expone taxId. |
| GET | /external/v1/clients/by-tax-id?taxId= | Búsqueda exacta por NIF o CIF, con tiempo de respuesta constante. 404 TAX_ID_NOT_INDEXED si no hay coincidencia. |
| GET | /external/v1/clients/by-slug?slug= | Búsqueda exacta por slug, con tiempo de respuesta constante. 404 SLUG_NOT_INDEXED si no hay coincidencia. |
| GET | /external/v1/clients/sync | Sincronización por cambios con cursor compuesto (updatedAt, id). |
| GET | /external/v1/clients/:id/contacts | Contactos vinculados al cliente. Requiere crm:clients:read y crm:contacts:read. |
| GET | /external/v1/clients/:id/group | Grupo del cliente: papel (PARENT, SUBSIDIARY o STANDALONE), matriz y filiales. Requiere crm:clients:read y que el plan incluya Grupos empresariales. |
| PATCH | /external/v1/clients/:id | Actualizar el cliente con bloqueo optimista (expectedUpdatedAt). Requiere crm:clients:write; cambiar parentClientId exige además crm:clients:write:hierarchy y que el plan incluya Grupos empresariales. |
| POST | /external/v1/clients/:id/deactivate | Pasa el cliente a status=INACTIVE. Es la única baja posible. Cuerpo: reason (hasta 500 caracteres) y source. Requiere crm:clients:write. |
| POST | /external/v1/clients/:id/reactivate | Devuelve el cliente a activo. Exige reason de 20 a 1.000 caracteres. Requiere crm:clients:write. |
source en la desactivación es obligatorio y admite external_app,
crm_sync, manual o automation.
Forma de las respuestas:
- Listados:
{ data, pagination }. - Cambios (alta, actualización, desactivación, reactivación):
{ client, changed, recordedAt }. - Sincronización:
{ data, nextCursor, hasMore }.
Filtros del listado
GET /external/v1/clients admite page, limit (25 por defecto),
status, type y size (listas de valores), industry, country,
updatedSince (fecha ISO 8601), sortBy (updatedAt, createdAt o
name; por defecto updatedAt) y sortOrder (asc o desc; por
defecto desc).
Sincronizar por cambios
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
Participan: Tu servidor, UTILIA OS.
De Tu servidor a UTILIA OS:
GET /api/external/v1/clients/sync?updatedAfter=…
La fecha de tu última sincronización. Sin ella, se empieza desde el principio.De UTILIA OS a Tu servidor:
data, nextCursor y hasMore: true (respuesta)
Hasta limit clientes, ordenados por updatedAt e id.De Tu servidor a UTILIA OS:
GET /api/external/v1/clients/sync?cursor=…
Con el nextCursor recibido. Sigue justo donde lo dejaste.De UTILIA OS a Tu servidor:
hasMore: false y nextCursor: null (respuesta)
No queda nada pendiente en esta ronda.Nota en Tu servidor:
Guarda el updatedAt más reciente para la próxima ronda.
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í:
parentClientIdviaja en todas las respuestas: listados, búsqueda, sincronización y detalle. Cuando el vínculo cambia, elupdatedAtde la filial se actualiza y la fila vuelve a salir enGET /external/v1/clients/sync.parent { id, name, slug }solo aparece en las respuestas de detalle (GET /external/v1/clients/:id,/by-tax-id,/by-slugy las de cambios).GET /external/v1/clients/:id/groupdevuelvehierarchyRole(PARENT,SUBSIDIARYoSTANDALONE), la referencia a la matriz, las filiales con la misma forma que el listado (sintaxIdninotes) ytotalSubsidiaries. 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/:idconparentClientIdvincula el cliente como filial de la matriz indicada, o lo desvincula connull. Pedir el vínculo que ya existe no cambia nada (changed: false). Si el plan no incluye Grupos empresariales, la petición responde403 CAPABILITY_NOT_ENABLEDaunque el vínculo pedido sea el vigente: quitaparentClientIddel cuerpo para seguir actualizando el resto.
La API valida las reglas del grupo:
- La matriz tiene que ser de tipo
EMPRESAy 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.
Ejemplos
# 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"// 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 });# 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. |
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 |
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
DELETEdesde fuera: solo/deactivate. - No se exponen el IBAN ni los datos financieros agregados.
taxIdnunca sale en listados ni en la búsqueda libre.
Siguiente paso
Combina este recurso con los endpoints de leads y los endpoints de contactos para cubrir el recorrido lead, contacto, cliente y factura. El catálogo completo de eventos está en Contenido de los avisos.