El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.
## Multi-tenancy
El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.
- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
acabaríamos mandando como credencial al CRM. La clave maestra vive en
`CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
`locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.
Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
## Consola de superadministración
`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.
## Sincronización por identificador
`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.
- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.
## Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.
47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.
110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.
## Deuda conocida, dicha sin rodeos
- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
de esa base: no debe quedar expuesta a internet hasta endurecerla.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
13 KiB
Referencia de la API de Bucéfalo CRM
Host base: https://services.leadconnectorhq.com · Autenticación: Authorization: Bearer <token privado de subcuenta>.
Este documento es la referencia de endpoints. Los hallazgos empíricos —lo que se ha ejercido
contra la subcuenta real y con qué resultado— viven en HALLAZGOS.md, y mandan
sobre lo que diga aquí: la documentación oficial de esta API está incompleta o desactualizada en
varios puntos concretos, todos marcados abajo.
Cada entrada lleva su origen: [DOC] de la especificación OpenAPI pública del proveedor · [MEDIDO] ejercido contra la subcuenta real de este proyecto · [INFERENCIA] deducción no verificada, que no debe usarse como base para escribir código sin comprobarla antes.
La cabecera Version: donde más chocan la documentación y la realidad
| Familia | Según la documentación | Lo que funciona [MEDIDO] |
|---|---|---|
/contacts/ |
2021-07-28 |
2021-07-28 ✅ |
/locations/ |
2021-07-28 |
2021-07-28 ✅ |
/conversations/ |
2021-04-15 |
2021-07-28 ⚠ |
/calendars/ |
2021-04-15 |
v3 ⚠ |
/opportunities/ |
2021-07-28 |
2021-07-28 ✅ |
client.ts la elige sola por el prefijo de la ruta. No la cambies «para alinearla con la
documentación»: con estos valores se ejercieron los 3 210 contactos, los 3 213 hilos y los 7
calendarios. Equivocarla es un 400.
Tres convenciones de paginación distintas en la misma API
Confundirlas devuelve listas incompletas sin ningún error, que es la peor forma de fallar.
| Qué se pagina | Cómo | Detalle |
|---|---|---|
| Contactos | searchAfter |
El cursor sale del último elemento del array, no de la raíz de la respuesta. Con page se topa un techo de profundidad antes de los 3 200 |
| Conversaciones | startAfterDate |
El valor de ordenación del último hilo de la página |
| Mensajes | lastMessageId + nextPage |
El id del último mensaje, y un booleano que dice si hay más |
A · Contactos
GET /contacts/{contactId}
Scope contacts.readonly. Devuelve el contacto envuelto en contact.
[MEDIDO] El 404 existe aunque la documentación no lo liste.
[MEDIDO] attributionSource trae sessionSource y adId poblados, que el esquema oficial no
declara. mapAtribucion() es más fiel que el esquema.
POST /contacts/search
Scope contacts.readonly. El esquema del cuerpo NO está documentado: el $ref del OpenAPI
apunta a un objeto vacío. Todo lo que sabemos es medido:
{ "locationId": "…", "pageLimit": 100, "searchAfter": [1724900000000, "seD4Pf…"] }
[MEDIDO] 3 210 contactos en ~22 s, idempotente. [INFERENCIA] 100 es probablemente el máximo
de pageLimit; no está escrito en ningún sitio.
[MEDIDO] El buscador descarta utmCampaign y conserva campaign. Al dar de alta hay que mandar
los dos.
Filtrar por id, teléfono o correo dentro de este buscador no está documentado en ninguna parte.
Lo que funciona: por id → GET /contacts/{id}; por teléfono o correo → GET /contacts/?query=.
GET /contacts/
Marcado DEPRECADO por la propia documentación. limit máximo 100, default 20. Útil solo para
búsquedas puntuales de 1-5 resultados, que es como lo usa buscarPorIdentificador.
Trampa de forma: aquí la atribución se llama attributions (array); en GET /contacts/{id}
se llama attributionSource (objeto). Misma información, dos formas.
POST /contacts/
Scope contacts.write.
[MEDIDO] locationId va en el cuerpo del POST y rompe el PUT con 422 property locationId should not exist.
[MEDIDO] Un duplicado responde 400 con meta.contactId: es idempotencia regalada por el
servidor, y mejor que un upsert, cuya rama de actualización descarta la atribución.
[MEDIDO] La atribución solo se escribe en el alta; un PUT posterior devuelve 200 sin
guardar nada.
B · Conversaciones y mensajes
GET /conversations/search
Scope conversations.readonly. Filtros documentados: locationId (obligatorio), contactId,
id, assignedTo, followers, mentions, query, sort, sortBy, status,
lastMessageType (43 valores), lastMessageDirection, lastMessageAction, startDate/endDate
(epoch ms), startAfterDate, limit.
GET /conversations/{conversationId}
[MEDIDO] Devuelve los campos en la raíz, sin envoltorio.
[MEDIDO, hallazgo 38] NO devuelve el nombre del contacto ni un canal reconocible — eso solo
viene del buscador. Sincronizar un hilo por su id degradaba el nombre a «Sin nombre»; ver
syncConversations.ts, que ya no deja que un dato pobre pise a uno bueno.
Trampa de forma: aquí type es un número; en el buscador es la cadena TYPE_PHONE.
GET /conversations/{conversationId}/messages
Scope conversations/message.readonly — distinto de conversations.readonly. Tener uno no da
el otro.
[MEDIDO] La respuesta viene anidada dos niveles: { messages: { messages: [...], lastMessageId, nextPage } }.
GET /conversations/messages/{id}
[MEDIDO] Funciona y devuelve el mensaje en la raíz. El parámetro de ruta no está declarado en la especificación — es un fallo de la documentación, no de la API.
POST /conversations/messages
Scope conversations/message.write. Tipos: SMS, RCS, Email, WhatsApp, IG, FB, Custom,
Live_Chat, TIKTOK.
[CONTRADICCIÓN] La documentación marca subType y status como obligatorios. enviarCorreo()
no manda ninguno y responde 200. Manda lo medido; no los añadas «por cumplir el esquema» sin
un sondeo, porque cualquier clave inesperada es 422.
[MEDIDO] La respuesta es Email queued successfully: acuse de encolado, no de entrega. Y al
releer el hilo, el status del mensaje propio viene null (hallazgo 23). No se puede afirmar que
llegó.
C · Calendarios, citas y servicios
GET /calendars/
Scope calendars.readonly, Version: v3. [MEDIDO] 7 calendarios en la subcuenta, uno
Servicio Spa (LXZIuRPYa3uCPlUlsqY7).
GET /calendars/events
Scope calendars/events.readonly.
[MEDIDO, hallazgo 28] Exige uno de calendarId, userId o groupId; sin ellos es
422 Either of userId, calendarId or groupId is required. No existe «todas las citas de la
subcuenta»: hay que iterar los calendarios.
Asimetría de formato: la entrada (startTime/endTime del query) va en milisegundos
epoch; la salida viene en ISO con desplazamiento (2026-12-28T14:00:00-06:00).
GET /calendars/events/appointments/{eventId}
[MEDIDO, hallazgo 43] Sigue devolviendo la cita después de borrarla — es un borrado lógico. Para comprobar si una cita existe, lista el rango del calendario; por id da un falso positivo.
POST /calendars/events/appointments
Scope calendars/events.write — [MEDIDO, hallazgo 33] el token lo tiene.
Obligatorios: calendarId, locationId, contactId, startTime.
startTime/endTime en ISO con desplazamiento, no en epoch — al revés que el filtro de arriba.
Palancas que este proyecto usa a propósito:
toNotify: false— la plataforma ya avisó a la clienta; que el CRM no lo haga otra vez.ignoreFreeSlotValidation: true— AgendaMax es la fuente de verdad del horario y su base ya impide el solape.
[MEDIDO, hallazgo 42] Verificado de punta a punta: creada, releída con la hora de pared idéntica a la escrita, y borrada.
PUT /calendars/events/appointments/{eventId}
No admite locationId ni contactId. Misma trampa que en contactos: no recicles el cuerpo del
alta.
DELETE /calendars/events/{eventId}
[MEDIDO] Existe y el token lo tiene. Borrado lógico (ver hallazgo 43).
Servicios: GET/POST /calendars/services/catalog
Scopes calendars.readonly / calendars.write — [MEDIDO, hallazgo 34] el token lo tiene.
Un «servicio» es una prestación vendible con duración, precio, categoría, personal y variaciones:
literalmente el modelo de un spa.
[MEDIDO, hallazgos 6 y 32] El catálogo de esta subcuenta está VACÍO. Por eso la duración y el precio viven en la plataforma y «sincronizar servicios» solo puede significar publicar.
Obligatorios: locationId, name, slug, staff[] con al menos un miembro (como
[{ id: "…" }], no como ["…"]).
[MEDIDO, hallazgos 44-45] La primera publicación en una subcuenta sin catálogo falla con
400 No default service category found for this location — y ese mismo intento hace que el CRM
cree la categoría por defecto. El reintento entra sin cambiar nada. publicarServicio reintenta
una vez y solo ante ese mensaje.
GET /calendars/service-categories
[MEDIDO, hallazgo 46] Esta es la ruta correcta. /calendars/services/categories cae en el
comodín /{serviceId} y devuelve 404 Please provide a valid service ID, que se lee como «no
existe el recurso» cuando en realidad significa «no existe la ruta».
DELETE /calendars/services/catalog/{serviceId}
[MEDIDO] Existe y el token lo tiene.
D · Subcuentas
GET /locations/{locationId}
Scope locations.readonly. Un token privado de subcuenta basta; no hace falta token de agencia.
[MEDIDO] settings trae una quinta clave que la documentación no declara:
"contactUniqueIdentifiers": ["email", "phone"]
No es un detalle: es la cadena de identidad que el CRM aplica para deduplicar, y la justificación de que la del proyecto (id → teléfono → correo) coincida con la suya en vez de pelearse con ella.
allowDuplicateOpportunity: false es la causa del 400 OPPORTUNITY_NO_DUPLICATE, y es un
interruptor de la interfaz, no un límite duro.
Cómo validar que un token corresponde a una subcuenta
No hay endpoint de introspección de token. El método correcto, y el que usa
PUT /api/admin/businesses/:id/crm, es leer GET /locations/{locationId} con ese token y comparar
location.id con el esperado — identidad contra identidad, no un 200 genérico.
[MEDIDO] El 401 de esta API es ambiguo: significa a la vez «token caducado», «al token le falta
el scope» y (probablemente) «el token es de otra subcuenta». Por eso el mensaje de error de la
consola nombra las tres posibilidades en vez de afirmar una.
E · Límites de tasa
[MEDIDO, hallazgo 37] Cabeceras reales de la subcuenta:
| Cabecera | Valor observado |
|---|---|
x-ratelimit-max |
100 |
x-ratelimit-interval-milliseconds |
10000 |
x-ratelimit-limit-daily |
200000 |
x-ratelimit-remaining |
va bajando en la ventana |
x-ratelimit-daily-remaining |
199970 |
O sea 1 petición cada 100 ms, no cada 650 como asumía el cliente originalmente. client.ts lee
esas cabeceras en cada respuesta y ajusta el intervalo con un 50 % de margen; el 650 ms queda solo
como respaldo para la primera petición de un token, antes de haber visto ninguna cabecera.
La cuota es por aplicación y por subcuenta: añadir cuentas no reparte el límite, cada una tiene el suyo. Por eso el estrangulador es por token y no global.
F · Scopes
contacts.readonly contacts.write
conversations.readonly conversations.write
conversations/message.readonly conversations/message.write
calendars.readonly calendars.write
calendars/events.readonly calendars/events.write
calendars/groups.readonly calendars/groups.write
locations.readonly locations.write
locations/customFields.* locations/customValues.* locations/tags.*
Dos parejas que se confunden fácil y dan 401 donde no se espera:
conversations.readonly no incluye conversations/message.readonly, y calendars.readonly
no incluye calendars/events.readonly.
[MEDIDO] Estado del token de esta subcuenta, al 2026-08-29:
| Scope | Estado |
|---|---|
| contactos, conversaciones, mensajes, subcuenta | ✔ |
calendars/events.write |
✔ (hallazgo 33) |
calendars.write |
✔ (hallazgo 34) |
| usuarios | ✔ hoy — daba 401 cuando se midió por primera vez (hallazgos 14 → 35) |
| crear pipelines | ✖ 401 The token is not authorized for this scope |
Un permiso medido una vez no queda medido para siempre. El de usuarios cambió entre dos
mediciones porque alguien tocó el token. Conviene comprobar los permisos al vincular una subcuenta y
volver a hacerlo cuando algo falle con 401, en vez de fiarse de una tabla escrita en el pasado.
Lo que NO está documentado y no debe inventarse
- El esquema del cuerpo de
POST /contacts/search: filtros, operadores y campos filtrables. El OpenAPI lo declara como objeto vacío. - El máximo real de
pageLimit. - El valor del techo de profundidad al paginar por número de página. Que existe está medido; cuánto es, no.
- El cuerpo de la respuesta
429y la política de reintento recomendada. - Si los tokens privados tienen cuota distinta de las aplicaciones de marketplace.
- Un endpoint de introspección de token.
- El código exacto que devuelve un token de otra subcuenta. Se infiere
401; comprobarlo exige un segundo token y este proyecto solo tiene uno.
Webhooks
Exigen OAuth. Un token privado de subcuenta no puede suscribirse, así que la entrada de datos
es por sondeo: los botones de sincronización y POST /api/crm/sync/:entidad/:id. Si algún día se
quiere tiempo real, hay que pasar por OAuth, y eso es un entregable propio.