Cibi Meet API backend-only

Backend de análisis de reuniones de ventas (Recall.ai + OpenAI + Supabase). Servido como serverless function en Vercel desde api/index.ts.

Análisis de costos y comparativa con DIIO/Gong/Chorus →

Probar la API

Importa la colección y el environment a Postman (o Insomnia, Bruno, etc.):

Descargar Postman Collection Descargar Environment

En Postman: Import → arrastra los dos archivos. Configura baseUrl, serviceApiKey (o authToken) y cronSecret en el environment.

Autenticación

TipoHeaderCuándo usar
JWT/X-API-KeyAuthorization: Bearer <jwt>Dashboard cb-panel (Supabase Auth, Google sign-in @clickbalance.com)
JWT/X-API-KeyX-API-Key: <SERVICE_API_KEY>n8n / AI agent. Requiere salesRepEmail en el body o query.
Llave del CRMX-API-Key: <SERVICE_API_KEY_CRM>Twenty CRM. En las rutas de capacidad (/api/meetings/existentes) autoriza sola, sin identidad. En el resto de la API autenticada se comporta como cualquier llave de servicio: pide salesRepEmail.
Llave del agendadorX-API-Key: <AGENDADOR_API_KEY>Sirve únicamente en PATCH /api/meetings/:id/customer. Acotada a propósito: no hay fallback desde ni hacia las otras llaves.
CRON_SECRETAuthorization: Bearer <CRON_SECRET>Solo para /api/cron/*. Vercel Cron lo inyecta.
Svixsvix-id, svix-timestamp, svix-signatureSolo para /api/webhooks/recall. Recall.ai firma con Svix.

Qué ve una llave de servicio (y por qué depende del salesRepEmail)

Una llave de servicio no trae alcance propio: autentica «soy este sistema» y después actúa en nombre del rep que va en salesRepEmail, heredando su is_admin real. El alcance de la respuesta lo decide ese correo, no la llave.

LlamadaQué devuelve GET /api/meetings
salesRepEmail de un adminTodas las reuniones de la empresa, con contenido completo: el filtro de visibilidad sólo se aplica a quien no es admin.
salesRepEmail de un rep normalLas suyas más las de tipo público. Sobre las ajenas, redactOwnerData deja en null calificación, desglose, probabilidad de cierre, oportunidades, objeciones y los campos hubspot_*.
sin salesRepEmail400 MISSING_SALES_REP_EMAIL — salta antes de consultar ninguna reunión.

Consecuencia que conviene tener escrita: quien tenga una llave de servicio puede leer el contenido de toda la empresa con sólo poner el correo de un admin en el parámetro —hoy son 17 personas, no hay cuenta de servicio—. Nada ata una llave a un correo en particular. Es el modelo elegido a propósito (cada sistema lee con la identidad de quien está actuando) y la trazabilidad es el log Request de servicio autenticada, que registra serviceConsumer y actingAs en cada llamada. Si eso no alcanza para un consumidor nuevo, lo que corresponde es darle una ruta de capacidad —como /api/meetings/existentes, que no resuelve identidad y por eso no puede devolver contenido— y no una llave de servicio más.

Endpoints

MétodoPathAuthDescripción
POST /api/meetings/schedule JWT/X-API-Key Agendar reunión (genera Meet vía n8n si no se pasa meetingUrl). Acepta links de Google Meet, Zoom o Microsoft Teams.
POST /api/meetings/start JWT/X-API-Key Iniciar reunión ahora (legacy wrapper)
GET /api/meetings JWT/X-API-Key Tiene DOS modos de paginación y no se mezclan. Sin updatedSince/analysisUpdatedSince es por página: ?page= + ?limit=, total = el total de coincidencias, y NUNCA devuelve nextSince — hay que recorrer las páginas hasta cubrir total. Con cualquiera de esos dos filtros pasa a modo cursor: page se IGNORA, total es el tamaño de ESTE lote (no el total de coincidencias, así que un lote lleno no significa que no falte nada) y se avanza siguiendo nextSince hasta que deje de venir — ausente significa "estás al día". Esperar un cursor en el primer modo hace leer sólo el primer lote y creer que se terminó. Y ojo con quién cae ahí: todo cliente nuevo empieza sin cursor, así que su primera llamada es siempre en modo por página — el barrido inicial se hace con page y el cursor entra recién a partir de la segunda pasada. Es la asimetría que hay que tener presente: sin updatedSince no hay cursor y hay que paginar con page; con updatedSince no se puede paginar con page y el cursor es el único camino.
El orden también cambia con el modo: por página va por ocurrio_en DESCENDENTE (desempatando por id) — no por updated_at; en modo cursor va ASCENDENTE por el instante efectivo de cambio, que es el orden que el cursor necesita. ?orden=fecha_asc invierte el primero, para preguntar "qué sigue" en vez de "qué pasó"; combinado con dateFrom=<ahora> y limit da las próximas N.
Filtros: status, type, dateFrom, dateTo, search, sinCliente, cliente. cliente acota a UNA empresa por nombre —el que quedó en customer_info.company, que es la única clave que hay: no existe tabla de clientes ni id—. Compara ignorando mayúsculas, pero NO ignora acentos ni puntuación, así que hay que mandar el nombre tal como viene en GET /api/clients/nombres. cliente y sinCliente se contradicen: juntos no devuelven nada. dateFrom/dateTo filtran por ocurrio_en — cuándo OCURRIÓ el evento, no cuándo se creó la fila (started_at es la hora del INSERT y nadie la actualiza). Aceptan un día del calendario (2026-09-04) o un instante ISO 8601. Un día suelto cubre el día ENTERO en la zona horaria de quien pregunta: dateTo=2026-09-04 incluye lo que ocurrió a las 23:00.
Visibilidad: un rep NO admin sólo ve lo suyo (responsable u organizador) más los tipos no comerciales; un admin ve todo. Con X-API-Key el salesRepEmail decide cuál de los dos casos aplica, así que una llave que actúa como no-admin sincroniza MENOS filas sin que nada avise.
updatedSince mira análisis, fallos y la propia fila; analysisUpdatedSince sólo análisis y fallos. Los dos cursores NO son intercambiables.
GET /api/meetings/:id JWT/X-API-Key Detalle: meeting + analyses + transcript + signed URLs (1h)
GET /api/meetings/:id/video JWT/X-API-Key Signed URL del video (1h). 409 VIDEO_NOT_READY si todavía no está (reintentar sirve) o 409 VIDEO_NO_DISPONIBLE si no hay de dónde sacarlo (esperar no sirve).
POST /api/meetings/:id/reanalyze JWT/X-API-Key Re-corre el análisis IA de cualquier tipo de reunión. Body opcional: { sales_subtype: auto|, parts: [score|summary|action_items|opportunities|facts|coaching] }. El override de subtipo solo aplica si meeting_type=ventas. `parts` recalcula SOLO esas partes y deja el resto intacto — parts:["score"] corrige la rúbrica sin pisar resumen ni tareas (que pueden estar editados a mano). Omitir parts = análisis completo.
GET /api/meetings/:id/analysis-status JWT/X-API-Key Endpoint liviano para hacer polling de un reanálisis: { status, analysisUpdatedAt, errorCode }. No resuelve media (el detalle sí, y eso llama a Recall: ~900ms vs ~50ms). La señal de "terminó" es analysisUpdatedAt. errorCode viene null salvo status=FAILED.
POST /api/meetings/:id/analyze-call JWT/X-API-Key Análisis on-demand de una llamada importada de HubSpot (source=hubspot_call): encola descarga de audio + transcripción + análisis y responde 202. Sin body. Idempotente por minuto: un reintento devuelve el mismo jobId en vez de encolar otra transcripción.
GET /api/meetings/:id/call-audio JWT/X-API-Key Stream del audio de una llamada de HubSpot (soporta Range). Mismo guard de lectura que /audio y /video.
DELETE /api/meetings/:id JWT/X-API-Key Cancelar reunión SCHEDULED (ordena leave_call al bot)
GET /api/version público Qué build está corriendo y qué funciones tiene prendidas este despliegue. Sólo booleanos: nunca dominios, orígenes ni nada que identifique a la empresa — esta ruta es pública. Sirve para que un integrador sepa si vale la pena pedir la nota de HubSpot antes de intentarlo.
GET /api/calendar/connect JWT/X-API-Key Arranca el vínculo con Google Calendar. Responde 302 a accounts.google.com, no JSON: el consentimiento tiene que ocurrir en el navegador de la persona. Se llama de SERVIDOR A SERVIDOR, no desde el navegador: tu backend lo pide con redirect: 'manual', lee el Location y le devuelve esa URL a tu front, que recién ahí navega. Desde el navegador no funciona y el síntoma es «no se pudo iniciar la conexión»: la cabecera de autenticación obliga a un preflight CORS que este API no responde, así que el GET nunca sale; y aun si saliera, fetch con redirect: 'manual' devuelve una respuesta opaca en la que el Location no se puede leer. Además la API key no puede vivir en un navegador. Acepta ?success_url= y ?error_url=, que viajan firmadas (HMAC) dentro del state; si su origen no está permitido, el callback vuelve al panel de este despliegue en vez de a donde pediste, y lo registra con el origen rechazado y la lista que sí vale — agregá el tuyo con PUT /api/admin/configuracion, sin desplegar. Antes caía a "/" en silencio, que sirve esta misma página: si alguien te dijo que «el OAuth termina en la documentación», era esto. 409 CALENDAR_ALREADY_CONNECTED si ya hay uno: hay que desconectar antes.
GET /api/calendar/google/callback público Donde vuelve Google. Público a propósito: la identidad viaja firmada en el state, no en la sesión. Intercambia el código, reusa o reconecta el calendario en Recall y redirige a success_url. No adjunta NADA al redirigir — ni código ni token.
GET /api/calendar/status JWT/X-API-Key { connected: false } o { connected: true, provider, google_email, connected_at }.
GET /api/calendar/meetings JWT/X-API-Key Eventos del calendario conectado. ?start_time_after= y ?start_time_before= (ISO 8601). Los asistentes vienen CON CORREO — es la fuente confiable para saber quién es el cliente, porque participation.participants[].email casi siempre viene null (lo entrega Recall, y Meet no lo da). 404 CALENDAR_NOT_CONNECTED si no hay calendario conectado.
PATCH /api/calendar/meetings/:eventId/record JWT/X-API-Key Prende o apaga la grabación de un evento. Body: { should_record: bool, meeting_password?: string } — estricto: un campo mal escrito es 400 y no un no-op mudo, porque una contraseña descartada en silencio deja al bot afuera de la sala sin que nada lo diga. meeting_password se reenvía a Recall y NO se persiste; sólo aplica a Zoom (Teams no tiene equivalente en la configuración del bot). La respuesta trae calendar_attendees[] con nombre, correo, si organizó y su respuesta a la invitación — es el único lugar con correo confiable por participante, porque participation.participants[].email es passthrough de Recall y viene null casi siempre. Vienen también al APAGAR, para no tener que escribir dos caminos según el sentido del interruptor. Y trae contrasena con qué pasó con la que mandaste: aplicada, no_habia o no_aplica_a_esta_plataforma — esto último es una sala que no es de Zoom o un apagado, donde el campo se acepta pero no hay dónde ponerlo. No es un 400 a propósito: quien guarda la contraseña del cliente la manda siempre, y un 400 sobre un evento de Meet le rompería la grabación por un campo que sólo sobra. Mandar la contraseña sobre un evento YA agendado reemplaza el bot (lo borra y lo vuelve a agendar): la clave de deduplicación de Recall no incluye la contraseña, así que sin eso el 409 se veía como éxito y el bot se quedaba sin ella para siempre. Al prender agenda el bot para 2 minutos antes del inicio y crea (o actualiza) la reunión con su cliente; al apagar borra el bot y pasa la reunión a CANCELLED. El evento tiene que pertenecer al calendario del vendedor que se declara (con X-API-Key, el de salesRepEmail): el límite es ése, no "no podés tocar lo ajeno". Volver a prender sobre una reunión COMPLETED o PROCESSING no reescribe su desenlace. Requiere link de videollamada.
DELETE /api/calendar/disconnect JWT/X-API-Key Desconecta el calendario. BORRA el calendario en Recall, no sólo el vínculo local: no es un apagado reversible con un clic.
GET /api/notifications JWT/X-API-Key Últimas 20 notificaciones + unreadCount
PATCH /api/notifications/:id/read JWT/X-API-Key Marcar una notificación como leída
POST /api/notifications/mark-all-read JWT/X-API-Key Marcar todas como leídas
DELETE /api/notifications/read JWT/X-API-Key Eliminar las notificaciones ya leídas. Devuelve { deleted: N }
GET /api/me JWT/X-API-Key Perfil del rep autenticado
PATCH /api/me JWT/X-API-Key Editar nombre del rep
GET /api/sales-reps JWT/X-API-Key Listar todos los reps (admin only)
GET /api/clients/nombres JWT/X-API-Key Los nombres de empresa que existen, uno por cliente y ordenados alfabéticamente: { empresas: string[] }. Es la lista válida para el filtro ?cliente= de /api/meetings — hay que mandar el nombre TAL CUAL viene acá, porque la comparación ignora mayúsculas pero no acentos ni puntuación. Respeta la misma visibilidad que el resto: un rep no admin no ve el nombre de un cliente cuyos eventos no podría abrir. Hoy son ~1 000 empresas y ~19 KB, pensado para llenar un desplegable de una sola vez.
PATCH /api/sales-reps/:id JWT/X-API-Key Toggle is_admin de otro rep (admin only). Body: { is_admin: bool }
GET /api/sales-reps/by-email JWT/X-API-Key Buscar rep por email (?email=). Devuelve id, name, email y rol (vendedor|soporte|general) — es la vía no-admin para conocer el rol, ya que el listado completo pide admin. is_admin NO se expone acá: es un permiso, no un rol.
GET /api/sales-reps/:id/performance JWT/X-API-Key Métricas: avgScore, estimatedCloseRate, topOpportunities, recentMeetings
GET /api/sales-reps/:id/performance/breakdown JWT/X-API-Key Promedio por dimensión 0-20 sobre los últimos N días (?days=30)
GET /api/sales-reps/:id/performance/weekly JWT/X-API-Key Score semanal ISO (Lunes CDMX) últimas N semanas (?weeks=8)
GET /api/admin/configuracion JWT/X-API-Key La configuración de esta empresa: dominios que autorizan a entrar, dominios «de la casa» (quién NO es el cliente de una reunión) y orígenes permitidos para el callback del calendario. Dice además de dónde salió cada campo —`base` o `entorno`— y qué quedaría si se deja sin configurar (admin).
PUT /api/admin/configuracion JWT/X-API-Key Cambiarla sin desplegar. INSERTA una versión nueva, no actualiza: cada cambio queda con fecha y autor, y parte de la vigente. Tres cosas distintas: omitir un campo lo deja como está; mandarlo en null lo devuelve al control de la variable de entorno; mandarlo vacío ([]) lo deja configurado y vacío a propósito. Cero dominios que autoricen se rechaza —nadie podría entrar— y la lista tiene que incluir tu propio dominio. Los orígenes se guardan normalizados a esquema+host+puerto. integraciones.hubspot: false apaga HubSpot en este despliegue, y apaga CUATRO cosas y no una: deja de publicar la nota, de enriquecer el contacto del cliente, de importar llamadas telefónicas y de mapear owners; sus rutas contestan 409 INTEGRACION_NO_ACTIVA (admin).
GET /api/admin/criterio-de-desempeno JWT/X-API-Key Qué cuenta hoy para el desempeño, con su versión y desde cuándo rige (admin).
PUT /api/admin/criterio-de-desempeno JWT/X-API-Key Cambiar qué cuenta. INSERTA una versión nueva, no actualiza: cada cambio queda con fecha y autor. Mueve números que la gente ya vio (admin).
GET /api/admin/team-performance JWT/X-API-Key Performance agregado de todo el equipo (admin only). Reemplaza N+1 de /admin. avgScore y closeRate usan el mismo criterio de filas que /performance (COMPLETED + ventas + sin_conversacion=false + ended_at no nulo); lo que no comparte es la ventana, este es de todo el tiempo.
GET /api/cron/process-queue CRON_SECRET Procesa la cola de jobs (download_audio, analyze_transcript, sync_hubspot). Vercel lo dispara cada minuto.
POST /api/webhooks/recall Svix Webhook firmado por Recall.ai (transcript.done, bot.done, etc.)
GET /api/meetings/:id/agents JWT/X-API-Key Agentes disponibles para la reunión (según meeting_type) + preguntas sugeridas por agente.
GET /api/meetings/:id/agents/:agentKey/messages JWT/X-API-Key Historial del chat (privado por usuario) con ese agente.
POST /api/meetings/:id/agents/:agentKey/messages JWT/X-API-Key Pregunta al agente. Respuesta por SSE: frames {delta} y frame final {done, message_id}.
DELETE /api/meetings/:id/agents/:agentKey/conversation JWT/X-API-Key Reinicia el chat: borra el thread de OpenAI y la conversación.
POST /api/meetings/:id/agent-feedback JWT/X-API-Key 👍/👎 (+ corrección en dislike) sobre una respuesta del agente. source_ref = message_id del chat.
DELETE /api/meetings/:id/agent-feedback JWT/X-API-Key Deshacer mi voto PENDIENTE de un origen (toggle off). Idempotente (200 aunque no haya voto). 409 si ya lo revisó un admin.
GET /api/meetings/:id/my-agent-feedback JWT/X-API-Key Mis votos pendientes en la reunión (para pintar el estado del badge).
GET /api/sales-subtypes JWT/X-API-Key Subtipos de venta ACTIVOS (evaluadores) en orden de funnel, con label y max — la escala de la rúbrica (suma de sus dimensiones), para poder normalizar la nota a /100 sin adivinar. Es la lista válida para sales_subtype en /reanalyze: NO es un enum fijo, son filas, y se pueden crear más sin desplegar. ?canal= acota a los que aplican a ese canal, y sus valores son videollamada, telefonica y chat — no son los del tipo de evento interno, donde una llamada es "llamada" y el canal del evaluador es "telefonica". Un canal sin evaluadores activos devuelve lista vacía: hoy es el caso de chat, cuya rúbrica existe pero está desactivada.
GET /api/admin/agents JWT/X-API-Key Listar agentes del registro, incl. inactivos (admin).
POST /api/admin/agents JWT/X-API-Key Alta de agente (admin). `category: evaluacion` + `definition` crea un EVALUADOR: sus instrucciones se arman de la rúbrica y nace APAGADO hasta que alguien lo pruebe y lo prenda.
GET /api/admin/agents/models JWT/X-API-Key Allowlist de modelos permitidos (con flag file_search) (admin).
GET /api/admin/agents/:key JWT/X-API-Key Detalle del agente + historial de versiones de instrucciones (admin).
PUT /api/admin/agents/:key/instructions JWT/X-API-Key Editar instrucciones (versionado; OpenAI primero, luego BD) (admin).
PUT /api/admin/agents/:key/suggested-questions JWT/X-API-Key Editar las preguntas sugeridas (chips) (admin).
POST /api/admin/agents/:key/test JWT/X-API-Key Probar el agente con transcript/pregunta sin persistir (admin).
GET /api/admin/agents/:key/files JWT/X-API-Key Archivos del vector store del agente + shared_with (admin).
POST /api/admin/agents/:key/files JWT/X-API-Key Subir archivo (raw body + header X-Filename; pdf/txt/md/csv/docx, máx 20MB) (admin).
DELETE /api/admin/agents/:key/files/:fileId JWT/X-API-Key Quitar un archivo del vector store (admin).
PUT /api/admin/agents/:key/model JWT/X-API-Key Cambiar el modelo (guard de file_search si tiene store) (admin).
PUT /api/admin/agents/:key/params JWT/X-API-Key Ajustar temperature (0-2) y top_p (0-1) (admin).
PUT /api/admin/agents/:key/name JWT/X-API-Key Renombrar el agente (display_name + name del assistant) (admin).
PUT /api/admin/agents/:key/active JWT/X-API-Key Baja lógica: archivar (false) o reactivar (true) (admin).
GET /api/admin/corrections JWT/X-API-Key Cola de correcciones de agentes por estado (?status=pending) (admin).
PUT /api/admin/corrections/:id JWT/X-API-Key Aprobar (lesson_text + pii_checked) o rechazar; aprobar regenera lecciones.md (admin).
POST /api/meetings/invite-bot JWT/X-API-Key Invitar al bot a una reunión NO-ventas (general). Crea fila con sales_rep_id=NULL y meeting_type=general.
PATCH /api/meetings/:id JWT/X-API-Key Editar title y/o customerInfo —company, contactName, contactEmail, phone, dealValue, dealCurrency— (y hubspotContactId; sales_rep_id solo admin). No toca el texto IA. meetingType (solo admin) fija el tipo a mano cuando el clasificador se equivocó: queda marcado como manual, el clasificador deja de opinar sobre esa reunión y se encola un reanálisis completo para reencuadrar el texto. meetingType: null suelta el tipo y se lo devuelve al clasificador. Ojo: el tipo decide quién ve la reunión.
POST /api/meetings/:id/sync-hubspot JWT/X-API-Key Re-disparar manualmente el sync a HubSpot (encola job force). 202 con jobId.
PATCH /api/meetings/:id/customer Llave del agendador Write-back del CRM (Twenty). SÓLO la llave dedicada AGENDADOR_API_KEY: la del CRM y la de servicio dan 401 acá, y ésta da 401 en todo lo demás. Body: { salesRepEmail, customerInfo?, title? }. customerInfo acepta company, contactName, contactEmail, phone, dealValue y dealCurrency, y se aplica como merge. El salesRepEmail acá NO es identidad de lectura: autoriza que ese vendedor sea dueño de la reunión (cierra el IDOR). Nunca 404 de negocio: los no-ops responden 200 {applied:false, reason} con reason ∈ meeting_not_found | not_owner | general_meeting | nothing_to_apply, así que hay que leer el BODY y no sólo el status. Un sub-campo desconocido dentro de customerInfo se ignora en vez de tirar 400 (forward-compat), pero un valor inválido en un campo conocido sí es 400 — y ese 400 se lleva puesto el title que viajaba en la misma request.
GET /api/meetings/existentes Llave del CRM SÓLO la llave del CRM (403 para cualquier otra identidad, incluido un admin con sesión). NO lleva `salesRepEmail`: es una ruta de capacidad y no resuelve ninguna identidad — si lo mandan igual, se ignora. Feed de EXISTENCIA para el descubrimiento: id, updated_at y ocurrio_en, sin contenido. No aplica filtro de visibilidad — por eso no devuelve nada que se pueda filtrar. Su cursor tiene la misma FORMA que el de ?updatedSince= pero NO es intercambiable: cruzarlos da 400. No reporta borrados.
GET /api/meetings/:id/audio JWT/X-API-Key Signed URL del audio (1h) con fallback a Recall. En llamadas devuelve la URL firmada de HubSpot. 409 AUDIO_NOT_READY si todavía no está (reintentar sirve), 409 AUDIO_NO_DISPONIBLE si no hay de dónde sacarlo (esperar no sirve) y 409 AUDIO_STREAM_ONLY si la llamada solo se puede oír desde el reproductor.
GET /api/meetings/:id/hubspot/candidates JWT/X-API-Key Picker de Contacts de HubSpot por full-text (?q=, ?limit=) cuando el match por email falló.
GET /api/meetings/:id/hubspot/preview JWT/X-API-Key Preview del HTML que tendrían la Nota y el Meeting engagement si se sincronizara ahora. No toca HubSpot.
GET /api/meetings/:id/hubspot/nota-existente JWT/X-API-Key Si el contacto ya tiene una nota de esta conversación que no publicamos nosotros (típicamente de Diio), la devuelve. Sólo lee HubSpot.
DELETE /api/meetings/:id/hubspot/nota JWT/X-API-Key Retira de HubSpot lo que publicamos: la Nota con el análisis y, si lo creamos, el Meeting engagement. Sólo admin. Deja la reunión como no sincronizada. No toca contacto, negocio ni la llamada original.
PUT /api/meetings/:id/hubspot/bloqueo JWT/X-API-Key Bloquear o desbloquear la publicación a HubSpot de esta conversación (body: {bloqueada}). Sólo admin. Bloquear NO retira lo ya publicado: impide de aquí en adelante.
GET /api/hubspot/contacts/search JWT/X-API-Key Búsqueda full-text de Contacts en HubSpot (?q=, ?limit=) para autocompletar el form de agendar.
GET /api/me/hubspot/owner JWT/X-API-Key Estado del vínculo del rep con su HubSpot owner (auto-resolución lazy por email si está vacío).
GET /api/me/hubspot/owner/candidates JWT/X-API-Key Picker de owners de HubSpot por texto (?q=, ?limit=) cuando el match automático por email falla.
PATCH /api/me/hubspot/owner JWT/X-API-Key El rep setea/limpia su override de HubSpot owner (null = volver a auto-resolución). Valida el id contra HubSpot.
PATCH /api/sales-reps/:id/hubspot-owner JWT/X-API-Key Admin asigna el HubSpot owner de otro rep (null = limpiar). Valida el id contra HubSpot (admin).
DELETE /api/admin/meetings/:id JWT/X-API-Key Borrado físico de la reunión en cualquier estado: BD + Recall + Storage (best-effort) (admin).
GET /api/admin/cron-health JWT/X-API-Key Métricas del processing_queue para detectar si el cron está atrasado (admin).
GET /api/cron/cleanup-storage CRON_SECRET Barrido diario: purga media de Storage vencida + filas viejas de processing_queue. Vercel Cron.
GET /api/transcript-corrections JWT/X-API-Key Listar correcciones de transcript (?onlyActive=true). Lectura abierta a cualquier autenticado.
POST /api/transcript-corrections JWT/X-API-Key Crear corrección de transcript (admin).
PATCH /api/transcript-corrections/:id JWT/X-API-Key Actualizar una corrección de transcript (admin).
DELETE /api/transcript-corrections/:id JWT/X-API-Key Borrar una corrección de transcript (admin).

Ejemplos por endpoint

Cada bloque expandible muestra request real (curl), response esperada con el JSON exacto que devuelve cb-dio, y los códigos de error más comunes con su causa. Copia el curl, exporta SERVICE_API_KEY=<tu valor> y prueba.

POST /api/meetings/schedule — Agendar reunión (futuro o ahora)

Si omites meetingUrl, cb-dio llama a n8n para generar un Google Meet (default). Si lo mandas, debe ser un link de Google Meet, Zoom o Microsoft Teams — otros providers (Webex, Whereby, etc.) son rechazados con 400. Si omites scheduledAt, agenda para ahora mismo. customerInfo entero es opcional: la reunión puede nacer sin cliente —es lo que hace el botón «Grabar reunión» del panel, porque al entrar a la llamada el contacto muchas veces todavía no existe en HubSpot— y se completa después por PATCH. Si lo mandas, dentro son obligatorios contactName y al menos uno de contactEmail o phone: es con lo que la reunión se liga a su cliente en HubSpot, y sin ninguno de los dos se quedaría sin cliente para siempre. El phone es la salida para el contacto de mensajería, que no tiene correo — se guarda tal cual llega (basta con que traiga diez dígitos; no hace falta forzar +52). meetingPassword es opcional: úsalo si pegas un link de Zoom sin el ?pwd=... embebido; se reenvía a Recall y no se persiste en BD.

Request
curl -X POST https://meet.cibi.clickbalance.net/api/meetings/schedule \
  -H "X-API-Key: $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "salesRepEmail": "desarrollo03@clickbalance.com",
    "salesRepName": "Emmanuel Hernández",
    "customerInfo": {
      "company": "Acme S.A.",
      "contactName": "Pedro García",
      "contactEmail": "pedro@acme.com",
      "dealValue": 50000,
      "dealCurrency": "MXN"
    },
    "scheduledAt": "2026-05-15T15:00:00-06:00"
  }'
Response — 201
{
  "meetingId": "8c1b6e2f-...-...-...-c1f2e3a4b5c6",
  "meetUrl": "https://meet.google.com/abc-defg-hij",
  "scheduledAt": "2026-05-15T15:00:00-06:00",
  "status": "SCHEDULED"
}
Errores comunes
StatusCodeCuándo
400VALIDATION_ERRORfalta contactName, no viene ni contactEmail ni phone, email inválido, teléfono con menos de diez dígitos, scheduledAt sin offset, etc.
400MISSING_SALES_REP_EMAILusaste X-API-Key pero no mandaste salesRepEmail. OJO: habla de la PETICIÓN, no de la reunión — salta antes de consultar ninguna. No aplica a las rutas de capacidad como /api/meetings/existentes, que no piden identidad
401UNAUTHORIZEDX-API-Key incorrecto o JWT expirado
502N8N_ERRORno se mandó meetingUrl y n8n falló al generar Meet
POST /api/meetings/start — Iniciar reunión ahora (legacy)

Wrapper de compatibilidad: scheduledAt fijo en null y meetingUrl obligatorio (Google Meet, Zoom o Microsoft Teams). meetingPassword opcional para links Zoom "limpios". Para nuevos integradores usa /schedule. Mismas reglas de customerInfo que /schedule: es opcional entero, y si lo mandas van contactName y al menos uno de contactEmail o phone — el ejemplo usa el teléfono, que es el caso del contacto de mensajería.

Request
curl -X POST https://meet.cibi.clickbalance.net/api/meetings/start \
  -H "X-API-Key: $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "meetingUrl": "https://meet.google.com/abc-defg-hij",
    "salesRepEmail": "desarrollo03@clickbalance.com",
    "customerInfo": { "contactName": "Pedro García", "phone": "+52 33 1020 0206" }
  }'
Response — 201
{
  "meetingId": "8c1b6e2f-...-c1f2e3a4b5c6",
  "meetUrl": "https://meet.google.com/abc-defg-hij",
  "scheduledAt": null,
  "status": "JOINING"
}
GET /api/meetings — Listar reuniones (paginado + filtros) — incluye el contrato de polling para el CRM

Non-admin ve sus propias + reuniones meeting_type en (general, tecnica, planificacion) ajenas (read-only). Admin ve todo. status: SCHEDULED|JOINING|IN_PROGRESS|PROCESSING|COMPLETED|FAILED|CANCELLED. search: busca en title, company y contactName.

Polling incremental — ?analysisUpdatedSince=<valor>: el mismo parámetro acepta dos cosas: (a) un ISO 8601 (offset permitido) en la primera llamada, o (b) el cursor opaco nextSince devuelto por una llamada anterior — se manda tal cual, sin interpretarlo. Cualquier otro valor responde 400 VALIDATION_ERROR, nunca filtra en silencio.

Devuelve, en orden ASCENDENTE por effectiveUpdatedAt (el campo que se agrega a cada item en este modo), las reuniones cuyo analyses.updated_at avanzó desde ese instante, más las FAILED cuyo ended_at cae en la ventana (esas no tienen fila en analyses; effectiveUpdatedAt en ese caso ES el ended_at). Las FAILED sin ended_at quedan afuera: no hay timestamp que comparar.

Semántica de borde: con un ISO 8601, la comparación es >= (inclusiva). Con un cursor nextSince, es ESTRICTAMENTE POSTERIOR al par (effectiveUpdatedAt, meetingId) que codifica — así un backfill con miles de filas con el mismo updated_at avanza igual en vez de repetir siempre el mismo lote.

nextSince en la respuesta: viene SOLO cuando hay más por traer. Ausente o null significa "estás al día" — no hace falta comparar nada más, sólo repetir la consulta más tarde con el mismo analysisUpdatedSince. No hay un tope fijo de candidatos: el pre-filtro pide limit + 1 filas a cada fuente (analyses / FAILED), así que el .in() de la query principal nunca lleva más de limit ids — esto es a propósito: con un tope fijo de 500 el .in() podía superar el límite de tamaño de headers de PostgREST (~16 KB) y la respuesta era 500 DB_ERROR en vez de datos.

?updatedSince=<valor>: la variante que además detecta cambios de la FILA de la reunión (título, customer_info, status, responsable) vía meetings.updated_at. Mismo formato de valor, mismo cursor, misma respuesta. analysisUpdatedSince se queda como está —sólo análisis y fallos— para no cambiarle el resultado en silencio a quien ya lo usa; mandar los dos a la vez responde 400. Si una reunión cambió en más de una fuente se fecha con el timestamp más ANTIGUO, así que puede repetirse entre pasadas. La garantía es «nunca se salta una reunión», no «sale una sola vez»: el consumidor tiene que ser idempotente.

Sin ninguno de los dos, el listado no cambia: sigue ordenado por created_at DESC y sin effectiveUpdatedAt ni nextSince.

Verificado contra producción el 2026-08-12 con el algoritmo anterior: una ventana de 517 reuniones cambiadas se recorrió en 3 llamadas encadenadas (limit=200: 200 + 200 + 117, la última con nextSince ausente), sin ids faltantes. El «sin ids repetidos» de esa corrida ya no es una promesa del contrato: la regla de dedupe cambió para no saltarse reuniones, y el precio es que una puede repetirse entre pasadas.

Request
curl "https://meet.cibi.clickbalance.net/api/meetings?analysisUpdatedSince=2026-08-10T00:00:00Z&limit=200" \
  -H "X-API-Key: $SERVICE_API_KEY"

# Siguiente llamada: reusar nextSince tal cual, sin interpretarlo
curl "https://meet.cibi.clickbalance.net/api/meetings?analysisUpdatedSince=eyJ2IjoxLCJ1IjoiMjAyNi0wOC0xMFQxNjoyOToyOC43MTU5NTUrMDA6MDAiLCJpZCI6ImZkZGUyNDM0LTkyY2MtNDE0Ni04NjYwLWRkYjFkZDg5NmExYyJ9" \
  -H "X-API-Key: $SERVICE_API_KEY"
Response — 200
// Capturada contra producción el 2026-08-12 (limit=2). customer_info y
// los datos de los reps y las URLs de Meet están anonimizados a mano; ids, timestamps,
// effectiveUpdatedAt y nextSince son exactamente lo que devolvió el servidor.
{
  "data": [
    {
      "id": "45e74186-5275-4cab-8068-078a4d52660c",
      "sales_rep_id": "af5abdee-6da5-4b1c-b157-429c61d94802",
      "created_by_rep_id": "af5abdee-6da5-4b1c-b157-429c61d94802",
      "meeting_url": "https://meet.google.com/abc-defg-hij",
      "platform": "Google Meet",
      "customer_info": {
        "company": "Acme S.A.",
        "contactName": "Persona de Contacto",
        "contactEmail": "contacto@ejemplo.com",
        "dealValue": 0,
        "dealCurrency": "MXN",
        "dealValueDisplay": "$0 MXN"
      },
      "status": "COMPLETED",
      "error_code": null,
      "error_message": null,
      "meeting_type": "general",
      "started_at": "2026-08-10T15:58:02.432161+00:00",
      "sales_reps": { "id": "af5abdee-...", "name": "Vendedor Ejemplo", "email": "vendedor@clickbalance.com" },
      "created_by": { "id": "af5abdee-...", "name": "Vendedor Ejemplo", "email": "vendedor@clickbalance.com" },
      "analyses": { "sales_subtype": null, "sales_rep_score": null, "close_probability": null },
      "effectiveUpdatedAt": "2026-08-10T16:14:18.56274+00:00"
    },
    {
      "id": "fdde2434-92cc-4146-8660-ddb1dd896a1c",
      "sales_rep_id": "60a84387-e134-4eb6-80b5-1d9c4dd47402",
      "created_by_rep_id": "60a84387-e134-4eb6-80b5-1d9c4dd47402",
      "meeting_url": "https://meet.google.com/klm-nopq-rst",
      "platform": "Google Meet",
      "customer_info": { "dealCurrency": "MXN", "dealValueDisplay": "$0 MXN" },
      "status": "COMPLETED",
      "error_code": null,
      "error_message": null,
      "meeting_type": "planificacion",
      "title": "Reunión de planificación semanal",
      "started_at": "2026-08-07T17:22:10.261504+00:00",
      "sales_reps": { "id": "60a84387-...", "name": "Organizador Ejemplo", "email": "organizador@clickbalance.com" },
      "created_by": { "id": "60a84387-...", "name": "Organizador Ejemplo", "email": "organizador@clickbalance.com" },
      "analyses": { "sales_subtype": null, "sales_rep_score": null, "close_probability": null },
      "effectiveUpdatedAt": "2026-08-10T16:29:28.715955+00:00"
    }
  ],
  "total": 2,
  "page": 1,
  "nextSince": "eyJ2IjoxLCJ1IjoiMjAyNi0wOC0xMFQxNjoyOToyOC43MTU5NTUrMDA6MDAiLCJpZCI6ImZkZGUyNDM0LTkyY2MtNDE0Ni04NjYwLWRkYjFkZDg5NmExYyJ9"
}
GET /api/meetings/:id — Detalle completo (meeting + analyses + transcript + signed URLs)

Devuelve TODO lo necesario para renderizar la página de detalle del dashboard en una sola request. Las URLs firmadas (video/audio) son válidas por 1h.

Request
curl "https://meet.cibi.clickbalance.net/api/meetings/8c1b6e2f-..." \
  -H "X-API-Key: $SERVICE_API_KEY"
Response — 200
{
  "meeting": { "id": "...", "status": "COMPLETED", "customer_info": {...}, ... },
  "transcript": {
    "id": "...",
    "language": "es",
    "raw_transcript": [
      { "speaker": "Orador 0 (Emmanuel)", "text": "Hola Pedro...", "start_ms": 0, "end_ms": 3200 }
    ],
    "full_text": "Hola Pedro... [transcript completo]"
  },
  "media": {
    "videoUrl": "https://...supabase.co/storage/v1/sign/...",
    "audioUrl": "https://...supabase.co/storage/v1/sign/...",
    "expiresAt": "2026-05-15T16:00:00.000Z"
  },
  "analyses": [
    {
      "summary": "...",
      "action_items": [...],
      "sales_rep_score": 78,
      "score_breakdown": { "scores": {...}, "justifications": {...}, "meeting_type": "ventas" },
      "opportunities": [...],
      "close_probability": 70,
      "key_objections": [...],
      "next_steps": "...",
      "participation": {
        "rep_identified": true,
        "total_speaking_seconds": 1842,
        "participants": [
          { "name": "Juan Pérez",  "email": "juan@clickbalance.com", "role": "rep",   "speaking_seconds": 1102, "percentage": 59.8 },
          { "name": "Pedro García","email": "pedro@acme.com",         "role": "guest", "speaking_seconds": 580,  "percentage": 31.5 },
          { "name": "María López", "email": null,                     "role": "guest", "speaking_seconds": 160,  "percentage": 8.7 }
        ]
      }
    }
  ]
}
DELETE /api/meetings/:id — Cancelar reunión agendada

Solo funciona en estado SCHEDULED. Si la reunión ya empezó (JOINING/IN_PROGRESS/etc.), responde 409 INVALID_STATE.

Request
curl -X DELETE "https://meet.cibi.clickbalance.net/api/meetings/8c1b6e2f-...?salesRepEmail=desarrollo03@clickbalance.com" \
  -H "X-API-Key: $SERVICE_API_KEY"
Response — 200
{ "ok": true, "status": "CANCELLED" }
Errores comunes
StatusCodeCuándo
403FORBIDDENla reunión no pertenece al rep autenticado
404MEETING_NOT_FOUNDel id no existe
409INVALID_STATEla reunión ya no está en SCHEDULED
POST /api/meetings/:id/reanalyze — Re-analizar reunión (override de subtipo)

Re-corre el pipeline IA contra la customer_info y meeting_type actuales. Aplica a cualquier meeting_type: el único requisito es que la reunión ya tenga transcripción. Body completamente opcional: si se omite —o si se manda sales_subtype: "auto"— el subtipo se autodetecta. Si se pasa un subtipo concreto, se usa ese en lugar del autodetectado, y eso sí requiere meeting_type === 'ventas'. El análisis anterior queda intacto hasta que el cron termina y hace upsert del nuevo.

Request
curl -X POST "https://meet.cibi.clickbalance.net/api/meetings/8c1b6e2f-.../reanalyze" \
  -H "X-API-Key: $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "sales_subtype": "demostracion" }'
Response — 202
{
  "jobId": "uuid-del-job",
  "meetingId": "8c1b6e2f-...",
  "status": "PROCESSING"
}
Errores comunes
StatusCodeCuándo
400VALIDATION_ERRORsales_subtype inválido o no aplica al meeting_type de la reunión
403FORBIDDENla reunión no pertenece al rep autenticado
404MEETING_NOT_FOUNDel id no existe
409TRANSCRIPT_NOT_READYel transcript aún no está disponible
PATCH /api/me — Editar nombre del rep
Request
curl -X PATCH "https://meet.cibi.clickbalance.net/api/me?salesRepEmail=desarrollo03@clickbalance.com" \
  -H "X-API-Key: $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Emmanuel Hernández" }'
Response — 200
{
  "id": "uuid-del-rep",
  "email": "desarrollo03@clickbalance.com",
  "name": "Emmanuel Hernández",
  "created_at": "2026-04-29T..."
}
GET /api/sales-reps/:id/performance — Métricas agregadas del rep

Suma reuniones COMPLETED, promedia scores, estima close rate (close_probability >= 60), y rescata top oportunidades de las 5 reuniones más recientes.

Request
curl "https://meet.cibi.clickbalance.net/api/sales-reps/<rep-id>/performance?salesRepEmail=desarrollo03@clickbalance.com" \
  -H "X-API-Key: $SERVICE_API_KEY"
Response — 200
{
  "salesRepId": "uuid",
  "name": "Emmanuel Hernández",
  "totalMeetings": 12,
  "avgScore": 76,
  "estimatedCloseRate": 58,
  "topOpportunities": ["Cliente abierto a multi-año", "Interés en módulo X", ...],
  "recentMeetings": [...]
}
GET /api/cron/process-queue — Procesar cola de jobs (Vercel Cron)

Vercel lo dispara automáticamente cada minuto. Para probar manualmente desde Postman necesitas el CRON_SECRET como Bearer.

Request
curl https://meet.cibi.clickbalance.net/api/cron/process-queue \
  -H "Authorization: Bearer $CRON_SECRET"
Response — 200
{ "processed": 3, "failed": 0 }
Errores comunes
StatusCodeCuándo
401UNAUTHORIZEDCRON_SECRET incorrecto o falta el header
POST /api/meetings/:id/agents/:agentKey/messages — Preguntar al agente de seguimiento (chat SSE)

Respuesta en streaming SSE (Content-Type: text/event-stream): cero o más frames data: {"delta":"..."} con los tokens, y un frame final data: {"done":true,"message_id":"uuid"}. El message_id es el source_ref para el feedback 👍/👎. Si el run falla después de abrir el stream, sale data: {"error":true,"code":"..."}. El body lleva question (string).

Request
curl -N -X POST "https://meet.cibi.clickbalance.net/api/meetings/<id>/agents/seguimiento_general/messages?salesRepEmail=desarrollo03@clickbalance.com" \
  -H "X-API-Key: $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "question": "¿Qué compromisos quedaron pendientes?" }'
Response — 200
data: {"delta":"Quedaron "}
data: {"delta":"dos compromisos: enviar la cotización..."}
data: {"done":true,"message_id":"8c1b6e2f-..."}
Errores comunes
StatusCodeCuándo
400MISSING_PARAMfalta question en el body
404AGENT_NOT_FOUNDagentKey no existe o no es de seguimiento
409TRANSCRIPT_NOT_READYel transcript de la reunión aún no está listo

Configuración

Lo que hace falta para integrarse no se configura acá. Estas cosas se guardan en la base con PUT /api/admin/configuracion y quedan vigentes en menos de un minuto — sin desplegar y sin entrar al servidor:

Y los hechos que la IA busca de cada prospecto se administran aparte, en /api/admin/fact-extractors: GET devuelve el catálogo completo, POST da de alta uno nuevo —siempre apagado, porque activo entra al prompt de todas las reuniones— y PUT /:key/activo lo prende o lo apaga. Ni la clave ni el tipo se pueden editar: los hechos ya extraídos quedarían con una forma distinta de la que declara su extractor. No hay borrado; apagar detiene lo que viene sin tocar lo ya citado.

Qué ve el CRM

GET /api/clasificaciones, con la llave del CRM, publica el catálogo contra el que mapear: los tipos de interacción con su etiqueta, los hechos del prospecto con su clave y su tipo, y los nombres de las reglas vigentes. El texto de cada regla no sale —es criterio interno— y los valores de los hechos tampoco: eso es contenido, y esa llave no lee contenido. version_de_configuracion es contra lo que se compara el clasificacion_version que cada reunión trae en el feed de existencia, para saber si lo que el CRM tiene se midió con el criterio de ahora o con uno anterior.

Cada campo tiene un respaldo en el entorno del despliegue, pero es sólo eso: en cuanto un administrador guarda el campo, la base manda. Y guardar valida — una entrada mal escrita vuelve con un 400 que dice qué tiene de malo, mientras que la misma entrada en el entorno se descarta al arrancar y sólo queda en el log. GET /api/admin/configuracion dice, campo por campo, si lo vigente sale de la base o del entorno.

Los orígenes se comparan por igualdad exacta y no admiten comodines. https://*.crm.example.com no vale: cada subdominio va como su propia entrada. Es la misma decisión que en los dominios de correo, y por el mismo motivo — con sufijos, un crm.example.com.attacker.net pasaría.

Las variables de entorno del despliegue no se listan en esta página: es pública, y enumerarlas describía la pila completa, el formato de las llaves y hasta el interruptor que ignora la configuración de la base. Quien opera el despliegue las tiene en .env.example, en el repositorio.

cb-dio · backend serverless en Vercel · Costos →