# Referencia de la API de Bucéfalo CRM Host base: `https://services.leadconnectorhq.com` · Autenticación: `Authorization: Bearer `. 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`](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: ```json { "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:** ```json "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.