Files
AgendaPro/platform/crm/API.md
AgendaPro DevandClaude Opus 5 6d67b23e55 feat(platform): multi-tenancy con credenciales por negocio y sincronización por id
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]>
2026-08-30 15:07:20 -06:00

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

  1. El esquema del cuerpo de POST /contacts/search: filtros, operadores y campos filtrables. El OpenAPI lo declara como objeto vacío.
  2. El máximo real de pageLimit.
  3. El valor del techo de profundidad al paginar por número de página. Que existe está medido; cuánto es, no.
  4. El cuerpo de la respuesta 429 y la política de reintento recomendada.
  5. Si los tokens privados tienen cuota distinta de las aplicaciones de marketplace.
  6. Un endpoint de introspección de token.
  7. 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.