Poner al día una cartera de clientes
Caso completo con la herramienta crm_clients: completar datos, escribir fichas en lote, fusionar duplicados con revisión humana y mantener un espejo al día.
Actualizado el . 10 min de lectura
Incluido en todos los planes. Ver precios
Esta guía recorre de principio a fin un caso real con la herramienta crm_clients del servidor utilia-crm: una cartera importada a medias que hay que completar, limpiar y mantener sincronizada con otro sistema.
El punto de partida son 65 fichas de cliente que llegaron de una hoja de cálculo con poco más que el nombre y el correo. Faltan la razón social, la dirección, el sector y el logotipo; dos fichas son la misma empresa con el NIF mal escrito en una; y, al terminar, hay que mantener al día una copia de la cartera en otro sistema sin releerla entera cada noche. Necesitas un token OAuth o una clave personal del servidor del CRM.
Poner al día una cartera en cuatro pasos
enrich y enrich_status
Buscar los datos que faltan
Propone razón social, dirección y sector. No toca ninguna ficha.create_many y update_many
Escribir las fichas en lote
Hasta 50 por llamada, con el logotipo en la misma llamada.merge y deduplicate
Fusionar los duplicados
Una persona revisa la previa; la fusión no se deshace.Si no: sin confirm, solo devuelve la previa con dryRun: true.
list con cursor
Mantener la copia al día
useCursor y updatedSince traen solo lo cambiado, sin saltos ni repeticiones.
Qué necesitas
| Paso | Permiso | Scope |
|---|---|---|
Buscar datos de empresa (enrich, enrich_status) | CLIENTS_CREATE | crm:clients:create |
Alta en lote (create_many) | CLIENTS_CREATE | crm:clients:create |
Edición en lote (update_many) | CLIENTS_EDIT | crm:clients:update |
Previa por reglas (deduplicate_preview) | CLIENTS_VIEW | crm:clients:view |
Fusionar (deduplicate, merge y su previa) | CLIENTS_MERGE | crm:clients:merge |
Recorrer y sincronizar (list) | CLIENTS_VIEW | crm:clients:view |
El scope de fusión se pide por su nombre
crm:clients:merge no está dentro de crm:read, crm:write ni crm:full. Fusionar archiva fichas y reescribe sus referencias sin vuelta atrás, así que ninguna autorización antigua lo concede sin que el titular lo apruebe. Añádelo a los scopes que pide tu aplicación. El catálogo completo está en Scopes y recursos de OAuth.
Los SDK cubren todo lo de esta guía en sdk.mcp.crm, con una sesión OAuth autorizada para el servidor utilia-crm (cómo se configura). Los ejemplos usan la variante asíncrona; en Python existe la síncrona con los mismos nombres.
Paso 1: buscar los datos que faltan
enrich encarga una búsqueda en internet de los datos de una empresa a partir de su nombre y devuelve un identificador de trabajo; la propuesta se lee después con enrich_status.
enrich no crea ni modifica ninguna ficha: devuelve una propuesta con su confianza y sus fuentes, y guardarla es una llamada aparte a create o update con los campos que aceptes. Pide el permiso de crear clientes, y no el de verlos, porque consume bolsa de IA y sale a internet. Los resultados caducan al cabo de una hora y solo se sirven a quien lanzó la búsqueda.
En existingData van los datos que ya conoces y que la búsqueda debe respetar: si la hoja de cálculo traía el correo bueno, pásalo ahí y no te propondrá otro.
import { UtiliaSDK } from '@utilia-os/sdk-js';
const sdk = new UtiliaSDK({
baseURL: 'https://os.utilia.ai/api',
oauth: {
clientId: process.env.UTILIA_CLIENT_ID!,
redirectUri: 'https://mi-app.example.com/callback',
},
});
// Una búsqueda por empresa. Devuelve el identificador del trabajo, no los datos.
const trabajos = [];
for (const fila of cartera) {
const { data } = await sdk.mcp.crm.clients.enrich({
name: fila.nombre,
existingData: { email: fila.email, website: fila.web },
});
trabajos.push({ fila, jobId: data.jobId });
}
// La propuesta se lee después. Solo con jobState 'completed' hay result.
const propuestas = [];
for (const t of trabajos) {
const { data } = await sdk.mcp.crm.clients.enrichStatus(t.jobId);
if (data.jobState === 'completed' && data.result) {
propuestas.push({ fila: t.fila, datos: data.result });
}
}Antes de dar de alta cada ficha, check_duplicate dice si ya existe un cliente parecido (mismo NIF, mismo correo, mismo teléfono o nombre parecido en la misma ciudad). Es una lectura con crm:clients:view. Si el parecido existe pero queda fuera de tu alcance, la respuesta es { hasDuplicates: true, reason: 'EXISTS_OUT_OF_SCOPE' }, sin datos de esa ficha: compruébalo con quien lleva esa cuenta antes de dar de alta otra.
Paso 2: escribir las 65 fichas y sus logotipos
Caben 50 fichas por llamada, así que la cartera va en dos lotes, de 50 y de 15. El lote viaja en items y cada elemento tiene la misma forma que los argumentos de create.
El logotipo va en la misma llamada, por una de tres vías que no se pueden combinar: logoUrl, una dirección https pública que descarga el servidor; logoBase64 con logoMimeType, la imagen pegada, hasta 512 KB por elemento del lote; o logoFileId, una imagen ya subida al gestor de archivos.
const primeros = await sdk.mcp.crm.clients.createMany(
propuestas.slice(0, 50).map((p, i) => ({
name: p.fila.nombre,
legalName: p.datos.legalName,
taxId: p.datos.taxId,
email: p.fila.email,
city: p.datos.city,
industry: p.datos.industry,
logoUrl: p.datos.logoUrl, // https pública; la descarga el servidor
// Clave distinta por elemento: evita repetirlo dentro de esta petición.
clientOperationId: `alta-2026-09-03-${i}`,
})),
);
console.log(primeros.data.summary); // { total: 50, ok: 48, failed: 2 }
console.log(primeros.data.failed); // los dos, con su index, su code y su motivo
// OJO: las fichas cuyo LOGOTIPO no entró están en `succeeded`, no en `failed`.
const sinLogo = primeros.data.succeeded.filter((s) => s.data.logo?.ok === false);
// Se reintenta solo la imagen, sin volver a tocar la ficha.
if (sinLogo.length > 0) {
await sdk.mcp.crm.clients.updateMany(
sinLogo.map((s, i) => ({
id: s.data.id,
logoUrl: otraFuenteDeLogotipo(s.data.name),
clientOperationId: `logo-2026-09-03-${i}`,
})),
);
}Lo que conviene saber del lote
- Un elemento inválido no detiene a los demás. La respuesta trae
succeeded, con la posición original de cada ficha que entró;failed, con la posición, el código y el motivo de cada fallo; ysummary, con los totales. Solo corta la llamada entera un lote mal formado: queitemsno sea una lista, esté vacía, pase de 50 elementos o alguno no sea un objeto. - La ficha vuelve en vista compacta: identidad, estado, contacto básico y el resultado del logotipo, sin contactos, direcciones ni personas asignadas. Para la ficha completa,
get.createyupdatesueltos también devuelven la vista compacta. clientOperationIdsolo vale dentro de la misma petición. Si dos elementos llevan la misma clave y el mismo contenido, se escribe una sola vez; con contenido distinto, el segundo falla conCONFLICT. Pero si reenvías el lote en otra llamada, las fichas que ya entraron se vuelven a crear: reenvía solo los elementos defailed. Usa una clave distinta por elemento.parentClientIdno se admite en un elemento del lote: es un cambio de jerarquía con permiso propio (CLIENTS_MANAGE_HIERARCHY) y el elemento falla con su motivo. El vínculo con la matriz se hace después concrm_clients_info(operation: "link_subsidiary").- En
update_many, unidque no es de tu organización sale comoNOT_FOUNDen ese elemento, sin frenar a los demás. - El nombre del lote cambia según la herramienta:
itemsen clientes,contactsen contactos yleadsen leads. Con otro nombre, la llamada da un error de validación.
Un logotipo que falla no da error
La ficha se crea igual, y la herramienta responde con isError en falso, así que un try/catch no se entera. En un lote, el elemento sale en succeeded con logo: { ok: false, error }; en create y update sueltos, la respuesta trae logoWarning con el motivo. Revisa siempre ese campo antes de dar el logotipo por puesto. En el SDK de Python, create y update devuelven el modelo CrmClient, que no lleva logoWarning: en un alta suelta, comprueba logo_file_id.
Paso 3: fusionar las dos fichas repetidas
deduplicate agrupa por NIF normalizado cuando lo hay y, si no, por correo, dominio o nombre y ciudad. Nunca junta dos NIF distintos, así que dos fichas de la misma empresa con el NIF mal escrito en una no forman grupo: la salida es merge, que fusiona las fichas que señalas a mano.
Enseña la previa antes de tocar nada
Hay dos previas, y no son lo mismo:
| Previa | Qué muestra | Permiso y scope |
|---|---|---|
deduplicate_preview | Los grupos que detectan las reglas, con la ficha que sobreviviría y lo que se movería. Es una lectura. | CLIENTS_VIEW y crm:clients:view |
merge sin confirm | El simulacro de una fusión concreta que eliges tú. | CLIENTS_MERGE y crm:clients:merge |
merge sin confirm no escribe nada, pero es la misma operación destructiva: exige el permiso y el scope de fusionar. Y la respuesta de la previa tiene la misma forma que la de la fusión aplicada; lo único que las distingue es dryRun. Míralo siempre antes de dar algo por fusionado.
// 1. Qué pasaría. No se escribe nada: dryRun llega en true.
const previa = await sdk.mcp.crm.clients.mergePreview({
survivorId: 'cli_canesa',
duplicateIds: ['cli_caneisa'],
// Los NIF son distintos porque uno está mal tecleado: hay que afirmarlo.
allowDifferentTaxId: true,
});
console.log(previa.data.dryRun); // true
console.log(previa.data.survivor.invoices); // 128 facturas ya tiene
console.log(previa.data.duplicates[0].invoices); // 34 se le re-colgarían
console.log(previa.data.reassigned); // el total de lo que se movería
console.log(previa.data.stripeCustomerConflict); // conflicto que NO se resuelve solo
// 2. Una persona lo mira y lo aprueba. Y entonces, y solo entonces:
const hecho = await sdk.mcp.crm.clients.merge({
survivorId: 'cli_canesa',
duplicateIds: ['cli_caneisa'],
allowDifferentTaxId: true,
});
console.log(hecho.data.dryRun); // false: esto sí ha ocurrido
console.log(hecho.data.archived); // 1 ficha archivada
console.log(hecho.data.invoicesReassigned); // 34Qué recibe la ficha que sobrevive
survivorId se conserva y recibe los contactos, oportunidades, proyectos, presupuestos, tareas, facturas, autorizaciones de cobro, accesos al portal, archivos, carpetas e interacciones de las fichas duplicadas, y rellena sus campos vacíos con los datos de ellas. Las fichas duplicadas quedan archivadas, no borradas. duplicateIds admite de 1 a 20 fichas por llamada, sin repetir y sin incluir la que sobrevive.
Un conflicto no se resuelve solo y la respuesta lo nombra: stripeCustomerConflict, cuando las dos fichas tenían su propio cliente en Stripe y el de la duplicada no se traslada.
allowDifferentTaxId y allowPortalAccessMerge son confirmaciones expresas de un riesgo, y exigen alcance total sobre el permiso de fusionar clientes. Sin ellas, esos dos casos se rechazan:
allowDifferentTaxIdafirma que dos NIF distintos son la misma empresa. No se admite si las dos fichas tienen facturas emitidas, porque mezclaría las declaraciones de operaciones con terceros.allowPortalAccessMergeacepta que los interlocutores externos de una empresa pasen a ver los documentos, las facturas y los tickets de la otra. Eso no se deshace desde la aplicación.
Cuando los duplicados los detectan las reglas
Para el resto de la cartera, el camino es la previa por reglas y después la fusión acotada a los grupos que una persona apruebe.
// Lectura pura bajo crm:clients:view: se puede enseñar sin pedir el de fusionar.
const grupos = await sdk.mcp.crm.clients.deduplicatePreview();
console.log(grupos.data.totalDuplicates);
grupos.data.groups.forEach((g) => {
console.log(g.groupId, g.matchType, g.primary.name, g.duplicates.length);
});
// Una persona marca los que acepta. Los demás se quedan como están.
const aceptados = grupos.data.groups.filter(esEvidente).map((g) => g.groupId);
const resultado = await sdk.mcp.crm.clients.deduplicate({
groupIds: aceptados,
// Opcional: quién sobrevive en cada grupo, en vez de la regla por defecto.
survivorIds: ['cli_la_que_queremos'],
});
console.log(resultado.data.groups); // grupos fusionados
console.log(resultado.data.ignoredGroupIds); // los que ya no eran duplicadossurvivorIdses todo o nada. Un identificador que no corresponde a ningún grupo detectado rechaza la llamada entera sin fusionar nada. Admite como mucho uno por grupo.groupIdsacota la fusión a los grupos aprobados. Sin él se fusionan todos los detectados.ignoredGroupIdsno es un error: son grupos que ya no existen porque alguien los fusionó entre la previa y la confirmación.- La previa omite el grupo entero si alguna de sus fichas queda fuera de tu alcance de fusión. Los recuentos de cada ficha viajan siempre; el NIF, el NIF-IVA, el límite de crédito y el descuento, solo con
CLIENTS_VIEW_SENSITIVEy el scopecrm:clients:view-sensitive.
Paso 4: mantener la copia al día
Los listados del CRM paginan por desplazamiento (limit y offset), y en un recorrido largo con escrituras a la vez eso se descuadra: cuando entra una fila en una página ya servida, las demás se desplazan y el lector se salta unas y repite otras. Para mantener una copia, eso es perder datos sin enterarse.
El cursor lo resuelve: guarda los valores de ordenación de la última fila servida y pide la página siguiente por comparación, así que las filas que entran o salen antes del cursor no mueven nada.
useCursor: trueabre el recorrido. Sin cursor, la llamada va por desplazamiento y no devuelve ninguno. ConuseCursor: true, la primera página sale ordenada por fecha de última modificación y traepageInfo.nextCursor; después basta con reenviar el cursor recibido.- En el recorrido por cursor el total no se conoce (
totalKnown: false). Lo que dice si has terminado espageInfo.hasMore. - Un cursor de otro listado, o ilegible, se rechaza, en lugar de volver a la primera página sin avisar.
// Marca del último refresco, guardada por tu lado. ISO 8601 CON zona.
const desde = '2026-09-01T00:00:00Z';
let cursor: string | undefined;
let primera = true;
do {
const { data } = await sdk.mcp.crm.clients.list({
updatedSince: desde,
...(primera ? { useCursor: true } : { cursor }),
limit: 100,
});
for (const cliente of data.items) {
await espejo.guardar(cliente);
}
cursor = data.pageInfo.nextCursor ?? undefined;
primera = false;
// `total` no sirve aquí: en el recorrido por cursor llega con totalKnown en falso.
} while (cursor);updatedSince solo acepta ISO 8601 con zona horaria explícita (2026-09-01T00:00:00Z o 2026-09-01T02:00:00+02:00). Una fecha sin zona se rechaza con un error, en lugar de ignorarse y devolver la cartera entera. No lo confundas con createdAtFrom y lastContactedFrom, que admiten YYYY-MM-DD porque son fechas del calendario de tu organización.
Las fichas archivadas por una fusión no salen en el listado. Si tu copia aún guarda el identificador de una, includeMerged: true las incluye para que puedas cerrarla o apuntarla a la que sobrevivió; las filas siguen pasando tu permiso y tu alcance.
cursor, useCursor y updatedSince funcionan igual en crm_contacts y en crm_leads.
Resumen
| Dónde | Lo que parece | Lo que es |
|---|---|---|
create_many / update_many | Reenviar el lote es seguro | clientOperationId solo deduplica dentro de la misma petición: reenvía solo lo que falló |
create_many / update_many | Un fallo tumba el lote | El elemento inválido sale en failed y los demás entran |
| Logotipo | Si no hay excepción, entró | Un fallo de la imagen vuelve en logoWarning o en logo.ok: false, con isError en falso |
merge | La previa es una lectura | Es la misma operación destructiva y exige el scope de fusionar |
merge | La respuesta dice si se fusionó | Solo lo dice dryRun |
deduplicate | Un survivorIds que sobra se descarta | Rechaza la llamada entera |
list | Basta con enviar cursor | La primera página necesita useCursor: true |
updatedSince | Vale una fecha suelta | Sin zona horaria se rechaza |
Siguiente paso
Con la cartera limpia y sincronizada, desde la ficha del cliente se abren estos caminos:
- Sus contactos, con
crm_contacts, que tiene los mismos lotes, previa y fusión, con su scopecrm:contacts:merge. Dos fichas con usuario del portal no se pueden unir, y si alguna no puede recibir correos, la que sobrevive tampoco podrá. - El acceso de esos contactos al portal del cliente, con
portal_access, que exigecrm:portal:viewycrm:portal:manage. - La venta, con
crm_opportunitiesycrm_budgets, y de ahí a la factura y el cobro en el servidor de Finanzas. - La empresa como proveedor:
crm_clients(operation: "get_counterpart")dice si la misma empresa está también dada de alta como proveedor. Exige los aplicativos CRM y Finanzas y los permisos de ver clientes, ver proveedores y sus datos sensibles.