# Documentación de la API de Totem > Exportación de solo lectura de un equipo de Totem Studio: leads, llamadas, conversaciones, citas y un feed de cambios. Todas las páginas de la edición en español, en el orden de la navegación, para la versión 1.0a de la API. Índice: https://totemstudio.ai/developers/es/llms.txt --- # API de Totem > Lee desde tus propios sistemas los leads, las llamadas, las conversaciones y las citas de tus equipos de Totem, y mantén una copia sincronizada. Fuente: https://totemstudio.ai/developers/es La API de Totem permite que tu servidor lea los datos del CRM de tus equipos de Totem: los leads con su posición en el embudo, su origen y sus permisos de contacto, lo que le ha pasado a cada lead, las llamadas, las conversaciones y las citas. Con ella puedes: - **Exportar tu CRM** a otro CRM, o a archivos y tablas que controles. - **Alimentar una herramienta de BI o un almacén de datos**, y analizar leads, llamadas y citas junto al resto de tu negocio. - **Automatizar el seguimiento en tus propios sistemas**, por ejemplo cuando un lead llega a una etapa, respetando siempre los permisos de contacto de cada lead. El [Inicio rápido](https://totemstudio.ai/developers/es/quickstart) te lleva de una clave nueva a tus primeros leads en cuatro pasos. ## Qué es la versión 1.0a - **Solo lectura.** Ningún endpoint crea ni modifica nada en Totem. - **Beta.** Disponible para los clientes de Totem que cumplen los requisitos. Crea una clave en **Ajustes → Desarrolladores**; no necesitas invitación. En [Convenciones](https://totemstudio.ai/developers/es/conventions#qué-significa-beta) se explica qué puede cambiar durante la Beta. - **Datos de contacto, sin contenido.** La API devuelve datos de contacto, como nombres, correos electrónicos y teléfonos, y los metadatos del CRM: etapas, llamadas, conversaciones y citas. Nunca devuelve el texto de los mensajes, las transcripciones de llamadas, los resúmenes, las grabaciones, las notas, las respuestas de texto libre ni los campos personalizados de los formularios. Trata cada respuesta como datos personales. - **Consulta periódica, sin webhooks.** La copia se mantiene al día leyendo el feed de cambios cada 1 a 5 minutos. El [registro de cambios](https://totemstudio.ai/developers/es/changelog) anuncia cualquier capacidad nueva. - **Acceso de administrador.** Una clave lee todo lo que puede leer un administrador de sus equipos, incluidos los canales y los registros marcados como privados. El administrador que crea la clave lo confirma expresamente. ## Qué puedes leer Todas las peticiones van a `https://api.totemstudio.ai/v1` por HTTPS, y la API responde en JSON. | Datos | Endpoints | |---|---| | Tu integración y sus equipos | [`GET /v1/me`](https://totemstudio.ai/developers/es/reference-account#me.get), [`GET /v1/teams`](https://totemstudio.ai/developers/es/reference-account#teams.list), [`GET /v1/teams/{id}`](https://totemstudio.ai/developers/es/reference-account#teams.get) | | Miembros del equipo | [`GET /v1/members`](https://totemstudio.ai/developers/es/reference-account#members.list) | | Tableros, con sus etapas | [`GET /v1/boards`](https://totemstudio.ai/developers/es/reference-leads#boards.list) | | Leads, con su atribución y su respuesta | [`GET /v1/leads`](https://totemstudio.ai/developers/es/reference-leads#leads.list), [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/es/reference-leads#leads.get), [`POST /v1/leads/search`](https://totemstudio.ai/developers/es/reference-leads#leads.search) | | Lo que le ha pasado a un lead | [`GET /v1/leads/{id}/activities`](https://totemstudio.ai/developers/es/reference-leads#leads.activities) | | Llamadas | [`GET /v1/calls`](https://totemstudio.ai/developers/es/reference-calls#calls.list), [`GET /v1/calls/{id}`](https://totemstudio.ai/developers/es/reference-calls#calls.get) | | Conversaciones, sin sus mensajes | [`GET /v1/conversations`](https://totemstudio.ai/developers/es/reference-conversations#conversations.list), [`GET /v1/conversations/{id}`](https://totemstudio.ai/developers/es/reference-conversations#conversations.get) | | Servicios reservables | [`GET /v1/booking_services`](https://totemstudio.ai/developers/es/reference-bookings#booking_services.list) | | Citas | [`GET /v1/bookings`](https://totemstudio.ai/developers/es/reference-bookings#bookings.list), [`GET /v1/bookings/{id}`](https://totemstudio.ai/developers/es/reference-bookings#bookings.get) | | Todos los cambios, equipo por equipo | [`GET /v1/sync/changes`](https://totemstudio.ai/developers/es/reference-sync#sync.changes) | | Si la API está disponible | [`GET /v1/health`](https://totemstudio.ai/developers/es/reference-account#health) | ## Antes de empezar a construir - **Guarda la clave en tu servidor.** Nunca la pongas en un navegador, en una app móvil ni en un repositorio público. En [Autenticación](https://totemstudio.ai/developers/es/authentication) se explica dónde puede guardarse. - **Aplica primero los permisos de contacto.** Copia los `contact_permissions` de cada lead antes de que cualquiera de tus sistemas contacte con esa persona. - **Aplica las eliminaciones.** Cuando el feed de cambios elimine un registro, bórralo cuanto antes de todos los sistemas a los que lo copiaste. - **Protege lo que copias.** En [Seguridad y datos personales](https://totemstudio.ai/developers/es/security) se explica qué protege Totem y qué hacer con los datos personales que recibes. - **Haz las pruebas con datos ficticios.** En [Pruebas y puesta en producción](https://totemstudio.ai/developers/es/testing) se explica cómo. ## Siguientes pasos - [Inicio rápido](https://totemstudio.ai/developers/es/quickstart): haz tus primeras peticiones. - [Sincroniza una copia](https://totemstudio.ai/developers/es/sync): copia un equipo con el feed de cambios y mantén la copia al día, a partir de un receptor probado. - [Autenticación](https://totemstudio.ai/developers/es/authentication): las claves, los equipos y la cabecera `Totem-Team`. - [Errores](https://totemstudio.ai/developers/es/errors): qué significa cada código de error y qué hacer. - [Convenciones](https://totemstudio.ai/developers/es/conventions): las reglas que siguen todas las respuestas, antes de la referencia de los endpoints. - [Seguridad y datos personales](https://totemstudio.ai/developers/es/security): las claves, los registros de actividad, dónde funciona la API y cómo tratar los datos que copias. --- # Inicio rápido > Haz tus primeras peticiones a la API de Totem en unos minutos. Comprueba tu clave, busca tu equipo, lista leads e inicia una sincronización. Fuente: https://totemstudio.ai/developers/es/quickstart Esta guía te lleva de una clave nueva a tus primeros leads en cuatro pasos. ## Antes de empezar Necesitas: - **Un equipo de Totem con el plan Pro o uno superior**, o con acceso a la API acordado con Totem, que haya guardado su configuración legal en [**Privacidad y acuerdos**](https://app.totemstudio.ai/privacy-setup). - **Una clave secreta.** Un administrador del equipo abre [**Ajustes → Desarrolladores**](https://app.totemstudio.ai/settings?tab=developers), elige **Crear clave API** y confirma el acceso de lectura al equipo. Totem muestra la clave una sola vez: cópiala directamente en el almacén de secretos de tu servidor. - **`curl`** y, para el paso 2, [`jq`](https://jqlang.org). Si **Crear clave API** no aparece o está desactivado, [Crear una clave](https://totemstudio.ai/developers/es/authentication#crear-una-clave) enumera todos los requisitos y lo que indica Ajustes en cada caso. Para seguir esta guía, carga la clave en una variable de entorno sin escribirla en la línea de comandos, para que no quede en el historial de la shell. Ejecuta esto, pega la clave y pulsa Intro; no se muestra nada: ```bash read -rs TOTEM_API_KEY && export TOTEM_API_KEY ``` En un servidor, haz que tu almacén de secretos proporcione `TOTEM_API_KEY` al proceso. > **Advertencia:** La clave lee datos con nivel de administrador. Guárdala en tu servidor: nunca en un navegador, en una app móvil, en una URL ni en un repositorio público. ## 1. Comprueba tu clave Llama a [`GET /v1/me`](https://totemstudio.ai/developers/es/reference-account#me.get): ```bash curl https://api.totemstudio.ai/v1/me \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` La respuesta describe tu clave: la integración a la que pertenece, los cuatro últimos caracteres de la clave, los equipos que lee y sus permisos. ```json { "object": "integration", "id": "prn_m1NFIcoIU8smXfqR4ymgt6j07TfRF", "livemode": false, "name": "Example CRM sync", "key": { "last4": "Q7xZ" }, "teams": [ "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ], "scopes": [ "teams:read", "crm:read", "calls:read", "messages:read", "calendar:read" ] } ``` Tu respuesta lleva tus propios valores y `"livemode": true`. Los ejemplos de esta documentación son ficticios, por eso dicen `"livemode": false`. La misma petición desde Node.js 22 o posterior, como módulo ES, y desde Python 3 con la biblioteca `requests` (`pip install requests`), que también usan los ejemplos de la referencia: ```js [me.mjs] const response = await fetch('https://api.totemstudio.ai/v1/me', { headers: { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }, signal: AbortSignal.timeout(30_000), }) // Lee el cuerpo como texto: un rechazo del cortafuegos situado delante de la API no es JSON. console.log(response.status, await response.text()) ``` ```python [me.py] import os import requests response = requests.get( "https://api.totemstudio.ai/v1/me", headers={"Authorization": f"Bearer {os.environ['TOTEM_API_KEY']}"}, timeout=30, ) # Muestra el cuerpo como texto: un rechazo del cortafuegos situado delante de la API no es JSON. print(response.status_code, response.text) ``` Un `401` significa que falta la clave, que está mal copiada o que ya no es válida. En [Autenticación](https://totemstudio.ai/developers/es/authentication) se enumeran todos los casos. ## 2. Busca tu equipo [`GET /v1/teams`](https://totemstudio.ai/developers/es/reference-account#teams.list) lista los equipos que lee tu clave: ```bash curl https://api.totemstudio.ai/v1/teams \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```json { "object": "list", "data": [ { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ], "has_more": false, "next_cursor": null } ``` Guarda el identificador del primer equipo para los pasos siguientes. Si la petición falla, el comando lo dice en lugar de guardar un valor erróneo: ```bash TOTEM_TEAM_ID=$(curl -sS https://api.totemstudio.ai/v1/teams \ -H "Authorization: Bearer $TOTEM_API_KEY" | jq -er '.data[0].id') \ && export TOTEM_TEAM_ID \ || { unset TOTEM_TEAM_ID; echo 'El paso 2 falló: ejecuta el curl de arriba para ver el error.' >&2; } ``` Las peticiones que leen datos de un equipo, como las listas y el feed de cambios, indican el equipo en la cabecera `Totem-Team`. Solo importa cuando una clave lee varios equipos: una clave con un solo equipo puede omitirla, y una clave con varios recibe `400 team_required` si no la envía. Los pasos siguientes la envían siempre, así que sirven en los dos casos. > **Nota: ¿Acabas de añadir el equipo?** > > Totem prepara los datos de un equipo para la API cuando el equipo se añade a una integración. Hasta que termina, las peticiones de ese equipo responden `409 capture_not_ready`, y **Ajustes → Desarrolladores** muestra el equipo como **Preparando datos** en lugar de **Listo**. Espera unos minutos y vuelve a intentarlo; un equipo con muchos registros tarda más. ## 3. Lista leads Pide a [`GET /v1/leads`](https://totemstudio.ai/developers/es/reference-leads#leads.list) los cinco primeros leads: ```bash curl "https://api.totemstudio.ai/v1/leads?limit=5" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` La respuesta es una lista: los leads están en `data`. Un lead tiene muchos más campos de los que muestra este ejemplo; la [referencia](https://totemstudio.ai/developers/es/reference-leads#leads.list) los enumera todos. ```json { "object": "list", "data": [ { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "phone": "+12025550123", "status": "contacted", "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoibGVhZCIsImEiOiJsZWFkX3I3MkVmYlJOVXp1cU9SRkpSME84SHhBd0VYZDkzakdVVFlNYU1NWEtTRjBRYkxTOENpVUtKcEVQMzRMRlFHIiwicyI6ImYzNTI3NzI2MTkwYiJ93iHyRg" } ``` `has_more: true` significa que hay más leads. Envía `next_cursor` en el parámetro `cursor`, con los mismos filtros y el mismo `Totem-Team`, para leer la página siguiente. En [Listas y paginación](https://totemstudio.ai/developers/es/pagination) se explican la paginación y los filtros. ## 4. Inicia una sincronización Una lista es una foto fija. Para mantener una copia de un equipo siempre al día, lee el feed de cambios, [`GET /v1/sync/changes`](https://totemstudio.ai/developers/es/reference-sync#sync.changes). La primera petición va sin cursor e inicia la carga inicial de todo el equipo: ```bash curl https://api.totemstudio.ai/v1/sync/changes \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` Cada elemento de `data` es un registro de un `kind`, como un lead, una llamada o una cita, o una eliminación que te indica que borres un registro. A partir de ahí, tu sincronización repite un ciclo sencillo: 1. Aplica a tu copia todos los elementos de la página. Omite los elementos cuya `version` no sea mayor que la que ya tienes. 2. Cuando la página entera esté guardada, guarda `next_cursor`. 3. Mientras `has_more` sea `true`, vuelve a pedir enviando el cursor guardado en `cursor`. 4. Cuando `has_more` sea `false`, tu copia está al día. Sigue pidiendo con el cursor guardado cada 1 a 5 minutos, y guarda el `next_cursor` de cada página, aunque esté vacía. En [Sincroniza una copia](https://totemstudio.ai/developers/es/sync) se explica el resto: las eliminaciones, las fusiones y los traspasos, la recuperación tras un fallo y la resincronización completa. También incluye un [cliente de referencia probado](https://totemstudio.ai/developers/es/sync#cliente-de-referencia-probado), en TypeScript, que puedes copiar en tu servidor. ## Siguientes pasos - [SDK de TypeScript](https://totemstudio.ai/developers/es/typescript): usa métodos tipados y paginación automática desde Node.js. - [Sincroniza una copia](https://totemstudio.ai/developers/es/sync): construye una sincronización que resista los reinicios. - [Autenticación](https://totemstudio.ai/developers/es/authentication): rota y revoca claves, y limítalas a las direcciones IP de tus servidores. - [Seguridad y datos personales](https://totemstudio.ai/developers/es/security): qué protege Totem y qué hacer con los datos que copias. - [Errores](https://totemstudio.ai/developers/es/errors): qué significa cada código de error y qué hacer. - [Pruebas y puesta en producción](https://totemstudio.ai/developers/es/testing): qué comprobar antes de poner tu integración en producción. - [Referencia de leads](https://totemstudio.ai/developers/es/reference-leads): todos los campos de un lead, y cómo buscarlo por teléfono o correo electrónico. --- # Autenticación > Las claves secretas y dónde pueden guardarse, la cabecera Totem-Team de las claves con varios equipos, la lista de IP permitidas y cómo rotar o revocar una clave. Fuente: https://totemstudio.ai/developers/es/authentication ## Claves secretas Cada petición lleva una clave secreta en la cabecera `Authorization`: ```http GET /v1/me HTTP/1.1 Host: api.totemstudio.ai Authorization: Bearer tsk_live_… ``` Una clave secreta empieza por `tsk_live_`, seguida de un identificador de búsqueda y del secreto propiamente dicho. Totem la muestra una sola vez, al crearla, y solo conserva un resumen irreversible, así que una clave perdida no se puede recuperar: rótala. En [**Ajustes → Desarrolladores**](https://app.totemstudio.ai/settings?tab=developers) se ven los cuatro últimos caracteres de cada clave para distinguirlas. Una clave no caduca por sí sola: funciona hasta que la rotas o la revocas. Las claves que empiezan por `tsk_test_` están reservadas para futuros entornos de pruebas. Hoy la API las rechaza con `401 invalid_credentials`. ### Crear una clave Un administrador del equipo abre [**Ajustes → Desarrolladores**](https://app.totemstudio.ai/settings?tab=developers), elige **Crear clave API**, da un nombre a la integración y confirma el acceso de lectura al equipo actual. En **Opciones avanzadas** están la descripción opcional, las restricciones por IP y, para un administrador de la familia, más equipos de la organización (en [Equipos y la cabecera Totem-Team](https://totemstudio.ai/developers/es/authentication#equipos-y-la-cabecera-totem-team) se explica quién puede gestionar qué). Si el administrador no ha iniciado sesión hace poco, Totem le pide antes que vuelva a hacerlo. Totem muestra entonces la clave secreta de la integración una sola vez. Cópiala directamente en el almacén de secretos de tu servidor. La API está en **Beta** y disponible sin invitación. **Crear clave API** funciona cuando: - quien crea la clave tiene el rol de administrador del equipo y una dirección de correo electrónico verificada; - la organización tiene el plan Pro o uno superior, o acceso a la API acordado con Totem; - el equipo tiene datos (una cuenta solo de facturación no los tiene) y no está desactivado ni archivado; - el equipo ha completado su configuración legal: un administrador del equipo guarda su política de privacidad, sus finalidades de tratamiento y su sector de actividad en [**Privacidad y acuerdos**](https://app.totemstudio.ai/privacy-setup); - por ahora, el equipo no pertenece a un sector de actividad sensible, como el sanitario. Si no ves **Crear clave API**, comprueba el rol, la dirección de correo y que el equipo no sea una cuenta solo de facturación; cuando el motivo es el plan, Ajustes lo indica. El botón se desactiva cuando la organización ya tiene 10 integraciones activas. Si Totem rechaza un equipo, Ajustes lo nombra y explica por qué. Si no, [contacta con soporte](https://totemstudio.ai/developers/es/support). ### Dónde puede guardarse una clave Una clave lee datos con nivel de administrador, así que guárdala en un servidor que controles: - en un gestor de secretos o en una variable de entorno que solo pueda leer el proceso que llama a la API; - nunca en un navegador, en una app móvil, en JavaScript del lado del cliente ni en un repositorio público; - nunca en una URL, en una línea de registro, en un comando que escribas en una shell ni en una solicitud de soporte. La API no envía cabeceras CORS y responde `400 secret_key_in_browser` a una petición que lleve a la vez una clave secreta y una cabecera `Origin`. Si una clave ha estado alguna vez en una página web, una app móvil o una extensión del navegador, dala por filtrada: rótala y marca **Esta clave puede estar comprometida**, como se explica en [Rotar y revocar](https://totemstudio.ai/developers/es/authentication#rotar-y-revocar). ### Qué puede leer una clave Todas las claves de la versión 1.0a son de solo lectura y llevan el mismo conjunto fijo de permisos de lectura: no hay nada que elegir. [`GET /v1/me`](https://totemstudio.ai/developers/es/reference-account#me.get) los enumera en `scopes`: `teams:read`, `crm:read`, `calls:read`, `messages:read` y `calendar:read`. `messages:read` solo cubre los metadatos de las conversaciones; ningún endpoint devuelve el texto de un mensaje. Una clave lee todo lo que puede leer un administrador de sus equipos, incluidos los canales y los registros marcados como privados. El administrador que crea la integración lo confirma antes de que se emita la clave. Da a cada integración solo los equipos que necesita. ## Equipos y la cabecera Totem-Team Una integración cubre un equipo o una lista de equipos de la misma organización (una cuenta de facturación y sus subequipos; en **Ajustes → Desarrolladores**, el ámbito del acceso **Equipos elegidos de la familia**). La lista es explícita: un equipo nuevo nunca se añade solo. Lo añade un administrador, y se avisa a los administradores que gestionan la integración y a los de cada equipo que cubre. Quién puede gestionar una integración depende de lo que cubre: - Un administrador de equipo crea y gestiona una integración para su propio equipo. - Solo un administrador de la familia puede crear o modificar una integración que cubra varios equipos: un administrador de la cuenta de facturación que figura en ella como administrador de la familia. Los administradores de cada equipo cubierto la ven en Ajustes, en modo de solo lectura. - Una organización, o un equipo que no pertenece a ninguna, puede tener hasta 10 integraciones activas, contando todos sus equipos. Revoca las que ya no uses para liberar una plaza. [`GET /v1/teams`](https://totemstudio.ai/developers/es/reference-account#teams.list) lista los equipos que puede leer una clave, cada uno con un identificador que empieza por `team_`. - `GET /v1/me` y `GET /v1/teams` describen la clave y nunca llevan la cabecera. - Las peticiones sobre un registro concreto, como `GET /v1/leads/{id}`, no la necesitan: el identificador del registro ya indica su equipo. - Las peticiones que leen una colección de un equipo (listas como `GET /v1/leads`, `GET /v1/boards` o `GET /v1/members`, `POST /v1/leads/search` y `GET /v1/sync/changes`) usan el único equipo de la clave. Si la clave cubre varios, indica uno en la cabecera `Totem-Team`: ```bash curl "https://api.totemstudio.ai/v1/leads?limit=50" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ``` Los ejemplos de la referencia de la API envían `Totem-Team: $TOTEM_TEAM_ID` en estas peticiones. Con una clave de un solo equipo, deja `TOTEM_TEAM_ID` sin definir y no envían la cabecera: cURL descarta una cabecera con el valor vacío, y los ejemplos de Node.js y Python comprueban la variable. Una clave con un solo equipo también puede enviar la cabecera, siempre que indique ese equipo. Qué significa cada respuesta para tu copia del equipo: - Sin la cabecera, una clave con varios equipos recibe `400 team_required`. Una cabecera que no es un identificador de equipo, o que aparece dos veces, recibe `400 invalid_request`. - Un equipo que la clave no cubre, o que ya no existe, recibe `404 team_not_accessible`. Consulta `GET /v1/teams`: si el equipo ya no aparece, borra sus datos y su cursor. - Una clave de un solo equipo cubre su equipo solo mientras el equipo siga donde estaba al crearse la clave: en la misma organización o fuera de cualquier organización. Si el equipo entra en una organización, sale de ella o pasa a otra, la clave recibe `404 team_not_accessible` y `GET /v1/teams` deja de mostrar el equipo. Revoca la clave en **Ajustes → Desarrolladores** y crea una nueva. - Un equipo desactivado o archivado, o cuyo acceso a la API ha pausado Totem, recibe `403 team_state_restricted`. Conserva los datos del equipo y su cursor, omítelo en esta ejecución y vuelve a intentarlo más tarde. Un equipo desactivado o un acceso pausado pueden volver; un equipo archivado sigue restringido salvo que Totem lo restaure. Mientras un equipo está restringido, el feed de cambios no entrega nada de él, tampoco eliminaciones, así que, si la restricción dura más de lo que permite tu política de conservación, borra tu copia. - Por ahora, Totem no exporta los equipos de un sector de actividad sensible, como el sanitario. Un equipo así no se puede añadir a una integración, y una clave que ya lo cubre recibe `403 sector_restricted` para él. Omite el equipo: esperar no lo cambia. El feed de cambios no entrega nada de él, tampoco eliminaciones, así que conserva lo que ya tengas solo mientras lo permita tu política de conservación y después borra tu copia. ## Lista de IP permitidas Una integración puede limitarse a un máximo de 20 direcciones IP o rangos en notación CIDR. Una petición desde cualquier otra dirección recibe `403 ip_not_allowed`. La lista se configura en **Ajustes → Desarrolladores**, en **Opciones avanzadas** al crear la clave o con **Editar integración** más tarde; cambiarla exige un inicio de sesión reciente. - La API solo es accesible por IPv4, así que indica las direcciones IPv4 públicas desde las que salen tus peticiones. Hoy una entrada IPv6 nunca coincide. - Escribe una dirección o un rango por línea. Un rango debe empezar en su dirección de red, como `203.0.113.0/24`, y `/0` no se admite. Una lista vacía permite cualquier dirección. - Si tu plataforma no tiene una dirección de salida fija, como muchas plataformas serverless, haz que su tráfico salga antes por una IP estática. - Después de guardar la lista, llama a `GET /v1/me` desde el servidor desplegado para comprobarla. Totem comprueba la dirección después de la clave, así que una clave incorrecta recibe `401 invalid_credentials` venga de donde venga. ## Rotar y revocar **Rota** una clave con **Rotar clave** para sustituirla. Totem emite una clave nueva y mantiene la antigua en funcionamiento durante el tiempo que elijas en el diálogo: 1 hora, 24 horas (por defecto) o 7 días. Despliega la clave nueva dentro de ese plazo y nada se detiene. Cuando termina ese tiempo, la clave antigua recibe `401 key_expired`. Volver a rotar revoca cualquier clave antigua que siga en su periodo de gracia, y el diálogo te pide que lo confirmes. Para desactivar la clave antigua al instante, marca **Esta clave puede estar comprometida** al rotarla, o elige más tarde **Desactivar ya la clave anterior**. La clave antigua recibe entonces `401 invalid_credentials`. **Si una clave puede haberse filtrado**, rótala y marca **Esta clave puede estar comprometida**: la clave antigua se revoca al instante, sin periodo de gracia. Después avísanos, como se explica en [Soporte](https://totemstudio.ai/developers/es/support). **Revoca** una integración con **Revocar acceso** para detener todas sus claves. Una clave revocada deja de funcionar en un máximo de 5 segundos, y revocar no se puede deshacer. Los datos que ya copiaste siguen en tus sistemas y dejan de recibir eliminaciones de Totem: bórralos, o consérvalos solo mientras lo permitan las normas del propio cliente. En [Seguridad y datos personales](https://totemstudio.ai/developers/es/security#datos-personales-que-recibes) se explica qué hacer con ellos. Crear o rotar una clave, añadir un equipo y cambiar la lista de IP permitidas piden al administrador que vuelva a iniciar sesión si no lo ha hecho hace poco. Cada cambio aparece en la **Actividad reciente** de la integración, y se avisa de cada uno a los administradores que gestionan la integración y a los de cada equipo que cubre, en Totem y por correo electrónico. Revocar y **Desactivar ya la clave anterior** nunca exigen iniciar sesión: quien puede gestionar la integración puede detenerla al momento. ## Intentos fallidos Totem cuenta los intentos fallidos: las peticiones con una clave que falta, está mal formada, no existe o es incorrecta. A partir de tan solo 60 fallos desde una dirección IP en un minuto, o 20 con una misma clave, Totem puede bloquear esa dirección o esa clave durante 600 segundos. Mientras dura el bloqueo, las peticiones reciben `429 rate_limited` con `Retry-After` igual a la duración completa del bloqueo; el bloqueo puede terminar antes. Una clave revocada o caducada, una integración que ya no está activa y una petición desde fuera de la lista de IP permitidas no cuentan. Mientras una clave está bloqueada, las peticiones con su secreto real siguen funcionando: solo se rechazan los intentos de adivinarla. Mientras una dirección está bloqueada, se rechazan todas sus peticiones. Si te quedas bloqueado, corrige primero la clave y después espera lo que indica `Retry-After` antes de volver a intentarlo. Muchas peticiones sin una clave bien formada desde una misma dirección también pueden hacer que el cortafuegos situado delante de la API responda `429` por sí mismo, sin `Retry-After` ni cuerpo JSON, durante un máximo de 10 minutos. Detén el bucle, corrige la cabecera y reintenta con espera exponencial, como se explica en [Errores](https://totemstudio.ai/developers/es/errors). ## Errores de autenticación Cada código enlaza con su explicación completa en la página de [Errores](https://totemstudio.ai/developers/es/errors). | Estado | `code` | Qué hacer | |---|---|---| | 400 | [`secret_key_in_browser`](https://totemstudio.ai/developers/es/errors#secret_key_in_browser) | La petición salió de un navegador. Llama a la API desde tu servidor y, si la clave estuvo alguna vez en una página web o una app, rótala y márcala como comprometida. | | 400 | [`team_required`](https://totemstudio.ai/developers/es/errors#team_required) | La clave cubre varios equipos. Envía `Totem-Team`. | | 401 | [`invalid_credentials`](https://totemstudio.ai/developers/es/errors#invalid_credentials) | Falta la clave, está mal formada, no existe, es incorrecta o se ha revocado. Revisa la cabecera y la clave. | | 401 | [`key_expired`](https://totemstudio.ai/developers/es/errors#key_expired) | La clave se rotó y su periodo de gracia ha terminado. Usa la clave nueva. | | 403 | [`ip_not_allowed`](https://totemstudio.ai/developers/es/errors#ip_not_allowed) | La petición salió de una dirección que no está en la lista de IP permitidas de la integración. La API solo es accesible por IPv4. | | 403 | [`plan_not_entitled`](https://totemstudio.ai/developers/es/errors#plan_not_entitled) | El plan de la organización no incluye la API. Conserva tus datos y habla con el cliente; el feed no entrega eliminaciones hasta que vuelva el acceso, así que, si eso dura más de lo que permite tu política de conservación, borra tu copia. | | 403 | [`insufficient_scope`](https://totemstudio.ai/developers/es/errors#insufficient_scope) | A la clave le falta un permiso que este endpoint necesita. Todas las claves de la versión 1.0a tienen los mismos permisos, así que avísanos con el identificador de la petición. | | 403 | [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted) | El equipo está desactivado, archivado o en pausa. Conserva sus datos y su cursor, omítelo en esta ejecución y vuelve a intentarlo más tarde; si la restricción dura más de lo que permite tu política de conservación, borra tu copia. | | 403 | [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted) | Por ahora, Totem no exporta los equipos de un sector sensible, como el sanitario. Omite el equipo: esperar no lo cambia. El feed no entrega eliminaciones de él, así que conserva lo que tengas solo mientras lo permita tu política de conservación y después borra tu copia. | | 404 | [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible) | La clave no cubre este equipo. Si `GET /v1/teams` ya no lo incluye, borra sus datos y su cursor. | | 429 | [`rate_limited`](https://totemstudio.ai/developers/es/errors#rate_limited) | Demasiados intentos fallidos o demasiadas peticiones. Espera los segundos que indica `Retry-After`. Un `429` sin cuerpo JSON viene del cortafuegos: reintenta con espera exponencial. | --- # SDK de TypeScript > Usa el cliente oficial de TypeScript para leer tus datos de Totem, paginar listas y gestionar errores de la API desde tu servidor. Fuente: https://totemstudio.ai/developers/es/typescript El SDK oficial de TypeScript, `@totemstudioai/sdk`, cubre todos los endpoints de la API de solo lectura en **Beta**. Se ejecuta en tu servidor con Node.js 22.12 o posterior. Un administrador del equipo crea su clave en [**Ajustes → Desarrolladores**](https://app.totemstudio.ai/settings?tab=developers); el acceso a la API pública está incluido desde Pro. ## Instala el SDK de TypeScript Instala el paquete de la versión publicada en el [repositorio oficial de GitHub](https://github.com/kiketordera/totem-studio-typescript). Contiene el paquete ya compilado, así que no necesita compilar nada y funciona con los scripts de instalación desactivados. Descárgalo con su suma de comprobación, compruébalo y después instala el archivo que comprobaste: ```bash curl -fLO https://github.com/kiketordera/totem-studio-typescript/releases/download/v0.1.0-beta.2/totemstudioai-sdk-0.1.0-beta.2.tgz curl -fLO https://github.com/kiketordera/totem-studio-typescript/releases/download/v0.1.0-beta.2/totemstudioai-sdk-0.1.0-beta.2.tgz.sha256 shasum -a 256 -c totemstudioai-sdk-0.1.0-beta.2.tgz.sha256 npm install ./totemstudioai-sdk-0.1.0-beta.2.tgz ``` La suma de comprobación demuestra que la descarga está completa y sin cambios; viene de la misma [página de la versión](https://github.com/kiketordera/totem-studio-typescript/releases) que el paquete, así que no demuestra quién lo generó. npm registra la dependencia como una ruta `file:` a ese paquete, así que deja el paquete en la ruta desde la que lo instalaste. También puedes instalar la etiqueta de Git con `npm install github:kiketordera/totem-studio-typescript#v0.1.0-beta.2`, que compila el paquete durante la instalación y por eso necesita los scripts de instalación activados. En ambos casos el paquete se importa como `@totemstudioai/sdk`. Todavía no está publicado en el registro de npm, así que un paquete con ese nombre en npm no es de Totem: no le des nunca tu clave e instala el SDK solo de una de las dos formas anteriores. Guarda la clave en el almacén de secretos de tu servidor y proporciónala como `TOTEM_API_KEY`. No incluyas nunca este cliente ni su clave en un navegador ni en una aplicación móvil. ## Haz una petición tipada Los ejemplos de esta página son módulos ES y usan `await` en el nivel superior. En un proyecto CommonJS, colócalos dentro de una función `async`. ```ts import { TotemClient } from '@totemstudioai/sdk' const apiKey = process.env.TOTEM_API_KEY if (!apiKey) throw new Error('Set TOTEM_API_KEY on your server') const client = new TotemClient({ apiKey }) const integration = await client.me.get() const teams = await client.teams.list() console.log(integration.id, teams.data.map(team => team.id)) ``` Si la clave lee varios equipos, elige uno con la opción `team` del cliente. También puedes indicar `team` en cada petición de las rutas que leen la colección de un equipo: ```ts const team = process.env.TOTEM_TEAM_ID if (!team) throw new Error('Set TOTEM_TEAM_ID on your server') const client = new TotemClient({ apiKey, team }) const page = await client.leads.list({ limit: 100 }) const matching = await client.leads.search({ email: 'alex.rivera@example.com' }) console.log(page.data.length, matching.data.length) ``` Una ruta que lee un registro por su identificador, como `client.leads.get(id)`, toma el equipo del identificador y no admite la opción `team`: compara el `team` del registro con el que esperas. El cliente añade `Authorization` y `Totem-Team`, codifica los filtros y devuelve objetos de respuesta tipados. Los nombres de los campos son los mismos que en el JSON de la API. Los campos con un conjunto fijo de valores están tipados de forma que un valor añadido más adelante siga compilando, porque [los enums pueden crecer](https://totemstudio.ai/developers/es/conventions). ## Lee todas las páginas con el SDK Usa `items` para recorrer una lista sin gestionar sus cursores de página: ```ts for await (const lead of client.items('leads.list', { query: { limit: 100 } })) { console.log(lead.id, lead.version) } ``` Usa `pages` si necesitas el objeto completo de cada página. Se detiene cuando `has_more` es `false`; no programa futuras consultas. `timeoutMs` se aplica a cada página; pasa `signal: AbortSignal.timeout(ms)` para limitar el recorrido completo. Lee el feed de cambios con `pages` o con `client.sync.changes({ cursor, limit: 100 })`, nunca con `items`: aplica cada página antes de guardar su `next_cursor`, también en una página vacía con `has_more: false`. Un `cursor` `undefined` o `null` empieza desde el principio. Guarda un cursor por equipo y vuelve a consultar cada 1 a 5 minutos. Usa `isFeedRemoval(item)` para distinguir una eliminación del estado actual de un registro, y aplica un elemento solo si su `version` es mayor que la que tienes. [Sincroniza una copia](https://totemstudio.ai/developers/es/sync) explica las eliminaciones, las versiones y la resincronización completa; el SDK no guarda los datos ni los cursores por ti. > **Nota: ¿SDK o cliente de referencia?** > > Usa el SDK para las peticiones tipadas, las consultas y la paginación. Para mantener una copia de un equipo, parte del [cliente de referencia probado](https://totemstudio.ai/developers/es/sync#cliente-de-referencia-probado) de la guía de sincronización, que implementa las marcas de eliminación, el marcado y barrido de la resincronización completa y un resultado por equipo, o sigue el ejemplo [`examples/sync.ts`](https://github.com/kiketordera/totem-studio-typescript/blob/main/examples/sync.ts) del SDK, que sincroniza un equipo y deja la resincronización completa a tu almacenamiento. ## Errores y metadatos del SDK ```ts import { TotemApiError, TotemError } from '@totemstudioai/sdk' try { const response = await client.requestWithResponse('me.get') console.log(response.requestId, response.data.id) } catch (error) { if (error instanceof TotemApiError) { console.error(error.status, error.code, error.action, error.param, error.requestId) } else if (error instanceof TotemError) { console.error(error.name) } throw error } ``` Todos los errores que lanza el SDK por una petición que ha enviado extienden `TotemError`: - `TotemApiError` para un error con el que respondió la API. Incluye el estado HTTP, el `code` de error de la API, la `action` recomendada, el `param` que ha fallado, `title`, el identificador de la petición y `retryAfterMs` cuando se proporciona. - `TotemProtocolError` para una respuesta que el contrato no permite, como una respuesta correcta cuyo cuerpo no es JSON, una página de lista sin `data` o un cursor ausente o repetido durante `pages()` o `items()`. - `TotemConnectionError` para un fallo de red que los reintentos no resuelven, y `TotemTimeoutError` para el plazo de la petición. Las opciones o los argumentos no válidos, como un `apiKey` o un `team` vacíos, lanzan un `TypeError` normal antes de enviar ninguna petición, y el motivo de cancelación de tu propia `signal` se relanza sin cambios. Decide según `action`, la agrupación de códigos de error que hace el SDK: `fix_request`, `check_credentials`, `resync`, `wait_capture`, `drop_team`, `keep_and_retry`, `retry_after`, `backoff` o `contact_support`. El `errorCatalogue` que exporta el SDK asigna a cada código documentado su acción, y el [catálogo de errores](https://totemstudio.ai/developers/es/errors) explica cada código; trata un código desconocido según su estado HTTP. En el feed de cambios, `action === 'resync'` (`resync_required` o `sync_cursor_expired`) significa una resincronización completa; `capture_not_ready` significa conservar tus datos y tu cursor y volver a intentarlo más tarde; `team_not_accessible` significa volver a leer `GET /v1/teams` y descartar el equipo solo si ya no aparece. Sin el SDK, decide según el `code` de la respuesta. Guarda el identificador de la petición para [soporte](https://totemstudio.ai/developers/es/support). El cliente hace un número limitado de reintentos, con esperas aleatorizadas, ante fallos temporales y respeta `Retry-After`. Si la espera supera `maxRetryDelayMs`, o el tiempo que queda hasta el plazo de la petición, devuelve el error a tu código al momento en lugar de acortarla. Usa `timeoutMs`, `maxRetries`, `maxRetryDelayMs` y `signal` para ajustar el plazo de tu proceso. Los errores de autenticación, autorización o petición inválida necesitan una corrección, no un bucle de reintentos. ## Verifica tu integración con el SDK Inyecta una implementación de `fetch` con `new TotemClient({ apiKey, fetch })` para hacer pruebas HTTP deterministas. Prueba por separado cómo tu receptor gestiona las versiones, las eliminaciones y la persistencia del cursor, y después haz peticiones reales con tu propia clave. [Pruebas y puesta en producción](https://totemstudio.ai/developers/es/testing) enumera el comportamiento que debes verificar; una respuesta simulada no demuestra que funcionen una clave o un servidor reales. --- # Sincroniza una copia > Copia un equipo con el feed de cambios y mantén la copia al día. Aplica las páginas sin riesgos, recupérate de los errores y parte de un cliente de referencia que Totem prueba. Fuente: https://totemstudio.ai/developers/es/sync El feed de cambios, [`GET /v1/sync/changes`](https://totemstudio.ai/developers/es/reference-sync#sync.changes), es la forma de tener tu propia copia de un equipo. Tu primera petición copia todos los registros. A partir de ahí, cada petición devuelve solo lo que ha cambiado desde la anterior, así que sincronizar cada pocos minutos cuesta poco y nunca se pierde un cambio. 1. Empieza cada equipo sin cursor: tu primera petición a GET /v1/sync/changes inicia su carga inicial. 2. Llama a GET /v1/sync/changes con el equipo en Totem-Team y tu cursor guardado. 3. Aplica la página: omite cada elemento cuya versión ya tengas, guarda cada registro y borra cada registro eliminado, pero conserva su versión como marca de eliminación. 4. Guarda next_cursor, solo cuando hayas guardado la página entera. 5. Si has_more es true, vuelve a pedir enseguida. Si es false, estás al día: espera de 1 a 5 minutos y vuelve a pedir con el cursor guardado. 6. Si la petición falla: - `410 sync_cursor_expired` o `409 resync_required`: Resincronización completa: olvida las versiones, marca y barre. - `409 capture_not_ready`: Totem aún prepara el equipo. Reintenta en unos minutos. - `403`: Conserva los datos y el cursor. Vuelve a intentarlo más tarde. - `404 team_not_accessible`: Confírmalo con GET /v1/teams y después quita el equipo. - `429`: Espera lo que indique Retry-After y repite con el mismo cursor. - `5xx`: Reintenta con espera exponencial. Nunca guardes un cursor tras un error. ## Cómo funciona el feed Hay un feed por equipo. Una clave que lee varios equipos indica el equipo en la [cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication#equipos-y-la-cabecera-totem-team) y guarda un cursor por equipo. Cada elemento del feed es el estado actual de un registro, de un tipo (`kind`): - `lead`, `lead_attribution` y `lead_response`: un lead y sus dos facetas; - `activity`: un cambio de etapa, de estado o de miembros asignados de un lead; - `call`, `conversation` y `booking`; - `board`, con sus etapas dentro. La atribución y la respuesta de un lead viajan como tipos propios, con el identificador del lead y su propia `version`. Por eso un mensaje nuevo cambia la respuesta del lead sin cambiar el lead. Los miembros y los servicios reservables no están en el feed: los [lees aparte](https://totemstudio.ai/developers/es/sync#actualiza-tableros-miembros-y-servicios). Los elementos llegan en el orden en que Totem registró los cambios, del más antiguo al más reciente. Un elemento nunca es una diferencia: trae el registro entero tal como está ahora. ### Registros y eliminaciones La mayoría de los elementos traen el registro en `object`. Es el mismo objeto que devuelven los endpoints de lista; aquí aparece abreviado: ```json { "kind": "call", "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "version": 2, "object": { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "direction": "inbound", "outcome": "answered_ai", "version": 2 } } ``` Una **eliminación** trae `removed` en lugar de `object`. Te indica que borres el registro: ```json { "kind": "call", "id": "call_m1pf4APjJWVi3Z7ulyNaW8y3lUi7V", "version": 5, "removed": { "reason": "deleted" } } ``` `reason` es `erased` cuando el registro se suprimió: por privacidad, por ejemplo a petición de la persona, o porque alguien eliminó definitivamente en Totem un lead, una llamada o una conversación, lo que siempre los suprime. Es `deleted` cuando el registro desapareció de cualquier otra forma, por ejemplo porque ya no forma parte de los datos de la API del equipo o porque Totem lo identifica ahora con un identificador nuevo. Trata los dos casos igual, como explica [Aplica una página](https://totemstudio.ai/developers/es/sync#aplica-una-página). Un registro que vuelve es un registro nuevo con un identificador nuevo ([Convenciones](https://totemstudio.ai/developers/es/conventions#identificadores)), así que relaciona a las personas entre identificadores por teléfono o correo electrónico con la [búsqueda](https://totemstudio.ai/developers/es/reference-leads#leads.search), nunca por identificador. ## Planifica tu almacenamiento Una copia construida así puede aplicar cada página sin riesgos, lleguen los registros en el orden que lleguen: - **Una fila por registro**, con la clave formada por el equipo, el `kind` y el `id`, y con su `version`. Un lead y sus dos facetas comparten identificador, así que el `kind` forma parte de la clave. - **Una marca por eliminación**: el `kind`, el `id` y la `version` de un registro que borraste, sin sus datos. - **Un cursor por equipo**, guardado junto con la página a la que sigue cuando tu base de datos lo permita. - **Los tableros, los miembros y los servicios reservables** en listas propias, que sustituyes enteras cada vez que las lees. - **Ninguna clave foránea entre registros mientras aplicas páginas.** Un registro puede indicar otro que aún no te ha llegado, como un lead que indica una etapa de un tablero o una actividad que indica su lead. Después, ejecuta una sincronización por clave cada 1 a 5 minutos, y vigila los equipos que se omiten o se aplazan una y otra vez, o cuyo cursor lleva un día sin guardarse. ## Haz la carga inicial Tu primera petición de un equipo no lleva cursor. Empieza la **carga inicial**: el feed recorre todos los registros del equipo, del cambio más antiguo al más reciente. ```bash curl "https://api.totemstudio.ai/v1/sync/changes?limit=100" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` Aplica la página, guarda su `next_cursor` y vuelve a pedir con `cursor` igual a ese valor. Repite mientras `has_more` sea `true`. Cuando `has_more` sea `false`, tu copia estará completa. Pide la página más grande, de 100 elementos, para que la carga inicial necesite las menos peticiones posibles. Durante una carga inicial hay dos cosas normales. Un registro que cambia mientras se ejecuta vuelve a llegar más tarde, con una versión mayor. Y, tras un reintento, puedes recibir la misma página dos veces. La comprobación de versiones del apartado siguiente hace que ninguna de las dos cause problemas. ## Aplica una página Para cada elemento de la página, en orden: 1. **Omite lo que ya tienes.** Si ya tienes ese `kind` e `id` con la misma `version` o una mayor, omite el elemento. Compara también con las versiones de tus marcas de eliminación. 2. **Guarda un registro.** Si el elemento trae `object`, inserta tu copia o sustitúyela. Si es un lead, guarda sus `contact_permissions` antes de que nada pueda actuar sobre él. 3. **Aplica una eliminación.** Si el elemento trae `removed`, borra tu copia, pero conserva su `kind`, su `id` y su `version` como **marca de eliminación**. Si el registro eliminado es un lead, borra también su atribución, su respuesta y sus actividades, e ignora cualquiera de ellas que llegue después. > **Advertencia: Conserva la marca de eliminación** > > Sin la marca, el paso 1 no tiene con qué comparar, y una copia anterior del registro, entregada otra vez en una página repetida, lo haría reaparecer. Si el registro se suprimió a petición de una persona, eso es un incidente de privacidad, no un fallo de sincronización. ## Guarda el cursor Guarda `next_cursor` solo cuando hayas guardado la página entera. Si tu proceso se detiene entre medias, la siguiente ejecución vuelve a leer la misma página, y el paso 1 omite lo que ya aplicaste. Si tu base de datos lo permite, guarda la página y su cursor en una misma transacción. Un cursor es opaco: guárdalo tal como lo recibiste y no construyas ni modifiques ninguno. Pertenece a un solo equipo. Si lo envías con otro equipo, recibe `400 invalid_request`. Un cursor caduca 30 días después del punto del feed al que llegó. Mientras una carga inicial sigue en curso, esa antigüedad se cuenta desde que empezó la carga. El cursor de un equipo que consultas con regularidad nunca llega a caducar. ## Consulta los cambios periódicamente Cuando `has_more` sea `false`, vuelve a pedir con el cursor guardado cada 1 a 5 minutos. Si no ha cambiado nada, recibes una página vacía con un `next_cursor` nuevo. Guárdalo también: una página vacía hace avanzar el cursor, así que el cursor de un equipo con poca actividad nunca envejece. El feed retiene los últimos 10 segundos de cambios, para no saltarse nunca un cambio que aún se está escribiendo. Un cambio aparece en el feed poco después de producirse en Totem. Sincroniza cada combinación de clave y equipo desde un solo proceso a la vez. Dos procesos que aplican páginas del mismo equipo pueden guardar sus cursores en un orden equivocado. ## Eliminaciones, fusiones y traspasos ### Eliminaciones Aplica cada eliminación en cuanto la recibas: borra el registro de tu copia y de todos los sistemas a los que lo copiaste. Las eliminaciones siguen en el feed durante 45 días, más de lo que dura un cursor, así que una sincronización que sigue leyendo el feed con éxito nunca se pierde ninguna. Mientras la API rechaza un equipo, no entrega nada de él, tampoco eliminaciones; en [Recupérate de los errores](https://totemstudio.ai/developers/es/sync#recupérate-de-los-errores) se explica qué hacer entonces. Cuando se elimina un lead, su atribución, su respuesta y sus actividades se eliminan con él, y el feed entrega también sus eliminaciones. Lo que ocurre con los demás registros del lead depende de `reason`: - **`erased`**, también cuando alguien elimina definitivamente el lead en Totem: sus llamadas y conversaciones también se suprimen, igual que los leads archivados cuyo `merged_into` lo indica, con sus propias llamadas y conversaciones. Cada registro llega como una eliminación propia, con `reason: "erased"`. Sus citas no se eliminan: vuelven a llegar con `lead` igual a `null`. - **`deleted`**: sus llamadas, conversaciones y citas no se eliminan. Vuelven a llegar con `lead` igual a `null`. ### Fusiones Cuando en Totem se fusionan dos leads de la misma persona, el lead que no se conserva queda archivado, no eliminado. Su `archive_reason` es `merged` y su `merged_into` indica el lead que se conservó. Enlaza tu copia con ese lead en lugar de crear a la persona dos veces. El lead que se conserva se queda además con los permisos de contacto más restrictivos de los dos. `merged_into` es `null` cuando el lead que indicaba ya no existe. ### Traspasos Cuando un lead pasa a otro equipo de la misma organización, el lead original queda archivado con `archive_reason` igual a `family_transfer`, y `transferred_to` indica adónde fue: - `transferred_to.team` siempre tiene valor. Puede ser un equipo que tu clave no lee. - `transferred_to.lead` es el lead en ese equipo, o `null` cuando Totem no puede indicarlo. Si tu clave lee el equipo de destino, el lead nuevo llega en el feed de ese equipo. Si no lo lee, [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/es/reference-leads#leads.get) sobre el lead de destino responde `404 resource_missing`. Conserva el lead archivado como constancia de adónde fue la persona, y no intentes adivinarlo. ## Primero, los permisos de contacto Antes de que cualquiera de tus sistemas contacte con un lead, guarda sus `contact_permissions` y aplícalos: - `do_not_contact: true`: la persona pidió que no se la contacte nunca. No uses ningún canal. - `email_opt_out: true`: la persona se dio de baja de los correos comerciales y de seguimiento. - `whatsapp_consent: false`: la persona rechazó WhatsApp. `null` significa que nadie registró su respuesta, así que nunca lo trates como un consentimiento. Que un lead aparezca en el feed es un dato, no un permiso para contactar con la persona. La base legal para contactar con alguien sigue siendo responsabilidad tuya. La API es de solo lectura, así que Totem no puede conocer una baja registrada en tus sistemas. Cuando una persona se dé de baja en ellos, asegúrate de que también quede registrada en el lead en Totem, para que Totem deje de contactarla. ## Recupérate de los errores Cuando falla la petición de una página, no guardes ningún cursor. La siguiente petición con el mismo cursor vuelve a pedir la misma página. | Respuesta | Qué significa | Qué hacer | |---|---|---| | `410 sync_cursor_expired` | El cursor tiene más de 30 días. | Haz una [resincronización completa](https://totemstudio.ai/developers/es/sync#resincronización-completa). | | `409 resync_required` | Totem no puede continuar desde este cursor, por ejemplo tras restaurar una copia de seguridad. | Haz una [resincronización completa](https://totemstudio.ai/developers/es/sync#resincronización-completa). | | `409 capture_not_ready` | Totem aún está preparando los datos de este equipo. Ocurre una vez, cuando un equipo se añade por primera vez a una clave. | Conserva todo y vuelve a intentarlo en unos minutos. Nunca resincronices por esto. Los demás equipos de la clave siguen sincronizándose. | | `403` | El acceso está denegado por ahora: por ejemplo, el equipo está restringido (`team_state_restricted`) o el endpoint está desactivado (`capability_disabled`). | Conserva los datos y el cursor del equipo, y omítelo en esta ejecución. La página de [Errores](https://totemstudio.ai/developers/es/errors) indica, código a código, si sirve de algo esperar. `plan_not_entitled` rechaza la clave entera, así que la ejecución se detiene en `GET /v1/teams`. Mientras el acceso está denegado, el feed no entrega nada del equipo, tampoco eliminaciones: si la denegación dura más de lo que permite tu política de conservación, borra la copia del equipo. | | `404 team_not_accessible` | Puede que la clave ya no lea este equipo. | Lee [`GET /v1/teams`](https://totemstudio.ai/developers/es/reference-account#teams.list). Si el equipo no aparece, borra sus datos y su cursor. Si aparece, conserva todo y vuelve a intentarlo en la siguiente ejecución. | | `400 invalid_request` | El cursor se ha modificado o es de otro equipo. | Usa el cursor que guardaste para este equipo. | | `429` | Has alcanzado el [límite de frecuencia o la cuota diaria](https://totemstudio.ai/developers/es/rate-limits). | Espera los segundos que indique `Retry-After` y repite con el mismo cursor. Un `429` del cortafuegos no trae `Retry-After`: reintenta con espera exponencial. | | `5xx` | Algo ha fallado dentro de Totem. | Reintenta con espera exponencial. | Todos los códigos se explican en la página de [Errores](https://totemstudio.ai/developers/es/errors). ### Resincronización completa Una resincronización completa reconstruye tu copia de un equipo **marcando y barriendo**: 1. Olvida las versiones y el cursor que guardaste del equipo. Conserva los registros: tu copia sigue siendo utilizable mientras dura la resincronización. 2. Repite la carga inicial, sin cursor. Aplica cada página como siempre y marca cada registro que entregue el feed. 3. Cuando `has_more` sea `false`, borra todos los registros del equipo que no hayas marcado. Guarda las marcas de forma duradera. Si el proceso se detiene a medias, la siguiente ejecución debe continuar la misma resincronización, no barrer con la mitad de las marcas. También importa olvidar antes las versiones: después de una restauración, el feed puede entregar versiones más bajas que las que tenías. ## Cuando cambian los equipos Lee [`GET /v1/teams`](https://totemstudio.ai/developers/es/reference-account#teams.list) al principio de cada ejecución. Lista todos los equipos que lee tu clave, también los que están restringidos por ahora. - Un equipo que aún no has sincronizado: haz su carga inicial. - Un equipo que tienes y que ya no aparece: ha salido de la clave. Borra sus datos y su cursor. - Un equipo que aparece pero responde `403`: consérvalo, como se explica más arriba. ## Actualiza tableros, miembros y servicios Los miembros y los servicios reservables no están en el feed. Los tableros sí, pero un lead puede indicar una etapa nueva antes de que te llegue el cambio de su tablero. Por eso, lee los tres: - cuando empieces a sincronizar un equipo; - cuando un elemento indique un tablero, una etapa, un miembro o un servicio que no conoces; - al menos una vez al día. Un registro puede seguir indicando un miembro que salió del equipo, o un tablero, una etapa o un servicio que se borró. Si una actualización no encuentra un identificador, recuérdalo y no vuelvas a actualizar por él antes de la actualización diaria. [`GET /v1/boards`](https://totemstudio.ai/developers/es/reference-leads#boards.list) se pagina como cualquier lista. [`GET /v1/members`](https://totemstudio.ai/developers/es/reference-account#members.list) y [`GET /v1/booking_services`](https://totemstudio.ai/developers/es/reference-bookings#booking_services.list) devuelven la lista entera en una sola respuesta y no admiten `limit` ni `cursor`. Sustituye entera tu copia de cada lista. Un miembro que deja el equipo, o un servicio que se borra, sale de su lista, pero los registros anteriores siguen indicando su identificador: si muestras nombres en registros anteriores, conserva el de una lectura anterior. Los miembros y servicios desactivados siguen en la lista, sin nada que indique que lo están. ## Ritmo Una clave lee hasta 2.000 elementos por minuto, así que la carga inicial de un equipo grande lleva un tiempo. En [Límites de frecuencia y cuota](https://totemstudio.ai/developers/es/rate-limits) tienes las cifras, una estimación de cuánto tarda una carga inicial y por qué las cifras son aproximadas durante la Beta. Si recibes un `429`, espera lo que indique `Retry-After` y continúa con el mismo cursor: no se pierde nada. Los límites son de la clave, así que un `429` detiene todos los equipos que lee, no solo el que estabas sincronizando. ## Cliente de referencia probado El código TypeScript de más abajo es un cliente de sincronización completo que hace todo lo que describe esta página. Totem lo ejecuta en sus propias pruebas contra un feed simulado, con caídas, páginas repetidas, eliminaciones, resincronizaciones y errores; el código que se muestra aquí es ese mismo archivo, y por eso sus comentarios están en inglés. No tiene dependencias y solo necesita `fetch`: compílalo con tu build de TypeScript o ejecútalo directamente con Node.js 22.18 o posterior. Cópialo en tu servidor e implementa `SyncStore` sobre tu base de datos. > **Nota: ¿Cliente de referencia o SDK?** > > Para sincronizar, parte de este cliente: implementa las marcas de eliminación, el marcado y barrido de la resincronización completa y un resultado por equipo. El [SDK de TypeScript](https://totemstudio.ai/developers/es/typescript) ofrece peticiones tipadas y paginación; su propio ejemplo de sincronización trata un solo equipo y deja la [resincronización completa](https://totemstudio.ai/developers/es/sync#resincronización-completa) a tu almacenamiento, así que impleméntala como explica esta página. Ejecútalo desde una tarea programada cada 1 a 5 minutos, con una sola ejecución por clave a la vez. El ejemplo es un módulo ES; con la resolución de módulos de Node.js, importa el archivo con la extensión `.js` que genera tu build, o con `.ts` si Node.js ejecuta el TypeScript directamente: ```ts import { syncKey } from './syncTeam.js' const outcomes = await syncKey({ key: process.env.TOTEM_API_KEY!, store }) ``` La clave nunca debe llegar a un navegador. Cada método de `SyncStore` debe escribir de forma duradera antes de que se resuelva su promesa, y las escrituras de un mismo equipo no deben ejecutarse en paralelo. ### Opciones y resultados `syncKey` lee `GET /v1/teams`, borra los equipos que han salido de la clave y sincroniza, uno tras otro, todos los equipos de la lista. Devuelve un resultado por equipo: - `synced`: el equipo está al día y su cursor está guardado. - `partial`: el equipo leyó `maxPagesPerTeam` páginas y todavía no está al día. Su cursor está guardado, y la siguiente ejecución continúa desde él. - `dropped`: el equipo ha salido de la clave, y su copia y su cursor se han borrado. - `skipped`, con el `code`: ahora mismo no se puede leer el equipo (`403`, `capture_not_ready` o un `team_not_accessible` que `GET /v1/teams` no confirma). Se conservan su copia y su cursor, y la siguiente ejecución lo vuelve a intentar. - `retry_later`, con el `code` y `retryAt` (milisegundos desde la época Unix): la API pidió esperar más de lo que permite esta ejecución, o un `429` o un `5xx` seguía fallando tras `maxRetries` reintentos. Se conservan la copia y el cursor. No vuelvas a sincronizar ese equipo antes de `retryAt`. Un `429` es de la clave, no de un equipo, así que en cuanto uno aparta la clave, todos los equipos a los que esta ejecución aún no ha llegado se marcan `retry_later` con el mismo `code` y el mismo `retryAt`, sin hacer ninguna petición. Un equipo restringido, todavía en preparación o que ya no está nunca detiene a los demás; un `429` que esta ejecución no puede esperar detiene la clave entera hasta `retryAt`. `syncKey` lanza un error cuando se rechaza la propia clave, cuando no se puede leer `GET /v1/teams` o ante un error que apunta a un fallo de programación y no al equipo: un `400`, un `404` inesperado, un feed que vuelve a pedir una resincronización completa justo después de otra, una lista o un feed que repite un cursor o no envía ninguno, o un fallo de red. Tampoco entonces se pierde ningún cursor; si el error trae `retryAt`, programa la siguiente ejecución para ese momento. Además de `key` y `store`, admite: - `maxWaitMs`: lo máximo que espera de una vez, en milisegundos. Por defecto, 60.000. Un `Retry-After` más largo, como la espera hasta que la cuota diaria se renueva a las 00:00 UTC, termina la ejecución con `retry_later` en lugar de dejar tu proceso dormido. - `maxRetries`: cuántas veces se reintenta una petición tras un `429` o un `5xx`. Por defecto, 3. - `maxPagesPerTeam`: el máximo de páginas del feed que lee un equipo en una ejecución. Por defecto no hay límite. Con un límite, una carga inicial larga se detiene con `partial`, se sincronizan los demás equipos de la clave y la siguiente ejecución continúa desde el cursor guardado. - `fetch`, `baseUrl`, `sleep` y `now`: sustitúyelos en tus pruebas. ```ts title="syncTeam.ts" /** * Reference receiver for the Totem changes feed. No dependencies: it needs * only `fetch`. Compile it with your TypeScript build, or run it directly on * Node.js 22.18 or later. * * It keeps a copy of every team a key reads, safely across crashes: * - Run it on your server only. The key is a secret and must never reach a browser. * - Run one `syncKey` per key at a time, every 1 to 5 minutes. * - Persist the `SyncStore` in your own database. `MemorySyncStore` * (memoryStore.ts) loses everything on restart and is for tests only. * - Each page is applied before its cursor is saved, so a crash in between * replays the page, and the version check makes the replay harmless. * - One team's trouble never stops the others: a team that cannot be read * right now keeps its copy and its cursor, and the next run tries again. * - It stops instead of looping if the API ever repeats a cursor or sends * none, before saving it, so a fault on either side cannot spend the key's * daily quota. */ export interface FeedItem { kind: string id: string version: number object?: Record removed?: { reason: string } } export interface FeedPage { object: 'list' data: FeedItem[] has_more: boolean next_cursor: string } interface ListPage { data: unknown[] has_more: boolean next_cursor: string | null } /** Your persistence. Every method must be durable before its promise resolves. */ export interface SyncStore { teamIds(): Promise ensureTeam(teamId: string): Promise cursor(teamId: string): Promise /** The version you hold for this record, removals included; 0 when you hold none. */ version(teamId: string, kind: string, id: string): Promise setContactPermissions(teamId: string, leadId: string, permissions: Record): Promise upsert(teamId: string, item: FeedItem): Promise /** * Delete the record but keep its version (a tombstone). Removing a lead also * deletes its attribution, response and activities. */ remove(teamId: string, item: FeedItem): Promise saveCursor(teamId: string, cursor: string): Promise /** Start a full resync: forget the team's versions and cursor, keep its records, clear the seen set. */ startResync(teamId: string): Promise /** Remember that the feed still delivers this record. Must survive a restart until the resync finishes. */ markSeen(teamId: string, item: FeedItem): Promise /** End a full resync: delete the team's records the feed did not deliver. A no-op when no resync is running. */ finishResync(teamId: string): Promise /** Delete everything you hold for the team, cursor included. */ dropTeam(teamId: string): Promise dictionariesAt(teamId: string): Promise saveDictionaries(teamId: string, dictionaries: Record, at: number): Promise /** * True when the item names a board, stage, member or service that is neither * in your dictionaries nor remembered as unresolved (`rememberUnresolved`). */ hasUnknownReferences(teamId: string, item: FeedItem): Promise /** * Called for an item that still names unknown ids right after the * dictionaries were refreshed. A record can keep naming a member who left * the team, or a board, stage or service that was deleted: remember those * ids so they never cause another refresh. The daily refresh still picks * up any that come back. */ rememberUnresolved(teamId: string, item: FeedItem): Promise } export interface SyncOptions { /** A secret key (`tsk_live_…`). */ key: string store: SyncStore fetch?: typeof globalThis.fetch baseUrl?: string sleep?: (ms: number) => Promise now?: () => number /** How many times one request is retried after a `429` or a `5xx`. Default 3. */ maxRetries?: number /** * The longest this process waits in one go, in milliseconds. Default 60 000. * A longer `Retry-After` (the daily quota resets at 00:00 UTC) ends that * team's run with `retryAt` instead of keeping the process asleep. */ maxWaitMs?: number /** * The most feed pages one team reads in one run. No limit by default. A * team's initial sync can take hours: with a limit, it stops with `partial` * after that many pages, the key's other teams are synced, and the next run * continues from the saved cursor. */ maxPagesPerTeam?: number } /** What happened to one team in one `syncKey` run. */ export type TeamOutcome = /** Caught up; the cursor is saved. */ | { team: string, status: 'synced' } /** Read `maxPagesPerTeam` pages and not caught up yet; the cursor is saved and the next run continues. */ | { team: string, status: 'partial' } /** The team left the key: its copy and cursor are deleted. */ | { team: string, status: 'dropped' } /** Not readable right now (`403`, `capture_not_ready`, unconfirmed `team_not_accessible`); copy and cursor kept. */ | { team: string, status: 'skipped', code: string } /** * Asked to wait longer than `maxWaitMs`, or still failing after `maxRetries`; * copy and cursor kept. Do not call before `retryAt` (epoch milliseconds). */ | { team: string, status: 'retry_later', code: string, retryAt: number } /** An error answer from the API. `retryAt` (epoch milliseconds) is set when asking again later can succeed. */ export class SyncHttpError extends Error { readonly status: number readonly code: string readonly retryAt: number | null constructor(status: number, code: string, retryAt: number | null = null) { super(`Totem API answered ${status} ${code}`) this.name = 'SyncHttpError' this.status = status this.code = code this.retryAt = retryAt } } const DEFAULT_BASE_URL = 'https://api.totemstudio.ai/v1' const DAY_MS = 86_400_000 /** * The API always moves a cursor forward. Throws when a page says there is * more but repeats a cursor this loop already used, or sends none: following * it would read the same page forever. */ function nextCursor(seen: Set, cursor: string | null): string { if (!cursor || seen.has(cursor)) throw new Error('Totem API sent a missing or repeated cursor; stopping so the loop cannot spend the key\'s quota') seen.add(cursor) return cursor } /** `Retry-After` is always whole seconds. Null when the header is missing or unreadable. */ function retryAfterMs(response: Response): number | null { const header = response.headers.get('Retry-After') if (header === null || header.trim() === '') return null const seconds = Number(header) return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null } /** One GET. A `429` or `5xx` is retried while the wait fits within `maxWaitMs`. */ async function get(options: SyncOptions, path: string, team?: string): Promise { const fetcher = options.fetch ?? globalThis.fetch const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) const now = options.now ?? Date.now const maxRetries = options.maxRetries ?? 3 const maxWaitMs = options.maxWaitMs ?? 60_000 for (let attempt = 0; ; attempt++) { const response = await fetcher(`${options.baseUrl ?? DEFAULT_BASE_URL}${path}`, { headers: { Authorization: `Bearer ${options.key}`, ...(team ? { 'Totem-Team': team } : {}) }, }) if (response.ok) return await response.json() as T // Errors are application/problem+json. The firewall in front of the API may answer without a body. const problem = await response.json().catch(() => ({})) as { code?: string } const code = problem.code ?? `http_${response.status}` // Only a 429 or a 5xx is worth sending again within this run; the caller decides about the rest. if (response.status !== 429 && response.status < 500) throw new SyncHttpError(response.status, code) const waitMs = retryAfterMs(response) ?? 1000 * 2 ** attempt // Too long to wait here (the daily quota, say), or out of retries: report when to come back. if (waitMs > maxWaitMs || attempt >= maxRetries) throw new SyncHttpError(response.status, code, now() + waitMs) await sleep(waitMs) } } /** Every team `GET /v1/teams` lists. It lists restricted teams too, so a missing team has left the key. */ async function listedTeams(options: SyncOptions): Promise { const page = await get<{ data: Array<{ id: string }> }>(options, '/teams') return page.data.map(team => team.id) } /** * Members and bookable services are not in the feed: read them in full when a * team starts, when an item names an id you do not know, and once a day. * Boards do travel in the feed; they are read here as well because a lead can * name a new stage before that board's change reaches you. */ async function refreshDictionaries(options: SyncOptions, teamId: string): Promise { const boards: unknown[] = [] const seen = new Set() let cursor: string | null = null do { const query: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' const page: ListPage = await get(options, `/boards?limit=100${query}`, teamId) boards.push(...page.data) cursor = page.has_more ? nextCursor(seen, page.next_cursor) : null } while (cursor) // These two arrive whole, in one response, and take no `limit` or `cursor`. const members = await get(options, '/members', teamId) const services = await get(options, '/booking_services', teamId) const now = options.now ?? Date.now const dictionaries = { boards, members: members.data, booking_services: services.data } await options.store.saveDictionaries(teamId, dictionaries, now()) } /** Applies one page. Every item is checked against the version you hold, removals included. */ async function applyPage(options: SyncOptions, teamId: string, page: FeedPage): Promise { const { store } = options let refreshed = false for (const item of page.data) { // Apply only what is newer than your copy. A removal keeps its version as a // tombstone, so an older copy replayed later cannot bring the record back. if (item.version > await store.version(teamId, item.kind, item.id)) { if (await store.hasUnknownReferences(teamId, item)) { // One refresh per page at most. What it does not resolve (a member who // left, a deleted stage) is remembered, so it never asks again. if (!refreshed) { await refreshDictionaries(options, teamId) refreshed = true } await store.rememberUnresolved(teamId, item) } if (item.removed) { await store.remove(teamId, item) } else { // Commit contact permissions before anything downstream can act on the lead. const permissions = item.kind === 'lead' ? item.object?.contact_permissions : undefined if (permissions && typeof permissions === 'object') { await store.setContactPermissions(teamId, item.id, permissions as Record) } await store.upsert(teamId, item) } } // During a full resync, remember every record the feed still delivers. await store.markSeen(teamId, item) } } /** * Brings one team's copy up to date: reads the feed from the saved cursor * until it has caught up (`synced`), or until it has read `maxPagesPerTeam` * pages (`partial`). Throws `SyncHttpError` when the API refuses; the saved * cursor is untouched, so the next run resumes from the same place. */ export async function syncTeam(options: SyncOptions & { teamId: string }): Promise<'synced' | 'partial'> { const { store, teamId } = options const now = options.now ?? Date.now await store.ensureTeam(teamId) const dictionariesAt = await store.dictionariesAt(teamId) if (dictionariesAt === null || now() - dictionariesAt >= DAY_MS) await refreshDictionaries(options, teamId) let resyncing = false let pages = 0 let seen = new Set() for (;;) { const cursor = await store.cursor(teamId) const query = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' let page: FeedPage try { page = await get(options, `/sync/changes?limit=100${query}`, teamId) } catch (error) { // The cursor expired (410) or the feed cannot continue from it (409 resync_required): // run a full resync, once per run. const restart = error instanceof SyncHttpError && (error.status === 410 || error.code === 'resync_required') if (restart && !resyncing) { await store.startResync(teamId) resyncing = true seen = new Set() continue } throw error } await applyPage(options, teamId, page) // Check the cursor before saving it, so a missing or repeated one never // replaces the last good cursor. A last page only has to send one. if (cursor) seen.add(cursor) if (page.has_more || !page.next_cursor) nextCursor(seen, page.next_cursor) // Save the cursor only after the page is applied, and save it for an empty // page too: that keeps the cursor of a quiet team from expiring. await store.saveCursor(teamId, page.next_cursor) if (!page.has_more) { // After a full resync, delete what the feed no longer delivers. await store.finishResync(teamId) return 'synced' } pages++ // Leave the rest for the next run, so the key's other teams are not kept waiting. if (options.maxPagesPerTeam !== undefined && pages >= options.maxPagesPerTeam) return 'partial' } } /** * What one team's error means for this run. Every outcome but `dropped` keeps * the team's copy and cursor. Any other error (a `401`, a `400`, an unexpected * `404`, a feed that still asks for a resync after one) concerns the whole key * or a bug, so it is rethrown and stops the run. */ async function outcomeOf(options: SyncOptions, team: string, error: SyncHttpError): Promise { // A 429 or 5xx that asked for a longer wait than this run allows (or kept failing). if (error.retryAt !== null) return { team, status: 'retry_later', code: error.code, retryAt: error.retryAt } // Restricted, sector-restricted or switched off: keep everything and skip the team. if (error.status === 403) return { team, status: 'skipped', code: error.code } // Totem is still preparing this team's data (once per team, ever): a later run reads it. if (error.code === 'capture_not_ready') return { team, status: 'skipped', code: error.code } // The team may have left the key. Only `/teams` can confirm it; a team it still lists keeps its copy. if (error.code === 'team_not_accessible') { if ((await listedTeams(options)).includes(team)) return { team, status: 'skipped', code: error.code } await options.store.dropTeam(team) return { team, status: 'dropped' } } throw error } /** * Syncs every team the key reads and reports what happened to each. Re-reads * `/teams` on every run, so a new team starts its initial sync and a team that * left the key is deleted. Once a `429` (the daily quota, say) sends the key * away for longer than `maxWaitMs`, the teams not reached yet are reported * `retry_later` for the same time, without a request. * * Throws when the key itself is refused, when `/teams` cannot be read, when a * team's answer points to a bug rather than to the team (a `400`, say), or * when the network fails. No cursor is lost. When the error carries `retryAt` * (the daily quota is spent, for example), schedule the next run for then. */ export async function syncKey(options: SyncOptions): Promise { const outcomes: TeamOutcome[] = [] const listed = await listedTeams(options) for (const team of await options.store.teamIds()) { if (listed.includes(team)) continue await options.store.dropTeam(team) outcomes.push({ team, status: 'dropped' }) } // Set when a `429` sends the key away for longer than this run waits. let keyWait: { code: string, retryAt: number } | null = null for (const team of listed) { if (keyWait) { outcomes.push({ team, status: 'retry_later', ...keyWait }) continue } try { outcomes.push({ team, status: await syncTeam({ ...options, teamId: team }) }) } catch (error) { if (!(error instanceof SyncHttpError)) throw error const outcome = await outcomeOf(options, team, error) outcomes.push(outcome) // A `429` concerns the key, never one team: the daily quota and the rate // limit are both the key's. The teams not reached yet would be refused // too, so they wait for the same moment without spending a request. if (error.status === 429 && outcome.status === 'retry_later') keyWait = { code: outcome.code, retryAt: outcome.retryAt } } } return outcomes } ``` ## Almacén en memoria para pruebas `MemorySyncStore` implementa en memoria todos los métodos de `SyncStore`. Muestra lo que debe hacer cada método y hace que tus pruebas sean deterministas. Lo pierde todo al reiniciarse, así que sustitúyelo por tu base de datos antes de pasar a producción. ```ts title="memoryStore.ts" /** * An in-memory `SyncStore`, for tests and for reading alongside syncTeam.ts. * It loses everything on restart: in production, implement every method on * your database, durably, and commit a page and its cursor together when you can. */ import type { FeedItem, SyncStore } from './syncTeam.js' /** One key per record: a lead and its two facets share an id but not a kind. */ const keyOf = (item: Pick) => `${item.kind}/${item.id}` /** * Every board, stage, service and member id an item names: a lead's `board`, * `stage` and `assignees`, a conversation's `assignee`, a booking's `service`, * and an activity's `stage_snapshot`, `from` and `to`: a stage with its board, * or a list of members. A status change's `from` and `to` are statuses. */ function referencedIds(item: FeedItem): string[] { const object = item.object ?? {} const ids: unknown[] = [object.board, object.stage, object.service, object.assignee] if (Array.isArray(object.assignees)) ids.push(...object.assignees) if (item.kind === 'activity') { for (const side of [object.from, object.to, object.stage_snapshot]) { if (Array.isArray(side)) { ids.push(...side) } else if (side && typeof side === 'object') { const { stage, board } = side as { stage?: unknown, board?: unknown } ids.push(stage, board) } } } return ids.filter((value): value is string => typeof value === 'string') } export class MemorySyncStore implements SyncStore { /** team → record key → the latest item applied. */ rows = new Map>() cursors = new Map() /** team → record key → version held, removals (tombstones) included. */ versions = new Map>() /** `team/lead` → the lead's contact permissions. */ permissions = new Map>() dictionaries = new Map>() dictionaryTimes = new Map() /** team → keys the feed delivered during a running full resync. */ recovery = new Map>() /** `team/lead` of every removed lead, so a facet or activity replayed later is not stored again. */ removedLeads = new Set() /** team → ids still unknown right after a dictionary refresh: departed members, deleted boards, stages or services. */ unresolved = new Map>() async teamIds() { return [...this.rows.keys()] } async ensureTeam(team: string) { if (!this.rows.has(team)) this.rows.set(team, new Map()) if (!this.versions.has(team)) this.versions.set(team, new Map()) } async cursor(team: string) { return this.cursors.get(team) ?? null } async version(team: string, kind: string, id: string) { return this.versions.get(team)?.get(`${kind}/${id}`) ?? 0 } async setContactPermissions(team: string, lead: string, permissions: Record) { this.permissions.set(`${team}/${lead}`, structuredClone(permissions)) } async upsert(team: string, item: FeedItem) { await this.ensureTeam(team) this.versions.get(team)!.set(keyOf(item), item.version) // A facet or an activity of a removed lead keeps its version but is not stored. const lead = item.kind === 'lead_attribution' || item.kind === 'lead_response' ? item.id : item.object?.lead if (typeof lead === 'string' && this.removedLeads.has(`${team}/${lead}`)) return this.rows.get(team)!.set(keyOf(item), structuredClone(item)) } async remove(team: string, item: FeedItem) { await this.ensureTeam(team) // The tombstone: the version stays, so an older copy delivered later is ignored. this.versions.get(team)!.set(keyOf(item), item.version) const rows = this.rows.get(team)! rows.delete(keyOf(item)) if (item.kind !== 'lead') return // A removed lead takes its attribution, response, activities and permissions with it. this.removedLeads.add(`${team}/${item.id}`) this.permissions.delete(`${team}/${item.id}`) for (const [key, row] of rows) { const facet = (row.kind === 'lead_attribution' || row.kind === 'lead_response') && row.id === item.id const activity = row.kind === 'activity' && row.object?.lead === item.id if (facet || activity) rows.delete(key) } } async saveCursor(team: string, cursor: string) { this.cursors.set(team, cursor) } async startResync(team: string) { this.versions.set(team, new Map()) this.cursors.delete(team) this.recovery.set(team, new Set()) this.unresolved.delete(team) // A restore can reissue ids and versions, so the resync starts from a clean slate. for (const key of this.removedLeads) if (key.startsWith(`${team}/`)) this.removedLeads.delete(key) } async markSeen(team: string, item: FeedItem) { this.recovery.get(team)?.add(keyOf(item)) } async finishResync(team: string) { const seen = this.recovery.get(team) if (!seen) return const rows = this.rows.get(team) ?? new Map() for (const [key, row] of rows) { if (seen.has(key)) continue rows.delete(key) if (row.kind === 'lead') this.permissions.delete(`${team}/${row.id}`) } this.recovery.delete(team) } async dropTeam(team: string) { this.rows.delete(team) this.cursors.delete(team) this.versions.delete(team) this.recovery.delete(team) this.dictionaries.delete(team) this.dictionaryTimes.delete(team) this.unresolved.delete(team) for (const key of this.permissions.keys()) if (key.startsWith(`${team}/`)) this.permissions.delete(key) for (const key of this.removedLeads) if (key.startsWith(`${team}/`)) this.removedLeads.delete(key) } async dictionariesAt(team: string) { return this.dictionaryTimes.get(team) ?? null } async saveDictionaries(team: string, data: Record, at: number) { this.dictionaries.set(team, structuredClone(data)) this.dictionaryTimes.set(team, at) } async hasUnknownReferences(team: string, item: FeedItem) { const unresolved = this.unresolved.get(team) return this.unknownIds(team, item).some(id => !unresolved?.has(id)) } async rememberUnresolved(team: string, item: FeedItem) { const ids = this.unknownIds(team, item) if (!ids.length) return const unresolved = this.unresolved.get(team) ?? new Set() for (const id of ids) unresolved.add(id) this.unresolved.set(team, unresolved) } /** The board, stage, service and member ids the item names that the dictionaries do not hold. */ private unknownIds(team: string, item: FeedItem): string[] { if (!item.object) return [] const known = new Set() for (const entry of Object.values(this.dictionaries.get(team) ?? {}).flat()) { const object = entry as { id: string, stages?: Array<{ id: string }> } known.add(object.id) for (const stage of object.stages ?? []) known.add(stage.id) } return referencedIds(item).filter(id => !known.has(id)) } } ``` --- # Listas y paginación > Cómo devuelven los endpoints de lista los registros página a página, cómo leer todas las páginas y qué orden y qué filtros admite una lista. Fuente: https://totemstudio.ai/developers/es/pagination Los endpoints de lista, como [`GET /v1/leads`](https://totemstudio.ai/developers/es/reference-leads#leads.list) o [`GET /v1/calls`](https://totemstudio.ai/developers/es/reference-calls#calls.list), devuelven los registros de un equipo página a página. Esta página explica cómo leer todas las páginas, cómo se ordena y se filtra una lista y qué endpoints no se paginan. ## Páginas y cursores Todas las páginas llegan en el mismo sobre de lista: ```json { "object": "list", "data": [ { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoiY2FsbCIsImEiOiJjYWxsX20ya1ViRmNheUhsWjVMZ3M5eFpObURTM2MzSnZrIiwicyI6ImM0OTk4YTdlNDJkYiJ9357H5Y" } ``` - `limit` fija el tamaño de la página, de 1 a 100. Por defecto es 25. Puedes cambiarlo de una página a otra. - Mientras `has_more` sea `true`, devuelve `next_cursor` como `cursor` para obtener la página siguiente. En la última página, `has_more` es `false` y `next_cursor` es `null`. - Una página puede traer menos elementos que `limit`, o ninguno, y aun así indicar `has_more: true`. Totem omite los registros que aún no ha preparado y prefiere terminar antes una página a hacerte esperar. Sigue pidiendo hasta que `has_more` sea `false`. Un cursor es opaco: guárdalo tal como lo recibiste y no construyas ni modifiques ninguno. Solo sirve con el endpoint, los filtros y el equipo de los que salió. Cualquier otro uso recibe `400 invalid_request`, con `param` igual a `cursor`. Un cursor de conversaciones también deja de funcionar si se suprime la conversación a la que apunta: si un cursor que no cambiaste recibe `400 invalid_request` con `param` igual a `cursor`, vuelve a empezar esa lista desde la primera página. ### Lee todas las páginas Para leer una lista entera, mantén los mismos filtros y el mismo `Totem-Team` en cada petición, y para solo cuando `has_more` sea `false`. Estos bucles listan las llamadas de un equipo con cURL y `jq`, con TypeScript en Node.js 22.18 o posterior (un módulo ES, así que `await` funciona en el nivel superior) y con Python y `requests`: ```bash [list-calls.sh] cursor="" while :; do page=$(curl -sS --fail-with-body \ "https://api.totemstudio.ai/v1/calls?limit=100${cursor:+&cursor=$cursor}" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID") || { printf '%s\n' "$page" >&2; break; } printf '%s\n' "$page" | jq -r '.data[].id' [ "$(printf '%s' "$page" | jq -r '.has_more')" = true ] || break next=$(printf '%s' "$page" | jq -er '.next_cursor') || { echo 'La página no trae next_cursor' >&2; break; } [ "$next" != "$cursor" ] || { echo 'La lista repitió un cursor' >&2; break; } cursor=$next done ``` ```ts [list-calls.ts] interface CallPage { data: Array<{ id: string }> has_more: boolean next_cursor: string | null } const apiKey = process.env.TOTEM_API_KEY if (!apiKey) throw new Error('Define TOTEM_API_KEY en tu servidor') const headers: Record = { Authorization: `Bearer ${apiKey}` } if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID let cursor: string | null = null while (true) { const url = new URL('https://api.totemstudio.ai/v1/calls') url.searchParams.set('limit', '100') if (cursor) url.searchParams.set('cursor', cursor) const response = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) }) if (!response.ok) { const problem = (await response.json().catch(() => ({}))) as { code?: string } throw new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${response.headers.get('Totem-Request-Id') ?? 'n/a'}`) } const page = (await response.json()) as CallPage // Aquí va tu código. Muestra identificadores, nunca registros enteros: contienen datos personales. for (const call of page.data) console.log(call.id) if (!page.has_more) break if (!page.next_cursor || page.next_cursor === cursor) throw new Error('La lista no avanzó') cursor = page.next_cursor } ``` ```python [list_calls.py] # pip install requests import os import requests headers = {"Authorization": f"Bearer {os.environ['TOTEM_API_KEY']}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] params = {"limit": 100} while True: response = requests.get("https://api.totemstudio.ai/v1/calls", headers=headers, params=params, timeout=30) if not response.ok: try: code = response.json().get("code") except ValueError: code = None raise RuntimeError( f"Totem API {response.status_code} {code or response.reason}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) page = response.json() # Aquí va tu código. Muestra identificadores, nunca registros enteros: contienen datos personales. for call in page["data"]: print(call["id"]) if not page["has_more"]: break if not page["next_cursor"] or page["next_cursor"] == params.get("cursor"): raise RuntimeError("La lista no avanzó") params["cursor"] = page["next_cursor"] ``` Cada bucle se detiene también cuando la API responde con un error, o cuando una página repite el cursor que se le pidió, en lugar de pedir la misma página para siempre. Para mantener una copia de un equipo, usa el feed de cambios, como se explica en [Mantener una copia](https://totemstudio.ai/developers/es/pagination#mantener-una-copia). ## Orden Una lista llega en un orden estable que no significa nada: no es por fecha ni por nombre. Si necesitas un orden, ordena tú los datos, por ejemplo por `created_at`. ## Filtros [`GET /v1/leads`](https://totemstudio.ai/developers/es/reference-leads#leads.list) es la única lista con filtros. Admite como máximo uno, con un solo valor: - `board`: los leads de un tablero (`brd_…`); - `stage`: los leads de una etapa (`stg_…`); - `status`: los leads con un estado, como `qualified`. ```bash curl "https://api.totemstudio.ai/v1/leads?status=qualified" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` Dos filtros en una misma petición reciben `400 invalid_request`, con `param` indicando uno de ellos. Un tablero o una etapa que el equipo no tiene devuelve una página vacía, no un error. No existe un filtro `updated_since`. Para saber qué ha cambiado, usa el feed de cambios, que se explica en [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). ## Listas que llegan completas Tres listas son cortas, así que llegan completas, en una sola respuesta, y no admiten `limit` ni `cursor`: - [`GET /v1/teams`](https://totemstudio.ai/developers/es/reference-account#teams.list), los equipos que lee tu clave; - [`GET /v1/members`](https://totemstudio.ai/developers/es/reference-account#members.list), los miembros de un equipo; - [`GET /v1/booking_services`](https://totemstudio.ai/developers/es/reference-bookings#booking_services.list), los servicios reservables de un equipo. Usan el mismo sobre, con `has_more: false` y `next_cursor: null`. Si envías `limit` o `cursor` a alguna de ellas, recibes `400 invalid_request`. ## La búsqueda no es una lista [`POST /v1/leads/search`](https://totemstudio.ai/developers/es/reference-leads#leads.search) busca leads por teléfono o correo electrónico exactos, enviados en el cuerpo JSON. Devuelve como máximo 10 leads, en una sola respuesta que nunca se pagina: `has_more` siempre es `false` y `next_cursor` siempre es `null`. Si muchos registros comparten un teléfono o una dirección de correo electrónico, puede que la búsqueda no los devuelva todos. ## Registros que aún no están listos Totem prepara cada registro para la API poco después de que cambie. Hasta entonces, las listas y la búsqueda omiten un registro nuevo, y una petición de ese registro por su identificador, como [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/es/reference-leads#leads.get), responde `404 resource_missing`. No reintentes en bucle: el feed de cambios entrega el registro en cuanto está listo. ## Mantener una copia Una lista muestra los registros tal como están ahora. Sirve para consultar algo, no para mantener una copia: volver a leer todas las listas para encontrar cambios consume mucho más de tu [cuota diaria](https://totemstudio.ai/developers/es/rate-limits) y aun así no detecta las eliminaciones. Para mantener una copia de un equipo, sigue el feed de cambios, como explica [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). --- # Cobertura de los datos > Qué registra Totem, desde cuándo y cómo distinguir un valor vacío de un dato que nunca se registró. Fuente: https://totemstudio.ai/developers/es/coverage La API devuelve lo que Totem ha registrado y te dice de dónde sale cada dato. No reconstruye atribuciones antiguas, no se inventa cambios de estado que nadie registró y no incluye el contenido de los mensajes. Esta página te dice en qué datos puedes confiar y qué significa un valor vacío. > **Nota: Algunos datos empiezan en una fecha concreta** > > Totem registra algunos datos para la API solo desde el día en que empezó a registrar cada uno, el mismo para todos los equipos: el anuncio que abrió una conversación; las actividades de un lead y lo que Totem lee de ellas (`converted_at`, `revived_at`, `status_set_by` y `archived_from_status`); el conjunto de anuncios de Meta de un envío de formulario; y los enlaces entre una cita reprogramada y la que la sustituye. Ni esta página ni la API indican ese día; si lo necesitas, pregunta a [soporte](https://totemstudio.ai/developers/es/support). Los registros anteriores traen `null`, `unknown` o `not_captured` en esos datos, así que no des por completo tu historial. ## Qué registra Totem y desde cuándo | Dato | De dónde sale | Límites | |---|---|---| | **Quién es el lead y en qué punto está** | El lead tal como está ahora: datos de contacto, `status`, `board` y `stage`. | Las personas del equipo pueden editarlos en cualquier momento. Un valor que esta versión de la API no conoce llega como `null`, `other` o `unknown`, según el campo; [Todos los objetos](https://totemstudio.ai/developers/es/objects) indica cuál. | | **Cómo llegó el lead por primera vez** (`attribution.original_capture`) | Se registra una sola vez, al crear el lead (`capture: at_creation`). Un lead sin ese registro, porque se creó antes de que Totem empezara a registrarlo o porque su creación no se registró, recibe una copia del lead tal como estaba cuando su equipo se añadió por primera vez a una integración (`capture: at_activation`). | Una copia `at_activation` muestra el lead en ese día, no el formulario que la persona rellenó al principio. | | **El último formulario de un anuncio** (`attribution.latest_submission`) | El envío de formulario de anuncio más reciente registrado para el lead, también si repite un contacto que Totem ya tenía. | Nunca cambia cómo llegó el lead por primera vez. Los envíos de Google Ads y LinkedIn dejan de poder leerse a los 90 días (`expires_at`). Un envío de LinkedIn que la persona borró después deja de mostrarse. Cuando un envío caduca o se retira, el feed de cambios entrega una nueva versión de `lead_attribution` sin él, que puede mostrar un envío anterior; sustituye tu copia por ella. Hoy no se puede conectar LinkedIn Lead Sync, y los formularios de Google Ads solo los conecta Totem, así que la mayoría de los equipos no tienen envíos `google_ads` ni `linkedin`. | | **Datos de la campaña, el anuncio y el formulario** de Google, LinkedIn y Meta | Lo que envió la plataforma de anuncios, campo a campo, cada uno con su `provenance`. | Totem no atribuye leads a posteriori ni consulta los identificadores en las API de las plataformas para completar huecos. | | **El anuncio que abrió una conversación** (`original_capture.ad_referral`) | El primer referido de un anuncio de Meta en la conversación que creó el lead. | Solo desde el día en que Totem empezó a registrarlo, como explica la nota de arriba. Nunca los mensajes. | | **Las actividades y las fechas que fijan** (`converted_at`, `revived_at`) | Cada cambio de etapa, de estado o de miembros asignados que Totem registró. | Solo desde el día en que Totem empezó a registrarlos, como explica la nota de arriba. Totem no se inventa cambios que no registró. | | **Cómo ha respondido el equipo** (la `response` del lead) | Las horas de los mensajes y las llamadas de todas las conversaciones del lead. | No se cuentan los mensajes suprimidos ni los de los visitantes del chat web, y el historial de WhatsApp importado no cuenta como mensaje del cliente ni como respuesta. Una respuesta es un mensaje enviado registrado como respuesta de conversación o sin propósito. La prospección y los mensajes transaccionales, como los recordatorios, se excluyen cuando están registrados como tales, pero muchos envíos automáticos no tienen propósito y sí cuentan como respuesta. Totem lee como máximo 20 conversaciones, 200 mensajes y 50 llamadas por lead; por encima de eso, `coverage.truncated` es `true`. | | **Llamadas, conversaciones y citas** | Los metadatos que Totem guarda de cada una. | Un enlace a un lead que ya no existe es `null`. Sin grabaciones, transcripciones, notas ni pagos. Una cita reprogramada antes de que Totem empezara a registrar los enlaces de reprogramación puede no tener su enlace `rescheduled_from` o `rescheduled_to`. | | **Permisos de contacto** | Los rechazos que Totem guardó, cada uno con su `provenance`. | Un consentimiento `null` significa desconocido, nunca sí. Aplica los permisos antes de contactar con nadie. | ## Origen de los datos y valores vacíos Muchos campos te dicen de dónde sale su valor: - `capture`, en `original_capture`: `at_creation` si se registró al crear el lead, o `at_activation` si se copió una sola vez, cuando su equipo se añadió por primera vez a una integración, para un lead sin registro de captura: uno creado antes de que Totem empezara a registrar la atribución, o cuya creación no se registró. - `provenance`, en cada campo de campaña, anuncio, formulario y UTM: `provider` si lo envió la plataforma de anuncios, `team_alias` si lo escribió alguien del equipo, `stored_metadata` si sale de datos del lead que el equipo puede editar, `stored_metadata_at_activation` si esos datos se copiaron una sola vez al activarse, y `not_captured` exactamente cuando `value` es `null`. - `source_provenance`, en un lead: `server_written` si Totem fijó él mismo el origen a partir de la plataforma de anuncios, o `member_editable` si las personas del equipo pueden cambiarlo. - `original_capture_missing_reason` y `latest_submission_missing_reason`: `not_captured` siempre que el objeto que los acompaña es `null`, sea cual sea el motivo: Totem no lo registró, el lead nunca llegó por un formulario de anuncio, o el envío caducó o se retiró. Y cuando un valor está vacío: - Una fecha `null` no demuestra que algo no ocurriera. Puede significar que Totem no lo registró. - Un consentimiento `null` es desconocido. Nunca es un consentimiento. - Comprueba `coverage.truncated` antes de dar por completa la respuesta de un lead. [Todos los objetos](https://totemstudio.ai/developers/es/objects) describe cada campo y enumera los valores que puede tomar hoy cada uno de estos campos. --- # Pruebas y puesta en producción > Cómo construir y probar una integración con seguridad mientras la API no tiene entorno de pruebas, y qué comprobar antes de pasar a producción. Fuente: https://totemstudio.ai/developers/es/testing ## Todavía no hay entorno de pruebas La versión 1.0a no tiene entorno de pruebas ni claves de prueba. Cada clave lee un equipo real, y todo lo que devuelve lleva `livemode: true`. La API es de solo lectura, así que ninguna petición puede cambiar datos en Totem: probar es seguro siempre que trates lo que lees como datos personales reales. ## Dónde probar Empieza con un equipo que solo contenga datos ficticios, y usa datos reales solo cuando estés listo para pasar a producción. - **Un equipo dedicado con datos ficticios.** Usa un equipo de Totem separado que solo contenga contactos ficticios y crea una integración para él. Su clave sigue siendo una clave real, y el equipo necesita lo mismo que uno de producción: una organización cuyo plan incluya la API, la configuración legal completada, un sector de actividad que no sea sensible y un administrador que cree la integración (el [Inicio rápido](https://totemstudio.ai/developers/es/quickstart#antes-de-empezar) los enumera). La API es de solo lectura, así que los datos de prueba se crean en el propio Totem: añade a mano algunos leads con nombres ficticios y con teléfonos y direcciones de correo de los rangos de más abajo, y después llama, escribe o pide una cita al equipo de pruebas tú mismo para generar llamadas, conversaciones y citas. - **Tu propio equipo.** También puedes desarrollar con los datos reales de tu equipo y tu propia clave. Guarda esos datos solo en sistemas cubiertos por tus acuerdos de tratamiento de datos y borra las copias de prueba al terminar. ## Datos de ejemplo El kit del integrador incluye la descripción de la API y objetos de ejemplo: - [`openapi.json`](https://totemstudio.ai/developers/kit/openapi.json) describe todos los endpoints y campos; - [`fixtures.json`](https://totemstudio.ai/developers/kit/fixtures.json) contiene objetos de ejemplo de cada tipo, todos con `livemode: false`. Carga esos datos de ejemplo en tus pruebas unitarias antes de llamar a la API. Los ejemplos de esta documentación y del kit solo usan contactos ficticios: - teléfonos del `+1 202-555-0100` al `+1 202-555-0199`; - direcciones de correo electrónico del dominio `example.com`. Usa los mismos rangos en tus propios datos de prueba, para que ninguna prueba pueda llegar nunca a una persona real. ## Qué probar antes de pasar a producción Haz cada comprobación sobre tu propio código, con los datos de ejemplo o con un equipo de pruebas. [Sincroniza una copia](https://totemstudio.ai/developers/es/sync) explica el comportamiento que demuestra cada una. ### Aplicar el feed - **Idempotencia.** Aplica dos veces la misma página del feed: la segunda vez no debe cambiar nada. - **Versiones antiguas.** Entrega un elemento con una `version` menor que la que tienes: debe ignorarse. - **Recuperación tras un fallo.** Detén la sincronización después de guardar una página y antes de guardar su cursor, y vuelve a arrancarla: la página repetida no debe cambiar nada. - **Eliminaciones.** Una eliminación debe borrar el registro y, si es un lead, su atribución, su respuesta y sus actividades. Después, entrega una versión anterior del mismo registro: debe seguir borrado. - **Resincronización completa.** Tira el cursor y haz la [resincronización completa](https://totemstudio.ai/developers/es/sync#resincronización-completa): los registros que falten en la nueva copia deben desaparecer. - **Valores desconocidos.** Un valor de enumeración o un código de error que no conoces no debe detener la sincronización. ### Errores y límites - **Un equipo que no está listo.** Con una clave que lee dos equipos, haz que uno responda `409 capture_not_ready`: el otro debe seguir sincronizándose en la misma ejecución, y el primero debe conservar su cursor. - **Equipos que entran y salen.** `404 team_not_accessible` debe borrar los datos del equipo solo cuando `GET /v1/teams` confirme que el equipo ya no está. Un `403` debe conservar los datos y el cursor. - **Límite de frecuencia.** Un `429 rate_limited` debe esperar lo que indique `Retry-After` y después continuar con el mismo cursor. - **Cuota diaria.** Un `429 quota_exceeded` debe detener la sincronización hasta las 00:00 UTC y reanudarla entonces con el mismo cursor. Asegúrate de que el proceso que espera no pueda solaparse con la siguiente ejecución programada. ### Contactos - **Permisos de contacto.** Ningún sistema al que envíes datos debe contactar con un lead que tenga `contact_permissions.do_not_contact: true`. - **Consentimiento desconocido.** Un lead con `whatsapp_consent: null` no debe recibir mensajes de WhatsApp como si hubiera aceptado. ## Ensaya la rotación de una clave Rota una clave una vez antes de depender de ella, para que la primera rotación real sea rutina: 1. En **Ajustes → Desarrolladores**, elige **Rotar clave** y mantén la clave anterior durante 1 hora. 2. Despliega la clave nueva en tu servidor, igual que lo harás en producción. 3. Comprueba que tu sincronización sigue funcionando con la clave nueva. 4. Cuando haya pasado esa hora, comprueba que la clave antigua responde `401 key_expired` y que ya no la usa ninguno de tus sistemas. [Autenticación](https://totemstudio.ai/developers/es/authentication#rotar-y-revocar) explica cómo rotar y revocar claves. ## Comprobar la disponibilidad [`GET /v1/health`](https://totemstudio.ai/developers/es/reference-account#health) indica si la API está funcionando. No necesita clave ni devuelve datos, así que úsalo para la monitorización. Compruébalo con `GET`: el cortafuegos situado delante de la API responde a `HEAD` con un `404` simple. Llámalo sin cadena de consulta: cualquier parámetro, aunque sea para evitar la caché, recibe `400 invalid_request`. Su cabecera de respuesta `Totem-Release` indica la versión del servicio que respondió; inclúyela cuando nos avises de una incidencia. Para comprobar una clave, llama a [`GET /v1/me`](https://totemstudio.ai/developers/es/reference-account#me.get). --- # Seguridad y datos personales > Cómo protege Totem las claves de la API y los datos que sirve, qué registra de cada clave, dónde funciona la API y qué hacer con los datos personales que copias. Fuente: https://totemstudio.ai/developers/es/security Esta página es para quien revisa la seguridad de una integración, del lado del cliente o del tuyo. Resume cómo protege Totem las claves y los datos, y qué hacer con los datos personales que copias. En [Autenticación](https://totemstudio.ai/developers/es/authentication) se detalla cada control. ## Claves - **Una clave es un secreto aleatorio largo.** Empieza por `tsk_live_`, sigue con un identificador de búsqueda y después con unos 256 bits de secreto aleatorio y una suma de control. La suma de control permite a Totem rechazar una clave mal copiada antes de buscarla. - **Totem nunca guarda la clave.** Conserva un resumen irreversible del secreto, calculado con HMAC-SHA-256 y otro secreto que se guarda fuera de la base de datos de Totem, así que una copia de la base de datos no basta para usar una clave. Totem muestra la clave una sola vez, al crearla, y sus registros identifican una clave por su identificador de búsqueda, nunca por su secreto. - **Las claves no caducan.** Una clave funciona hasta que la rotas o la revocas. Rota las claves con la periodicidad que te convenga, por ejemplo cada 90 días, y siempre que se vaya alguien que tenía acceso; en [Pruebas y puesta en producción](https://totemstudio.ai/developers/es/testing#ensaya-la-rotación-de-una-clave) se explica cómo ensayarlo. - **Las claves solo se gestionan en Ajustes.** Las claves se crean, se rotan y se revocan en **Ajustes → Desarrolladores**, nunca a través de la API. Crear o rotar una clave, añadir un equipo y cambiar la lista de IP permitidas piden al administrador que vuelva a iniciar sesión si no lo ha hecho hace poco, y se avisa de cada cambio a los administradores que gestionan la integración y a los de cada equipo que cubre, en Totem y por correo electrónico. - **Revocar es rápido.** Una clave revocada deja de funcionar en un máximo de 5 segundos, y revocar nunca exige volver a iniciar sesión, así que puede hacerse al momento durante un incidente. - **El acceso es de solo lectura y por equipos.** Ninguna clave puede crear ni modificar nada en Totem. Una clave lee solo los equipos de su integración, con acceso de administrador a ellos, incluidos los canales y los registros marcados como privados, así que da a cada integración solo los equipos que necesita. - **Una clave puede atarse a tus servidores.** Una [lista de IP permitidas](https://totemstudio.ai/developers/es/authentication#lista-de-ip-permitidas) limita una clave a las direcciones IPv4 que indiques. - **Adivinar está limitado.** Los intentos fallidos repetidos bloquean durante un tiempo la dirección o la clave, como se explica en [Intentos fallidos](https://totemstudio.ai/developers/es/authentication#intentos-fallidos). ## Registros de actividad Para cada clave, **Ajustes → Desarrolladores** muestra el **Último uso**: cuándo hizo la clave su última petición y la red `/24` desde la que llegó. Puede ir hasta 10 minutos por detrás. La **Actividad reciente** de cada integración muestra sus cambios más recientes, como su creación, cada rotación o revocación de una clave y cada cambio de sus equipos, de su lista de IP permitidas o de su nombre, con la fecha y quién lo hizo. Totem conserva cada una de estas entradas durante 365 días. Totem cuenta además las peticiones de cada clave por día y por endpoint, y conserva esas cuentas durante 90 días. ## Dónde funciona la API - **Región.** La API funciona en Google Cloud, en la región `europe-west1`, en Bélgica, y lee la base de datos de Totem, que se almacena en la Unión Europea. - **Cifrado en tránsito.** `api.totemstudio.ai` solo responde en el puerto 443, por HTTPS con TLS 1.2 o posterior. No hay ningún servicio en HTTP sin cifrar, así que una clave nunca viaja sin cifrar, y las respuestas de la propia API llevan `Strict-Transport-Security` y `X-Content-Type-Options: nosniff`. - **Sin caché.** Delante de la API no hay ninguna CDN ni caché compartida, y cada respuesta de la propia API lleva `Cache-Control: no-store`. - **Sin navegadores.** La API no envía cabeceras CORS y rechaza una clave secreta enviada con una cabecera `Origin` (`400 secret_key_in_browser`), así que una clave pegada en una página web falla al momento en lugar de funcionar sin que nadie lo note. - **Un cortafuegos delante.** Un cortafuegos filtra las peticiones antes de que lleguen a la API, como se describe en [Errores](https://totemstudio.ai/developers/es/errors#formato-de-los-errores). ## Datos personales que recibes La API devuelve datos personales: el nombre, el correo electrónico, el teléfono, la empresa y las etiquetas de los leads, de dónde vino cada lead (valores UTM y nombres de anuncios, campañas y formularios) y sus actividades, el nombre visible de los miembros del equipo y los metadatos de las llamadas, las conversaciones y las citas. Nunca devuelve el texto de los mensajes, las transcripciones de las llamadas ni las grabaciones. Una vez copias estos datos, protegerlos os corresponde a ti y al cliente al que pertenecen. Te recomendamos que: - **Uses los datos solo para lo que pidió el cliente.** Si construyes la integración para un cliente de Totem, usa lo que devuelve solo para los fines de ese cliente. - **Protejas tu copia.** Guárdala cifrada, limita quién puede leerla y deja los registros fuera de los logs, los informes de errores y las solicitudes de soporte. Muestra identificadores, no registros enteros. - **Apliques las eliminaciones en cuanto lleguen.** Borra el registro de tu copia y de todos los sistemas a los que lo pasaste, y conserva solo su marca de eliminación. Se explica en [Eliminaciones](https://totemstudio.ai/developers/es/sync#eliminaciones), y [¿Cómo respeto las solicitudes de supresión?](https://totemstudio.ai/developers/es/faq#cómo-respeto-las-solicitudes-de-supresión) trata las supresiones. - **Apliques primero los permisos de contacto**, antes de que cualquiera de tus sistemas contacte con una persona, como se explica en [Primero, los permisos de contacto](https://totemstudio.ai/developers/es/sync#primero-los-permisos-de-contacto). - **Devuelvas las bajas a Totem.** La API es de solo lectura, así que Totem no puede conocer una baja registrada en tus sistemas. Cuando una persona se dé de baja en ellos, asegúrate de que también quede registrada en el lead en Totem. - **Trates los campos de texto como datos no fiables**, como se explica en [Convenciones](https://totemstudio.ai/developers/es/conventions#los-campos-de-texto-no-son-de-fiar). - **Borres lo que ya no recibes.** Cuando se revoca una integración o un equipo sale de ella, Totem ya no puede enviarte eliminaciones de esos datos: borra tu copia, salvo que el cliente te pida conservarla y tenga sus propios motivos para ello. Mientras el acceso está denegado, por ejemplo porque el plan del cliente ha dejado de incluir la API, tampoco llegan eliminaciones: si eso dura más de lo que permite tu política de conservación, borra tu copia. ## Informa de una vulnerabilidad Si encuentras un problema de seguridad en la API, en esta documentación o en el [SDK de TypeScript](https://totemstudio.ai/developers/es/typescript), escribe en privado a hello@totemstudio.ai. Cuenta qué encontraste y cómo reproducirlo con datos ficticios, e incluye los identificadores de petición si los tienes. No incluyas nunca una clave real ni datos reales de clientes, y no lo publiques en una incidencia pública. Si se ha filtrado una de tus claves, sigue primero [Si se filtra una clave](https://totemstudio.ai/developers/es/support#si-se-filtra-una-clave). --- # Convenciones > La notación de esta documentación y las reglas que siguen todas las respuestas: identificadores, fechas, valores vacíos, importes, enumeraciones, versiones, estabilidad, errores y textos no fiables. Fuente: https://totemstudio.ai/developers/es/conventions ## Notación - `GET /v1/leads/{id}` es un método y una ruta bajo `https://api.totemstudio.ai`. Una palabra entre llaves es un marcador que sustituyes por un valor. - `…` sustituye a un valor que se omite en un ejemplo. - `$TOTEM_API_KEY` es una variable de entorno que contiene tu clave secreta. - Los campos se escriben tal como aparecen en el JSON. Un punto entra en un objeto: `contact_permissions.do_not_contact`. ## Peticiones y respuestas - Solo HTTPS. Los cuerpos de las peticiones y las respuestas son JSON en UTF-8, y los nombres de los campos van en `snake_case`. - Todo recurso, como un lead, una llamada o un equipo, tiene un campo `object` con su tipo y un campo `livemode`. `livemode` es `true` en todo lo que devuelve la API; solo los datos de ejemplo de esta documentación llevan `livemode: false`. - Una colección llega en un envoltorio de lista con `object: "list"`, `data`, `has_more` y `next_cursor`. Se explica en [Listas y paginación](https://totemstudio.ai/developers/es/pagination). - Algunos cuerpos tienen una forma propia. Los elementos del feed de cambios llevan `kind`, y el registro va dentro de ellos, en `object`, o una eliminación, en `removed`. [`GET /v1/health`](https://totemstudio.ai/developers/es/reference-account#health) responde `{"object": "health", "status": "ok"}`. Los errores siguen el formato que se describe en [Errores](https://totemstudio.ai/developers/es/conventions#errores). ## Identificadores Los identificadores son cadenas con un prefijo que indica el tipo de registro: | Prefijo | Registro | |---|---| | `team_` | Equipo | | `mem_` | Miembro del equipo | | `svc_` | Servicio reservable | | `brd_` | Tablero | | `stg_` | Etapa | | `lead_` | Lead, y sus facetas `lead_attribution` y `lead_response` | | `act_` | Actividad de un lead | | `call_` | Llamada | | `conv_` | Conversación | | `bkg_` | Cita | | `prn_` | Integración | Trata cada identificador como un valor opaco: guárdalo entero, compáralo de forma exacta y no lo analices nunca. Un identificador no cambia mientras su registro existe. Un registro que se elimina y después se vuelve a crear puede volver con un identificador nuevo. Los identificadores de miembro son de cada equipo: la misma persona tiene un `mem_` distinto en cada equipo. ## Fechas y horas Las fechas y horas son cadenas ISO 8601 en UTC, como `2026-10-15T09:30:00Z`, y pueden llevar fracciones de segundo. La zona horaria de cada equipo llega con `GET /v1/teams/{id}`; úsala para mostrar horas, nunca para guardarlas. ## Valores vacíos En los objetos de la API, un campo sin valor es `null`, nunca una cadena vacía. Algunos campos explican por qué están vacíos: - el `phone` de un lead es `null` con `phone_status` en `missing` o `invalid`; - los campos del embudo de un lead son `null` con `pipeline_cleared: true` cuando alguien convirtió el lead y borró sus datos del embudo. ## Teléfonos e importes - Los teléfonos son cadenas E.164, como `+12025550123`. - El `estimated_value` y el `value_at_conversion` de un lead son importes tal como los guarda Totem: en unidades principales, como euros o dólares y no céntimos, con decimales si los hay. `estimated_value_currency` es siempre `null`, porque Totem no guarda la divisa de estos valores. - Ningún campo de la versión 1.0a lleva un importe en la unidad mínima de la divisa. ## Las enumeraciones pueden crecer Los campos con un conjunto fijo de valores, como `status` o `archive_reason`, pueden ganar valores en cualquier versión, igual que los códigos de error. Cuando encuentres un valor que no conoces, conserva el registro, trata el valor como «otro» y sigue con la sincronización. En el feed de cambios también pueden aparecer nuevos tipos de registro: guarda u omite un elemento cuyo `kind` no conozcas y sigue sincronizando. El documento OpenAPI enumera los valores conocidos hoy. Si generas un cliente a partir de él, asegúrate de que el cliente acepta un valor que no conoce en lugar de rechazar el objeto entero. ## Versiones de los registros Cada registro que entrega el feed de cambios lleva `version`, un entero que crece cada vez que el registro cambia, y `updated_at`, el momento de esa versión. Eso incluye los leads y sus dos facetas, las actividades, las llamadas, las conversaciones, las citas y los tableros. Aplica un registro solo si su `version` es mayor que la que tienes. El feed puede entregar un registro más de una vez; su versión indica qué copia es más reciente. Los equipos, los miembros y los servicios reservables no tienen versión. Cuando vuelvas a leerlos, sustituye tu copia entera. ## Versiones de la API y estabilidad `/v1` es la versión actual de la API. Pueden aparecer nuevos campos, valores de enumeración, endpoints y códigos de error, así que ignora lo que no uses. Cada operación indica su estabilidad en esta referencia y, en el documento OpenAPI, como `x-totem-stability`. En la versión 1.0a todas las operaciones son `beta`. Consulta las novedades en el [registro de cambios](https://totemstudio.ai/developers/es/changelog). ### Qué significa Beta Mientras la API está en Beta, Totem puede cambiar o retirar campos, valores y comportamientos, sin un plazo fijo de aviso. El [registro de cambios](https://totemstudio.ai/developers/es/changelog) recoge los cambios bajo la fecha en que se publicaron, y una entrada que te pide cambiar tu integración o hacer una resincronización completa empieza por **Acción necesaria**. Los cambios que solo añaden, como campos, valores de enumeración, endpoints o códigos de error nuevos, pueden aparecer en cualquier momento, así que construye tu integración para que [ignore lo que no conoce](https://totemstudio.ai/developers/es/conventions#las-enumeraciones-pueden-crecer). ## Identificadores de petición Cada respuesta de la API lleva una cabecera `Totem-Request-Id`, como `req_0f6b2c1e-8d4a-4b7e-9c3f-5a2d1e7b9c40`, y el cuerpo de un error la repite en `request_id`. Regístrala y cítala cuando pidas ayuda a [Soporte](https://totemstudio.ai/developers/es/support). ## Errores Los errores siguen la RFC 9457 y llegan como `application/problem+json`: ```json { "type": "https://totemstudio.ai/developers/errors#team_required", "title": "Team required", "status": 400, "code": "team_required", "request_id": "req_0f6b2c1e-8d4a-4b7e-9c3f-5a2d1e7b9c40", "param": "Totem-Team", "doc_url": "https://totemstudio.ai/developers/errors#team_required" } ``` - `code` indica qué ha fallado. Decide según `code`, nunca según `title`, que está en inglés y puede cambiar. `type` y `doc_url` enlazan con la entrada del código en la página de [Errores](https://totemstudio.ai/developers/es/errors), que explica cuándo ocurre y qué hacer. Ambos enlazan con la edición en inglés; en la edición en español, la misma entrada lleva el mismo fragmento, por ejemplo `/developers/es/errors#team_required`. - `param`, cuando aparece, nombra la cabecera, el parámetro o el campo que ha fallado. Nunca repite el valor que enviaste. - Pueden aparecer códigos nuevos. Trata un código que no conoces según su estado HTTP. - Un `401` lleva además la cabecera `WWW-Authenticate: Bearer realm="Totem API"`. - Un `429` lleva `Retry-After`, un número entero de segundos: espera al menos ese tiempo. Lo usan dos códigos, `rate_limited` y `quota_exceeded`; los dos se explican en [Límites de frecuencia y cuota](https://totemstudio.ai/developers/es/rate-limits). Reintenta un `5xx` con espera exponencial. Durante la Beta, el cortafuegos situado delante de la API responde él mismo a algunas peticiones con un `403`, un `404` o un `429` simple, sin este formato: sin cuerpo de error JSON, sin `Retry-After` y sin `Totem-Request-Id`. Responde `404` a cualquier ruta fuera de `/v1/` y a cualquier método que no sea `GET` ni `POST`, `HEAD` incluido. Trata su respuesta según su estado; [Errores](https://totemstudio.ai/developers/es/errors) indica cuándo ocurre cada una. ## Datos personales en las URL No pongas nunca un teléfono, una dirección de correo electrónico ni un nombre en una URL: las URL acaban en los registros de ambos lados. Para buscar un lead por teléfono o correo electrónico, envíalos en el cuerpo JSON de [`POST /v1/leads/search`](https://totemstudio.ai/developers/es/reference-leads#leads.search). ## Los campos de texto no son de fiar Los campos de texto, como `name`, `surname`, `company`, `email` y `tags` de un lead, `display_name` de un miembro o los valores de atribución, como los parámetros UTM y los nombres de anuncios, campañas y formularios, proceden de formularios web, de los enlaces por los que llegan los visitantes, de plataformas de anuncios, de perfiles de mensajería y de los propios miembros del equipo, y Totem los devuelve tal como se guardaron. Quien rellena un formulario o modifica un enlace elige lo que contienen. Escápalos allí donde los muestres y no los interpretes nunca como HTML, Markdown ni plantillas. Al exportarlos a CSV o a una hoja de cálculo, antepón una comilla simple a cualquier valor que empiece por `=`, `+`, `-`, `@`, un tabulador o un retorno de carro, para que ninguna celda se ejecute como fórmula. --- # Errores > El formato de los errores, cómo debe tratar tu integración cada tipo de error y todos los códigos de error, con cuándo ocurren y qué hacer. Fuente: https://totemstudio.ai/developers/es/errors Cuando una petición falla, la API responde con un estado HTTP y un cuerpo JSON que indica qué ha fallado. Cada cuerpo de error enlaza con su entrada en esta página. ## Formato de los errores Los errores siguen el RFC 9457 y llegan como `application/problem+json`. Por ejemplo, `GET /v1/leads?limit=500` responde: ```json { "type": "https://totemstudio.ai/developers/errors#invalid_request", "title": "Invalid request", "status": 400, "code": "invalid_request", "request_id": "req_0f6b2c1e-8d4a-4b7e-9c3f-5a2d1e7b9c40", "param": "limit", "doc_url": "https://totemstudio.ai/developers/errors#invalid_request" } ``` | Campo | Qué contiene | |---|---| | `code` | Qué ha fallado, como un código estable. Decide según él. | | `status` | El estado HTTP, repetido. | | `title` | Una descripción breve en inglés, para personas. Puede cambiar, así que nunca decidas según él. | | `type` y `doc_url` | Un enlace a la entrada del código más abajo. | | `request_id` | El identificador de esta petición, el mismo que el de la cabecera `Totem-Request-Id`. | | `param` | Solo aparece cuando falla una cabecera, un parámetro de consulta o un campo del cuerpo concreto. Lo nombra y nunca repite el valor que enviaste. | Con un error llegan estas cabeceras: - `Totem-Request-Id`, en todas las respuestas que envía la propia API. Regístrala y cítala cuando pidas ayuda a [Soporte](https://totemstudio.ai/developers/es/support). Nunca nos envíes tu clave ni datos de tus clientes. - `Retry-After`, en cada `429` que devuelve la propia API: un número entero de segundos que debes esperar. - `WWW-Authenticate: Bearer realm="Totem API"`, en cada `401`. Pueden aparecer códigos nuevos en cualquier momento. Trata un código que no conoces según su estado HTTP, como se describe en el apartado siguiente. Durante la Beta, el cortafuegos situado delante de la API responde él mismo a algunas peticiones, antes de que lleguen a la API. Su respuesta no trae un cuerpo de error JSON, sino la página predeterminada del propio cortafuegos, y tampoco lleva `Retry-After` ni `Totem-Request-Id`. No intentes leer su cuerpo como JSON: trátala según su estado: - `404` para una ruta fuera de `/v1/`, o para cualquier método distinto de `GET` y `POST`, incluidos `HEAD` y `OPTIONS`; - `403` para una petición con cuerpo que no sea `POST /v1/leads/search`, y para un cuerpo de más de 16 KiB; - `429` cuando una misma dirección envía demasiadas peticiones, por ejemplo muchas peticiones sin una clave bien formada. ## Cómo tratar los errores Decide primero según el estado HTTP y después según `code`: - **`400`: corrige la petición.** Si vuelves a enviar la misma petición, fallará igual. `param` indica qué corregir. Hay un `400` que no es culpa de tu petición: un cursor de conversaciones deja de funcionar cuando se suprime la conversación a la que apunta, así que vuelve a empezar esa lista desde la primera página, como se explica en [Listas y paginación](https://totemstudio.ai/developers/es/pagination#páginas-y-cursores). Dos códigos piden algo más que una corrección: `team_required` significa que tu clave lee varios equipos y la petición debe indicar uno en `Totem-Team`; `secret_key_in_browser` significa que la clave se usó desde un navegador, así que dala por filtrada. - **`401`: corrige la clave.** No reintentes en bucle: los fallos repetidos bloquean durante un tiempo tu dirección IP o tu clave. `key_expired` significa que la clave se rotó y su periodo de gracia ha terminado: despliega la clave nueva. - **`403`: el acceso está denegado por ahora.** Conserva tus datos y tus cursores, y omite el equipo o el endpoint. Algunos rechazos pueden resolverse solos, como `capability_disabled` o un equipo cuyo acceso ha pausado Totem; otros necesitan que alguien actúe, como `plan_not_entitled` o `ip_not_allowed`; y `sector_restricted` no se resuelve esperando: dura mientras se aplique la política de sectores actual de Totem, y el [registro de cambios](https://totemstudio.ai/developers/es/changelog) recoge cualquier cambio en ella. Cada código de más abajo indica en qué caso estás. Mientras el acceso está denegado, el feed de cambios no entrega nada del equipo, tampoco eliminaciones, así que, si un rechazo dura más de lo que permite tu política de conservación, borra tu copia. - **`404`: comprueba qué has pedido.** `resource_missing` significa que el registro no existe, ya no está o aún no está listo; el feed de cambios entrega los registros nuevos en cuanto están listos. `team_not_accessible` significa que puede que la clave ya no lea el equipo: [confírmalo con `GET /v1/teams`](https://totemstudio.ai/developers/es/sync#cuando-cambian-los-equipos) antes de borrar nada. Un `404` sin cuerpo de error JSON viene del cortafuegos: comprueba el método y la ruta. - **`409` y `410`: el feed de cambios necesita que actúes.** `resync_required` y `sync_cursor_expired` piden una [resincronización completa](https://totemstudio.ai/developers/es/sync#resincronización-completa). `capture_not_ready` te pide que esperes unos minutos, mientras los demás equipos de la clave siguen sincronizándose. - **`429`: ve más despacio.** Espera los segundos que indique `Retry-After` y repite la misma petición. `rate_limited` suele resolverse en segundos, pero tras un [bloqueo por intentos fallidos](https://totemstudio.ai/developers/es/authentication#intentos-fallidos) dura 600 segundos. `quota_exceeded` dura hasta las 00:00 UTC, cuando se renueva la cuota diaria. Un `429` del cortafuegos no trae `Retry-After`: reintenta con espera exponencial. En [Límites de frecuencia y cuota](https://totemstudio.ai/developers/es/rate-limits) se explican los dos límites. - **`5xx`: reintenta más tarde.** Reintenta con espera exponencial y conserva tus cursores. Si sigue fallando, escribe a [Soporte](https://totemstudio.ai/developers/es/support) con el `Totem-Request-Id`. En todos los casos, nunca guardes un cursor del feed de cambios tras una petición fallida. ## Todos los códigos de error Cada código tiene su propia ancla: la misma a la que apunta `doc_url` en la edición en inglés de esta página. | Estado HTTP | Código | Cuándo ocurre | Qué hacer | Reintentar | | --- | --- | --- | --- | --- | | 400 | `invalid_request` | Algo de la petición no es válido: un parámetro de consulta desconocido, repetido o fuera de rango, una cadena de consulta o un cuerpo en un endpoint que no los admite, un cursor emitido para otro endpoint, otros filtros u otro equipo, una cabecera `Totem-Team` mal formada o un cuerpo que no es el JSON que espera el endpoint. `param` indica el parámetro, la cabecera o el campo que ha fallado. Un cursor de conversaciones también se rechaza cuando se suprime la conversación a la que apunta. | Corrige lo que indica `param` antes de reintentar: la misma petición falla igual. Si es un cursor, envía el `next_cursor` que recibiste del mismo endpoint, con los mismos filtros y el mismo equipo. Si se rechaza un cursor de conversaciones que no cambiaste, vuelve a empezar esa lista desde la primera página. | No. La misma petición falla igual hasta que corrijas la causa. | | 400 | `team_required` | La clave lee más de un equipo y este endpoint lee los datos de uno, pero la petición no indica cuál. | Envía el identificador del equipo (`team_…`, tal como aparece en `GET /v1/teams`) en la cabecera `Totem-Team`. | No. La misma petición falla igual hasta que corrijas la causa. | | 400 | `secret_key_in_browser` | La petición llevaba una clave secreta junto con una cabecera `Origin`, lo que indica que salió de un navegador. Totem la rechaza sin leer nada. | Llama a la API solo desde tu servidor. Si la clave ha estado en una página web, una aplicación móvil o una extensión del navegador, dala por filtrada: rótala y márcala como comprometida para que la antigua deje de funcionar al instante. | No. La misma petición falla igual hasta que corrijas la causa. | | 401 | `invalid_credentials` | Falta la cabecera `Authorization` o está mal formada, o la clave no existe, es incorrecta o está revocada, o su integración ya no está activa. | Envía `Authorization: Bearer ` con una clave secreta vigente. No reintentes en bucle: muchos fallos desde la misma dirección IP, o con la misma clave, la bloquean durante un tiempo con `429 rate_limited`. | No. La misma petición falla igual hasta que corrijas la causa. | | 401 | `key_expired` | La clave se rotó y su periodo de gracia ha terminado. | Despliega la clave que la sustituyó: la antigua ya superó su periodo de gracia y no volverá a funcionar. Si no hay ninguna que la sustituya, crea una clave nueva en Ajustes → Desarrolladores. | No. La misma petición falla igual hasta que corrijas la causa. | | 403 | `insufficient_scope` | A la clave le falta un permiso que necesita este endpoint. Todas las claves de la versión 1.0a tienen el conjunto completo de lectura, así que no debería ocurrir. | Escribe a soporte e indica el `Totem-Request-Id` de la respuesta. | No. La misma petición falla igual hasta que corrijas la causa. | | 403 | `plan_not_entitled` | El plan de la organización no incluye ahora mismo la API de Totem. | Conserva tus datos y tus cursores y deja de llamar hasta que el cliente vuelva a tener un plan con acceso a la API; después, continúa donde lo dejaste. Mientras el acceso está denegado, el feed no entrega nada de los equipos de la clave, tampoco eliminaciones: si la denegación dura más de lo que permite tu política de conservación, borra tu copia. | Más tarde, cuando se restablezca el acceso. Mientras, conserva tus datos y tu cursor. | | 403 | `ip_not_allowed` | La clave es válida, pero la petición salió de una dirección IP que no está en la lista de IP permitidas de la integración. | Llama desde una dirección permitida o pide a un administrador que añada la tuya a la lista en Ajustes → Desarrolladores. | No. La misma petición falla igual hasta que corrijas la causa. | | 403 | `team_state_restricted` | El equipo está restringido por ahora: está desactivado (por ejemplo, suspendido por impago), archivado o es solo de facturación, o tiene pausado el acceso a la API. `GET /v1/teams` lo sigue mostrando. | Conserva los datos y el cursor del equipo, omítelo en esta ejecución y vuelve a intentarlo más tarde. Los demás equipos de la clave no se ven afectados. Mientras el equipo está restringido, el feed no entrega nada de él, tampoco eliminaciones: si la restricción dura más de lo que permite tu política de conservación, borra la copia del equipo. | Más tarde, cuando se restablezca el acceso. Mientras, conserva tus datos y tu cursor. | | 403 | `sector_restricted` | Totem no exporta los datos de este equipo por la API porque el equipo trabaja en un sector sensible, como el sanitario, en el que sus registros pueden revelar información de salud. | Omite el equipo: esperar no lo cambia. El feed no entrega nada de él, tampoco eliminaciones, así que conserva lo que ya tengas solo mientras lo permita tu política de conservación y después borra la copia del equipo. Si necesitas sus datos, habla con el cliente o con Totem. | No. Esperar no lo cambia. | | 403 | `capability_disabled` | Totem ha desactivado temporalmente este endpoint para todas las claves. | Conserva tus datos y tus cursores y vuelve a intentarlo más tarde; el resto de endpoints sigue funcionando. | Más tarde, cuando se restablezca el acceso. Mientras, conserva tus datos y tu cursor. | | 404 | `resource_missing` | El registro no existe, se eliminó, pertenece a un equipo que esta clave no lee o se creó hace tan poco que Totem aún no lo ha preparado. Un `GET` o un `POST` bajo `/v1/` que no corresponde a ningún endpoint también recibe este código. Cualquier otro método, y cualquier ruta fuera de `/v1/`, recibe un `404` simple del cortafuegos que protege la API, sin cuerpo de error ni `Totem-Request-Id`. | Comprueba el identificador y la ruta. Si el registro se acaba de crear, no reintentes en bucle: el feed de cambios lo entregará en cuanto esté listo. | No. La misma petición falla igual hasta que corrijas la causa. | | 404 | `team_not_accessible` | La cabecera `Totem-Team` indica un equipo que esta clave no lee: uno que nunca incluyó, uno que se quitó de la clave o de la organización, o uno que se eliminó. Sin la cabecera, significa que la clave ya no lee ningún equipo. | Vuelve a leer `GET /v1/teams` antes de actuar. Si el equipo ya no aparece, borra su copia y su cursor; si sigue apareciendo, conserva todo y vuelve a intentarlo en la siguiente ejecución. | Solo si `GET /v1/teams` sigue mostrando el equipo: entonces, en la siguiente ejecución. | | 409 | `resync_required` | Totem no puede continuar el feed desde este cursor: los datos se restauraron desde una copia de seguridad o se detectó un hueco en los cambios del equipo. | Haz una resincronización completa: olvida las versiones que guardaste del equipo, vuelve a leer el feed sin cursor y, cuando estés al día, borra los registros locales del equipo que no hayan llegado. Hasta entonces, sigue usando tu copia actual. | No. La misma petición falla igual hasta que corrijas la causa. | | 409 | `capture_not_ready` | Totem aún está preparando los datos de este equipo para la API. Ocurre una sola vez por equipo, después de que se añada por primera vez a una clave; añadirlo después a otra clave no lo repite. | Conserva lo que tengas del equipo y vuelve a intentarlo dentro de unos minutos; mientras tanto, los demás equipos de la clave siguen sincronizándose. | Sí, más tarde: la misma petición funciona cuando los datos estén listos. | | 410 | `sync_cursor_expired` | El cursor tiene más de 30 días, contados desde el punto del feed al que llegó (o, mientras una carga inicial sigue en curso, desde que empezó). | Haz una resincronización completa, como con `resync_required`. Para evitarlo, guarda el `next_cursor` de cada página, también de las vacías: el cursor de un equipo que consultas con regularidad nunca caduca. | No. La misma petición falla igual hasta que corrijas la causa. | | 429 | `rate_limited` | La clave ha superado su límite de frecuencia (elementos por minuto), o demasiados intentos de autenticación fallidos desde esta dirección IP o con esta clave la han bloqueado durante un tiempo. | Espera los segundos que indica `Retry-After` y repite la misma petición. Cada petición aparta su `limit` completo al empezar y devuelve lo que no usa, así que con un `limit` más pequeño avanzas aunque quede poco margen. | Sí, tras los segundos que indica `Retry-After`. | | 429 | `quota_exceeded` | La clave ha consumido su cuota diaria de elementos. Para la cuota cuenta cada elemento que devuelven el feed de cambios y todas las listas, incluidas `GET /v1/teams`, `GET /v1/members`, `GET /v1/booking_services` y las actividades de un lead. La cuota se renueva a las 00:00 UTC. | Deja de sincronizar hasta las 00:00 UTC (`Retry-After` indica los segundos que faltan) y continúa entonces con el mismo cursor: no se pierde nada. Las peticiones de un solo registro, como `GET /v1/leads/{id}`, la búsqueda y `GET /v1/me` no cuentan para la cuota y siguen funcionando. | Sí, tras los segundos que indica `Retry-After`. | | 500 | `internal_error` | Algo ha fallado dentro de Totem al responder a esta petición. | Reintenta con una espera exponencial. Si sigue fallando, escribe a soporte e indica el `Totem-Request-Id`. | Sí, con espera exponencial. | | 503 | `api_disabled` | Totem ha desactivado temporalmente la API para todas las claves. | Conserva tus datos y tus cursores y reintenta más tarde con una espera exponencial. | Sí, con espera exponencial. | | 503 | `service_unavailable` | Reservado: la API no devuelve hoy este código. Un `503` del balanceador de carga o de la plataforma que aloja la API llega sin cuerpo de error; trátalo como este código. | Reintenta con una espera exponencial; tus datos y tus cursores siguen siendo válidos. | Sí, con espera exponencial. | --- # Límites de frecuencia y cuota > Cuánto puede leer una clave por minuto y por día, qué cuenta para cada límite, cómo reintentar tras un 429 y cuánto tarda una carga inicial. Fuente: https://totemstudio.ai/developers/es/rate-limits Cada clave tiene dos límites, compartidos por todos los equipos que lee: un **límite de frecuencia**, que fija a qué velocidad lee, y una **cuota diaria**, que fija cuánto lee. Los dos cuentan **elementos**, no peticiones. Un elemento es un objeto de una respuesta: una página del feed con 100 cambios son 100 elementos, eliminaciones incluidas. > **Nota: Aproximados durante la Beta** > > En la Beta, cada servidor que atiende tus peticiones cuenta por separado estos límites y el bloqueo por intentos fallidos, y un reinicio empieza su cuenta de nuevo. Por eso las cifras de esta página son aproximadas: una clave a veces puede leer más, y una petición rechazada con `429` puede funcionar al reintentarla. Diseña tu integración para las cifras de esta página. ## Dos límites por clave Qué peticiones cuentan para cada límite: | Peticiones | Límite de frecuencia | Cuota diaria | |---|---|---| | El feed de cambios, todas las listas, las actividades de un lead, `GET /v1/teams`, `GET /v1/members` y `GET /v1/booking_services` | Sí | Sí | | Una petición de un solo registro, como `GET /v1/leads/{id}`, y también `POST /v1/leads/search` y `GET /v1/me` | Sí | No | | `GET /v1/health` | No | No | Cada endpoint de la referencia indica su fila: **Sincronización** para la primera y **Lectura** para la segunda. ### Límite de frecuencia Una clave lee hasta 2.000 elementos por minuto, con ráfagas de hasta 10.000. La capacidad que no usas se recupera a ese ritmo hasta completar de nuevo la ráfaga. Al superar el límite, la API responde `429 rate_limited`, y `Retry-After` indica cuánto falta para que haya sitio para tu petición. Cómo cuenta una petición: - Antes de ejecutarse, una petición aparta su `limit`. Una petición de lista o del feed que no envía `limit` aparta 25; una petición de un solo registro aparta uno. - Una vez ejecutada, se le cobran los elementos que devolvió, y como mínimo uno. Una página corta devuelve la diferencia, pero una petición que no devuelve nada cuesta igualmente un elemento: una búsqueda que no encuentra ningún lead, un identificador que responde `404` o cualquier otro error que reciba durante su ejecución, `quota_exceeded` incluido. - Una petición rechazada antes de ejecutarse no se cobra, como una autenticación fallida, un permiso que falta o, en una ruta que usa `Totem-Team`, un equipo que la clave no puede leer o que aún no está listo. Una petición que indica un registro por su identificador, como `GET /v1/leads/{id}` o las actividades de un lead, comprueba su equipo durante la ejecución, así que un rechazo por ese equipo cuesta un elemento. - A una petición de un solo registro se le cobra lo que devuelve: `GET /v1/leads/{id}` devuelve un lead con sus dos facetas, así que cuenta hasta tres elementos. Una búsqueda cuenta cada lead que devuelve, o uno si no encuentra ninguno. Como una petición aparta primero su `limit` completo, un `limit` más pequeño te permite seguir leyendo cuando queda poca capacidad. `429 rate_limited` también puede significar que tu dirección IP o tu clave están bloqueadas tras demasiados intentos de autenticación fallidos. `Retry-After` distingue los dos casos: un bloqueo siempre envía 600 segundos, la duración completa del bloqueo, mientras que el límite de frecuencia pide los pocos segundos que necesita tu petición. Ir más despacio no termina un bloqueo: busca el proceso de esa dirección que envía una clave ausente, mal formada, incorrecta o desconocida, corrígelo y espera. [Autenticación](https://totemstudio.ai/developers/es/authentication#intentos-fallidos) explica qué cuenta como intento fallido. ### Cuota diaria Cada día UTC, una clave puede leer 250.000 elementos, o 3 veces el número de registros de sus equipos si es mayor. Para ese número cuentan los registros de todos los tipos, eliminaciones incluidas. Para la cuota cuenta cada elemento que devuelven las peticiones de la primera fila de la tabla, sea del tipo que sea. El número de registros se toma con la primera petición de la clave de cada día UTC, así que un equipo que se añade a la clave más tarde ese día puede aumentar la cuota solo a partir del día siguiente. Planifica la carga inicial de un equipo grande para cuando su cuota lo permita. La cuota deja margen para una carga inicial de todos los equipos y un día de consultas periódicas. Lo que la agota es volver a leer listas enteras una y otra vez. Al agotar la cuota, esas peticiones responden `429 quota_exceeded` hasta las 00:00 UTC, cuando la cuota se renueva. `Retry-After` indica los segundos que faltan. Las peticiones de un solo registro, la búsqueda y `GET /v1/me` siguen funcionando. ## Reintenta tras un 429 `Retry-After` llega con cada `429` que devuelve la propia API, y siempre es un número entero de segundos. Espera al menos ese tiempo y vuelve a enviar la misma petición, con el mismo cursor. Mientras esperas no se pierde nada: el feed te guarda todos los cambios. - Tras `rate_limited`, la espera suele ser de unos segundos, o de 600 segundos tras un [bloqueo por intentos fallidos](https://totemstudio.ai/developers/es/authentication#intentos-fallidos). - Tras `quota_exceeded`, la espera dura hasta las 00:00 UTC y puede ser de horas. No dejes un proceso dormido tanto tiempo: termina la ejecución y programa la siguiente para esa hora. El [cliente de referencia](https://totemstudio.ai/developers/es/sync#cliente-de-referencia-probado) lo hace con su resultado `retry_later`. Un `429` del cortafuegos que protege la API no trae `Retry-After` ni cuerpo de error JSON: reintenta con espera exponencial. Un `5xx` tampoco lleva `Retry-After`: reinténtalo también con espera exponencial. ## Estimación de la carga inicial La tabla da el tiempo mínimo que el límite de frecuencia añade a una carga inicial, según los límites de esta página, para una sola clave sin más tráfico; durante la Beta es aproximado, como explica la nota del principio de esta página. Cuenta los registros de todos los tipos, un elemento cada uno: un lead con su atribución, su respuesta y tres actividades son seis registros. El tiempo de red, y el que Totem necesita para preparar un equipo recién añadido a una clave, se suman a esas cifras. Una copia que cabe en la ráfaga muestra *Menos de 1 minuto*: el límite de frecuencia no le añade nada, pero sigue llevando el tiempo de sus peticiones. | Registros (de todos los tipos) | Tiempo mínimo | | ---: | ---: | | 1.000 | Menos de 1 minuto | | 10.000 | Menos de 1 minuto | | 50.000 | 20 min | | 100.000 | 45 min | | 250.000 | 2 h | Duración mínima de una carga inicial, solo por el límite de frecuencia del feed: los primeros 10.000 registros de una vez y después 2.000 registros por minuto. Una carga real tarda más. ## Buenas prácticas - Sincroniza cada combinación de clave y equipo desde un solo proceso. Varios procesos con la misma clave comparten los mismos límites, así que solo consiguen hacerse esperar unos a otros. - Pide páginas completas, de 100 elementos, durante una carga inicial. - Consulta el feed de cambios cada 1 a 5 minutos. Una consulta que no encuentra nada nuevo cuesta un elemento del límite de frecuencia y nada de la cuota diaria. - Lee los miembros y los servicios reservables al empezar con un equipo, cuando encuentres un identificador que no conoces y una vez al día: no en cada ejecución, ni otra vez por un identificador que la última lectura no resolvió. - Nunca crees más integraciones ni claves para sortear los límites. Si se te quedan cortos, [avísanos](https://totemstudio.ai/developers/es/support). --- # Cuenta, equipos y miembros > Comprueba que una clave funciona y qué puede leer, lista sus equipos con su zona horaria, lista los miembros de cada equipo y comprueba que la API está funcionando. Fuente: https://totemstudio.ai/developers/es/reference-account ## Resumen Empieza aquí para saber qué puede leer tu clave. - [`GET /v1/me`](https://totemstudio.ai/developers/es/reference-account#me.get) indica la integración de tu clave, los cuatro últimos caracteres de la clave, los equipos que lee y sus permisos. Llámalo primero para comprobar que una clave funciona. - [`GET /v1/teams`](https://totemstudio.ai/developers/es/reference-account#teams.list) lista todos los equipos que lee la clave, con su nombre, su zona horaria y su país por defecto, en una sola respuesta. Incluye los equipos que están restringidos por ahora. [`GET /v1/teams/{id}`](https://totemstudio.ai/developers/es/reference-account#teams.get) devuelve uno de ellos. - [`GET /v1/members`](https://totemstudio.ai/developers/es/reference-account#members.list) lista los miembros de un equipo, solo con su nombre visible, en una sola respuesta. Totem no exporta el correo electrónico, el teléfono ni el rol de ningún miembro. - [`GET /v1/health`](https://totemstudio.ai/developers/es/reference-account#health) indica si la API está funcionando. Es el único endpoint que no necesita clave. Un **equipo** es un espacio de trabajo de Totem, con sus propios tableros, miembros, leads y citas. Una clave lee un equipo o varios equipos de la misma organización. `GET /v1/me` y `GET /v1/teams` describen la clave y nunca llevan la cabecera `Totem-Team`; `GET /v1/members` lee un solo equipo, así que una clave con varios equipos lo indica en esa cabecera. [Autenticación](https://totemstudio.ai/developers/es/authentication#equipos-y-la-cabecera-totem-team) explica cuándo enviarla. Todas las fechas y horas que devuelve la API están en UTC. Usa la `timezone` de un equipo para mostrar sus horas en hora local, nunca para guardarlas. El identificador de un miembro pertenece a un solo equipo: la misma persona tiene un identificador `mem_` distinto en cada equipo. Los miembros no están en el feed de cambios, así que lee la lista cuando empieces a sincronizar un equipo, cuando un registro indique un miembro que no conoces y al menos una vez al día. Un miembro que deja el equipo sale de la lista, pero los registros anteriores siguen indicando su identificador: no vuelvas a leer la lista por un identificador que no resolvió la última vez y, si muestras nombres en registros anteriores, conserva el de una lectura anterior. Los miembros desactivados siguen en la lista, sin nada que indique que lo están. Lee [`GET /v1/teams`](https://totemstudio.ai/developers/es/reference-account#teams.list) en cada ejecución de la sincronización, porque un equipo que ya no aparece ha salido de la clave. ## Endpoints | Método | Ruta | Resumen | | --- | --- | --- | | GET | `/v1/me` | Obtén la integración de esta clave | | GET | `/v1/teams` | Lista los equipos que lee esta clave | | GET | `/v1/teams/{id}` | Obtén un equipo | | GET | `/v1/members` | Lista los miembros del equipo | | GET | `/v1/health` | Comprueba que la API está disponible | ## GET /v1/me **Obtén la integración de esta clave** Devuelve la integración a la que pertenece esta clave: su nombre, los cuatro últimos caracteres de la clave, los equipos que lee hoy y sus permisos. Llámalo primero para comprobar que la clave funciona y ver qué equipos lee; sus datos están en `GET /v1/teams`. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Lectura: cuenta para el límite de frecuencia ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta ### Respuesta Devuelve un objeto [`Me`](https://totemstudio.ai/developers/es/objects#Me). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"integration"` | Sí | Siempre `integration`. | | `id` | `string` | Sí | Identificador público de la integración. | | `livemode` | `boolean` | Sí | `true` con una clave real; `false` en los datos de ejemplo. | | `name` | `string` | Sí | El nombre que un administrador dio a la integración. | | `key` | `object` | Sí | La clave usada en esta petición. | | `key.last4` | `string` | Sí | Los cuatro últimos caracteres de la clave usada en esta petición. | | `teams` | `Array` | Sí | Los equipos que lee esta clave hoy. Sus datos están en `GET /v1/teams`. | | `scopes` | `Array<"teams:read" \| "crm:read" \| "calls:read" \| "messages:read" \| "calendar:read">` | Sí | Los permisos de la clave: el conjunto fijo de lectura en v1.0a. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/me' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/me', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/me", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Respuesta de ejemplo ```json { "object": "integration", "id": "prn_m1NFIcoIU8smXfqR4ymgt6j07TfRF", "livemode": false, "name": "Example CRM sync", "key": { "last4": "Q7xZ" }, "teams": [ "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ], "scopes": [ "teams:read", "crm:read", "calls:read", "messages:read", "calendar:read" ] } ``` ### Errores Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/teams **Lista los equipos que lee esta clave** Lista todos los equipos que lee esta clave, en una sola respuesta que nunca se pagina, incluidos los que están restringidos por ahora (sus otros endpoints responden `403`). Vuelve a leer esta lista en cada sincronización: un equipo que ya no aparece ha salido de la clave, así que borra su copia y su cursor. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `teams:read` ### Respuesta Devuelve una lista de objetos [`Team`](https://totemstudio.ai/developers/es/objects#Team). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Team) | Sí | Todos los equipos que lee esta clave, incluidos los restringidos. | | `has_more` | `false` | Sí | Siempre `false`: esta lista nunca se pagina. | | `next_cursor` | `null` | Sí | Siempre `null`: no hay página siguiente. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/teams' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/teams', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/teams", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/teams/{id} **Obtén un equipo** Devuelve un equipo que lee esta clave: su nombre, su zona horaria y su país predeterminado. Un equipo que la clave no lee responde `404 resource_missing`. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Lectura: cuenta para el límite de frecuencia ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `teams:read` ### Parámetros de ruta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | El identificador público del equipo. | ### Respuesta Devuelve un objeto [`Team`](https://totemstudio.ai/developers/es/objects#Team). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"team"` | Sí | Siempre `team`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `name` | `string \| null` | Sí | El nombre del equipo tal como aparece en Totem, o null si no hay ninguno guardado. | | `timezone` | `string` | Sí | La zona horaria IANA del equipo: la de su horario comercial si es válida; si no, la de su país por defecto (Europe/Madrid si no hay ninguna). Úsala para interpretar las horas locales del equipo. | | `default_country` | `string \| null` | Sí | El país por defecto del equipo (ISO 3166-1 alfa-2), que Totem usa cuando alguien escribe un teléfono sin prefijo; null si no hay ninguno. La API no lo aplica: `Lead.phone` se valida sin país por defecto, así que un número guardado sin prefijo de país tiene `phone_status: invalid`. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/teams/team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/teams/team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/teams/team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Respuesta de ejemplo ```json { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ``` ### Errores - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`resource_missing`](https://totemstudio.ai/developers/es/errors#resource_missing): Recurso no encontrado Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/members **Lista los miembros del equipo** Lista todos los miembros del equipo en una sola respuesta, que nunca se pagina y no admite `limit` ni `cursor`. Los miembros no llegan por el feed de cambios: lee esta lista al empezar a sincronizar un equipo, de nuevo cuando un elemento indique un miembro que no conoces y, como mínimo, una vez al día. Un miembro que deja el equipo sale de la lista, pero los registros anteriores siguen indicando su identificador, así que conserva de una lectura anterior el nombre que necesites. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `teams:read` ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Respuesta Devuelve una lista de objetos [`Member`](https://totemstudio.ai/developers/es/objects#Member). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Member) | Sí | Todos los miembros del equipo. | | `has_more` | `false` | Sí | Siempre `false`: esta lista nunca se pagina. | | `next_cursor` | `null` | Sí | Siempre `null`: no hay página siguiente. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/members' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/members', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/members", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "object": "member", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "display_name": "Sam Lee" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/health **Comprueba que la API está disponible** Indica si la API está disponible; llama a este endpoint con `GET`, porque el cortafuegos que protege la API responde a cualquier otro método, `HEAD` incluido, con un `404` simple. No necesita clave ni admite cadena de consulta: cualquier parámetro, incluso uno para evitar la caché, recibe `400 invalid_request`. La cabecera de respuesta `Totem-Release` indica la versión que respondió. - **Autenticación:** Sin clave - **Estabilidad:** Beta ### Respuesta Devuelve un objeto `Health`. | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"health"` | Sí | Siempre `health`. | | `status` | `"ok"` | Sí | Siempre `ok`: si la API no está disponible, la respuesta es un error. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/health' ``` ```javascript const response = await fetch('https://api.totemstudio.ai/v1/health'); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data); ``` ```python # pip install requests import requests response = requests.get( "https://api.totemstudio.ai/v1/health", timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data) ``` ### Respuesta de ejemplo ```json { "object": "health", "status": "ok" } ``` ### Errores - `400` [`invalid_request`](https://totemstudio.ai/developers/es/errors#invalid_request): Petición no válida - `400` [`secret_key_in_browser`](https://totemstudio.ai/developers/es/errors#secret_key_in_browser): Clave secreta enviada desde un navegador - `500` [`internal_error`](https://totemstudio.ai/developers/es/errors#internal_error): Error interno - `503` [`service_unavailable`](https://totemstudio.ai/developers/es/errors#service_unavailable): Servicio no disponible ## El objeto Me La integración propietaria de la clave, con los equipos que lee. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"integration"` | Sí | Siempre `integration`. | | `id` | `string` | Sí | Identificador público de la integración. | | `livemode` | `boolean` | Sí | `true` con una clave real; `false` en los datos de ejemplo. | | `name` | `string` | Sí | El nombre que un administrador dio a la integración. | | `key` | `object` | Sí | La clave usada en esta petición. | | `key.last4` | `string` | Sí | Los cuatro últimos caracteres de la clave usada en esta petición. | | `teams` | `Array` | Sí | Los equipos que lee esta clave hoy. Sus datos están en `GET /v1/teams`. | | `scopes` | `Array<"teams:read" \| "crm:read" \| "calls:read" \| "messages:read" \| "calendar:read">` | Sí | Los permisos de la clave: el conjunto fijo de lectura en v1.0a. | ### Ejemplo ```json { "object": "integration", "id": "prn_m1NFIcoIU8smXfqR4ymgt6j07TfRF", "livemode": false, "name": "Example CRM sync", "key": { "last4": "Q7xZ" }, "teams": [ "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ], "scopes": [ "teams:read", "crm:read", "calls:read", "messages:read", "calendar:read" ] } ``` ## El objeto Team Un equipo que lee esta clave, con su zona horaria y su país predeterminado. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"team"` | Sí | Siempre `team`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `name` | `string \| null` | Sí | El nombre del equipo tal como aparece en Totem, o null si no hay ninguno guardado. | | `timezone` | `string` | Sí | La zona horaria IANA del equipo: la de su horario comercial si es válida; si no, la de su país por defecto (Europe/Madrid si no hay ninguna). Úsala para interpretar las horas locales del equipo. | | `default_country` | `string \| null` | Sí | El país por defecto del equipo (ISO 3166-1 alfa-2), que Totem usa cuando alguien escribe un teléfono sin prefijo; null si no hay ninguno. La API no lo aplica: `Lead.phone` se valida sin país por defecto, así que un número guardado sin prefijo de país tiene `phone_status: invalid`. | ### Ejemplo ```json { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ``` ## El objeto Member Un miembro del equipo, solo por su nombre visible: Totem no exporta su correo electrónico, su teléfono ni su rol. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"member"` | Sí | Siempre `member`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `display_name` | `string \| null` | Sí | El nombre visible del miembro, o su nombre y apellido, o null si no hay ninguno guardado. Totem no exporta su correo electrónico, su teléfono ni su rol. | ### Ejemplo ```json { "id": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "object": "member", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "display_name": "Sam Lee" } ``` --- # Leads, tableros y actividades > Lista, obtén y busca los leads y los clientes de un equipo, lee las actividades de cada lead y lista los tableros y las etapas por los que pasan. Fuente: https://totemstudio.ai/developers/es/reference-leads ## Resumen Un **lead** es una persona o una empresa del embudo de un equipo: sus datos de contacto, en qué punto está (`board` y `stage`), su `status` y sus `contact_permissions`. Un registro con `record_kind: "client"` es un cliente, no un lead: no tiene estado del embudo, así que su `status` es `null`. Los leads y los clientes salen de los mismos endpoints. Los ocho estados son `new`, `contacted`, `qualified`, `negotiating`, `converted`, `lost`, `archived` y `unreachable`. `converted` no significa "ganado". Un lead cuyo estado no conoce esta versión de la API conserva `record_kind: "lead"` y trae `status: null`. Un lead archivado indica el motivo en `archive_reason`, incluidos `merged` y `family_transfer` para los leads que dejan atrás las [fusiones y los traspasos](https://totemstudio.ai/developers/es/sync#eliminaciones-fusiones-y-traspasos). Aplica `contact_permissions` antes de que cualquiera de tus sistemas contacte con la persona: [Sincroniza una copia](https://totemstudio.ai/developers/es/sync#primero-los-permisos-de-contacto) explica cada uno. ### Atribución y respuesta [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/es/reference-leads#leads.get) devuelve el lead con dos facetas: `attribution`, de dónde vino, y `response`, cómo le ha respondido el equipo en sus conversaciones y llamadas. Cada faceta tiene su propia `version`, así que un mensaje nuevo cambia la respuesta del lead sin cambiar el lead. Una faceta es `null` mientras Totem aún la prepara. Las listas devuelven solo el lead. El feed de cambios entrega las facetas como elementos propios, `lead_attribution` y `lead_response`, con el identificador del lead. [Cobertura de los datos](https://totemstudio.ai/developers/es/coverage) explica qué puede decirte cada faceta y qué no. ### Buscar por teléfono o correo electrónico [`POST /v1/leads/search`](https://totemstudio.ai/developers/es/reference-leads#leads.search) busca los leads de un equipo por teléfono o correo electrónico exactos. Envía exactamente uno de `phone` o `email` en el cuerpo JSON, nunca en una URL: ```bash curl https://api.totemstudio.ai/v1/leads/search \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" \ -H "Content-Type: application/json" \ -d '{"email": "alex.rivera@example.com"}' ``` La coincidencia es exacta con el número tal como lo guarda Totem, no con el `phone` normalizado que muestra el lead. Un teléfono coincide con un registro guardado en E.164, con o sin su `+` inicial; un registro guardado con espacios o sin prefijo de país no se encuentra, ni siquiera con el `phone` que la API devuelve para él. Un correo coincide con la dirección tal como la enviaste o en minúsculas. Los registros que Totem aún no ha entregado a la API se omiten hasta que lo haga. La búsqueda también devuelve registros archivados, fusionados y clientes; sigue `merged_into` hasta el lead que se conservó. Devuelve como máximo 10 leads, en una sola respuesta que nunca se pagina, así que, si muchos registros comparten un teléfono o una dirección de correo electrónico, puede que no los devuelva todos. ### Actividades [`GET /v1/leads/{id}/activities`](https://totemstudio.ai/developers/es/reference-leads#leads.activities) devuelve el historial de un lead, página a página: cada cambio de etapa, de estado o de miembros asignados, y cuándo ocurrió. `actor_kind` indica quién lo hizo: una persona (`human`), la IA (`ai`), una automatización de Totem (`system`), alguien a través de la API (`api`) o `unknown`. Totem sabe quién hizo un cambio solo cuando el mismo guardado escribió el estado del lead, así que un cambio de etapa o de responsables hecho por sí solo es `unknown`. Las páginas no van por fecha, así que ordena por `occurred_at`. Las actividades nunca cambian una vez escritas, pero se eliminan junto con su lead, y las nuevas llegan también por el feed de cambios. ### Tableros y etapas Un **tablero** es un embudo, con sus etapas en orden. La `stage` de un lead indica la etapa en la que está. Cada etapa tiene un `mode` que indica quién mueve los leads: solo las personas (`standard`), las personas y la IA, que puede mover leads a la etapa (`supervised`), o la IA, que hace la prospección y mueve los leads a la etapa y fuera de ella (`automated`). [`GET /v1/boards`](https://totemstudio.ai/developers/es/reference-leads#boards.list) devuelve los tableros de un equipo con sus etapas, página a página. Los tableros también llegan por el feed de cambios; lee esta lista cuando un lead indique una etapa que aún no has visto. Una etapa sin posición guardada no aparece en su tablero, así que no vuelvas a leer la lista por una etapa que no incluyó la última vez. ## Endpoints | Método | Ruta | Resumen | | --- | --- | --- | | GET | `/v1/leads` | Lista los leads | | GET | `/v1/leads/{id}` | Obtén un lead con su atribución y su respuesta | | POST | `/v1/leads/search` | Busca leads por teléfono o correo exactos | | GET | `/v1/leads/{id}/activities` | Lista las actividades de un lead | | GET | `/v1/boards` | Lista los tableros con sus etapas | ## GET /v1/leads **Lista los leads** Devuelve una página de leads y clientes del equipo, en un orden estable que no tiene significado. Filtra como máximo por uno de `board`, `stage` o `status`; un tablero o una etapa que el equipo no tiene devuelve una página vacía. Para mantener una copia al día, sigue el feed de cambios en lugar de volver a leer esta lista. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `crm:read` ### Parámetros de consulta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `limit` | `integer` | No | Elementos por página, de 1 a 100. Por defecto, 25. | | `cursor` | `string` | No | El `next_cursor` de la página anterior. Omítelo en la primera página. | | `board` | `string` | No | Solo los leads de este tablero. Como máximo uno de `board`, `stage` y `status`. | | `stage` | `string` | No | Solo los leads de esta etapa. Como máximo uno de `board`, `stage` y `status`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable"` | No | Solo los leads con este estado. Como máximo uno de `board`, `stage` y `status`. | ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Respuesta Devuelve una lista de objetos [`Lead`](https://totemstudio.ai/developers/es/objects#Lead). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Lead) | Sí | Los elementos de esta página. | | `has_more` | `boolean` | Sí | `true` si hay otra página: pídela con `next_cursor`, aunque esta traiga menos elementos que `limit`. | | `next_cursor` | `string \| null` | Sí | Cursor opaco de la página siguiente, o null en la última. No lo interpretes. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/leads?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/leads?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/leads?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoibGVhZCIsImEiOiJsZWFkX3I3MkVmYlJOVXp1cU9SRkpSME84SHhBd0VYZDkzakdVVFlNYU1NWEtTRjBRYkxTOENpVUtKcEVQMzRMRlFHIiwicyI6ImYzNTI3NzI2MTkwYiJ93iHyRg" } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/leads/{id} **Obtén un lead con su atribución y su respuesta** Devuelve un lead con sus dos facetas: `attribution` (de dónde viene) y `response` (cómo le ha respondido el equipo), cada una con su propia `version`. Un lead creado hace un momento puede responder `404 resource_missing` hasta que Totem lo prepare; el feed de cambios lo entrega entonces. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Lectura: cuenta para el límite de frecuencia ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `crm:read` ### Parámetros de ruta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | El identificador público del lead. | ### Respuesta Devuelve un objeto [`LeadDetail`](https://totemstudio.ai/developers/es/objects#LeadDetail). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead"` | Sí | Siempre `lead`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el registro. | | `record_kind` | `"lead" \| "client"` | Sí | `lead` si el registro tiene un estado del embudo; si no, `client`. Un lead cuyo estado no conoce esta versión trae `status: null`. | | `name` | `string \| null` | Sí | Nombre o nombre visible, o null. | | `surname` | `string \| null` | Sí | Apellidos, o null. | | `email` | `string \| null` | Sí | Email tal como está guardado, o null. | | `company` | `string \| null` | Sí | Empresa, o null. | | `phone` | `string \| null` | Sí | Teléfono en E.164, solo si el número guardado es válido; si no, null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Sí | `valid` (ver `phone`), `invalid` (hay un número guardado que no es válido, por ejemplo uno guardado sin prefijo de país) o `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | Estado en el embudo; null en un cliente o si es un estado que esta versión no conoce. `converted` no significa "ganado". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Sí | Quién fijó el estado actual: una persona, la IA, la automatización de Totem, una llamada a la API o desconocido (ninguna transición registrada llega a él). Null cuando `status` es null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Sí | Por qué se archivó el registro, como código cerrado; el texto libre guardado es `other`. Null si no hay ninguno. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | El estado que tenía el lead antes de su archivo actual; null si no está archivado o si se desconoce. | | `contact_permissions` | `object` | Sí | Permisos de contacto. Aplícalos antes de cualquier contacto desde tus propios sistemas. | | `contact_permissions.do_not_contact` | `boolean` | Sí | Verdadero cuando la persona pidió no ser contactada nunca (motivo de archivo `opt_out`). Detén todos los canales. | | `contact_permissions.email_opt_out` | `boolean` | Sí | Verdadero cuando la persona se dio de baja del correo electrónico comercial y de seguimiento. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Sí | Cuándo se registró la baja del correo electrónico, o null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Sí | Consentimiento de WhatsApp: false si se rechazó, true si consta, null si se desconoce. | | `contact_permissions.provenance` | `object` | Sí | El origen de cada permiso. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Sí | De dónde viene `do_not_contact`, o null cuando es falso. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Sí | Quién registró la baja del correo electrónico: el enlace de baja, una persona en Totem o desconocido. Null si no hay baja. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Sí | `archive_reason` si el rechazo consta como motivo de archivo; `stored` para el valor guardado en la ficha, que el equipo puede editar. Null si se desconoce. | | `merged_into` | `string \| null` | Sí | El lead en el que se fusionó este registro, o null (también si ese lead ya no existe). | | `transferred_to` | `object \| null` | Sí | Adónde se traspasó este lead dentro de tu organización, o null. El equipo puede ser uno que tu clave no lee. | | `transferred_to.team` | `string` | Sí | El equipo de destino. | | `transferred_to.lead` | `string \| null` | Sí | El lead de destino, o null si su id no puede nombrarse entre equipos: lo creó el traspaso de un asistente, o el traspaso cayó en un lead existente de anuncios (Meta, Google Ads o LinkedIn) u otro lead con id fijo. | | `revived_at` | `string (date-time) \| null` | Sí | Cuándo volvió el contacto por su cuenta y reactivó un lead archivado por última vez, o null. | | `pipeline_cleared` | `boolean` | Sí | Verdadero si el lead se convirtió con "borrar datos del embudo": tablero, etapa, prioridad, responsables, valor estimado y los campos de origen salvo `source_channel` son null porque se borraron. | | `converted_at` | `string (date-time) \| null` | Sí | Cuándo se convirtió el lead, mientras su estado es `converted`; si no, null. | | `value_at_conversion` | `number \| null` | Sí | El valor estimado al convertirlo, en unidades principales. Se conserva al borrar los datos del embudo. | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Sí | Prioridad, o null. | | `tags` | `Array` | Sí | Etiquetas tal como están guardadas. `lead` y `booking` son etiquetas del sistema. | | `board` | `string \| null` | Sí | El tablero, resuelto a través de la etapa si la hay; o null. | | `stage` | `string \| null` | Sí | La etapa, o null. | | `assignees` | `Array \| null` | Sí | Miembros asignados, ordenados. Null cuando el registro no tiene datos del embudo. | | `estimated_value` | `number \| null` | Sí | Valor estimado en unidades principales tal como está guardado (con decimales), o null. | | `estimated_value_currency` | `null` | Sí | Siempre null: Totem no guarda moneda con el valor. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Sí | El origen guardado del lead; `api` cubre también los leads que crea el propio Totem (por ejemplo, desde una conversación o una llamada), ver `source_detail`. Null si no hay ninguno. | | `source_channel` | `string \| null` | Sí | El canal por el que empezó el registro (por ejemplo `whatsapp`, `phone`, `widget`), o null. El equipo puede editarlo. | | `source_detail` | `string \| null` | Sí | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` o `unknown`; null cuando `source` no necesita detalle. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Sí | `server_written` si Totem fijó el origen a partir de lo que informó la plataforma de anuncios; si no, `member_editable` (el equipo puede cambiarlo). Null junto con `source`. | | `created_at` | `string (date-time)` | Sí | Cuándo se creó el registro. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | | `attribution` | [`LeadAttribution \| null`](https://totemstudio.ai/developers/es/objects#LeadAttribution) | Sí | La faceta de atribución del lead, o null mientras Totem aún la prepara. | | `response` | [`LeadResponse \| null`](https://totemstudio.ai/developers/es/objects#LeadResponse) | Sí | La faceta de respuesta del lead, o null mientras Totem aún la prepara. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Respuesta de ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z", "attribution": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" }, "response": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } } ``` ### Errores - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`resource_missing`](https://totemstudio.ai/developers/es/errors#resource_missing): Recurso no encontrado - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## POST /v1/leads/search **Busca leads por teléfono o correo exactos** Busca los leads del equipo cuyo teléfono o correo electrónico coincida exactamente, incluidos los archivados, los fusionados y los clientes (sigue `merged_into` hasta el lead que se conservó). Envía exactamente uno de `phone` o `email` en el cuerpo JSON, nunca en la URL. Devuelve como máximo 10 leads, elegidos entre los 20 primeros registros que coincidan, en una sola respuesta que nunca se pagina. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Lectura: cuenta para el límite de frecuencia ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `crm:read` ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Cuerpo de la petición Exactamente uno de `phone` o `email`, en el cuerpo JSON y nunca en la URL. | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `phone` | `string` | Condicional | Un teléfono en formato E.164 (`+12025550123`). Encuentra el número guardado en E.164, con o sin `+`, y en los formatos que escriben las bibliotecas de teléfonos: `+1 202-555-0123`, `(202) 555-0123` y `2025550123`. Otros formatos, como `202 555 0123`, no aparecen. | | `email` | `string` | Condicional | Un correo electrónico. Encuentra los registros guardados exactamente igual o en minúsculas. | ### Respuesta Devuelve una lista de objetos [`Lead`](https://totemstudio.ai/developers/es/objects#Lead). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Lead) | Sí | Los leads que coinciden: como máximo 10. | | `has_more` | `false` | Sí | Siempre `false`, aunque coincidan más registros: la búsqueda devuelve como máximo 10 leads y no tiene página siguiente. | | `next_cursor` | `null` | Sí | Siempre `null`: la búsqueda no se pagina. | ### Petición de ejemplo ```bash curl -X POST 'https://api.totemstudio.ai/v1/leads/search' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" \ -H "Content-Type: application/json" \ --data '{"phone": "+12025550123"}' ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}`, 'Content-Type': 'application/json', }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/leads/search', { method: 'POST', headers, body: JSON.stringify({ phone: '+12025550123' }), }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.post( "https://api.totemstudio.ai/v1/leads/search", headers=headers, json={"phone": "+12025550123"}, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/leads/{id}/activities **Lista las actividades de un lead** Devuelve una página de actividades del lead: cada cambio de etapa, de estado o de miembros asignados, con cuándo ocurrió y, si Totem lo registró, quién lo hizo. Las páginas no van por fecha, así que ordena por `occurred_at`. Una actividad no cambia una vez escrita, y las nuevas también llegan por el feed de cambios. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `crm:read` ### Parámetros de ruta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | El identificador público del lead. | ### Parámetros de consulta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `limit` | `integer` | No | Elementos por página, de 1 a 100. Por defecto, 25. | | `cursor` | `string` | No | El `next_cursor` de la página anterior. Omítelo en la primera página. | ### Respuesta Devuelve una lista de objetos [`Activity`](https://totemstudio.ai/developers/es/objects#Activity). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Activity) | Sí | Los elementos de esta página. | | `has_more` | `boolean` | Sí | `true` si hay otra página: pídela con `next_cursor`, aunque esta traiga menos elementos que `limit`. | | `next_cursor` | `string \| null` | Sí | Cursor opaco de la página siguiente, o null en la última. No lo interpretes. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG/activities?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG/activities?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG/activities?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "act_aBgzeetVKDEjpAAkXfEFF53ZGd2ZMmGhUDKgagqLbh6c7R1GqOl0xsFw7pyRhD3SqqYWGMdb371xxj", "object": "activity", "livemode": false, "type": "stage_changed", "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "occurred_at": "2026-09-28T10:20:00.000Z", "actor_kind": "ai", "stage_snapshot": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "name": "Contacted" }, "value_snapshot": 1200, "from": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "to": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "version": 1, "updated_at": "2026-09-28T10:20:01.500Z" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`resource_missing`](https://totemstudio.ai/developers/es/errors#resource_missing): Recurso no encontrado - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/boards **Lista los tableros con sus etapas** Devuelve una página de tableros del equipo, cada uno con sus etapas en el orden del embudo. Los tableros también llegan por el feed de cambios; lee esta lista cuando los necesites al momento, por ejemplo cuando un lead indique una etapa que aún no conoces. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `crm:read` ### Parámetros de consulta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `limit` | `integer` | No | Elementos por página, de 1 a 100. Por defecto, 25. | | `cursor` | `string` | No | El `next_cursor` de la página anterior. Omítelo en la primera página. | ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Respuesta Devuelve una lista de objetos [`Board`](https://totemstudio.ai/developers/es/objects#Board). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Board) | Sí | Los elementos de esta página. | | `has_more` | `boolean` | Sí | `true` si hay otra página: pídela con `next_cursor`, aunque esta traiga menos elementos que `limit`. | | `next_cursor` | `string \| null` | Sí | Cursor opaco de la página siguiente, o null en la última. No lo interpretes. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/boards?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/boards?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/boards?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "object": "board", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "New patients", "is_default": true, "is_archived": false, "appointment_booked_stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "stages": [ { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "name": "New", "position": 0, "is_default": true, "is_final": false, "is_archive": false, "mode": "automated" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "name": "Contacted", "position": 1, "is_default": false, "is_final": false, "is_archive": false, "mode": "supervised" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "name": "Visit booked", "position": 2, "is_default": false, "is_final": true, "is_archive": false, "mode": "standard" } ], "version": 4, "updated_at": "2026-09-02T16:40:00.000Z" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## El objeto Lead Un lead o cliente: solo los campos del lead. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead"` | Sí | Siempre `lead`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el registro. | | `record_kind` | `"lead" \| "client"` | Sí | `lead` si el registro tiene un estado del embudo; si no, `client`. Un lead cuyo estado no conoce esta versión trae `status: null`. | | `name` | `string \| null` | Sí | Nombre o nombre visible, o null. | | `surname` | `string \| null` | Sí | Apellidos, o null. | | `email` | `string \| null` | Sí | Email tal como está guardado, o null. | | `company` | `string \| null` | Sí | Empresa, o null. | | `phone` | `string \| null` | Sí | Teléfono en E.164, solo si el número guardado es válido; si no, null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Sí | `valid` (ver `phone`), `invalid` (hay un número guardado que no es válido, por ejemplo uno guardado sin prefijo de país) o `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | Estado en el embudo; null en un cliente o si es un estado que esta versión no conoce. `converted` no significa "ganado". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Sí | Quién fijó el estado actual: una persona, la IA, la automatización de Totem, una llamada a la API o desconocido (ninguna transición registrada llega a él). Null cuando `status` es null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Sí | Por qué se archivó el registro, como código cerrado; el texto libre guardado es `other`. Null si no hay ninguno. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | El estado que tenía el lead antes de su archivo actual; null si no está archivado o si se desconoce. | | `contact_permissions` | `object` | Sí | Permisos de contacto. Aplícalos antes de cualquier contacto desde tus propios sistemas. | | `contact_permissions.do_not_contact` | `boolean` | Sí | Verdadero cuando la persona pidió no ser contactada nunca (motivo de archivo `opt_out`). Detén todos los canales. | | `contact_permissions.email_opt_out` | `boolean` | Sí | Verdadero cuando la persona se dio de baja del correo electrónico comercial y de seguimiento. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Sí | Cuándo se registró la baja del correo electrónico, o null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Sí | Consentimiento de WhatsApp: false si se rechazó, true si consta, null si se desconoce. | | `contact_permissions.provenance` | `object` | Sí | El origen de cada permiso. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Sí | De dónde viene `do_not_contact`, o null cuando es falso. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Sí | Quién registró la baja del correo electrónico: el enlace de baja, una persona en Totem o desconocido. Null si no hay baja. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Sí | `archive_reason` si el rechazo consta como motivo de archivo; `stored` para el valor guardado en la ficha, que el equipo puede editar. Null si se desconoce. | | `merged_into` | `string \| null` | Sí | El lead en el que se fusionó este registro, o null (también si ese lead ya no existe). | | `transferred_to` | `object \| null` | Sí | Adónde se traspasó este lead dentro de tu organización, o null. El equipo puede ser uno que tu clave no lee. | | `transferred_to.team` | `string` | Sí | El equipo de destino. | | `transferred_to.lead` | `string \| null` | Sí | El lead de destino, o null si su id no puede nombrarse entre equipos: lo creó el traspaso de un asistente, o el traspaso cayó en un lead existente de anuncios (Meta, Google Ads o LinkedIn) u otro lead con id fijo. | | `revived_at` | `string (date-time) \| null` | Sí | Cuándo volvió el contacto por su cuenta y reactivó un lead archivado por última vez, o null. | | `pipeline_cleared` | `boolean` | Sí | Verdadero si el lead se convirtió con "borrar datos del embudo": tablero, etapa, prioridad, responsables, valor estimado y los campos de origen salvo `source_channel` son null porque se borraron. | | `converted_at` | `string (date-time) \| null` | Sí | Cuándo se convirtió el lead, mientras su estado es `converted`; si no, null. | | `value_at_conversion` | `number \| null` | Sí | El valor estimado al convertirlo, en unidades principales. Se conserva al borrar los datos del embudo. | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Sí | Prioridad, o null. | | `tags` | `Array` | Sí | Etiquetas tal como están guardadas. `lead` y `booking` son etiquetas del sistema. | | `board` | `string \| null` | Sí | El tablero, resuelto a través de la etapa si la hay; o null. | | `stage` | `string \| null` | Sí | La etapa, o null. | | `assignees` | `Array \| null` | Sí | Miembros asignados, ordenados. Null cuando el registro no tiene datos del embudo. | | `estimated_value` | `number \| null` | Sí | Valor estimado en unidades principales tal como está guardado (con decimales), o null. | | `estimated_value_currency` | `null` | Sí | Siempre null: Totem no guarda moneda con el valor. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Sí | El origen guardado del lead; `api` cubre también los leads que crea el propio Totem (por ejemplo, desde una conversación o una llamada), ver `source_detail`. Null si no hay ninguno. | | `source_channel` | `string \| null` | Sí | El canal por el que empezó el registro (por ejemplo `whatsapp`, `phone`, `widget`), o null. El equipo puede editarlo. | | `source_detail` | `string \| null` | Sí | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` o `unknown`; null cuando `source` no necesita detalle. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Sí | `server_written` si Totem fijó el origen a partir de lo que informó la plataforma de anuncios; si no, `member_editable` (el equipo puede cambiarlo). Null junto con `source`. | | `created_at` | `string (date-time)` | Sí | Cuándo se creó el registro. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ``` ## El objeto LeadDetail Un lead con sus facetas `attribution` y `response`, cada una con su propia `version`. Una faceta es `null` solo mientras Totem aún la prepara. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead"` | Sí | Siempre `lead`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el registro. | | `record_kind` | `"lead" \| "client"` | Sí | `lead` si el registro tiene un estado del embudo; si no, `client`. Un lead cuyo estado no conoce esta versión trae `status: null`. | | `name` | `string \| null` | Sí | Nombre o nombre visible, o null. | | `surname` | `string \| null` | Sí | Apellidos, o null. | | `email` | `string \| null` | Sí | Email tal como está guardado, o null. | | `company` | `string \| null` | Sí | Empresa, o null. | | `phone` | `string \| null` | Sí | Teléfono en E.164, solo si el número guardado es válido; si no, null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Sí | `valid` (ver `phone`), `invalid` (hay un número guardado que no es válido, por ejemplo uno guardado sin prefijo de país) o `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | Estado en el embudo; null en un cliente o si es un estado que esta versión no conoce. `converted` no significa "ganado". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Sí | Quién fijó el estado actual: una persona, la IA, la automatización de Totem, una llamada a la API o desconocido (ninguna transición registrada llega a él). Null cuando `status` es null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Sí | Por qué se archivó el registro, como código cerrado; el texto libre guardado es `other`. Null si no hay ninguno. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | El estado que tenía el lead antes de su archivo actual; null si no está archivado o si se desconoce. | | `contact_permissions` | `object` | Sí | Permisos de contacto. Aplícalos antes de cualquier contacto desde tus propios sistemas. | | `contact_permissions.do_not_contact` | `boolean` | Sí | Verdadero cuando la persona pidió no ser contactada nunca (motivo de archivo `opt_out`). Detén todos los canales. | | `contact_permissions.email_opt_out` | `boolean` | Sí | Verdadero cuando la persona se dio de baja del correo electrónico comercial y de seguimiento. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Sí | Cuándo se registró la baja del correo electrónico, o null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Sí | Consentimiento de WhatsApp: false si se rechazó, true si consta, null si se desconoce. | | `contact_permissions.provenance` | `object` | Sí | El origen de cada permiso. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Sí | De dónde viene `do_not_contact`, o null cuando es falso. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Sí | Quién registró la baja del correo electrónico: el enlace de baja, una persona en Totem o desconocido. Null si no hay baja. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Sí | `archive_reason` si el rechazo consta como motivo de archivo; `stored` para el valor guardado en la ficha, que el equipo puede editar. Null si se desconoce. | | `merged_into` | `string \| null` | Sí | El lead en el que se fusionó este registro, o null (también si ese lead ya no existe). | | `transferred_to` | `object \| null` | Sí | Adónde se traspasó este lead dentro de tu organización, o null. El equipo puede ser uno que tu clave no lee. | | `transferred_to.team` | `string` | Sí | El equipo de destino. | | `transferred_to.lead` | `string \| null` | Sí | El lead de destino, o null si su id no puede nombrarse entre equipos: lo creó el traspaso de un asistente, o el traspaso cayó en un lead existente de anuncios (Meta, Google Ads o LinkedIn) u otro lead con id fijo. | | `revived_at` | `string (date-time) \| null` | Sí | Cuándo volvió el contacto por su cuenta y reactivó un lead archivado por última vez, o null. | | `pipeline_cleared` | `boolean` | Sí | Verdadero si el lead se convirtió con "borrar datos del embudo": tablero, etapa, prioridad, responsables, valor estimado y los campos de origen salvo `source_channel` son null porque se borraron. | | `converted_at` | `string (date-time) \| null` | Sí | Cuándo se convirtió el lead, mientras su estado es `converted`; si no, null. | | `value_at_conversion` | `number \| null` | Sí | El valor estimado al convertirlo, en unidades principales. Se conserva al borrar los datos del embudo. | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Sí | Prioridad, o null. | | `tags` | `Array` | Sí | Etiquetas tal como están guardadas. `lead` y `booking` son etiquetas del sistema. | | `board` | `string \| null` | Sí | El tablero, resuelto a través de la etapa si la hay; o null. | | `stage` | `string \| null` | Sí | La etapa, o null. | | `assignees` | `Array \| null` | Sí | Miembros asignados, ordenados. Null cuando el registro no tiene datos del embudo. | | `estimated_value` | `number \| null` | Sí | Valor estimado en unidades principales tal como está guardado (con decimales), o null. | | `estimated_value_currency` | `null` | Sí | Siempre null: Totem no guarda moneda con el valor. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Sí | El origen guardado del lead; `api` cubre también los leads que crea el propio Totem (por ejemplo, desde una conversación o una llamada), ver `source_detail`. Null si no hay ninguno. | | `source_channel` | `string \| null` | Sí | El canal por el que empezó el registro (por ejemplo `whatsapp`, `phone`, `widget`), o null. El equipo puede editarlo. | | `source_detail` | `string \| null` | Sí | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` o `unknown`; null cuando `source` no necesita detalle. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Sí | `server_written` si Totem fijó el origen a partir de lo que informó la plataforma de anuncios; si no, `member_editable` (el equipo puede cambiarlo). Null junto con `source`. | | `created_at` | `string (date-time)` | Sí | Cuándo se creó el registro. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | | `attribution` | [`LeadAttribution \| null`](https://totemstudio.ai/developers/es/objects#LeadAttribution) | Sí | La faceta de atribución del lead, o null mientras Totem aún la prepara. | | `response` | [`LeadResponse \| null`](https://totemstudio.ai/developers/es/objects#LeadResponse) | Sí | La faceta de respuesta del lead, o null mientras Totem aún la prepara. | ### Ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z", "attribution": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" }, "response": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } } ``` ## El objeto LeadAttribution De dónde viene un lead: su captura original y su último envío. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead_attribution"` | Sí | Siempre `lead_attribution`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el lead. | | `original_capture` | `object \| null` | Sí | Cómo entró el lead en el CRM por primera vez, fijado una sola vez; null si no se capturó. "Original" es la primera vez que el contacto entró en el CRM, no el primer anuncio al que respondió. | | `original_capture.capture` | `"at_creation" \| "at_activation"` | Sí | `at_creation`: fijado con el lead tal como se guardó por primera vez. `at_activation`: copiado una vez del lead tal como estaba al activar la API, para un lead sin registro de captura (creado antes de que Totem empezara a registrar la atribución, o cuya creación no se registró). | | `original_capture.captured_at` | `string (date-time)` | Sí | Cuándo se escribió el registro. | | `original_capture.source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Sí | El origen del lead en la captura, del mismo conjunto que el `source` del lead; `api` cubre también los leads que crea el propio Totem (por ejemplo, desde una conversación o una llamada, ver `origin`). Null si no había ninguno. | | `original_capture.source_hint` | `string \| null` | Sí | El origen real de un lead de anuncios restringido guardado como `api`, o null. | | `original_capture.source_channel` | `string \| null` | Sí | El canal en la captura, o null. | | `original_capture.external_provider` | `string \| null` | Sí | El portal o la plataforma de citas de un lead reenviado o sincronizado, o null. | | `original_capture.received_at` | `string (date-time) \| null` | Sí | Cuándo se recibió el lead, tal como se escribió la primera vez, o null. | | `original_capture.submitted_at` | `string (date-time) \| null` | Sí | Cuándo dice la plataforma de anuncios que se envió el formulario, o null. | | `original_capture.origin` | `object \| null` | Sí | La conversación o llamada que creó el lead (nunca su id), o null (también para un origen de otro tipo). | | `original_capture.origin.kind` | `"conversation" \| "call"` | Sí | `conversation` o `call`. | | `original_capture.origin.channel` | `string \| null` | Sí | Su canal, o null. | | `original_capture.campaign` | `object` | Sí | La campaña. | | `original_capture.campaign.id` | `object` | Sí | Su id. | | `original_capture.campaign.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.campaign.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.campaign.name` | `object` | Sí | Su nombre. | | `original_capture.campaign.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.campaign.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad` | `object` | Sí | El anuncio. | | `original_capture.ad.id` | `object` | Sí | Su id. | | `original_capture.ad.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad.name` | `object` | Sí | Su nombre. | | `original_capture.ad.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad_set` | `object` | Sí | El conjunto de anuncios de Meta. | | `original_capture.ad_set.id` | `object` | Sí | Su id. | | `original_capture.ad_set.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad_set.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad_set.name` | `object` | Sí | Su nombre. | | `original_capture.ad_set.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad_set.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad_group` | `object` | Sí | El grupo de anuncios de Google Ads. | | `original_capture.ad_group.id` | `object` | Sí | Su id. | | `original_capture.ad_group.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.form` | `object` | Sí | El formulario de clientes potenciales. | | `original_capture.form.id` | `object` | Sí | Su id. | | `original_capture.form.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.form.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.form.name` | `object` | Sí | Su nombre. | | `original_capture.form.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.form.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.creative` | `object` | Sí | La creatividad. | | `original_capture.creative.id` | `object` | Sí | Su id. | | `original_capture.creative.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.creative.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.asset_group` | `object` | Sí | El grupo de recursos de Google Ads. | | `original_capture.asset_group.id` | `object` | Sí | Su id. | | `original_capture.asset_group.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.asset_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.page` | `object` | Sí | La página de Facebook. | | `original_capture.page.id` | `object` | Sí | Su id. | | `original_capture.page.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.page.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.page.name` | `object` | Sí | Su nombre. | | `original_capture.page.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.page.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm` | `object \| null` | Sí | Las cinco claves UTM estándar, o null si no se capturó ninguna. | | `original_capture.utm.source` | `object` | Sí | `utm_source`. | | `original_capture.utm.source.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.source.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm.medium` | `object` | Sí | `utm_medium`. | | `original_capture.utm.medium.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.medium.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm.campaign` | `object` | Sí | `utm_campaign`. | | `original_capture.utm.campaign.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.campaign.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm.term` | `object` | Sí | `utm_term`. | | `original_capture.utm.term.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.term.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm.content` | `object` | Sí | `utm_content`. | | `original_capture.utm.content.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.content.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.click_network` | `object` | Sí | La red del primer identificador de clic (`google` o `meta`). El identificador nunca se guarda. | | `original_capture.click_network.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.click_network.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad_referral` | `object \| null` | Sí | El primer referido de anuncio de Meta de la conversación que creó el lead, o null. | | `original_capture.ad_referral.channel` | `"whatsapp" \| "facebook" \| "instagram"` | Sí | El canal. | | `original_capture.ad_referral.source_type` | `"ad" \| "post" \| "link" \| "other"` | Sí | Qué abrió la conversación. | | `original_capture.ad_referral.ad_id` | `string \| null` | Sí | El id del anuncio o la publicación, o null. | | `original_capture.ad_referral.referred_at` | `string (date-time)` | Sí | La hora de Meta del referido. | | `original_capture.ad_referral.provenance` | `"provider"` | Sí | Siempre `provider`: solo lo escriben los webhooks firmados de Meta. | | `original_capture.family_origin` | `object \| null` | Sí | El traspaso desde otro equipo de tu organización que creó el lead, o null. | | `original_capture.family_origin.team` | `string` | Sí | El equipo de tu organización del que vino el lead. | | `original_capture.family_origin.lead` | `string \| null` | Sí | Su lead allí, o null si ese id no puede nombrarse entre equipos: un asistente traspasó a alguien que no lo tenía, o el lead de allí es un lead de anuncios (Meta, Google Ads o LinkedIn) u otro lead con id fijo. `automated` distingue el traspaso de un asistente del que hace una persona. | | `original_capture.family_origin.automated` | `boolean` | Sí | Verdadero en el traspaso de un asistente. | | `original_capture.audience` | `object` | Sí | No disponible en esta versión. | | `original_capture.audience.value` | `null` | Sí | Siempre null. | | `original_capture.audience.missing_reason` | `"unsupported"` | Sí | Totem no registra audiencias; un conjunto de anuncios no lo es. | | `original_capture_missing_reason` | `"not_captured" \| null` | Sí | `not_captured` cuando `original_capture` es null; si no, null. | | `latest_submission` | `object \| null` | Sí | El envío de anuncio más reciente registrado para este lead, incluida una repetición sobre un contacto existente; null si no hay ninguno. Un envío que la plataforma de anuncios informa como borrado (un miembro de LinkedIn que borra su respuesta al formulario) deja de mostrarse, y cuando el más reciente caduca o se retira puede mostrarse uno anterior. | | `latest_submission.provider` | `"meta" \| "google_ads" \| "linkedin"` | Sí | La plataforma de anuncios. | | `latest_submission.submitted_at` | `string (date-time) \| null` | Sí | Cuándo dice la plataforma que se envió, o null. | | `latest_submission.campaign` | `object` | Sí | La campaña. | | `latest_submission.campaign.id` | `object` | Sí | Su id. | | `latest_submission.campaign.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.campaign.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.campaign.name` | `object` | Sí | Su nombre. | | `latest_submission.campaign.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.campaign.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad` | `object` | Sí | El anuncio. | | `latest_submission.ad.id` | `object` | Sí | Su id. | | `latest_submission.ad.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad.name` | `object` | Sí | Su nombre. | | `latest_submission.ad.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad_set` | `object` | Sí | El conjunto de anuncios de Meta. | | `latest_submission.ad_set.id` | `object` | Sí | Su id. | | `latest_submission.ad_set.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad_set.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad_set.name` | `object` | Sí | Su nombre. | | `latest_submission.ad_set.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad_set.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad_group` | `object` | Sí | El grupo de anuncios de Google Ads. | | `latest_submission.ad_group.id` | `object` | Sí | Su id. | | `latest_submission.ad_group.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.form` | `object` | Sí | El formulario de clientes potenciales. | | `latest_submission.form.id` | `object` | Sí | Su id. | | `latest_submission.form.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.form.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.form.name` | `object` | Sí | Su nombre. | | `latest_submission.form.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.form.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.creative` | `object` | Sí | La creatividad. | | `latest_submission.creative.id` | `object` | Sí | Su id. | | `latest_submission.creative.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.creative.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.asset_group` | `object` | Sí | El grupo de recursos de Google Ads. | | `latest_submission.asset_group.id` | `object` | Sí | Su id. | | `latest_submission.asset_group.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.asset_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm` | `object \| null` | Sí | Sus claves UTM, o null. | | `latest_submission.utm.source` | `object` | Sí | `utm_source`. | | `latest_submission.utm.source.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.source.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm.medium` | `object` | Sí | `utm_medium`. | | `latest_submission.utm.medium.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.medium.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm.campaign` | `object` | Sí | `utm_campaign`. | | `latest_submission.utm.campaign.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.campaign.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm.term` | `object` | Sí | `utm_term`. | | `latest_submission.utm.term.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.term.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm.content` | `object` | Sí | `utm_content`. | | `latest_submission.utm.content.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.content.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.expires_at` | `string (date-time) \| null` | Sí | Cuándo deja de poder leerse este envío (Google Ads y LinkedIn lo guardan 90 días); null en Meta. | | `latest_submission_missing_reason` | `"not_captured" \| null` | Sí | `not_captured` cuando `latest_submission` es null; si no, null. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" } ``` ## El objeto LeadResponse Cómo ha respondido el equipo a un lead en sus conversaciones y llamadas: los últimos mensajes en cada sentido y si tiene una respuesta pendiente. Usa el identificador de su lead. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead_response"` | Sí | Siempre `lead_response`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `last_inbound_at` | `string (date-time) \| null` | Sí | El último mensaje del cliente en cualquiera de las conversaciones del lead (UTC), o null. No cuentan: mensajes borrados, historial de WhatsApp importado ni los turnos del visitante del chat web. Los contenidos compartidos de Instagram no se distinguen y sí cuentan. | | `last_outbound_at` | `string (date-time) \| null` | Sí | El último mensaje enviado al cliente que no falló, en cualquiera de las conversaciones del lead y lo enviara quien lo enviara (UTC), o null. El historial de WhatsApp importado sí cuenta. | | `awaiting_reply` | `boolean` | Sí | Verdadero cuando `last_inbound_at` es posterior a la última respuesta en cualquiera de las conversaciones del lead. Una respuesta es un mensaje enviado que no falló y que se registró como respuesta de conversación o sin propósito; no cuentan la prospección, los mensajes transaccionales (como los recordatorios) ni el historial importado. La mayoría de los mensajes guardados no tienen propósito, así que un envío automático registrado sin él también cuenta como respuesta. Falso si el cliente nunca ha escrito. | | `last_answered_call_at` | `string (date-time) \| null` | Sí | Cuándo empezó la última llamada del lead cuyo `outcome` es `answered_human` o `answered_ai` (UTC), o null. Una saliente conectada en las líneas de teléfono de Totem es `unknown`, así que no cuenta. | | `last_whatsapp_delivery_status` | `"delivered" \| "read" \| "failed" \| null` | Sí | `delivered`, `read` o `failed` del último mensaje enviado al lead por WhatsApp. Null si ese mensaje aún no tiene confirmación (`sent` nunca se guarda) o si al lead no se le ha enviado ningún mensaje por WhatsApp. | | `coverage` | `object` | Sí | De qué se calcularon estos datos. | | `coverage.conversations` | `integer` | Sí | Las conversaciones del lead con las que se calcularon estos datos, de la más reciente a la más antigua; como máximo 20. | | `coverage.calls` | `integer` | Sí | Las llamadas del lead con las que se calculó `last_answered_call_at`, de la más reciente a la más antigua; como máximo 50. | | `coverage.channels_without_delivery_data` | `Array<"sms" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email">` | Sí | Los canales de esas conversaciones que no guardan confirmaciones de entrega (todos menos WhatsApp), ordenados. | | `coverage.truncated` | `boolean` | Sí | Verdadero si se alcanzó un límite de lectura (20 conversaciones, 200 mensajes o 50 llamadas): la actividad más antigua no se leyó, así que una hora puede ser null o anterior a la real, y `awaiting_reply` puede no ver una respuesta más antigua. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } ``` ## El objeto Activity Una transición de etapa, estado o responsables de un lead. Una actividad no cambia una vez escrita. ### Atributos comunes a todas las variantes | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"activity"` | Sí | Siempre `activity`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el lead. | | `lead` | `string \| null` | Sí | El lead que cambió. Null solo mientras Totem aún no ha asignado su id al lead. | | `occurred_at` | `string (date-time)` | Sí | Cuándo se guardó el cambio. | | `actor_kind` | `"human" \| "ai" \| "system" \| "api" \| "unknown"` | Sí | Quién hizo el cambio: una persona, la IA, la automatización de Totem, una llamada a la API o desconocido. Totem sabe quién hizo un cambio solo cuando el mismo guardado escribió el estado del lead, así que un cambio de etapa o de responsables hecho por sí solo es desconocido. | | `stage_snapshot` | `object \| null` | Sí | La etapa en la que quedó el lead tras el cambio (al borrar los datos del embudo, la que dejó), o null. | | `stage_snapshot.stage` | `string \| null` | Sí | La etapa. | | `stage_snapshot.board` | `string \| null` | Sí | Su tablero, o null en una etapa antigua sin tablero. | | `stage_snapshot.name` | `string \| null` | Sí | El nombre de la etapa cuando se registró el cambio, o null si la etapa ya estaba borrada. | | `value_snapshot` | `number \| null` | Sí | El valor estimado en ese momento, en unidades principales, o null. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Variantes Cada `Activity` es exactamente una de estas 3 variantes. #### Una transición de estado Cuando `type` es uno de `status_changed`, `archived`, `unarchived`, `revived`. | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `type` | `"status_changed" \| "archived" \| "unarchived" \| "revived"` | Sí | `status_changed`, `archived`, `unarchived` o `revived` (el contacto volvió por su cuenta y reabrió un lead archivado). | | `from` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | El estado anterior, o null si no había. | | `to` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable"` | Sí | El estado posterior. | #### Un cambio de etapa Cuando `type` es `stage_changed`. | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `type` | `"stage_changed"` | Sí | Siempre `stage_changed`. | | `from` | `object \| null` | Sí | La etapa y el tablero anteriores, o null si el lead no tenía ninguno. | | `from.stage` | `string \| null` | Sí | La etapa. | | `from.board` | `string \| null` | Sí | El tablero, o null en una etapa antigua sin tablero. | | `to` | `object` | Sí | La etapa y el tablero posteriores. | | `to.stage` | `string \| null` | Sí | La etapa. | | `to.board` | `string \| null` | Sí | El tablero, o null en una etapa antigua sin tablero. | #### Un cambio de miembros asignados Cuando `type` es `assignees_changed`. | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `type` | `"assignees_changed"` | Sí | Siempre `assignees_changed`. | | `from` | `Array` | Sí | Los miembros asignados antes, ordenados. | | `to` | `Array` | Sí | Los miembros asignados después, ordenados. | ### Ejemplo ```json { "id": "act_aBgzeetVKDEjpAAkXfEFF53ZGd2ZMmGhUDKgagqLbh6c7R1GqOl0xsFw7pyRhD3SqqYWGMdb371xxj", "object": "activity", "livemode": false, "type": "stage_changed", "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "occurred_at": "2026-09-28T10:20:00.000Z", "actor_kind": "ai", "stage_snapshot": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "name": "Contacted" }, "value_snapshot": 1200, "from": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "to": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "version": 1, "updated_at": "2026-09-28T10:20:01.500Z" } ``` ## El objeto Board Un tablero con sus etapas en el orden del embudo. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"board"` | Sí | Siempre `board`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `name` | `string \| null` | Sí | El nombre del tablero, o null si no hay ninguno guardado. | | `is_default` | `boolean` | Sí | Verdadero en el tablero por defecto del equipo, donde llegan los leads nuevos salvo que se enruten a otro. | | `is_archived` | `boolean` | Sí | Verdadero si el tablero está archivado (oculto en el selector de tableros). Sus leads se siguen exportando. | | `appointment_booked_stage` | `string \| null` | Sí | La etapa de este tablero a la que pasa un lead cuando se confirma su primera cita, o null si no hay ninguna o no es una etapa de este tablero. | | `stages` | `Array` | Sí | Las etapas del tablero, ordenadas por posición y después por id. Una etapa sin ninguna posición guardada no aparece, aunque un lead puede seguir indicándola. | | `stages[].object` | `"stage"` | Sí | Siempre `stage`. | | `stages[].id` | `string` | Sí | Id público de la etapa; el `stage` de un lead la nombra. | | `stages[].name` | `string \| null` | Sí | El nombre de la etapa, o null si no hay ninguno guardado. | | `stages[].position` | `number \| null` | Sí | La posición de la etapa en su tablero, de izquierda a derecha; null si la posición guardada no es un número. Las etapas se listan en este orden. | | `stages[].is_default` | `boolean` | Sí | Verdadero en la etapa a la que entran los leads nuevos de este tablero. | | `stages[].is_final` | `boolean` | Sí | Verdadero en una etapa final: mover un lead aquí lo marca como convertido. | | `stages[].is_archive` | `boolean` | Sí | Verdadero en una etapa de archivo, cuyos leads no se muestran en la vista principal del tablero. | | `stages[].mode` | `"standard" \| "supervised" \| "automated" \| null` | Sí | Quién mueve los leads: `standard` (solo personas), `supervised` (la IA puede meter leads, las personas los sacan) o `automated` (la IA hace el contacto y mete y saca leads); null si es un valor que esta versión no conoce. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "object": "board", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "New patients", "is_default": true, "is_archived": false, "appointment_booked_stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "stages": [ { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "name": "New", "position": 0, "is_default": true, "is_final": false, "is_archive": false, "mode": "automated" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "name": "Contacted", "position": 1, "is_default": false, "is_final": false, "is_archive": false, "mode": "supervised" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "name": "Visit booked", "position": 2, "is_default": false, "is_final": true, "is_archive": false, "mode": "standard" } ], "version": 4, "updated_at": "2026-09-02T16:40:00.000Z" } ``` --- # Llamadas > Lista y obtén las llamadas de un equipo, con su dirección, su horario y un resultado normalizado. Sin grabaciones ni transcripciones. Fuente: https://totemstudio.ai/developers/es/reference-calls ## Resumen Una **llamada** son los metadatos de una llamada telefónica, o de una sesión de voz web, vinculada a un lead: su dirección (`direction`), cuándo empezó, cuánto duró y cómo terminó. Las grabaciones, las transcripciones y los resúmenes nunca se exportan. El nombre y el número de quien llama viajan en el lead vinculado, no en la llamada. `outcome` indica cómo terminó la llamada: `answered_human`, `answered_ai`, `voicemail`, `no_answer`, `busy`, `failed` o `unknown` cuando la llamada sigue en curso o nada indica quién respondió. Léelo junto con `outcome_basis`, que indica la prueba en la que se basa: | `outcome_basis` | El resultado se basa en | |---|---| | `inbound_verdict` | La clasificación de Totem de una llamada entrante. | | `ai_analysis` | La clasificación que hace Totem del estado, el motivo de fin y la duración que informa Retell. | | `duration_floor` | La duración de una llamada en una línea atendida por personas: una de al menos 10 segundos cuenta como respondida. | | `carrier_answer_only` | Solo la señal de respuesta del operador de telefonía. Nunca cuenta como respondida, así que una llamada conectada se lee `unknown`. | | `browser_session` | Una sesión de voz web con el asistente. | | `none` | Ninguna prueba. | La base depende de cómo se hizo la llamada. Las entrantes en las líneas de teléfono de Totem tienen `inbound_verdict`, o `carrier_answer_only` si Totem no las ha clasificado. Todas las salientes en las líneas de teléfono de Totem, las haga la IA o una persona, tienen `carrier_answer_only`, así que una saliente conectada es `unknown`. Las llamadas atendidas a través de la plataforma de voz Retell tienen `ai_analysis`, y las de una centralita Zadarma conectada, `duration_floor`. Una sesión de voz web tiene `browser_session`, y una llamada que sigue en curso, `none`. El `last_answered_call_at` de un lead solo cuenta `answered_human` y `answered_ai`, así que no tiene en cuenta las salientes conectadas en las líneas de teléfono de Totem. `duration_seconds` se redondea a la baja en segundos enteros y es `null` mientras la llamada sigue en curso. `lead` es `null` cuando la llamada no está vinculada a ningún lead o cuando el lead ya no existe. Usa [`GET /v1/calls`](https://totemstudio.ai/developers/es/reference-calls#calls.list) y [`GET /v1/calls/{id}`](https://totemstudio.ai/developers/es/reference-calls#calls.get) para consultar llamadas; `GET /v1/calls?lead=` lista las llamadas de un lead. Para mantener una copia, sigue el feed de cambios, que entrega cada llamada nueva y cada cambio: lo explica [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). ## Endpoints | Método | Ruta | Resumen | | --- | --- | --- | | GET | `/v1/calls` | Lista las llamadas | | GET | `/v1/calls/{id}` | Obtén una llamada | ## GET /v1/calls **Lista las llamadas** Devuelve una página de llamadas del equipo, cada una con su dirección, su hora de inicio, su duración y un resultado normalizado, y vinculada a su lead cuando lo hay. Envía `lead` para obtener las llamadas de un lead. Las grabaciones y las transcripciones no se exportan. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `calls:read` ### Parámetros de consulta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `limit` | `integer` | No | Elementos por página, de 1 a 100. Por defecto, 25. | | `cursor` | `string` | No | El `next_cursor` de la página anterior. Omítelo en la primera página. | | `lead` | `string` | No | Solo las llamadas vinculadas a este lead: el `lead` de cada elemento es este identificador. Un lead de otro equipo, o que no existe, no coincide con nada. | ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Respuesta Devuelve una lista de objetos [`Call`](https://totemstudio.ai/developers/es/objects#Call). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Call) | Sí | Los elementos de esta página. | | `has_more` | `boolean` | Sí | `true` si hay otra página: pídela con `next_cursor`, aunque esta traiga menos elementos que `limit`. | | `next_cursor` | `string \| null` | Sí | Cursor opaco de la página siguiente, o null en la última. No lo interpretes. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/calls?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/calls?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/calls?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/calls/{id} **Obtén una llamada** Devuelve una llamada por su identificador. Una llamada que esta clave no puede leer, o que se eliminó, responde `404 resource_missing`. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Lectura: cuenta para el límite de frecuencia ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `calls:read` ### Parámetros de ruta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | El identificador público de la llamada. | ### Respuesta Devuelve un objeto [`Call`](https://totemstudio.ai/developers/es/objects#Call). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"call"` | Sí | Siempre `call`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que está vinculada la llamada, o null si no está vinculada o el lead ya no existe. El nombre y el teléfono solo aparecen en el lead. | | `direction` | `"inbound" \| "outbound" \| null` | Sí | `inbound` o `outbound`; null si no está guardada. | | `started_at` | `string (date-time) \| null` | Sí | Cuándo empezó la llamada (UTC), o cuándo se registró si el proveedor no dio hora de inicio. | | `duration_seconds` | `integer \| null` | Sí | Segundos enteros de la llamada (redondeados hacia abajo); null mientras la llamada sigue en curso, o si no están guardados. | | `outcome` | `"answered_human" \| "answered_ai" \| "voicemail" \| "no_answer" \| "busy" \| "failed" \| "unknown"` | Sí | Cómo terminó la llamada: `answered_human`, `answered_ai`, `voicemail`, `no_answer`, `busy`, `failed` o `unknown` (aún en curso, o sin pruebas de quién contestó). Léelo junto con `outcome_basis`. | | `outcome_basis` | `"inbound_verdict" \| "ai_analysis" \| "duration_floor" \| "carrier_answer_only" \| "browser_session" \| "none"` | Sí | La prueba detrás de `outcome`: `inbound_verdict` (la clasificación de Totem de una llamada entrante en sus líneas de teléfono), `ai_analysis` (la clasificación que hace Totem del estado, el motivo de fin y la duración que informa Retell), `duration_floor` (una llamada en una centralita Zadarma conectada, donde al menos 10 segundos cuentan como contestada), `carrier_answer_only` (solo la señal de respuesta del operador, que nunca cuenta como contestada, así que una llamada conectada es `unknown`: todas las salientes en las líneas de Totem, las haga la IA o una persona, y una entrante que Totem no ha clasificado), `browser_session` (una sesión de voz web con el asistente) o `none` (ninguna prueba, como en una llamada que sigue en curso). | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/calls/call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/calls/call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/calls/call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Respuesta de ejemplo ```json { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ``` ### Errores - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`resource_missing`](https://totemstudio.ai/developers/es/errors#resource_missing): Recurso no encontrado - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## El objeto Call Una llamada: su dirección, su horario y un resultado normalizado. Sin grabación ni transcripción. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"call"` | Sí | Siempre `call`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que está vinculada la llamada, o null si no está vinculada o el lead ya no existe. El nombre y el teléfono solo aparecen en el lead. | | `direction` | `"inbound" \| "outbound" \| null` | Sí | `inbound` o `outbound`; null si no está guardada. | | `started_at` | `string (date-time) \| null` | Sí | Cuándo empezó la llamada (UTC), o cuándo se registró si el proveedor no dio hora de inicio. | | `duration_seconds` | `integer \| null` | Sí | Segundos enteros de la llamada (redondeados hacia abajo); null mientras la llamada sigue en curso, o si no están guardados. | | `outcome` | `"answered_human" \| "answered_ai" \| "voicemail" \| "no_answer" \| "busy" \| "failed" \| "unknown"` | Sí | Cómo terminó la llamada: `answered_human`, `answered_ai`, `voicemail`, `no_answer`, `busy`, `failed` o `unknown` (aún en curso, o sin pruebas de quién contestó). Léelo junto con `outcome_basis`. | | `outcome_basis` | `"inbound_verdict" \| "ai_analysis" \| "duration_floor" \| "carrier_answer_only" \| "browser_session" \| "none"` | Sí | La prueba detrás de `outcome`: `inbound_verdict` (la clasificación de Totem de una llamada entrante en sus líneas de teléfono), `ai_analysis` (la clasificación que hace Totem del estado, el motivo de fin y la duración que informa Retell), `duration_floor` (una llamada en una centralita Zadarma conectada, donde al menos 10 segundos cuentan como contestada), `carrier_answer_only` (solo la señal de respuesta del operador, que nunca cuenta como contestada, así que una llamada conectada es `unknown`: todas las salientes en las líneas de Totem, las haga la IA o una persona, y una entrante que Totem no ha clasificado), `browser_session` (una sesión de voz web con el asistente) o `none` (ninguna prueba, como en una llamada que sigue en curso). | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ``` --- # Conversaciones > Lista y obtén las conversaciones de un equipo en todos los canales, con su estado, la persona asignada, si una persona ha tomado el control y cuándo llegaron y salieron los últimos mensajes. Nunca el contenido de los mensajes. Fuente: https://totemstudio.ai/developers/es/reference-conversations ## Resumen Una **conversación** es un hilo con un lead en un canal y una cuenta de empresa: WhatsApp, correo electrónico, el chat web (`chatbot`), Facebook, Instagram, SMS o TikTok. Un lead puede tener varias conversaciones en el mismo canal y cuenta de empresa, una por cada dirección de contacto (teléfono, correo electrónico o usuario) que haya usado, así que usa el `id` de la conversación como clave de tu copia. Recibes los metadatos de la conversación, nunca sus mensajes: - `channel_type`: el canal, o `null` para un canal que esta versión de la API no conoce. - `status`: `active` o `archived` en la bandeja de entrada del equipo. - `assignee`: el miembro del equipo al que está asignada la conversación, o `null`. - `reply_mode`: `human` mientras una persona ha tomado el control de la conversación, incluido un chat de WhatsApp que se atiende desde la app WhatsApp Business, y `ai` en los demás casos. `ai` significa que no hay una toma de control activa; no significa que haya un asistente configurado o respondiendo en ese canal. Una toma de control con límite de tiempo vuelve a `ai` cuando termina, y en ese momento la conversación recibe una versión nueva. - `last_inbound_at` y `last_outbound_at`: cuándo llegó el último mensaje del cliente y cuándo salió hacia él el último mensaje que no falló. Los mensajes suprimidos, el historial de WhatsApp importado y los mensajes de los visitantes del chat web no cuentan como entrantes, así que una conversación `chatbot` lo trae como `null`. El historial de WhatsApp importado sí cuenta como saliente. El nombre, el teléfono o el usuario del contacto viajan en el lead vinculado, no en la conversación. Para ver el conjunto de todos los canales de un lead, lee la faceta `response` del lead con [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/es/reference-leads#leads.get). Usa [`GET /v1/conversations`](https://totemstudio.ai/developers/es/reference-conversations#conversations.list) y [`GET /v1/conversations/{id}`](https://totemstudio.ai/developers/es/reference-conversations#conversations.get) para consultar conversaciones; `GET /v1/conversations?lead=` lista las conversaciones de un lead. Para mantener una copia, sigue el feed de cambios: lo explica [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). ## Endpoints | Método | Ruta | Resumen | | --- | --- | --- | | GET | `/v1/conversations` | Lista las conversaciones | | GET | `/v1/conversations/{id}` | Obtén una conversación | ## GET /v1/conversations **Lista las conversaciones** Devuelve una página de conversaciones del equipo, una por canal, cuenta de empresa y dirección de contacto: canal, estado en la bandeja de entrada, responsable y la hora de los últimos mensajes recibidos y enviados. Envía `lead` para obtener las conversaciones de un lead. El contenido de los mensajes nunca se exporta. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `messages:read` ### Parámetros de consulta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `limit` | `integer` | No | Elementos por página, de 1 a 100. Por defecto, 25. | | `cursor` | `string` | No | El `next_cursor` de la página anterior. Omítelo en la primera página. | | `lead` | `string` | No | Solo las conversaciones vinculadas a este lead: el `lead` de cada elemento es este identificador. Un lead de otro equipo, o que no existe, no coincide con nada. | ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Respuesta Devuelve una lista de objetos [`Conversation`](https://totemstudio.ai/developers/es/objects#Conversation). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Conversation) | Sí | Los elementos de esta página. | | `has_more` | `boolean` | Sí | `true` si hay otra página: pídela con `next_cursor`, aunque esta traiga menos elementos que `limit`. | | `next_cursor` | `string \| null` | Sí | Cursor opaco de la página siguiente, o null en la última. No lo interpretes. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/conversations?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/conversations?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/conversations?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", "object": "conversation", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "channel_type": "whatsapp", "status": "active", "assignee": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "reply_mode": "ai", "created_at": "2026-09-28T10:16:00.000Z", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "version": 12, "updated_at": "2026-10-01T08:59:31.040Z" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/conversations/{id} **Obtén una conversación** Devuelve una conversación por su identificador, sin el contenido de los mensajes. Una conversación que esta clave no puede leer, o que se eliminó, responde `404 resource_missing`. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Lectura: cuenta para el límite de frecuencia ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `messages:read` ### Parámetros de ruta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | El identificador público de la conversación. | ### Respuesta Devuelve un objeto [`Conversation`](https://totemstudio.ai/developers/es/objects#Conversation). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"conversation"` | Sí | Siempre `conversation`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que está vinculada la conversación, o null si no está vinculada o el lead ya no existe. El nombre, el teléfono y el usuario solo aparecen en el lead. | | `channel_type` | `"sms" \| "whatsapp" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email" \| null` | Sí | El canal: `whatsapp`, `email`, `chatbot` (el chat web), `facebook`, `instagram`, `sms` o `tiktok`; null si es un valor que esta versión no conoce. Un lead puede tener varias conversaciones en el mismo canal y cuenta de empresa, una por cada dirección de contacto (teléfono, correo electrónico o usuario) que haya usado, así que usa el `id` como clave de tu copia. | | `status` | `"active" \| "archived" \| null` | Sí | `active` o `archived` en la bandeja del equipo; null si es un valor que esta versión no conoce. | | `assignee` | `string \| null` | Sí | El miembro del equipo asignado a la conversación (ver `/members`), o null. | | `reply_mode` | `"ai" \| "human"` | Sí | `human` mientras una persona ha tomado la conversación, incluido un chat de WhatsApp que se atiende desde la app WhatsApp Business; `ai` en otro caso. `ai` significa que no hay una toma activa, no que haya un asistente configurado o respondiendo en este canal. Una toma temporal vuelve a `ai` cuando caduca, y la versión del registro cambia entonces. | | `created_at` | `string (date-time) \| null` | Sí | Cuándo se creó la conversación (UTC). | | `last_inbound_at` | `string (date-time) \| null` | Sí | El último mensaje del cliente (UTC). No cuentan: mensajes borrados, historial de WhatsApp importado ni los turnos del visitante del chat web, así que una conversación `chatbot` da null. Los contenidos compartidos de Instagram no se distinguen y sí cuentan. | | `last_outbound_at` | `string (date-time) \| null` | Sí | El último mensaje enviado al cliente que no falló (UTC), lo enviara quien lo enviara. El historial de WhatsApp importado sí cuenta. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/conversations/conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/conversations/conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/conversations/conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Respuesta de ejemplo ```json { "id": "conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", "object": "conversation", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "channel_type": "whatsapp", "status": "active", "assignee": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "reply_mode": "ai", "created_at": "2026-09-28T10:16:00.000Z", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "version": 12, "updated_at": "2026-10-01T08:59:31.040Z" } ``` ### Errores - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`resource_missing`](https://totemstudio.ai/developers/es/errors#resource_missing): Recurso no encontrado - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## El objeto Conversation Una conversación en un canal: su estado, su responsable y la hora de los últimos mensajes, nunca su contenido. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"conversation"` | Sí | Siempre `conversation`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que está vinculada la conversación, o null si no está vinculada o el lead ya no existe. El nombre, el teléfono y el usuario solo aparecen en el lead. | | `channel_type` | `"sms" \| "whatsapp" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email" \| null` | Sí | El canal: `whatsapp`, `email`, `chatbot` (el chat web), `facebook`, `instagram`, `sms` o `tiktok`; null si es un valor que esta versión no conoce. Un lead puede tener varias conversaciones en el mismo canal y cuenta de empresa, una por cada dirección de contacto (teléfono, correo electrónico o usuario) que haya usado, así que usa el `id` como clave de tu copia. | | `status` | `"active" \| "archived" \| null` | Sí | `active` o `archived` en la bandeja del equipo; null si es un valor que esta versión no conoce. | | `assignee` | `string \| null` | Sí | El miembro del equipo asignado a la conversación (ver `/members`), o null. | | `reply_mode` | `"ai" \| "human"` | Sí | `human` mientras una persona ha tomado la conversación, incluido un chat de WhatsApp que se atiende desde la app WhatsApp Business; `ai` en otro caso. `ai` significa que no hay una toma activa, no que haya un asistente configurado o respondiendo en este canal. Una toma temporal vuelve a `ai` cuando caduca, y la versión del registro cambia entonces. | | `created_at` | `string (date-time) \| null` | Sí | Cuándo se creó la conversación (UTC). | | `last_inbound_at` | `string (date-time) \| null` | Sí | El último mensaje del cliente (UTC). No cuentan: mensajes borrados, historial de WhatsApp importado ni los turnos del visitante del chat web, así que una conversación `chatbot` da null. Los contenidos compartidos de Instagram no se distinguen y sí cuentan. | | `last_outbound_at` | `string (date-time) \| null` | Sí | El último mensaje enviado al cliente que no falló (UTC), lo enviara quien lo enviara. El historial de WhatsApp importado sí cuenta. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", "object": "conversation", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "channel_type": "whatsapp", "status": "active", "assignee": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "reply_mode": "ai", "created_at": "2026-09-28T10:16:00.000Z", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "version": 12, "updated_at": "2026-10-01T08:59:31.040Z" } ``` --- # Citas y servicios > Lista y obtén las citas de un equipo, y lista los servicios que pueden reservar sus clientes. Fuente: https://totemstudio.ai/developers/es/reference-bookings ## Resumen Una **cita** (en la API, `booking`) ocupa un hueco en el calendario del equipo. Su `kind` indica de dónde viene: - `service_booking`: reservada para un servicio reservable, por la IA, un cliente o alguien del equipo. `service` indica el servicio. - `calendar_event`: un evento del calendario conectado del equipo. - `external_import`: sincronizada desde un sistema de citas externo, o leída del correo electrónico que envía un portal externo. `status` es `scheduled`, `rescheduled`, `cancelled`, `postponed`, `attended`, `no_show` o `team_no_show`. Solo las personas registran la asistencia. `source` indica quién la reservó: `ai`, `customer` u `operator`. `meeting_type` es `online`, `in_person` o `phone`. Los pagos no se exportan. Una reprogramación aparece de una de dos formas. Se actualiza la misma cita cuando alguien del equipo la mueve en Totem, cuando la IA mueve una cita de un sistema de citas externo como Koibox, cuando ese sistema informa de un cambio o cuando lo hace el correo de un portal de citas: `starts_at` y `ends_at` indican la nueva hora, y la hora anterior no se exporta. Su `status` pasa a `rescheduled`, aunque un cambio que informa un sistema de citas externo puede dejar el estado como estaba. Cuando la IA reprograma cualquier otra cita, crea una nueva: la antigua queda en `cancelled`, con `rescheduled_to` apuntando a la nueva, y la nueva apunta a la antigua con `rescheduled_from`. Durante unos segundos tras este tipo de reprogramación, la cita antigua puede seguir indicando `scheduled`. Un cambio hecho directamente en Google Calendar u Outlook puede no llegar a la cita de Totem. Una cita reprogramada antes de que Totem empezara a registrar estos enlaces puede no tener ninguno de los dos, como explica [Cobertura de los datos](https://totemstudio.ai/developers/es/coverage). Un **servicio reservable** es algo que los clientes pueden reservar con el equipo, como una primera consulta. [`GET /v1/booking_services`](https://totemstudio.ai/developers/es/reference-bookings#booking_services.list) devuelve todos los servicios del equipo, cada uno con su `meeting_type`, en una sola respuesta que no admite `limit` ni `cursor`. Los servicios no están en el feed de cambios: lee la lista cuando empieces a sincronizar un equipo, cuando una cita indique un servicio que no conoces y al menos una vez al día. Un servicio borrado sale de la lista, pero sus citas siguen indicando su identificador: no vuelvas a leer la lista por un identificador que no resolvió la última vez y, si muestras nombres de servicio en citas anteriores, conserva el de una lectura anterior. Los servicios desactivados siguen en la lista, sin nada que indique que lo están. Usa [`GET /v1/bookings`](https://totemstudio.ai/developers/es/reference-bookings#bookings.list) y [`GET /v1/bookings/{id}`](https://totemstudio.ai/developers/es/reference-bookings#bookings.get) para consultar citas; `GET /v1/bookings?lead=` lista las citas de un lead. Para mantener una copia, sigue el feed de cambios: lo explica [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). ## Endpoints | Método | Ruta | Resumen | | --- | --- | --- | | GET | `/v1/bookings` | Lista las citas | | GET | `/v1/bookings/{id}` | Obtén una cita | | GET | `/v1/booking_services` | Lista los servicios reservables | ## GET /v1/bookings **Lista las citas** Devuelve una página de citas del equipo: citas para un servicio reservable, eventos del calendario conectado y citas importadas de otros sistemas, cada una con su estado y su horario. Envía `lead` para obtener las citas de un lead. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `calendar:read` ### Parámetros de consulta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `limit` | `integer` | No | Elementos por página, de 1 a 100. Por defecto, 25. | | `cursor` | `string` | No | El `next_cursor` de la página anterior. Omítelo en la primera página. | | `lead` | `string` | No | Solo las citas vinculadas a este lead: el `lead` de cada elemento es este identificador. Un lead de otro equipo, o que no existe, no coincide con nada. | ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Respuesta Devuelve una lista de objetos [`Booking`](https://totemstudio.ai/developers/es/objects#Booking). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#Booking) | Sí | Los elementos de esta página. | | `has_more` | `boolean` | Sí | `true` si hay otra página: pídela con `next_cursor`, aunque esta traiga menos elementos que `limit`. | | `next_cursor` | `string \| null` | Sí | Cursor opaco de la página siguiente, o null en la última. No lo interpretes. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/bookings?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/bookings?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/bookings?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", "object": "booking", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "kind": "service_booking", "service": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "status": "scheduled", "starts_at": "2026-10-06T15:00:00.000Z", "ends_at": "2026-10-06T15:30:00.000Z", "meeting_type": "in_person", "source": "ai", "rescheduled_from": null, "rescheduled_to": null, "created_at": "2026-10-01T09:01:00.000Z", "version": 1, "updated_at": "2026-10-01T09:01:02.000Z" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/bookings/{id} **Obtén una cita** Devuelve una cita por su identificador. Una cita que esta clave no puede leer, o que se eliminó, responde `404 resource_missing`. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Lectura: cuenta para el límite de frecuencia ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `calendar:read` ### Parámetros de ruta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | El identificador público de la cita. | ### Respuesta Devuelve un objeto [`Booking`](https://totemstudio.ai/developers/es/objects#Booking). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"booking"` | Sí | Siempre `booking`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que pertenece la cita, o null si la cita no tiene lead o el lead ya no existe. El nombre y los datos de contacto solo aparecen en el lead. | | `kind` | `"service_booking" \| "calendar_event" \| "external_import"` | Sí | `service_booking` (una cita de un servicio reservable, creada por la IA, un cliente o una persona), `calendar_event` (un evento del calendario conectado) o `external_import` (sincronizada desde un sistema de citas externo o leída de un correo electrónico de un portal). Una cita de un origen que esta versión no conoce aparece como `calendar_event`. | | `service` | `string \| null` | Sí | El servicio de la cita (ver `/booking_services`) en una `service_booking`; null en otro caso. | | `status` | `"scheduled" \| "rescheduled" \| "cancelled" \| "postponed" \| "attended" \| "no_show" \| "team_no_show" \| null` | Sí | `scheduled`, `rescheduled`, `cancelled`, `postponed`, `attended`, `no_show` o `team_no_show` (la asistencia solo la registran personas); null si es un valor que esta versión no conoce. `rescheduled` significa que la cita se movió sin cambiar de registro y sus horas son las nuevas; una cita que la IA sustituyó por otra queda en `cancelled`, con `rescheduled_to`. | | `starts_at` | `string (date-time) \| null` | Sí | Cuándo empieza la cita (UTC). | | `ends_at` | `string (date-time) \| null` | Sí | Cuándo termina la cita (UTC). | | `meeting_type` | `"online" \| "in_person" \| "phone" \| null` | Sí | `online`, `in_person` o `phone`; null si no está guardado. | | `source` | `"ai" \| "customer" \| "operator" \| null` | Sí | Quién creó la cita: `ai`, `customer` u `operator`; null si no está registrado. | | `rescheduled_from` | `string \| null` | Sí | La cita a la que esta sustituyó al reprogramar, o null. Durante unos segundos la cita sustituida puede seguir en `scheduled`; las citas reprogramadas antes de que Totem empezara a registrar este enlace pueden no tenerlo. | | `rescheduled_to` | `string \| null` | Sí | La cita que sustituyó a esta al reprogramar, o null. Cuando está indicada, esta cita queda en `cancelled`. | | `created_at` | `string (date-time) \| null` | Sí | Cuándo se registró la cita en Totem (UTC). | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/bookings/bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/bookings/bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/bookings/bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Respuesta de ejemplo ```json { "id": "bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", "object": "booking", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "kind": "service_booking", "service": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "status": "scheduled", "starts_at": "2026-10-06T15:00:00.000Z", "ends_at": "2026-10-06T15:30:00.000Z", "meeting_type": "in_person", "source": "ai", "rescheduled_from": null, "rescheduled_to": null, "created_at": "2026-10-01T09:01:00.000Z", "version": 1, "updated_at": "2026-10-01T09:01:02.000Z" } ``` ### Errores - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`resource_missing`](https://totemstudio.ai/developers/es/errors#resource_missing): Recurso no encontrado - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## GET /v1/booking_services **Lista los servicios reservables** Lista todos los servicios reservables del equipo en una sola respuesta, que nunca se pagina y no admite `limit` ni `cursor`. Los servicios reservables no llegan por el feed de cambios: lee esta lista al empezar a sincronizar un equipo, de nuevo cuando una cita indique un servicio que no conoces y, como mínimo, una vez al día. Un servicio borrado sale de la lista, pero sus citas siguen indicando su identificador, así que conserva de una lectura anterior el nombre de servicio que necesites. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `calendar:read` ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Respuesta Devuelve una lista de objetos [`BookingService`](https://totemstudio.ai/developers/es/objects#BookingService). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#BookingService) | Sí | Todos los servicios reservables del equipo. | | `has_more` | `false` | Sí | Siempre `false`: esta lista nunca se pagina. | | `next_cursor` | `null` | Sí | Siempre `null`: no hay página siguiente. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/booking_services' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/booking_services', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/booking_services", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "id": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "object": "booking_service", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "First visit", "meeting_type": "in_person" } ], "has_more": false, "next_cursor": null } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## El objeto Booking Una cita y su tipo (`kind`): para un servicio reservable, un evento del calendario conectado o una cita importada de otro sistema. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"booking"` | Sí | Siempre `booking`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que pertenece la cita, o null si la cita no tiene lead o el lead ya no existe. El nombre y los datos de contacto solo aparecen en el lead. | | `kind` | `"service_booking" \| "calendar_event" \| "external_import"` | Sí | `service_booking` (una cita de un servicio reservable, creada por la IA, un cliente o una persona), `calendar_event` (un evento del calendario conectado) o `external_import` (sincronizada desde un sistema de citas externo o leída de un correo electrónico de un portal). Una cita de un origen que esta versión no conoce aparece como `calendar_event`. | | `service` | `string \| null` | Sí | El servicio de la cita (ver `/booking_services`) en una `service_booking`; null en otro caso. | | `status` | `"scheduled" \| "rescheduled" \| "cancelled" \| "postponed" \| "attended" \| "no_show" \| "team_no_show" \| null` | Sí | `scheduled`, `rescheduled`, `cancelled`, `postponed`, `attended`, `no_show` o `team_no_show` (la asistencia solo la registran personas); null si es un valor que esta versión no conoce. `rescheduled` significa que la cita se movió sin cambiar de registro y sus horas son las nuevas; una cita que la IA sustituyó por otra queda en `cancelled`, con `rescheduled_to`. | | `starts_at` | `string (date-time) \| null` | Sí | Cuándo empieza la cita (UTC). | | `ends_at` | `string (date-time) \| null` | Sí | Cuándo termina la cita (UTC). | | `meeting_type` | `"online" \| "in_person" \| "phone" \| null` | Sí | `online`, `in_person` o `phone`; null si no está guardado. | | `source` | `"ai" \| "customer" \| "operator" \| null` | Sí | Quién creó la cita: `ai`, `customer` u `operator`; null si no está registrado. | | `rescheduled_from` | `string \| null` | Sí | La cita a la que esta sustituyó al reprogramar, o null. Durante unos segundos la cita sustituida puede seguir en `scheduled`; las citas reprogramadas antes de que Totem empezara a registrar este enlace pueden no tenerlo. | | `rescheduled_to` | `string \| null` | Sí | La cita que sustituyó a esta al reprogramar, o null. Cuando está indicada, esta cita queda en `cancelled`. | | `created_at` | `string (date-time) \| null` | Sí | Cuándo se registró la cita en Totem (UTC). | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", "object": "booking", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "kind": "service_booking", "service": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "status": "scheduled", "starts_at": "2026-10-06T15:00:00.000Z", "ends_at": "2026-10-06T15:30:00.000Z", "meeting_type": "in_person", "source": "ai", "rescheduled_from": null, "rescheduled_to": null, "created_at": "2026-10-01T09:01:00.000Z", "version": 1, "updated_at": "2026-10-01T09:01:02.000Z" } ``` ## El objeto BookingService Un servicio que los clientes pueden reservar, con su tipo de reunión. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"booking_service"` | Sí | Siempre `booking_service`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `name` | `string \| null` | Sí | El nombre del servicio, o null si no hay ninguno guardado. | | `meeting_type` | `"online" \| "in_person" \| "phone"` | Sí | Cómo se presta el servicio: `online` (el valor por defecto si no hay ninguno guardado), `in_person` o `phone`. | ### Ejemplo ```json { "id": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "object": "booking_service", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "First visit", "meeting_type": "in_person" } ``` --- # Feed de cambios > Un único endpoint que copia un equipo y, después, devuelve cada cambio, página a página. Fuente: https://totemstudio.ai/developers/es/reference-sync ## Resumen [`GET /v1/sync/changes`](https://totemstudio.ai/developers/es/reference-sync#sync.changes) devuelve los cambios de un equipo, página a página, en el orden en que Totem los registró. Sin `cursor`, empieza una carga inicial de todos los registros. Con el `next_cursor` de tu última página, devuelve lo que ha cambiado desde entonces. Cada elemento es el estado actual de un registro, o una eliminación que te indica que lo borres. En cualquier versión pueden aparecer en el feed nuevos tipos de registro: guarda u omite un elemento cuyo `kind` no conozcas y sigue sincronizando. Guarda `next_cursor` después de aplicar cada página, aunque venga vacía, y envíalo en tu siguiente petición. Un cursor caduca 30 días después del punto del feed al que llegó; un equipo que consultas cada pocos minutos nunca llega a ese punto. Esta página es la referencia: los parámetros, el objeto de cada elemento y los errores. [Sincroniza una copia](https://totemstudio.ai/developers/es/sync) es la guía: cómo aplicar una página, cómo recuperarte de cada error y cómo ejecutar un cliente de sincronización que Totem prueba. ## Endpoints | Método | Ruta | Resumen | | --- | --- | --- | | GET | `/v1/sync/changes` | Lee el feed de cambios de un equipo | ## GET /v1/sync/changes **Lee el feed de cambios de un equipo** Devuelve la siguiente página de cambios de un equipo, en el orden en que Totem los registró: cada elemento es el estado actual de un registro o su eliminación. Sin `cursor` empieza una carga inicial de todos los registros; después de aplicar cada página, guarda su `next_cursor` y envíalo en la siguiente petición, aunque la página llegue vacía. Un cursor caduca 30 días después del punto del feed al que llegó o, mientras una carga inicial sigue en curso, 30 días después de que empezara. - **Autenticación:** [Clave secreta](https://totemstudio.ai/developers/es/authentication) - **Equipo:** [Cabecera `Totem-Team`](https://totemstudio.ai/developers/es/authentication) - **Límite de frecuencia:** Sincronización: cuenta para el límite de frecuencia y para la cuota diaria ([límites](https://totemstudio.ai/developers/es/rate-limits)). - **Estabilidad:** Beta - **Permisos:** `crm:read`, `calls:read`, `messages:read`, `calendar:read` ### Parámetros de consulta | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `cursor` | `string` | No | El `next_cursor` de la página anterior. Omítelo para empezar una carga inicial desde el principio. | | `limit` | `integer` | No | Elementos por página, de 1 a 100. Por defecto, 25. | ### Cabeceras | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `Totem-Team` | `string` | Condicional | El equipo que se lee, como identificador `team_…`. Omítelo si la clave lee un solo equipo; es obligatorio si lee varios (`400 team_required`). Si lo envías, debe indicar un equipo que la clave lee: un identificador mal formado recibe `400 invalid_request`, y un equipo fuera de la clave, `404 team_not_accessible`, igual que una petición sin la cabecera cuando la clave ya no lee ningún equipo. | ### Respuesta Devuelve una lista de objetos [`FeedItem`](https://totemstudio.ai/developers/es/objects#FeedItem). | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"list"` | Sí | Siempre `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/es/objects#FeedItem) | Sí | Cambios en el orden en que Totem los registró. Durante una carga inicial, un registro puede aparecer más de una vez. | | `has_more` | `boolean` | Sí | `true` si ya hay más cambios listos; `false` cuando estás al día. | | `next_cursor` | `string` | Sí | Siempre presente, también en una página vacía. Guárdalo después de aplicar la página y envíalo en la siguiente petición. | ### Petición de ejemplo ```bash curl 'https://api.totemstudio.ai/v1/sync/changes?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/sync/changes?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/sync/changes?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Respuesta de ejemplo ```json { "object": "list", "data": [ { "kind": "lead", "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "version": 3, "object": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } }, { "kind": "call", "id": "call_m1pf4APjJWVi3Z7ulyNaW8y3lUi7V", "version": 5, "removed": { "reason": "deleted" } }, { "kind": "lead_response", "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "version": 7, "object": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } } ], "has_more": false, "next_cursor": "eyJ2IjoxLCJ0ZWFtIjoidGVhbV9yOXVZcklXNGdTRkpKNmhaUTlpcDFVSXBQYUFEMUNGN05SIiwiZXBvY2giOjAsInQiOnsicyI6MTc5MDg0NTE3MSwibnMiOjQwMDAwMDAwfSwibiI6IjVkMjJhMTVlM2E5MmNmYTE4NzUzZTVmYzkyZmVjNzI0MjQyNjIzZDE2MGIxZTUxMzRiYTM1ODE5MmUzNzM0ZjEiLCJicyI6bnVsbH01ZPX02" } ``` ### Errores - `400` [`team_required`](https://totemstudio.ai/developers/es/errors#team_required): Falta el equipo - `403` [`team_state_restricted`](https://totemstudio.ai/developers/es/errors#team_state_restricted): Equipo restringido - `403` [`sector_restricted`](https://totemstudio.ai/developers/es/errors#sector_restricted): Sector restringido - `404` [`team_not_accessible`](https://totemstudio.ai/developers/es/errors#team_not_accessible): Equipo no accesible - `409` [`resync_required`](https://totemstudio.ai/developers/es/errors#resync_required): Hay que volver a sincronizar - `409` [`capture_not_ready`](https://totemstudio.ai/developers/es/errors#capture_not_ready): Los datos del equipo aún se están preparando - `410` [`sync_cursor_expired`](https://totemstudio.ai/developers/es/errors#sync_cursor_expired): Cursor de sincronización caducado - `429` [`quota_exceeded`](https://totemstudio.ai/developers/es/errors#quota_exceeded): Cuota diaria superada Además, 12 errores comunes que puede devolver cualquier petición autenticada; consulta [Errores](https://totemstudio.ai/developers/es/errors). ## El objeto FeedItem Un cambio: el estado actual del registro, o su eliminación. ### Atributos comunes a todas las variantes | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `kind` | `"lead" \| "lead_attribution" \| "lead_response" \| "activity" \| "call" \| "conversation" \| "booking" \| "board"` | Sí | El tipo del registro, como `lead` o `call`. | | `version` | `integer` | Sí | La versión del registro. Aplica el elemento solo si es mayor que la versión que guardas. | ### Variantes Cada `FeedItem` es exactamente una de estas 2 variantes. #### Con `object` | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro; las facetas `lead_attribution` y `lead_response` usan el de su lead. | | `object` | `Lead \| LeadAttribution \| LeadResponse \| Activity \| Call \| Conversation \| Booking \| Board` | Sí | El registro tal como está ahora. | #### Con `removed` | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro eliminado. | | `removed` | `object` | Sí | Presente solo en una eliminación. | | `removed.reason` | `"erased" \| "deleted"` | Sí | `erased` tras una supresión, que es también lo que hace borrar un lead, una llamada o una conversación en Totem: suprimir un lead suprime también sus llamadas, sus conversaciones y sus duplicados fusionados, mientras que sus citas se conservan con `lead` a `null`. `deleted` en cualquier otra eliminación. En ambos casos, borra tu copia y conserva esta versión para ignorar una copia anterior que llegue después. | ### Ejemplo ```json { "kind": "lead", "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "version": 3, "object": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } } ``` --- # Todos los objetos > Todos los objetos que devuelve la API, campo a campo, con un ejemplo de cada uno. Fuente: https://totemstudio.ai/developers/es/objects En esta página están todos los objetos que devuelve la API, con sus campos y un ejemplo. La página de referencia de cada recurso muestra los mismos objetos junto a los endpoints que los devuelven. ## Cómo son las respuestas Las respuestas tienen una de estas cinco formas: - **Un objeto.** Una petición de un solo registro devuelve el propio objeto. [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/es/reference-leads#leads.get) devuelve el lead con sus dos facetas, `attribution` y `response`, dentro. - **Una lista.** Las listas devuelven `object: "list"`, los objetos en `data`, `has_more` y `next_cursor`, que es `null` en la última página. Lo explica [Listas y paginación](https://totemstudio.ai/developers/es/pagination). - **Una página del feed de cambios.** El mismo sobre, pero `data` contiene elementos del feed (el objeto [`FeedItem`](https://totemstudio.ai/developers/es/objects#FeedItem) de más abajo) y `next_cursor` siempre es una cadena, también en una página vacía. Lo explica [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). - **Un error.** Un cuerpo `application/problem+json`, descrito en [Errores](https://totemstudio.ai/developers/es/errors#formato-de-los-errores). - **La comprobación de estado.** [`GET /v1/health`](https://totemstudio.ai/developers/es/reference-account#health) responde `{"object": "health", "status": "ok"}`. Los registros, como un lead, una llamada o un equipo, llevan `object`, que indica su tipo, y `livemode`, que es `true` en todo lo que devuelve la API. Los registros que entrega el feed de cambios llevan además `version` y `updated_at`, como explica [Convenciones](https://totemstudio.ai/developers/es/conventions#versiones-de-los-registros). ## Cómo leer una lista de campos - **Todos los campos están siempre.** En una respuesta, cada campo de la lista aparece en todos los objetos, con dos excepciones: un elemento del feed lleva exactamente uno de `object` y `removed`, y un error lleva `param` solo cuando indica uno. Un campo sin valor es `null`: nunca falta y nunca es una cadena vacía. - **Puede ser null** marca un campo que puede ser `null`. [Cobertura de los datos](https://totemstudio.ai/developers/es/coverage) explica qué puede significar un `null` en cada tipo de dato. - **Valores posibles** enumera los valores que un campo puede tomar hoy. En cualquier versión pueden aparecer valores nuevos, así que conserva el registro cuando encuentres uno que no conoces, como explica [Convenciones](https://totemstudio.ai/developers/es/conventions#las-enumeraciones-pueden-crecer). - **Los identificadores** muestran el prefijo del registro al que apuntan, como `brd_` para un tablero, y un tipo que es otro objeto enlaza con él. - **Los objetos anidados** aparecen plegados: abre uno para ver sus campos. La misma información está en el [documento OpenAPI](https://totemstudio.ai/developers/kit/openapi.json), y todos los ejemplos salen de los datos de ejemplo del kit, [`fixtures.json`](https://totemstudio.ai/developers/kit/fixtures.json): contactos ficticios con `livemode: false`. ## El objeto Me La integración propietaria de la clave, con los equipos que lee. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `object` | `"integration"` | Sí | Siempre `integration`. | | `id` | `string` | Sí | Identificador público de la integración. | | `livemode` | `boolean` | Sí | `true` con una clave real; `false` en los datos de ejemplo. | | `name` | `string` | Sí | El nombre que un administrador dio a la integración. | | `key` | `object` | Sí | La clave usada en esta petición. | | `key.last4` | `string` | Sí | Los cuatro últimos caracteres de la clave usada en esta petición. | | `teams` | `Array` | Sí | Los equipos que lee esta clave hoy. Sus datos están en `GET /v1/teams`. | | `scopes` | `Array<"teams:read" \| "crm:read" \| "calls:read" \| "messages:read" \| "calendar:read">` | Sí | Los permisos de la clave: el conjunto fijo de lectura en v1.0a. | ### Ejemplo ```json { "object": "integration", "id": "prn_m1NFIcoIU8smXfqR4ymgt6j07TfRF", "livemode": false, "name": "Example CRM sync", "key": { "last4": "Q7xZ" }, "teams": [ "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ], "scopes": [ "teams:read", "crm:read", "calls:read", "messages:read", "calendar:read" ] } ``` ## El objeto Team Un equipo que lee esta clave, con su zona horaria y su país predeterminado. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"team"` | Sí | Siempre `team`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `name` | `string \| null` | Sí | El nombre del equipo tal como aparece en Totem, o null si no hay ninguno guardado. | | `timezone` | `string` | Sí | La zona horaria IANA del equipo: la de su horario comercial si es válida; si no, la de su país por defecto (Europe/Madrid si no hay ninguna). Úsala para interpretar las horas locales del equipo. | | `default_country` | `string \| null` | Sí | El país por defecto del equipo (ISO 3166-1 alfa-2), que Totem usa cuando alguien escribe un teléfono sin prefijo; null si no hay ninguno. La API no lo aplica: `Lead.phone` se valida sin país por defecto, así que un número guardado sin prefijo de país tiene `phone_status: invalid`. | ### Ejemplo ```json { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ``` ## El objeto Member Un miembro del equipo, solo por su nombre visible: Totem no exporta su correo electrónico, su teléfono ni su rol. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"member"` | Sí | Siempre `member`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `display_name` | `string \| null` | Sí | El nombre visible del miembro, o su nombre y apellido, o null si no hay ninguno guardado. Totem no exporta su correo electrónico, su teléfono ni su rol. | ### Ejemplo ```json { "id": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "object": "member", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "display_name": "Sam Lee" } ``` ## El objeto Lead Un lead o cliente: solo los campos del lead. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead"` | Sí | Siempre `lead`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el registro. | | `record_kind` | `"lead" \| "client"` | Sí | `lead` si el registro tiene un estado del embudo; si no, `client`. Un lead cuyo estado no conoce esta versión trae `status: null`. | | `name` | `string \| null` | Sí | Nombre o nombre visible, o null. | | `surname` | `string \| null` | Sí | Apellidos, o null. | | `email` | `string \| null` | Sí | Email tal como está guardado, o null. | | `company` | `string \| null` | Sí | Empresa, o null. | | `phone` | `string \| null` | Sí | Teléfono en E.164, solo si el número guardado es válido; si no, null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Sí | `valid` (ver `phone`), `invalid` (hay un número guardado que no es válido, por ejemplo uno guardado sin prefijo de país) o `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | Estado en el embudo; null en un cliente o si es un estado que esta versión no conoce. `converted` no significa "ganado". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Sí | Quién fijó el estado actual: una persona, la IA, la automatización de Totem, una llamada a la API o desconocido (ninguna transición registrada llega a él). Null cuando `status` es null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Sí | Por qué se archivó el registro, como código cerrado; el texto libre guardado es `other`. Null si no hay ninguno. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | El estado que tenía el lead antes de su archivo actual; null si no está archivado o si se desconoce. | | `contact_permissions` | `object` | Sí | Permisos de contacto. Aplícalos antes de cualquier contacto desde tus propios sistemas. | | `contact_permissions.do_not_contact` | `boolean` | Sí | Verdadero cuando la persona pidió no ser contactada nunca (motivo de archivo `opt_out`). Detén todos los canales. | | `contact_permissions.email_opt_out` | `boolean` | Sí | Verdadero cuando la persona se dio de baja del correo electrónico comercial y de seguimiento. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Sí | Cuándo se registró la baja del correo electrónico, o null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Sí | Consentimiento de WhatsApp: false si se rechazó, true si consta, null si se desconoce. | | `contact_permissions.provenance` | `object` | Sí | El origen de cada permiso. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Sí | De dónde viene `do_not_contact`, o null cuando es falso. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Sí | Quién registró la baja del correo electrónico: el enlace de baja, una persona en Totem o desconocido. Null si no hay baja. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Sí | `archive_reason` si el rechazo consta como motivo de archivo; `stored` para el valor guardado en la ficha, que el equipo puede editar. Null si se desconoce. | | `merged_into` | `string \| null` | Sí | El lead en el que se fusionó este registro, o null (también si ese lead ya no existe). | | `transferred_to` | `object \| null` | Sí | Adónde se traspasó este lead dentro de tu organización, o null. El equipo puede ser uno que tu clave no lee. | | `transferred_to.team` | `string` | Sí | El equipo de destino. | | `transferred_to.lead` | `string \| null` | Sí | El lead de destino, o null si su id no puede nombrarse entre equipos: lo creó el traspaso de un asistente, o el traspaso cayó en un lead existente de anuncios (Meta, Google Ads o LinkedIn) u otro lead con id fijo. | | `revived_at` | `string (date-time) \| null` | Sí | Cuándo volvió el contacto por su cuenta y reactivó un lead archivado por última vez, o null. | | `pipeline_cleared` | `boolean` | Sí | Verdadero si el lead se convirtió con "borrar datos del embudo": tablero, etapa, prioridad, responsables, valor estimado y los campos de origen salvo `source_channel` son null porque se borraron. | | `converted_at` | `string (date-time) \| null` | Sí | Cuándo se convirtió el lead, mientras su estado es `converted`; si no, null. | | `value_at_conversion` | `number \| null` | Sí | El valor estimado al convertirlo, en unidades principales. Se conserva al borrar los datos del embudo. | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Sí | Prioridad, o null. | | `tags` | `Array` | Sí | Etiquetas tal como están guardadas. `lead` y `booking` son etiquetas del sistema. | | `board` | `string \| null` | Sí | El tablero, resuelto a través de la etapa si la hay; o null. | | `stage` | `string \| null` | Sí | La etapa, o null. | | `assignees` | `Array \| null` | Sí | Miembros asignados, ordenados. Null cuando el registro no tiene datos del embudo. | | `estimated_value` | `number \| null` | Sí | Valor estimado en unidades principales tal como está guardado (con decimales), o null. | | `estimated_value_currency` | `null` | Sí | Siempre null: Totem no guarda moneda con el valor. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Sí | El origen guardado del lead; `api` cubre también los leads que crea el propio Totem (por ejemplo, desde una conversación o una llamada), ver `source_detail`. Null si no hay ninguno. | | `source_channel` | `string \| null` | Sí | El canal por el que empezó el registro (por ejemplo `whatsapp`, `phone`, `widget`), o null. El equipo puede editarlo. | | `source_detail` | `string \| null` | Sí | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` o `unknown`; null cuando `source` no necesita detalle. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Sí | `server_written` si Totem fijó el origen a partir de lo que informó la plataforma de anuncios; si no, `member_editable` (el equipo puede cambiarlo). Null junto con `source`. | | `created_at` | `string (date-time)` | Sí | Cuándo se creó el registro. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ``` ## El objeto LeadDetail Un lead con sus facetas `attribution` y `response`, cada una con su propia `version`. Una faceta es `null` solo mientras Totem aún la prepara. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead"` | Sí | Siempre `lead`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el registro. | | `record_kind` | `"lead" \| "client"` | Sí | `lead` si el registro tiene un estado del embudo; si no, `client`. Un lead cuyo estado no conoce esta versión trae `status: null`. | | `name` | `string \| null` | Sí | Nombre o nombre visible, o null. | | `surname` | `string \| null` | Sí | Apellidos, o null. | | `email` | `string \| null` | Sí | Email tal como está guardado, o null. | | `company` | `string \| null` | Sí | Empresa, o null. | | `phone` | `string \| null` | Sí | Teléfono en E.164, solo si el número guardado es válido; si no, null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Sí | `valid` (ver `phone`), `invalid` (hay un número guardado que no es válido, por ejemplo uno guardado sin prefijo de país) o `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | Estado en el embudo; null en un cliente o si es un estado que esta versión no conoce. `converted` no significa "ganado". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Sí | Quién fijó el estado actual: una persona, la IA, la automatización de Totem, una llamada a la API o desconocido (ninguna transición registrada llega a él). Null cuando `status` es null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Sí | Por qué se archivó el registro, como código cerrado; el texto libre guardado es `other`. Null si no hay ninguno. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | El estado que tenía el lead antes de su archivo actual; null si no está archivado o si se desconoce. | | `contact_permissions` | `object` | Sí | Permisos de contacto. Aplícalos antes de cualquier contacto desde tus propios sistemas. | | `contact_permissions.do_not_contact` | `boolean` | Sí | Verdadero cuando la persona pidió no ser contactada nunca (motivo de archivo `opt_out`). Detén todos los canales. | | `contact_permissions.email_opt_out` | `boolean` | Sí | Verdadero cuando la persona se dio de baja del correo electrónico comercial y de seguimiento. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Sí | Cuándo se registró la baja del correo electrónico, o null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Sí | Consentimiento de WhatsApp: false si se rechazó, true si consta, null si se desconoce. | | `contact_permissions.provenance` | `object` | Sí | El origen de cada permiso. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Sí | De dónde viene `do_not_contact`, o null cuando es falso. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Sí | Quién registró la baja del correo electrónico: el enlace de baja, una persona en Totem o desconocido. Null si no hay baja. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Sí | `archive_reason` si el rechazo consta como motivo de archivo; `stored` para el valor guardado en la ficha, que el equipo puede editar. Null si se desconoce. | | `merged_into` | `string \| null` | Sí | El lead en el que se fusionó este registro, o null (también si ese lead ya no existe). | | `transferred_to` | `object \| null` | Sí | Adónde se traspasó este lead dentro de tu organización, o null. El equipo puede ser uno que tu clave no lee. | | `transferred_to.team` | `string` | Sí | El equipo de destino. | | `transferred_to.lead` | `string \| null` | Sí | El lead de destino, o null si su id no puede nombrarse entre equipos: lo creó el traspaso de un asistente, o el traspaso cayó en un lead existente de anuncios (Meta, Google Ads o LinkedIn) u otro lead con id fijo. | | `revived_at` | `string (date-time) \| null` | Sí | Cuándo volvió el contacto por su cuenta y reactivó un lead archivado por última vez, o null. | | `pipeline_cleared` | `boolean` | Sí | Verdadero si el lead se convirtió con "borrar datos del embudo": tablero, etapa, prioridad, responsables, valor estimado y los campos de origen salvo `source_channel` son null porque se borraron. | | `converted_at` | `string (date-time) \| null` | Sí | Cuándo se convirtió el lead, mientras su estado es `converted`; si no, null. | | `value_at_conversion` | `number \| null` | Sí | El valor estimado al convertirlo, en unidades principales. Se conserva al borrar los datos del embudo. | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Sí | Prioridad, o null. | | `tags` | `Array` | Sí | Etiquetas tal como están guardadas. `lead` y `booking` son etiquetas del sistema. | | `board` | `string \| null` | Sí | El tablero, resuelto a través de la etapa si la hay; o null. | | `stage` | `string \| null` | Sí | La etapa, o null. | | `assignees` | `Array \| null` | Sí | Miembros asignados, ordenados. Null cuando el registro no tiene datos del embudo. | | `estimated_value` | `number \| null` | Sí | Valor estimado en unidades principales tal como está guardado (con decimales), o null. | | `estimated_value_currency` | `null` | Sí | Siempre null: Totem no guarda moneda con el valor. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Sí | El origen guardado del lead; `api` cubre también los leads que crea el propio Totem (por ejemplo, desde una conversación o una llamada), ver `source_detail`. Null si no hay ninguno. | | `source_channel` | `string \| null` | Sí | El canal por el que empezó el registro (por ejemplo `whatsapp`, `phone`, `widget`), o null. El equipo puede editarlo. | | `source_detail` | `string \| null` | Sí | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` o `unknown`; null cuando `source` no necesita detalle. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Sí | `server_written` si Totem fijó el origen a partir de lo que informó la plataforma de anuncios; si no, `member_editable` (el equipo puede cambiarlo). Null junto con `source`. | | `created_at` | `string (date-time)` | Sí | Cuándo se creó el registro. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | | `attribution` | [`LeadAttribution \| null`](https://totemstudio.ai/developers/es/objects#LeadAttribution) | Sí | La faceta de atribución del lead, o null mientras Totem aún la prepara. | | `response` | [`LeadResponse \| null`](https://totemstudio.ai/developers/es/objects#LeadResponse) | Sí | La faceta de respuesta del lead, o null mientras Totem aún la prepara. | ### Ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z", "attribution": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" }, "response": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } } ``` ## El objeto LeadAttribution De dónde viene un lead: su captura original y su último envío. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead_attribution"` | Sí | Siempre `lead_attribution`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el lead. | | `original_capture` | `object \| null` | Sí | Cómo entró el lead en el CRM por primera vez, fijado una sola vez; null si no se capturó. "Original" es la primera vez que el contacto entró en el CRM, no el primer anuncio al que respondió. | | `original_capture.capture` | `"at_creation" \| "at_activation"` | Sí | `at_creation`: fijado con el lead tal como se guardó por primera vez. `at_activation`: copiado una vez del lead tal como estaba al activar la API, para un lead sin registro de captura (creado antes de que Totem empezara a registrar la atribución, o cuya creación no se registró). | | `original_capture.captured_at` | `string (date-time)` | Sí | Cuándo se escribió el registro. | | `original_capture.source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Sí | El origen del lead en la captura, del mismo conjunto que el `source` del lead; `api` cubre también los leads que crea el propio Totem (por ejemplo, desde una conversación o una llamada, ver `origin`). Null si no había ninguno. | | `original_capture.source_hint` | `string \| null` | Sí | El origen real de un lead de anuncios restringido guardado como `api`, o null. | | `original_capture.source_channel` | `string \| null` | Sí | El canal en la captura, o null. | | `original_capture.external_provider` | `string \| null` | Sí | El portal o la plataforma de citas de un lead reenviado o sincronizado, o null. | | `original_capture.received_at` | `string (date-time) \| null` | Sí | Cuándo se recibió el lead, tal como se escribió la primera vez, o null. | | `original_capture.submitted_at` | `string (date-time) \| null` | Sí | Cuándo dice la plataforma de anuncios que se envió el formulario, o null. | | `original_capture.origin` | `object \| null` | Sí | La conversación o llamada que creó el lead (nunca su id), o null (también para un origen de otro tipo). | | `original_capture.origin.kind` | `"conversation" \| "call"` | Sí | `conversation` o `call`. | | `original_capture.origin.channel` | `string \| null` | Sí | Su canal, o null. | | `original_capture.campaign` | `object` | Sí | La campaña. | | `original_capture.campaign.id` | `object` | Sí | Su id. | | `original_capture.campaign.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.campaign.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.campaign.name` | `object` | Sí | Su nombre. | | `original_capture.campaign.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.campaign.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad` | `object` | Sí | El anuncio. | | `original_capture.ad.id` | `object` | Sí | Su id. | | `original_capture.ad.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad.name` | `object` | Sí | Su nombre. | | `original_capture.ad.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad_set` | `object` | Sí | El conjunto de anuncios de Meta. | | `original_capture.ad_set.id` | `object` | Sí | Su id. | | `original_capture.ad_set.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad_set.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad_set.name` | `object` | Sí | Su nombre. | | `original_capture.ad_set.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad_set.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad_group` | `object` | Sí | El grupo de anuncios de Google Ads. | | `original_capture.ad_group.id` | `object` | Sí | Su id. | | `original_capture.ad_group.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.ad_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.form` | `object` | Sí | El formulario de clientes potenciales. | | `original_capture.form.id` | `object` | Sí | Su id. | | `original_capture.form.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.form.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.form.name` | `object` | Sí | Su nombre. | | `original_capture.form.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.form.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.creative` | `object` | Sí | La creatividad. | | `original_capture.creative.id` | `object` | Sí | Su id. | | `original_capture.creative.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.creative.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.asset_group` | `object` | Sí | El grupo de recursos de Google Ads. | | `original_capture.asset_group.id` | `object` | Sí | Su id. | | `original_capture.asset_group.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.asset_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.page` | `object` | Sí | La página de Facebook. | | `original_capture.page.id` | `object` | Sí | Su id. | | `original_capture.page.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.page.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.page.name` | `object` | Sí | Su nombre. | | `original_capture.page.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.page.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm` | `object \| null` | Sí | Las cinco claves UTM estándar, o null si no se capturó ninguna. | | `original_capture.utm.source` | `object` | Sí | `utm_source`. | | `original_capture.utm.source.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.source.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm.medium` | `object` | Sí | `utm_medium`. | | `original_capture.utm.medium.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.medium.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm.campaign` | `object` | Sí | `utm_campaign`. | | `original_capture.utm.campaign.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.campaign.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm.term` | `object` | Sí | `utm_term`. | | `original_capture.utm.term.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.term.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.utm.content` | `object` | Sí | `utm_content`. | | `original_capture.utm.content.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.utm.content.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.click_network` | `object` | Sí | La red del primer identificador de clic (`google` o `meta`). El identificador nunca se guarda. | | `original_capture.click_network.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `original_capture.click_network.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `original_capture.ad_referral` | `object \| null` | Sí | El primer referido de anuncio de Meta de la conversación que creó el lead, o null. | | `original_capture.ad_referral.channel` | `"whatsapp" \| "facebook" \| "instagram"` | Sí | El canal. | | `original_capture.ad_referral.source_type` | `"ad" \| "post" \| "link" \| "other"` | Sí | Qué abrió la conversación. | | `original_capture.ad_referral.ad_id` | `string \| null` | Sí | El id del anuncio o la publicación, o null. | | `original_capture.ad_referral.referred_at` | `string (date-time)` | Sí | La hora de Meta del referido. | | `original_capture.ad_referral.provenance` | `"provider"` | Sí | Siempre `provider`: solo lo escriben los webhooks firmados de Meta. | | `original_capture.family_origin` | `object \| null` | Sí | El traspaso desde otro equipo de tu organización que creó el lead, o null. | | `original_capture.family_origin.team` | `string` | Sí | El equipo de tu organización del que vino el lead. | | `original_capture.family_origin.lead` | `string \| null` | Sí | Su lead allí, o null si ese id no puede nombrarse entre equipos: un asistente traspasó a alguien que no lo tenía, o el lead de allí es un lead de anuncios (Meta, Google Ads o LinkedIn) u otro lead con id fijo. `automated` distingue el traspaso de un asistente del que hace una persona. | | `original_capture.family_origin.automated` | `boolean` | Sí | Verdadero en el traspaso de un asistente. | | `original_capture.audience` | `object` | Sí | No disponible en esta versión. | | `original_capture.audience.value` | `null` | Sí | Siempre null. | | `original_capture.audience.missing_reason` | `"unsupported"` | Sí | Totem no registra audiencias; un conjunto de anuncios no lo es. | | `original_capture_missing_reason` | `"not_captured" \| null` | Sí | `not_captured` cuando `original_capture` es null; si no, null. | | `latest_submission` | `object \| null` | Sí | El envío de anuncio más reciente registrado para este lead, incluida una repetición sobre un contacto existente; null si no hay ninguno. Un envío que la plataforma de anuncios informa como borrado (un miembro de LinkedIn que borra su respuesta al formulario) deja de mostrarse, y cuando el más reciente caduca o se retira puede mostrarse uno anterior. | | `latest_submission.provider` | `"meta" \| "google_ads" \| "linkedin"` | Sí | La plataforma de anuncios. | | `latest_submission.submitted_at` | `string (date-time) \| null` | Sí | Cuándo dice la plataforma que se envió, o null. | | `latest_submission.campaign` | `object` | Sí | La campaña. | | `latest_submission.campaign.id` | `object` | Sí | Su id. | | `latest_submission.campaign.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.campaign.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.campaign.name` | `object` | Sí | Su nombre. | | `latest_submission.campaign.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.campaign.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad` | `object` | Sí | El anuncio. | | `latest_submission.ad.id` | `object` | Sí | Su id. | | `latest_submission.ad.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad.name` | `object` | Sí | Su nombre. | | `latest_submission.ad.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad_set` | `object` | Sí | El conjunto de anuncios de Meta. | | `latest_submission.ad_set.id` | `object` | Sí | Su id. | | `latest_submission.ad_set.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad_set.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad_set.name` | `object` | Sí | Su nombre. | | `latest_submission.ad_set.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad_set.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.ad_group` | `object` | Sí | El grupo de anuncios de Google Ads. | | `latest_submission.ad_group.id` | `object` | Sí | Su id. | | `latest_submission.ad_group.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.ad_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.form` | `object` | Sí | El formulario de clientes potenciales. | | `latest_submission.form.id` | `object` | Sí | Su id. | | `latest_submission.form.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.form.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.form.name` | `object` | Sí | Su nombre. | | `latest_submission.form.name.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.form.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.creative` | `object` | Sí | La creatividad. | | `latest_submission.creative.id` | `object` | Sí | Su id. | | `latest_submission.creative.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.creative.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.asset_group` | `object` | Sí | El grupo de recursos de Google Ads. | | `latest_submission.asset_group.id` | `object` | Sí | Su id. | | `latest_submission.asset_group.id.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.asset_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm` | `object \| null` | Sí | Sus claves UTM, o null. | | `latest_submission.utm.source` | `object` | Sí | `utm_source`. | | `latest_submission.utm.source.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.source.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm.medium` | `object` | Sí | `utm_medium`. | | `latest_submission.utm.medium.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.medium.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm.campaign` | `object` | Sí | `utm_campaign`. | | `latest_submission.utm.campaign.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.campaign.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm.term` | `object` | Sí | `utm_term`. | | `latest_submission.utm.term.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.term.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.utm.content` | `object` | Sí | `utm_content`. | | `latest_submission.utm.content.value` | `string \| null` | Sí | El valor, o null si no se capturó. | | `latest_submission.utm.content.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Sí | `provider` (lo informó la plataforma de anuncios), `team_alias` (lo escribió el equipo), `stored_metadata` (datos editables del lead), `stored_metadata_at_activation` (copiado una vez al activar la API, para un lead anterior a la captura) o `not_captured` exactamente cuando `value` es null. | | `latest_submission.expires_at` | `string (date-time) \| null` | Sí | Cuándo deja de poder leerse este envío (Google Ads y LinkedIn lo guardan 90 días); null en Meta. | | `latest_submission_missing_reason` | `"not_captured" \| null` | Sí | `not_captured` cuando `latest_submission` es null; si no, null. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" } ``` ## El objeto LeadResponse Cómo ha respondido el equipo a un lead en sus conversaciones y llamadas: los últimos mensajes en cada sentido y si tiene una respuesta pendiente. Usa el identificador de su lead. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"lead_response"` | Sí | Siempre `lead_response`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `last_inbound_at` | `string (date-time) \| null` | Sí | El último mensaje del cliente en cualquiera de las conversaciones del lead (UTC), o null. No cuentan: mensajes borrados, historial de WhatsApp importado ni los turnos del visitante del chat web. Los contenidos compartidos de Instagram no se distinguen y sí cuentan. | | `last_outbound_at` | `string (date-time) \| null` | Sí | El último mensaje enviado al cliente que no falló, en cualquiera de las conversaciones del lead y lo enviara quien lo enviara (UTC), o null. El historial de WhatsApp importado sí cuenta. | | `awaiting_reply` | `boolean` | Sí | Verdadero cuando `last_inbound_at` es posterior a la última respuesta en cualquiera de las conversaciones del lead. Una respuesta es un mensaje enviado que no falló y que se registró como respuesta de conversación o sin propósito; no cuentan la prospección, los mensajes transaccionales (como los recordatorios) ni el historial importado. La mayoría de los mensajes guardados no tienen propósito, así que un envío automático registrado sin él también cuenta como respuesta. Falso si el cliente nunca ha escrito. | | `last_answered_call_at` | `string (date-time) \| null` | Sí | Cuándo empezó la última llamada del lead cuyo `outcome` es `answered_human` o `answered_ai` (UTC), o null. Una saliente conectada en las líneas de teléfono de Totem es `unknown`, así que no cuenta. | | `last_whatsapp_delivery_status` | `"delivered" \| "read" \| "failed" \| null` | Sí | `delivered`, `read` o `failed` del último mensaje enviado al lead por WhatsApp. Null si ese mensaje aún no tiene confirmación (`sent` nunca se guarda) o si al lead no se le ha enviado ningún mensaje por WhatsApp. | | `coverage` | `object` | Sí | De qué se calcularon estos datos. | | `coverage.conversations` | `integer` | Sí | Las conversaciones del lead con las que se calcularon estos datos, de la más reciente a la más antigua; como máximo 20. | | `coverage.calls` | `integer` | Sí | Las llamadas del lead con las que se calculó `last_answered_call_at`, de la más reciente a la más antigua; como máximo 50. | | `coverage.channels_without_delivery_data` | `Array<"sms" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email">` | Sí | Los canales de esas conversaciones que no guardan confirmaciones de entrega (todos menos WhatsApp), ordenados. | | `coverage.truncated` | `boolean` | Sí | Verdadero si se alcanzó un límite de lectura (20 conversaciones, 200 mensajes o 50 llamadas): la actividad más antigua no se leyó, así que una hora puede ser null o anterior a la real, y `awaiting_reply` puede no ver una respuesta más antigua. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } ``` ## El objeto Activity Una transición de etapa, estado o responsables de un lead. Una actividad no cambia una vez escrita. ### Atributos comunes a todas las variantes | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"activity"` | Sí | Siempre `activity`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece el lead. | | `lead` | `string \| null` | Sí | El lead que cambió. Null solo mientras Totem aún no ha asignado su id al lead. | | `occurred_at` | `string (date-time)` | Sí | Cuándo se guardó el cambio. | | `actor_kind` | `"human" \| "ai" \| "system" \| "api" \| "unknown"` | Sí | Quién hizo el cambio: una persona, la IA, la automatización de Totem, una llamada a la API o desconocido. Totem sabe quién hizo un cambio solo cuando el mismo guardado escribió el estado del lead, así que un cambio de etapa o de responsables hecho por sí solo es desconocido. | | `stage_snapshot` | `object \| null` | Sí | La etapa en la que quedó el lead tras el cambio (al borrar los datos del embudo, la que dejó), o null. | | `stage_snapshot.stage` | `string \| null` | Sí | La etapa. | | `stage_snapshot.board` | `string \| null` | Sí | Su tablero, o null en una etapa antigua sin tablero. | | `stage_snapshot.name` | `string \| null` | Sí | El nombre de la etapa cuando se registró el cambio, o null si la etapa ya estaba borrada. | | `value_snapshot` | `number \| null` | Sí | El valor estimado en ese momento, en unidades principales, o null. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Variantes Cada `Activity` es exactamente una de estas 3 variantes. #### Una transición de estado Cuando `type` es uno de `status_changed`, `archived`, `unarchived`, `revived`. | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `type` | `"status_changed" \| "archived" \| "unarchived" \| "revived"` | Sí | `status_changed`, `archived`, `unarchived` o `revived` (el contacto volvió por su cuenta y reabrió un lead archivado). | | `from` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Sí | El estado anterior, o null si no había. | | `to` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable"` | Sí | El estado posterior. | #### Un cambio de etapa Cuando `type` es `stage_changed`. | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `type` | `"stage_changed"` | Sí | Siempre `stage_changed`. | | `from` | `object \| null` | Sí | La etapa y el tablero anteriores, o null si el lead no tenía ninguno. | | `from.stage` | `string \| null` | Sí | La etapa. | | `from.board` | `string \| null` | Sí | El tablero, o null en una etapa antigua sin tablero. | | `to` | `object` | Sí | La etapa y el tablero posteriores. | | `to.stage` | `string \| null` | Sí | La etapa. | | `to.board` | `string \| null` | Sí | El tablero, o null en una etapa antigua sin tablero. | #### Un cambio de miembros asignados Cuando `type` es `assignees_changed`. | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `type` | `"assignees_changed"` | Sí | Siempre `assignees_changed`. | | `from` | `Array` | Sí | Los miembros asignados antes, ordenados. | | `to` | `Array` | Sí | Los miembros asignados después, ordenados. | ### Ejemplo ```json { "id": "act_aBgzeetVKDEjpAAkXfEFF53ZGd2ZMmGhUDKgagqLbh6c7R1GqOl0xsFw7pyRhD3SqqYWGMdb371xxj", "object": "activity", "livemode": false, "type": "stage_changed", "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "occurred_at": "2026-09-28T10:20:00.000Z", "actor_kind": "ai", "stage_snapshot": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "name": "Contacted" }, "value_snapshot": 1200, "from": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "to": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "version": 1, "updated_at": "2026-09-28T10:20:01.500Z" } ``` ## El objeto Board Un tablero con sus etapas en el orden del embudo. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"board"` | Sí | Siempre `board`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `name` | `string \| null` | Sí | El nombre del tablero, o null si no hay ninguno guardado. | | `is_default` | `boolean` | Sí | Verdadero en el tablero por defecto del equipo, donde llegan los leads nuevos salvo que se enruten a otro. | | `is_archived` | `boolean` | Sí | Verdadero si el tablero está archivado (oculto en el selector de tableros). Sus leads se siguen exportando. | | `appointment_booked_stage` | `string \| null` | Sí | La etapa de este tablero a la que pasa un lead cuando se confirma su primera cita, o null si no hay ninguna o no es una etapa de este tablero. | | `stages` | `Array` | Sí | Las etapas del tablero, ordenadas por posición y después por id. Una etapa sin ninguna posición guardada no aparece, aunque un lead puede seguir indicándola. | | `stages[].object` | `"stage"` | Sí | Siempre `stage`. | | `stages[].id` | `string` | Sí | Id público de la etapa; el `stage` de un lead la nombra. | | `stages[].name` | `string \| null` | Sí | El nombre de la etapa, o null si no hay ninguno guardado. | | `stages[].position` | `number \| null` | Sí | La posición de la etapa en su tablero, de izquierda a derecha; null si la posición guardada no es un número. Las etapas se listan en este orden. | | `stages[].is_default` | `boolean` | Sí | Verdadero en la etapa a la que entran los leads nuevos de este tablero. | | `stages[].is_final` | `boolean` | Sí | Verdadero en una etapa final: mover un lead aquí lo marca como convertido. | | `stages[].is_archive` | `boolean` | Sí | Verdadero en una etapa de archivo, cuyos leads no se muestran en la vista principal del tablero. | | `stages[].mode` | `"standard" \| "supervised" \| "automated" \| null` | Sí | Quién mueve los leads: `standard` (solo personas), `supervised` (la IA puede meter leads, las personas los sacan) o `automated` (la IA hace el contacto y mete y saca leads); null si es un valor que esta versión no conoce. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "object": "board", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "New patients", "is_default": true, "is_archived": false, "appointment_booked_stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "stages": [ { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "name": "New", "position": 0, "is_default": true, "is_final": false, "is_archive": false, "mode": "automated" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "name": "Contacted", "position": 1, "is_default": false, "is_final": false, "is_archive": false, "mode": "supervised" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "name": "Visit booked", "position": 2, "is_default": false, "is_final": true, "is_archive": false, "mode": "standard" } ], "version": 4, "updated_at": "2026-09-02T16:40:00.000Z" } ``` ## El objeto Call Una llamada: su dirección, su horario y un resultado normalizado. Sin grabación ni transcripción. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"call"` | Sí | Siempre `call`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que está vinculada la llamada, o null si no está vinculada o el lead ya no existe. El nombre y el teléfono solo aparecen en el lead. | | `direction` | `"inbound" \| "outbound" \| null` | Sí | `inbound` o `outbound`; null si no está guardada. | | `started_at` | `string (date-time) \| null` | Sí | Cuándo empezó la llamada (UTC), o cuándo se registró si el proveedor no dio hora de inicio. | | `duration_seconds` | `integer \| null` | Sí | Segundos enteros de la llamada (redondeados hacia abajo); null mientras la llamada sigue en curso, o si no están guardados. | | `outcome` | `"answered_human" \| "answered_ai" \| "voicemail" \| "no_answer" \| "busy" \| "failed" \| "unknown"` | Sí | Cómo terminó la llamada: `answered_human`, `answered_ai`, `voicemail`, `no_answer`, `busy`, `failed` o `unknown` (aún en curso, o sin pruebas de quién contestó). Léelo junto con `outcome_basis`. | | `outcome_basis` | `"inbound_verdict" \| "ai_analysis" \| "duration_floor" \| "carrier_answer_only" \| "browser_session" \| "none"` | Sí | La prueba detrás de `outcome`: `inbound_verdict` (la clasificación de Totem de una llamada entrante en sus líneas de teléfono), `ai_analysis` (la clasificación que hace Totem del estado, el motivo de fin y la duración que informa Retell), `duration_floor` (una llamada en una centralita Zadarma conectada, donde al menos 10 segundos cuentan como contestada), `carrier_answer_only` (solo la señal de respuesta del operador, que nunca cuenta como contestada, así que una llamada conectada es `unknown`: todas las salientes en las líneas de Totem, las haga la IA o una persona, y una entrante que Totem no ha clasificado), `browser_session` (una sesión de voz web con el asistente) o `none` (ninguna prueba, como en una llamada que sigue en curso). | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ``` ## El objeto Conversation Una conversación en un canal: su estado, su responsable y la hora de los últimos mensajes, nunca su contenido. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"conversation"` | Sí | Siempre `conversation`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que está vinculada la conversación, o null si no está vinculada o el lead ya no existe. El nombre, el teléfono y el usuario solo aparecen en el lead. | | `channel_type` | `"sms" \| "whatsapp" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email" \| null` | Sí | El canal: `whatsapp`, `email`, `chatbot` (el chat web), `facebook`, `instagram`, `sms` o `tiktok`; null si es un valor que esta versión no conoce. Un lead puede tener varias conversaciones en el mismo canal y cuenta de empresa, una por cada dirección de contacto (teléfono, correo electrónico o usuario) que haya usado, así que usa el `id` como clave de tu copia. | | `status` | `"active" \| "archived" \| null` | Sí | `active` o `archived` en la bandeja del equipo; null si es un valor que esta versión no conoce. | | `assignee` | `string \| null` | Sí | El miembro del equipo asignado a la conversación (ver `/members`), o null. | | `reply_mode` | `"ai" \| "human"` | Sí | `human` mientras una persona ha tomado la conversación, incluido un chat de WhatsApp que se atiende desde la app WhatsApp Business; `ai` en otro caso. `ai` significa que no hay una toma activa, no que haya un asistente configurado o respondiendo en este canal. Una toma temporal vuelve a `ai` cuando caduca, y la versión del registro cambia entonces. | | `created_at` | `string (date-time) \| null` | Sí | Cuándo se creó la conversación (UTC). | | `last_inbound_at` | `string (date-time) \| null` | Sí | El último mensaje del cliente (UTC). No cuentan: mensajes borrados, historial de WhatsApp importado ni los turnos del visitante del chat web, así que una conversación `chatbot` da null. Los contenidos compartidos de Instagram no se distinguen y sí cuentan. | | `last_outbound_at` | `string (date-time) \| null` | Sí | El último mensaje enviado al cliente que no falló (UTC), lo enviara quien lo enviara. El historial de WhatsApp importado sí cuenta. | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", "object": "conversation", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "channel_type": "whatsapp", "status": "active", "assignee": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "reply_mode": "ai", "created_at": "2026-09-28T10:16:00.000Z", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "version": 12, "updated_at": "2026-10-01T08:59:31.040Z" } ``` ## El objeto Booking Una cita y su tipo (`kind`): para un servicio reservable, un evento del calendario conectado o una cita importada de otro sistema. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"booking"` | Sí | Siempre `booking`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `lead` | `string \| null` | Sí | El lead al que pertenece la cita, o null si la cita no tiene lead o el lead ya no existe. El nombre y los datos de contacto solo aparecen en el lead. | | `kind` | `"service_booking" \| "calendar_event" \| "external_import"` | Sí | `service_booking` (una cita de un servicio reservable, creada por la IA, un cliente o una persona), `calendar_event` (un evento del calendario conectado) o `external_import` (sincronizada desde un sistema de citas externo o leída de un correo electrónico de un portal). Una cita de un origen que esta versión no conoce aparece como `calendar_event`. | | `service` | `string \| null` | Sí | El servicio de la cita (ver `/booking_services`) en una `service_booking`; null en otro caso. | | `status` | `"scheduled" \| "rescheduled" \| "cancelled" \| "postponed" \| "attended" \| "no_show" \| "team_no_show" \| null` | Sí | `scheduled`, `rescheduled`, `cancelled`, `postponed`, `attended`, `no_show` o `team_no_show` (la asistencia solo la registran personas); null si es un valor que esta versión no conoce. `rescheduled` significa que la cita se movió sin cambiar de registro y sus horas son las nuevas; una cita que la IA sustituyó por otra queda en `cancelled`, con `rescheduled_to`. | | `starts_at` | `string (date-time) \| null` | Sí | Cuándo empieza la cita (UTC). | | `ends_at` | `string (date-time) \| null` | Sí | Cuándo termina la cita (UTC). | | `meeting_type` | `"online" \| "in_person" \| "phone" \| null` | Sí | `online`, `in_person` o `phone`; null si no está guardado. | | `source` | `"ai" \| "customer" \| "operator" \| null` | Sí | Quién creó la cita: `ai`, `customer` u `operator`; null si no está registrado. | | `rescheduled_from` | `string \| null` | Sí | La cita a la que esta sustituyó al reprogramar, o null. Durante unos segundos la cita sustituida puede seguir en `scheduled`; las citas reprogramadas antes de que Totem empezara a registrar este enlace pueden no tenerlo. | | `rescheduled_to` | `string \| null` | Sí | La cita que sustituyó a esta al reprogramar, o null. Cuando está indicada, esta cita queda en `cancelled`. | | `created_at` | `string (date-time) \| null` | Sí | Cuándo se registró la cita en Totem (UTC). | | `version` | `integer` | Sí | Aumenta cada vez que cambia alguno de los campos exportados del registro. Aplica un cambio solo si su versión es mayor que la que guardas. | | `updated_at` | `string (date-time)` | Sí | Cuándo registró Totem esta versión (UTC). No es la hora en que ocurrió el hecho. | ### Ejemplo ```json { "id": "bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", "object": "booking", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "kind": "service_booking", "service": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "status": "scheduled", "starts_at": "2026-10-06T15:00:00.000Z", "ends_at": "2026-10-06T15:30:00.000Z", "meeting_type": "in_person", "source": "ai", "rescheduled_from": null, "rescheduled_to": null, "created_at": "2026-10-01T09:01:00.000Z", "version": 1, "updated_at": "2026-10-01T09:01:02.000Z" } ``` ## El objeto BookingService Un servicio que los clientes pueden reservar, con su tipo de reunión. ### Atributos | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro. | | `object` | `"booking_service"` | Sí | Siempre `booking_service`. | | `livemode` | `boolean` | Sí | `true` en datos reales; `false` en los datos de ejemplo. | | `team` | `string` | Sí | El equipo al que pertenece este registro. | | `name` | `string \| null` | Sí | El nombre del servicio, o null si no hay ninguno guardado. | | `meeting_type` | `"online" \| "in_person" \| "phone"` | Sí | Cómo se presta el servicio: `online` (el valor por defecto si no hay ninguno guardado), `in_person` o `phone`. | ### Ejemplo ```json { "id": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "object": "booking_service", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "First visit", "meeting_type": "in_person" } ``` ## El objeto FeedItem Un cambio: el estado actual del registro, o su eliminación. ### Atributos comunes a todas las variantes | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `kind` | `"lead" \| "lead_attribution" \| "lead_response" \| "activity" \| "call" \| "conversation" \| "booking" \| "board"` | Sí | El tipo del registro, como `lead` o `call`. | | `version` | `integer` | Sí | La versión del registro. Aplica el elemento solo si es mayor que la versión que guardas. | ### Variantes Cada `FeedItem` es exactamente una de estas 2 variantes. #### Con `object` | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro; las facetas `lead_attribution` y `lead_response` usan el de su lead. | | `object` | `Lead \| LeadAttribution \| LeadResponse \| Activity \| Call \| Conversation \| Booking \| Board` | Sí | El registro tal como está ahora. | #### Con `removed` | Campo | Tipo | Siempre presente | Descripción | | --- | --- | --- | --- | | `id` | `string` | Sí | Identificador público del registro eliminado. | | `removed` | `object` | Sí | Presente solo en una eliminación. | | `removed.reason` | `"erased" \| "deleted"` | Sí | `erased` tras una supresión, que es también lo que hace borrar un lead, una llamada o una conversación en Totem: suprimir un lead suprime también sus llamadas, sus conversaciones y sus duplicados fusionados, mientras que sus citas se conservan con `lead` a `null`. `deleted` en cualquier otra eliminación. En ambos casos, borra tu copia y conserva esta versión para ignorar una copia anterior que llegue después. | ### Ejemplo ```json { "kind": "lead", "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "version": 3, "object": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } } ``` --- # Registro de cambios > Los cambios de la API de Totem, del más reciente al más antiguo, cada uno bajo la fecha en que se publicó. Fuente: https://totemstudio.ai/developers/es/changelog Cada entrada lleva su fecha, y una entrada que te pide cambiar tu integración o hacer una resincronización completa empieza por **Acción necesaria**. En [Convenciones](https://totemstudio.ai/developers/es/conventions#qué-significa-beta) se explica qué puede cambiar durante la Beta. ## 1.0a: actualizaciones de la Beta (2026-10-18) - `GET /v1/calls`, `GET /v1/conversations` y `GET /v1/bookings` admiten un filtro opcional `lead`: las llamadas, las conversaciones o las citas de un lead, sin recorrer toda la lista. - `POST /v1/leads/search` por `phone` también encuentra el número guardado en los formatos que escriben las bibliotecas de teléfonos (`+1 202-555-0123`, `(202) 555-0123` y `2025550123`), como lo guardaban las importaciones antiguas. - La documentación en español muestra el título de cada error en español. El `title` del cuerpo de un error sigue en inglés. - Totem deja de exportar los equipos de un sector de actividad sensible, como el sanitario, hasta que cambie su política de sectores. Un equipo así ya no se puede añadir a una integración, y una clave que ya lo cubre recibe `403 sector_restricted` para él. Omite el equipo. El feed de cambios no entrega nada de él, tampoco eliminaciones, así que conserva lo que ya tengas solo mientras lo permita tu política de conservación y después borra tu copia. - Las actividades cuya primera captura falló se capturan ahora más tarde, así que un lead puede recibir actividades anteriores a las que ya tienes. - `transferred_to.lead` documenta ahora que también es `null` cuando el traspaso cayó en un lead de anuncios que ya existía o en otro lead con identificador fijo. - La documentación indica ahora que la API solo es accesible por IPv4, así que una entrada IPv6 de una lista de IP permitidas nunca coincide. - Las llamadas que Totem ha conciliado o importado de su proveedor de voz con IA indican ahora su `outcome` real, en lugar de `unknown`. Una llamada en curso indica `duration_seconds` como `null`. - Un lead cuyo estado no conoce esta versión trae ahora `record_kind: "lead"` con `status: null`, en lugar de `client`. - En un lead, `attribution.original_capture.origin.kind` es siempre `conversation` o `call`, y `original_capture.source` usa los mismos valores que el `source` del lead. Un envío de LinkedIn que la persona retiró ya no aparece como el último envío. - **Acción necesaria.** Una clave de un solo equipo deja de leer su equipo, con `404 team_not_accessible`, cuando el equipo entra en una organización, sale de ella o cambia de organización. Revócala y crea una nueva. - El límite de frecuencia por clave cobra al menos un elemento por cada petición que cuenta, también por una búsqueda sin resultados y por un `404`. [Límites de frecuencia y cuota](https://totemstudio.ai/developers/es/rate-limits) indica qué no se cobra. - Un `cursor` manipulado de `GET /v1/sync/changes` recibe `400 invalid_request`, en lugar de un `500`. - Los intentos fallidos con una clave revocada o caducada ya no cuentan para un bloqueo, y una clave bloqueada sigue funcionando para quien tiene su secreto real. - `limit` documenta su valor por defecto, 25. La documentación explica ahora qué envía el feed de cambios al borrar un lead, una llamada o una conversación en Totem, y qué responde el cortafuegos fuera de `/v1/`. - [SDK de TypeScript](https://totemstudio.ai/developers/es/typescript) 0.1.0-beta.2: acciones y códigos de error tipados, reintentos que respetan el plazo, enums abiertos y compatibilidad con Node.js 22.12. ## 1.0a: Beta (2026-10-18) Beta pública. Los administradores de equipos y de la organización que cumplen los requisitos pueden crear integraciones en **Ajustes → Desarrolladores**, sin invitación. - La primera versión de la API: de solo lectura y en beta. - Endpoints para la integración y sus equipos, tableros, miembros, servicios reservables, leads (con su atribución, su respuesta y sus actividades), llamadas, conversaciones y citas. - El feed de cambios, [`GET /v1/sync/changes`](https://totemstudio.ai/developers/es/reference-sync#sync.changes), con eliminaciones y resincronización completa. - Claves secretas con rotación, revocación y una lista opcional de IP permitidas, gestionadas en **Ajustes → Desarrolladores**. - Sin webhooks: consulta el feed de cambios cada 1 a 5 minutos. --- # Soporte > Cómo pedir ayuda con la API de Totem, qué incluir en una solicitud y qué hacer si se filtra una clave. Fuente: https://totemstudio.ai/developers/es/support ## Cómo contactarnos El equipo de Totem atiende las solicitudes de soporte de la API. - **Desde Totem.** Un administrador de tu equipo abre [Ayuda](https://app.totemstudio.ai/help) en la aplicación de Totem y envía una solicitud de soporte. Es la vía más rápida, porque la solicitud llega vinculada a tu equipo. - **Por correo electrónico.** Escribe a hello@totemstudio.ai. También puede escribir un desarrollador que trabaje para un cliente: indica de qué cliente y de qué integración se trata. Todavía no hay una página de estado específica de la API. Si la API parece caída, llama a [`GET /v1/health`](https://totemstudio.ai/developers/es/reference-account#health), que no necesita clave, y cuéntanos qué respondió. ## Qué incluir - el `Totem-Request-Id` de la respuesta que falló, si lo trae, y su cabecera `Totem-Release`, si la trae; - la hora de la petición, en UTC; - el método y la ruta, como `GET /v1/sync/changes`; - el estado HTTP y el `code` del error; - el nombre de la integración y los cuatro últimos caracteres de su clave; - el identificador del equipo (`team_…`) y los identificadores de los registros afectados. ## Qué no enviar nunca No envíes nunca una clave secreta, ni entera ni en parte más allá de sus cuatro últimos caracteres. No pegues datos personales de las respuestas, como nombres, teléfonos o direcciones de correo electrónico: con los identificadores de los registros y de las peticiones tenemos suficiente para investigar. ## Si se filtra una clave Elige **Rotar clave** de inmediato en [**Ajustes → Desarrolladores**](https://app.totemstudio.ai/settings?tab=developers) y marca **Esta clave puede estar comprometida**, para que la antigua se revoque sin periodo de gracia. Después indícanos el nombre de la integración, cuándo crees que se filtró la clave y quién o qué tenía acceso a ella. En [Autenticación](https://totemstudio.ai/developers/es/authentication#rotar-y-revocar) se explica cómo rotar y revocar. Para informar de una posible vulnerabilidad en la API, en esta documentación o en el SDK de TypeScript, escribe en privado a hello@totemstudio.ai, como se explica en [Seguridad y datos personales](https://totemstudio.ai/developers/es/security#informa-de-una-vulnerabilidad). ## Avisos La API está en **Beta**. El [registro de cambios](https://totemstudio.ai/developers/es/changelog) recoge los cambios bajo la fecha en que se publicaron, y en [Convenciones](https://totemstudio.ai/developers/es/conventions#qué-significa-beta) se explica qué puede cambiar durante la Beta. --- # Glosario > Los términos de esta documentación, definidos en un solo lugar, cada uno con un enlace a la página que lo explica. Fuente: https://totemstudio.ai/developers/es/glossary Las palabras que usa esta documentación, en un solo lugar. Cada entrada enlaza con la página que la explica por completo. ## Cuenta **Integración.** La conexión con la API de Totem que un administrador crea en **Ajustes → Desarrolladores**: un nombre y los equipos que lee, con descripción y lista de IP permitidas opcionales. La interfaz lo llama **Crear clave API**. Su identificador empieza por `prn_`, y [`GET /v1/me`](https://totemstudio.ai/developers/es/reference-account#me.get) la describe. Consulta [Autenticación](https://totemstudio.ai/developers/es/authentication). **Clave.** El secreto, que empieza por `tsk_live_`, que tu servidor envía en la cabecera `Authorization`. Pertenece a una integración. Totem la muestra una sola vez; puedes rotarla o revocarla en cualquier momento. Consulta [Autenticación](https://totemstudio.ai/developers/es/authentication). **Equipo.** Un espacio de trabajo de Totem, con sus propios tableros, miembros, leads, llamadas, conversaciones y citas. Su identificador empieza por `team_`. Una clave lee uno o varios equipos, y si lee varios, las peticiones sobre las colecciones de un equipo lo indican en la cabecera `Totem-Team`. Consulta [Autenticación](https://totemstudio.ai/developers/es/authentication). **Organización.** Una cuenta de facturación y sus subequipos. Una integración puede cubrir varios equipos, siempre que pertenezcan a la misma organización. En **Ajustes → Desarrolladores**, una integración que cubre varios de sus equipos tiene el ámbito del acceso **Equipos elegidos de la familia**, y nombres de la API como `family_transfer` o `family_origin` se refieren a ella. Consulta [Autenticación](https://totemstudio.ai/developers/es/authentication). **Administrador de la familia.** Un administrador de la cuenta de facturación de una organización que figura en ella como administrador de la familia. Solo un administrador de la familia puede crear o modificar una integración que cubra varios equipos. Consulta [Autenticación](https://totemstudio.ai/developers/es/authentication#equipos-y-la-cabecera-totem-team). **Miembro.** Una persona de un equipo, identificada solo por su nombre visible. Los identificadores de miembro empiezan por `mem_` y son de un solo equipo: la misma persona tiene un identificador distinto en cada equipo. Consulta [`GET /v1/members`](https://totemstudio.ai/developers/es/reference-account#members.list). ## Registros del CRM **Tablero.** Un embudo que un equipo usa para organizar sus leads, con sus etapas en orden. Los identificadores de tablero empiezan por `brd_`. Consulta [`GET /v1/boards`](https://totemstudio.ai/developers/es/reference-leads#boards.list). **Etapa.** Un paso de un tablero, como «Contactado». Los identificadores de etapa empiezan por `stg_`. El `stage` de un lead indica el paso en el que está, y las etapas viajan dentro de su tablero. Consulta [`GET /v1/boards`](https://totemstudio.ai/developers/es/reference-leads#boards.list). **Lead.** Una persona o empresa con la que trabaja un equipo, con su posición en el embudo, su origen y sus permisos de contacto. Los identificadores de lead empiezan por `lead_`. Consulta la [referencia de leads](https://totemstudio.ai/developers/es/reference-leads). **Cliente.** Un registro de los endpoints de leads con `record_kind: "client"`. No tiene estado del embudo, así que su `status` es `null`. Consulta [`GET /v1/leads`](https://totemstudio.ai/developers/es/reference-leads#leads.list). **Actividad.** Un cambio de etapa, de estado o de responsables de un lead. Una actividad no cambia una vez escrita. Los identificadores de actividad empiezan por `act_`. Consulta [`GET /v1/leads/{id}/activities`](https://totemstudio.ai/developers/es/reference-leads#leads.activities). **Faceta.** Una parte de un lead que la API entrega como un objeto propio, con el identificador del lead y su propia versión. Un lead tiene dos: su **atribución**, de dónde vino, y su **respuesta**, cómo le ha respondido el equipo en sus conversaciones y llamadas. Un mensaje nuevo cambia la respuesta, no el lead. Consulta [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/es/reference-leads#leads.get). **Llamada.** Los metadatos de una llamada telefónica con un lead, como su dirección, su duración y su resultado. Nunca una grabación ni una transcripción. Los identificadores de llamada empiezan por `call_`. Consulta la [referencia de llamadas](https://totemstudio.ai/developers/es/reference-calls). **Conversación.** Los metadatos de una conversación con un lead en un canal, como su estado y cuándo llegaron y salieron los últimos mensajes, sin los mensajes en sí. Los identificadores de conversación empiezan por `conv_`. Consulta la [referencia de conversaciones](https://totemstudio.ai/developers/es/reference-conversations). **Cita.** Una cita en el calendario del equipo: reservada para un servicio reservable, un evento del calendario conectado o una importación de un sistema de citas externo. En la API se llama `booking`, y su identificador empieza por `bkg_`. Consulta la [referencia de citas](https://totemstudio.ai/developers/es/reference-bookings). **Servicio reservable.** Algo que se puede reservar con el equipo, como una primera consulta, en línea, en persona o por teléfono. Su identificador empieza por `svc_`. Consulta [`GET /v1/booking_services`](https://totemstudio.ai/developers/es/reference-bookings#booking_services.list). ## Sincronización **Feed de cambios.** [`GET /v1/sync/changes`](https://totemstudio.ai/developers/es/reference-sync#sync.changes): todos los cambios de los registros de un equipo, en orden, página a página. Cada elemento es un registro de un `kind` o una eliminación. Consulta [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). **Cursor.** Una cadena opaca que marca tu posición en una lista o en el feed de cambios. Guárdalo tal como lo recibiste, y no lo construyas ni lo modifiques nunca. Un cursor del feed de cambios es de un solo equipo, y deja de funcionar 30 días después del punto del feed al que llegó o, mientras una carga inicial sigue en curso, 30 días después de que esta empezara. Consulta [Listas y paginación](https://totemstudio.ai/developers/es/pagination) y [Guarda el cursor](https://totemstudio.ai/developers/es/sync#guarda-el-cursor). **Versión.** Un entero que lleva cada registro que entrega el feed de cambios y que crece cada vez que el registro cambia. Aplica un elemento solo si su versión es mayor que la que tienes. Consulta [Convenciones](https://totemstudio.ai/developers/es/conventions). **Carga inicial.** La primera pasada por el feed de cambios, sin cursor. Entrega todos los registros del equipo y después se pone al día con los cambios hechos mientras tanto. Consulta [Haz la carga inicial](https://totemstudio.ai/developers/es/sync#haz-la-carga-inicial). **Resincronización completa.** Una nueva carga inicial que reconstruye tu copia de un equipo, después de `410 sync_cursor_expired` o `409 resync_required`. Funciona **marcando y barriendo**: olvida las versiones que guardaste, marca cada registro que entrega el feed y, cuando se haya puesto al día, borra todos los registros de ese equipo que no marcaste. Consulta [Resincronización completa](https://totemstudio.ai/developers/es/sync#resincronización-completa). **Eliminación.** Un elemento del feed de cambios con `removed` en lugar de `object`. Borra tu copia de ese registro en todos los sitios a los que lo copiaste. Su `reason` indica el motivo: `erased` si el registro se suprimió, `deleted` si desapareció de Totem de cualquier otra forma. Consulta [Eliminaciones](https://totemstudio.ai/developers/es/sync#eliminaciones). **Borrado.** Una eliminación con `reason: "deleted"`: el registro desapareció de Totem sin suprimirse. Consulta [Eliminaciones](https://totemstudio.ai/developers/es/sync#eliminaciones). **Supresión.** Una eliminación con `reason: "erased"`: el registro se suprimió por motivos de privacidad, por ejemplo porque la persona ejerció su derecho de supresión (artículo 17 del RGPD), o porque alguien eliminó definitivamente en Totem un lead, una llamada o una conversación, lo que siempre los suprime. Consulta [¿Cómo respeto las solicitudes de supresión?](https://totemstudio.ai/developers/es/faq#cómo-respeto-las-solicitudes-de-supresión) en las preguntas frecuentes. **Marca de eliminación.** Lo que conservas después de aplicar una eliminación: el `kind`, el `id` y la `version` del registro, sin sus datos. En inglés se llama *tombstone*. Impide que una copia más antigua del registro, entregada de nuevo más tarde, lo haga reaparecer. Consulta [Aplica una página](https://totemstudio.ai/developers/es/sync#aplica-una-página). **Fusión.** Dos leads de la misma persona combinados en uno. El lead fusionado no se elimina: queda como un lead archivado cuyo `merged_into` indica el lead que continúa. Consulta [Fusiones](https://totemstudio.ai/developers/es/sync#fusiones). **Traspaso.** Un lead que pasa a otro equipo de la misma organización. El original queda como un lead archivado cuyo `transferred_to` indica el `team` de destino y, cuando Totem puede indicarlo, el `lead` de destino. El equipo de destino puede ser uno que tu clave no lee. Consulta [Traspasos](https://totemstudio.ai/developers/es/sync#traspasos). ## Límites y errores **Límite de frecuencia.** La velocidad a la que puede leer una clave: hasta 2.000 elementos por minuto, con ráfagas de hasta 10.000. Al superarlo, la API responde `429 rate_limited` con `Retry-After`. Consulta [Límite de frecuencia](https://totemstudio.ai/developers/es/rate-limits#límite-de-frecuencia). **Cuota diaria.** Cuántos elementos puede leer una clave en un día UTC del feed de cambios y de todas las listas, incluidos los equipos, los miembros, los servicios reservables y las actividades de un lead: 250.000 elementos, o 3 veces el número de registros de sus equipos si es mayor. Las consultas de un solo registro, la búsqueda y `GET /v1/me` no cuentan. Al superarla, esos endpoints responden `429 quota_exceeded` hasta las 00:00 UTC. Consulta [Cuota diaria](https://totemstudio.ai/developers/es/rate-limits#cuota-diaria). **livemode.** Un campo de todo recurso. Es `true` en todo lo que devuelve la API, y `false` en los datos de ejemplo de esta documentación y del archivo `fixtures.json` del kit. Consulta [Convenciones](https://totemstudio.ai/developers/es/conventions). **Identificador de petición.** La cabecera `Totem-Request-Id` de cada respuesta que envía la propia API, que el cuerpo de un error repite en `request_id`. Cítalo cuando pidas ayuda. Consulta [Soporte](https://totemstudio.ai/developers/es/support). **Código de error.** El `code` del cuerpo de un error, como `team_required`. Decide según él, y búscalo en la página de [Errores](https://totemstudio.ai/developers/es/errors). --- # Preguntas frecuentes > Respuestas breves a las preguntas más habituales sobre la API de Totem, con enlaces a los detalles. Fuente: https://totemstudio.ai/developers/es/faq ## Acceso y claves ### ¿Cómo consigo una clave? Un administrador de tu equipo de Totem crea una integración en [**Ajustes → Desarrolladores**](https://app.totemstudio.ai/settings?tab=developers) y copia su clave secreta en el almacén de secretos de tu servidor. La API está en **Beta**, sin invitación. El administrador necesita una dirección de correo verificada, tu organización necesita el plan Pro o uno superior, o acceso a la API acordado con Totem, y el equipo debe haber completado su configuración legal; el [Inicio rápido](https://totemstudio.ai/developers/es/quickstart#antes-de-empezar) enumera todos los requisitos y qué comprobar si no ves **Crear clave API**. Después te guía en tus primeras peticiones. ### ¿Puede una clave leer varios equipos? Sí, si los equipos pertenecen a la misma organización, y solo un administrador de la familia puede crear una clave así. Una integración cubre una lista explícita de hasta 30 equipos. Las peticiones sobre las colecciones de un equipo indican entonces el equipo en la cabecera `Totem-Team`, y guardas un cursor del feed de cambios por equipo. Consulta [Autenticación](https://totemstudio.ai/developers/es/authentication). ### ¿Puedo limitar una clave a las direcciones IP de mis servidores? Sí. Una integración puede llevar una lista de hasta 20 direcciones IP o rangos CIDR permitidos, que se configura en **Ajustes → Desarrolladores**. La API solo es accesible por IPv4, así que indica las direcciones IPv4 públicas desde las que salen tus peticiones. Una petición desde cualquier otra dirección recibe `403 ip_not_allowed`. En [Autenticación](https://totemstudio.ai/developers/es/authentication#lista-de-ip-permitidas) se explica cómo configurarla. ### ¿Puedo llamar a la API desde un navegador o una app móvil? No. Una clave lee datos con nivel de administrador, así que debe quedarse en tu servidor. La API no envía cabeceras CORS y responde `400 secret_key_in_browser` a una petición que lleve una clave y una cabecera `Origin`. Llama a la API desde tu backend y envía a tu app solo los datos que necesita. ## Datos ### ¿Puedo escribir datos con la API? No. La versión 1.0a es de solo lectura: ningún endpoint crea ni modifica nada en Totem. El [registro de cambios](https://totemstudio.ai/developers/es/changelog) anuncia cualquier capacidad nueva. ### ¿Puedo leer el texto de los mensajes, las transcripciones o las grabaciones? No. La versión 1.0a devuelve datos de contacto, como nombres, correos electrónicos y teléfonos, y metadatos. No devuelve el texto de los mensajes, las transcripciones de llamadas, los resúmenes, las grabaciones, las notas, las respuestas de texto libre ni los campos personalizados de los formularios. Una conversación indica su canal, su estado y cuándo llegaron y salieron los últimos mensajes. Una llamada indica su dirección, su duración y su resultado. ### ¿Cómo busco un lead por teléfono o por correo electrónico? Envía el teléfono o la dirección de correo electrónico en el cuerpo JSON de [`POST /v1/leads/search`](https://totemstudio.ai/developers/es/reference-leads#leads.search), nunca en una URL. Envía exactamente uno de los dos. La búsqueda es exacta, devuelve como máximo 10 leads y nunca pagina, así que, si muchos registros comparten el mismo teléfono o correo electrónico, puede que no los devuelva todos. ## Sincronización y actualidad ### ¿Hay webhooks? No. La API de Totem no tiene webhooks: lee el feed de cambios con tu cursor guardado cada 1 a 5 minutos. Los webhooks que incluyen los planes de Totem son otra función del producto, que se configura en Totem y no con una clave de la API. Una consulta que no encuentra nada nuevo devuelve una página vacía que, aun así, hace avanzar tu cursor, así que guarda también su `next_cursor`. Consulta [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). ### ¿Cuánto tardan en aparecer los cambios? Poco después de producirse en Totem. El feed de cambios retiene sus últimos 10 segundos, para no saltarse nunca un cambio que todavía se está escribiendo. Si consultas cada 1 a 5 minutos, tu copia va más o menos ese tiempo por detrás de Totem. ### ¿Por qué un registro nuevo responde 404 durante un momento? Totem prepara cada registro para la API poco después de que cambie. Hasta entonces, las listas y la búsqueda omiten un registro nuevo, y `GET` sobre su identificador responde `404 resource_missing`. El feed de cambios entrega el registro en cuanto está listo. Consulta [Listas y paginación](https://totemstudio.ai/developers/es/pagination). ## Pruebas y herramientas ### ¿Hay un entorno de pruebas? Todavía no. La versión 1.0a no tiene entorno de pruebas ni claves de prueba: cada clave lee un equipo real. Usa un equipo dedicado que solo contenga contactos ficticios. El archivo [`fixtures.json`](https://totemstudio.ai/developers/kit/fixtures.json) del kit contiene datos de ejemplo para tus pruebas unitarias. Consulta [Pruebas y puesta en producción](https://totemstudio.ai/developers/es/testing). ### ¿Hay un SDK? Sí. El [SDK oficial de TypeScript](https://totemstudio.ai/developers/es/typescript), `@totemstudioai/sdk`, cubre todos los endpoints y ofrece respuestas tipadas, paginación y errores de la API. Se ejecuta en tu servidor, con las versiones de Node.js que indica su página. Para mantener una copia de un equipo, parte del [cliente de referencia probado](https://totemstudio.ai/developers/es/sync#cliente-de-referencia-probado) de la guía de sincronización. El [documento OpenAPI](https://totemstudio.ai/developers/kit/openapi.json) sigue disponible para otros lenguajes. ## Límites ### ¿Cuáles son los límites? Una clave lee hasta 2.000 elementos por minuto, con ráfagas de hasta 10.000. Cada día UTC, los endpoints de lista y el feed de cambios le devuelven hasta 250.000 elementos, o 3 veces el número de registros de sus equipos si es mayor; las peticiones de un solo registro, la búsqueda y `GET /v1/me` no cuentan para esa cuota. Una página contiene hasta 100 elementos. Al superar un límite, la API responde `429` con `Retry-After`: espera esos segundos y continúa con el mismo cursor. Durante la Beta estas cifras son aproximadas, y una organización puede tener hasta 10 integraciones activas. Consulta [Límites de frecuencia y cuota](https://totemstudio.ai/developers/es/rate-limits). ## Privacidad ### ¿Cómo respeto las solicitudes de supresión? Cuando un registro se suprime en Totem, el feed de cambios entrega una eliminación con `reason: "erased"`. Suprimir un lead también suprime sus llamadas, sus conversaciones y los leads archivados que se fusionaron con él, y cada uno llega como una eliminación propia; sus citas se conservan, con `lead` igual a `null`. Borra el registro de tu copia y de todos los sistemas a los que lo pasaste, y conserva solo su marca de eliminación: su `kind`, su `id` y su `version`. Las eliminaciones permanecen en el feed 45 días, más de lo que vive un cursor, así que una sincronización que sigue consultando con éxito nunca se pierde ninguna. Mientras la API rechaza un equipo, no llega ninguna eliminación de él y, si el acceso vuelve cuando su cursor ya ha caducado, la resincronización completa borra lo que se eliminó mientras tanto. Un equipo de un sector excluido (`sector_restricted`) no recibe eliminaciones hasta que Totem cambie su política de sectores, así que conserva su copia solo mientras lo permita tu política de conservación. Consulta [Sincroniza una copia](https://totemstudio.ai/developers/es/sync). ### ¿Dónde funciona la API y quién protege los datos que copio? La API funciona en Google Cloud, en la Unión Europea, y solo responde por HTTPS. Una vez copias los datos, protegerlos os corresponde a ti y al cliente al que pertenecen. En [Seguridad y datos personales](https://totemstudio.ai/developers/es/security) se explican las dos cosas y cómo informar de una vulnerabilidad.