# Hallazgos medidos contra Bucéfalo CRM Subcuenta **Yola Franco Spa** (`Pk89Wa23QaxvkOfKgwjZ`) · **2026-08-29** · token PIT de subcuenta. Todo lo de aquí se ejerció contra el CRM real y **se verificó releyendo**, nunca aceptando un `200` como prueba. Los spikes que lo produjeron están en `platform/scripts/crm-spike*.ts` y se pueden volver a correr. --- ## Lo que quedó confirmado | # | Hallazgo | Consecuencia | |---|---|---| | 1 | La subcuenta responde: `Yola Franco Spa`, tz `America/Mexico_City`, país `MX` | El token y el `locationId` son correctos | | 2 | **3 209 contactos** y **3 210 conversaciones** | Hay material real que sincronizar | | 3 | `attributionSource` viene **poblado** con datos reales (`sessionSource`, `medium`, `campaign`, `campaignId`, `adId`, `utmMedium`, `utmContent`) | La atribución UTM que pide el proyecto **existe y se puede traer** | | 4 | Pipeline único: **`Standar`** `Mrclt4VzRZV1DI4Vbt5c`, 9 etapas, con **`Ganado`** (`b91c1653-…`) y **`Perdido`** (`04b28d7f-…`) | Hay dónde aterrizar `won` y `lost` sin inventar nada | | 5 | **SÍ existen 7 calendarios**, uno de ellos `Servicio Spa` (`LXZIuRPYa3uCPlUlsqY7`) | Resuelve la pregunta que el análisis previo marcaba como bloqueante | | 6 | **`GET /calendars/services/catalog` devuelve `services: []`** | El catálogo de servicios del CRM está **vacío**: la duración y el precio tienen que vivir en la plataforma. Confirma la sospecha previa | | 7 | `POST /contacts/` con `attributionSource` → **persiste íntegro** (verificado releyendo) | Se puede dar de alta con UTM completo | | 8 | `POST /contacts/` duplicado → **`400` con `meta.contactId` y `meta.matchingField`** | **Idempotencia real y gratuita.** Es mejor que `upsert`, cuya rama *actualizar* descarta la atribución | | 9 | `POST /opportunities/` → crea y **el importe persiste** | El valor del servicio llega al CRM | | 10 | `POST /conversations/messages` con `type: "Email"` → `200` `Email queued successfully` con `conversationId`, `messageId`, `threadId` | Hay canal de vuelta para probar mensajes | ## Lo que NO funciona como uno esperaría | # | Hallazgo | Cómo se sortea | |---|---|---| | 11 | **`PUT /opportunities/{id}/status` rechaza `pipelineStageId`** con `422 property pipelineStageId should not exist` | El estado y la etapa se cambian en **dos llamadas**: `/status` con solo `status`, y `PUT /opportunities/{id}` con `pipelineId` + `pipelineStageId` | | 12 | **`POST /opportunities/` rechaza una segunda oportunidad del mismo contacto aunque la primera esté en `won`** (`400 OPPORTUNITY_NO_DUPLICATE` con `meta.existingId`) | Ver «La decisión de las oportunidades» abajo | | 13 | La causa es el ajuste **`settings.allowDuplicateOpportunity: false`** de la subcuenta | **Es un ajuste, no un límite duro.** El cliente puede activarlo | | 14 | ~~`GET /users/?locationId` → **`401` fuera de scope**~~ **OBSOLETO — ver hallazgo 35: hoy responde `200`** | El token PIT no listaba personal. Volvió a medirse el 2026-08-29 y sí lo lista | | 15 | `POST /opportunities/pipelines` → **`401` `The token is not authorized for this scope`** | `pipelines.create` no está en el token. Rediseñar el pipeline a etapas de spa es trabajo de UI, no de código | | 16 | `GET /contacts/{id}/opportunities` → **`404`, la ruta no existe** | Se usa `GET /opportunities/search?location_id=&contact_id=`, que sí funciona | ## Lo que el CRM usa para deduplicar, y coincide con lo pedido `GET /locations/{id}` devuelve: ```json "settings": { "allowDuplicateContact": false, "allowDuplicateOpportunity": false, "contactUniqueIdentifiers": ["email", "phone"] } ``` La cadena de identidad pedida para el proyecto —**id de contacto → teléfono → correo**— es exactamente la que el CRM aplica. Con `allowDuplicateContact: false`, el propio CRM devuelve el `contactId` existente en el `400`: la deduplicación no hay que construirla, hay que **leerla del rechazo**. ## La decisión de las oportunidades El encargo es «una cita = una oportunidad», con el nombre `SERVICIO + NOMBRE CONTACTO`, el importe del servicio, y `open` / `won` / `lost` según el estado. El hallazgo 12 lo impide **hoy**: con `allowDuplicateOpportunity: false`, una clienta que vuelve por segunda vez no puede estrenar oportunidad, y una clienta de spa vuelve muchas veces. Se implementan los dos caminos y la plataforma elige solo, sin configuración: 1. **Intenta crear.** Si el CRM la acepta, una cita = una oportunidad, tal como se pidió. 2. **Si responde `400 OPPORTUNITY_NO_DUPLICATE`**, toma el `meta.existingId` y **recicla esa oportunidad**: le pone el nombre de la cita nueva, su importe y su estado. Verificado que se puede renombrar, cambiar el importe, cerrar y **reabrir** una ya cerrada. Con el ajuste desactivado, la oportunidad representa *la cita vigente de la clienta* y el histórico completo vive en AgendaMax. Con el ajuste activado, el modelo pasa a ser el pedido **sin tocar una línea de código**. > **Para que sea «una cita = una oportunidad» hace falta que alguien active > _Allow Duplicate Opportunity_ en los ajustes de la subcuenta.** Es un interruptor de la UI del > CRM; el token no puede cambiarlo. Mientras tanto el MVP funciona reciclando. ## Cabeceras y trampas - `Version: 2021-07-28` para contactos, oportunidades y conversaciones; **`Version: v3` para todo `/calendars/`**. Equivocarla es `400`. - `locationId` **va en el cuerpo del `POST /contacts/`** y **rompe el `PUT`** (`422 property locationId should not exist`). Es una asimetría fácil de cruzar reciclando código. - Hay lista blanca de propiedades: cualquier clave desconocida es `422` y no crea nada. Es un fallo seguro y sirve de herramienta de descubrimiento. - La atribución **es de una sola oportunidad**: se escribe en el alta y un `PUT` posterior devuelve `200` sin guardar nada. - `campaign` hay que mandarlo **además** de `utmCampaign`: el buscador de contactos descarta `utmCampaign` y conserva `campaign`. ## Hallazgos posteriores, ya con la integración escrita | # | Hallazgo | Consecuencia | |---|---|---| | 17 | **`PUT .../status` mueve la etapa por su cuenta.** Con la etapa escrita primero y el estado después, el CRM la devolvió de «Ganado» a «Cotización Aceptada» | El orden correcto es **estado primero, etapa después** | | 18 | Ese movimiento es **asíncrono y gana igualmente en `won`**: se reescribió y releyó tres veces y el CRM la volvió a mover después de que la relectura ya confirmaba la nuestra. En `lost` sí respeta «Perdido» | Hay una regla del lado del CRM que gobierna la etapa en `won`. **No se pelea con ella**: lo que el negocio pidió mapear es el `status`, y ese sí queda estable | | 19 | El buscador de contactos pagina con **`searchAfter`**, tomado del último contacto de la página anterior | Con `page` se topa un techo de profundidad mucho antes de los 3 200 | | 20 | Sincronización completa medida: **3 210 contactos en 22 s**, idempotente (segunda corrida: 0 creados, 3 210 actualizados) | El botón puede correr en primer plano sin tarea de fondo | | 21 | Calidad real de los contactos traídos: **59,4 % con teléfono normalizable**, **8 con correo** de 3 215, 801 con campaña, 824 con anuncio | Coincide con la auditoría independiente previa (59,8 % y 0,2 %). **Cuatro de cada diez clientas no son contactables** | | 22 | El prefijo `521` heredado de mensajería aparece en los teléfonos reales (`+5215656592254`) y se colapsa bien a `+525656592254`. **Cero duplicados** por teléfono tras sincronizar 3 210 | La deduplicación por E.164 funciona sobre datos reales | | 23 | El mensaje enviado **aparece al releer el hilo**, pero su `status` viene `null` | El CRM no expone el estado de entrega ahí: sigue sin poder afirmarse que llegó | ## Lo que sigue sin verificarse - **Que el correo se entregue.** `Email queued successfully` es acuse de encolado, no de entrega. Exige mirar una bandeja real. - **WhatsApp y SMS**: no están conectados en la subcuenta. Fuera del MVP. - **Escritura de citas al calendario del CRM** (`POST /calendars/events/appointments`): no se ha ejercido. El MVP proyecta las citas como oportunidades, no como eventos de calendario. - **Webhooks**: exigen OAuth, que este token no es. La sincronización de entrada es por sondeo. ## Datos de prueba creados en la subcuenta real Contacto `WzBTBaHkNnpmjMb1Avx3` (`urieljareth@grupo-e3.com`, tag `agendamax:prueba`) y su oportunidad `IMkYdAkBowggN9aKVbfc`. Se limpian con `node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts`. --- ## Sondeo de lectura por id — 2026-08-29 Medido con `crm-spike-lectura-id.ts` y `crm-spike-calendarios.ts`, ambos **solo lectura**. Cubre las cinco entidades que pide la sincronización por id: contactos, conversaciones, mensajes, citas y servicios. | # | Hallazgo | Consecuencia | |---|---|---| | 24 | `GET /conversations/{id}` **funciona** y trae `contactId`, `messageTypes`, `unreadCount`, `firstUnreadInboundMessageId` | Se puede anclar una conversación por su id sin recorrer la lista | | 25 | `GET /conversations/search?contactId=…` **filtra por contacto** | Es el camino para «las conversaciones de esta clienta» sin traerse las 3 213 | | 26 | `GET /conversations/{id}/messages` pagina con **`lastMessageId` + `nextPage`**, no con `page` ni `searchAfter` | Tercera convención de paginación distinta en la misma API. No reciclar la de contactos | | 27 | **`GET /conversations/messages/{id}` funciona**: trae un mensaje suelto por su id, con `from`, `messageType`, `contentType`, `meta` | Permite reconciliar un mensaje concreto sin releer el hilo entero | | 28 | `GET /calendars/events` **exige** uno de `userId`, `calendarId` o `groupId`; sin ellos es `422`. Con un `userId` inexistente es `400 User with id … not found` | No hay forma de pedir «todas las citas de la subcuenta» en una llamada: hay que iterar los calendarios | | 29 | **La subcuenta tiene 1 sola cita en total** en los 7 calendarios, en una ventana de 2 años atrás y 1 adelante, y está en **`Servicio Spa`** (`LXZIuRPYa3uCPlUlsqY7`) | El calendario del CRM está prácticamente sin usar. La agenda real no vive ahí: nace en la plataforma. Confirma que la sincronización de citas es **empuje**, no arrastre | | 30 | Forma del evento: `appointmentStatus`, `assignedUserId`, `calendarId`, `contactId`, `startTime`, `endTime`, `dateAdded`, `address`. Devuelve **además** `appoinmentStatus` — con la errata — con el mismo valor | Si se lee el estado, leer `appointmentStatus` y tolerar la errata: es del CRM, no nuestra | | 31 | `GET /calendars/groups` → 0 grupos | No hay agrupación que aprovechar | | 32 | `GET /calendars/services/catalog` → `services: []` **reconfirmado** | El catálogo de servicios del CRM sigue vacío. Duración y precio viven en la plataforma, y «sincronizar servicios» no puede significar traerlos de allá | ### Lo que esto decide - **Contactos, conversaciones y mensajes**: los tres se pueden traer por id concreto. La sincronización selectiva que pide el encargo es viable tal cual, sin rodeos. - **Citas**: no hay nada que arrastrar. La dirección útil es empujar la cita de la plataforma al calendario del CRM (`POST /calendars/events/appointments`, **aún sin ejercer**) además de la oportunidad que ya se proyecta. - **Servicios**: no hay catálogo en el CRM que sincronizar. Lo único con sentido es publicar hacia allá los de la plataforma, y eso exige comprobar antes si el token tiene permiso de escritura sobre `/calendars/services` — no se ha probado. --- ## Sondeo de permisos y de límites — 2026-08-29 Medido con `crm-spike-permisos.ts`. La técnica no crea nada: se manda un `POST` **deliberadamente incompleto** y se mira qué error vuelve. Un `401 not authorized for this scope` significa que falta el permiso; un `422` sobre los campos significa que el permiso está y lo que falla es el cuerpo. Distinguir esas dos cosas era lo único que faltaba para saber si se puede planificar escritura, y no costó un solo registro basura en la subcuenta del cliente. | # | Hallazgo | Consecuencia | |---|---|---| | 33 | **`calendars/events.write` SÍ está.** `POST /calendars/events/appointments` responde `422 calendarId should not be empty · startTime must be a valid ISO 8601 date string`, no `401` | **Se pueden escribir citas al calendario del CRM.** Deja de ser una incógnita: el MVP puede proyectar la cita como evento además de como oportunidad | | 34 | **`calendars.write` SÍ está.** `POST /calendars/services/catalog` responde `422 name should not be empty · At least one staff member is required` | **Se puede poblar el catálogo de servicios del CRM.** Que esté vacío (hallazgo 6) no es un límite de la API: es que nadie lo llenó | | 35 | **`GET /users/?locationId` responde `200` con 6 usuarios.** Contradice el hallazgo 14, medido semanas antes | El token tiene ahora permiso de personal. Esto **desbloquea el `staff[]` obligatorio** del alta de servicios, que era el impedimento práctico del hallazgo 34. Ids disponibles, entre ellos `6HCOVjDvvdUlv1bjbR7W` (Yola Spa Recepción), que es el `assignedUserId` de la única cita real | | 36 | `GET /contacts/{id}/appointments` responde `200` con `{"events":[]}` | Hay una ruta directa para «las citas de esta clienta» sin recorrer calendarios. Devuelve vacío porque la subcuenta casi no tiene citas (hallazgo 29) | | 37 | **Cabeceras de límite reales**: `x-ratelimit-max: 100`, `x-ratelimit-interval-milliseconds: 10000`, `x-ratelimit-limit-daily: 200000` | La cuota es **1 petición cada 100 ms**, no cada 650. El cliente estrangula **6,5× por debajo** de lo permitido. Bajar `MIN_INTERVAL_MS` acortaría la sincronización de 22 s a ~4 s. Hasta hoy nadie leía esas cabeceras: el 650 ms era una estimación observada, no una cuota conocida | ### Lo que esto cambia - **Las cinco entidades del encargo son viables.** Contactos, conversaciones y mensajes se leen por id (24-27); las citas se pueden **escribir** al calendario (33) además de proyectarse como oportunidad; y los servicios se pueden **publicar** al catálogo (34) ahora que hay ids de personal (35). Ninguna queda bloqueada por permisos. - **«Sincronizar servicios» solo puede significar empujar**, nunca traer: el catálogo del CRM está vacío y la duración y el precio los define el negocio en la plataforma. - **Un permiso medido una vez no queda medido para siempre.** El hallazgo 14 era cierto cuando se midió y hoy es falso, porque alguien cambió el token o sus permisos. Conviene que la plataforma compruebe los permisos al vincular una subcuenta y lo vuelva a hacer cuando algo falle con `401`, en vez de fiarse de una tabla escrita en el pasado. --- ## Ejercido con la sincronización por id escrita — 2026-08-29 | # | Hallazgo | Consecuencia | |---|---|---| | 38 | **`GET /conversations/{id}` no devuelve el nombre del contacto ni un canal reconocible.** Solo el buscador los trae | Sincronizar un hilo por su id **degradaba** un nombre bueno a «Sin nombre» y el canal a «Desconocido». El upsert ya no deja que un dato pobre pise a uno que ya se tenía, y el nombre se toma de la clienta enlazada | | 39 | El canal del hilo **se puede deducir de su último mensaje**, que sí lo trae | Evita el «Desconocido» sin gastar una petición más. Se excluyen los `TYPE_ACTIVITY_*`, que son notas del propio CRM y no un canal por el que hablar con la clienta | | 40 | Los hilos reales traen **`TYPE_INSTAGRAM`** y **`TYPE_ACTIVITY_OPPORTUNITY`**, que no estaban en ningún mapa | Instagram es un canal de verdad de este negocio; las actividades no lo son y conviene distinguirlas en la bandeja | | 41 | Sincronización por id verificada de punta a punta contra la subcuenta real: contacto (`creado`), conversación (`espejada`, 3 mensajes) y mensaje suelto (`espejado con su hilo`) | Las tres entidades que el CRM posee se pueden traer una a una. La conversación quedó enlazada con la clienta local por `crm_contact_id` | --- ## Primera escritura de citas y servicios al CRM — 2026-08-29 Ejercida con `crm-spike-escritura-cita-servicio.ts`, que crea, **relee**, borra y **confirma el borrado** en la misma corrida. Nada quedó en la subcuenta: la limpieza va en un `finally`, así que se ejecuta aunque el sondeo falle a mitad. Antes se comprobó con `crm-spike-borrado.ts` que el borrado existe — preguntar si se puede deshacer **antes** de escribir en el CRM de un cliente real, no después. | # | Hallazgo | Consecuencia | |---|---|---| | 42 | **Escribir una cita al calendario funciona.** `POST /calendars/events/appointments` la crea, se relee con el contacto correcto, el estado `confirmed`, y **la hora de pared idéntica a la escrita**: se mandó `2026-12-28T14:00:00-06:00` y se releyó igual | La proyección de citas al calendario deja de ser una incógnita. Y confirma que `isoConDesplazamiento` acierta: escribir con `toISOString()` habría movido la hora que el CRM enseña | | 43 | **`GET /calendars/events/appointments/{id}` SIGUE devolviendo la cita después de borrarla.** El listado del rango sí deja de incluirla | Es un borrado lógico. Comprobar existencia por id da un falso positivo: **la comprobación fiable es listar el calendario** | | 44 | **La primera publicación de un servicio falla** con `400 No default service category found for this location`, aunque `staff`, `name` y `slug` sean correctos | La documentación marca `serviceCategoryId` como opcional. No lo es cuando la subcuenta no tiene ninguna categoría | | 45 | **Ese mismo intento fallido hace que el CRM cree la categoría por defecto** (`GET /calendars/service-categories` la devuelve con `isSystemGenerated: true` y fecha del segundo del fallo). El reintento entra sin cambiar nada | `publicarServicio` reintenta **una vez** y **solo** ante ese mensaje. Reintentar un POST a ciegas fabrica duplicados | | 46 | La ruta de categorías es **`/calendars/service-categories`**, no `/calendars/services/categories` — esta última cae en el comodín `/{serviceId}` y devuelve `404 Please provide a valid service ID` | Un 404 con ese texto significa «la ruta no existe», no «el recurso no existe». Es fácil de leer al revés | | 47 | Publicar servicio verificado de punta a punta: catálogo **0 → 1**, con la duración correcta, y borrado después dejándolo en 0 | Las cinco entidades del encargo quedan ejercidas contra el CRM real |