Doc 08 API - ronalc90/koptup GitHub Wiki
Resumen. El backend de KopTup es una API REST en Express (Node.js) que guarda los datos en MongoDB, usa Redis para sesiones, cupos y topes de gasto, y llama a OpenAI en las demos con IA. Todas las rutas viven bajo
/api/…(salvo las de salud) y cada una aplica su política de autorización en el servidor: pública, con sesión, dueño del recurso, equipo, administrador o "acceso a la demo". Esta página es la referencia completa, módulo por módulo, con los errores, los límites de uso y los flujos clave dibujados.Para quién: un desarrollador que va a usar o cambiar la API, y el dueño que quiere entender qué pasa "por dentro" cuando alguien pide una demo, la aprueba el equipo y la abre el prospecto. Todos los ejemplos son respuestas reales del backend local con datos de ejemplo; los tokens se muestran como
<oculto>.
En esta página:
- La API en una imagen
- Convenciones
- Quién puede llamar cada ruta
- Referencia por módulo
- Códigos de error comunes
- Límites de uso y topes de gasto
- Flujos clave paso a paso
- Ejemplos con curl
- Salud del servicio y documentación interactiva
- Para desarrolladores
- Limitaciones conocidas
- Páginas relacionadas
Cada petición pasa por las mismas capas antes de llegar al código que hace el trabajo. Si una capa la rechaza, la petición no sigue.
flowchart TD
classDef cliente fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef capa fill:#e0e7ff,stroke:#4f46e5,color:#312e81
classDef dato fill:#f1f5f9,stroke:#64748b,color:#334155
classDef no fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
C["Web koptup.com, curl o servidor de la web"]:::cliente
H["Cabeceras de seguridad (helmet)"]:::capa
CO{"Origen permitido (CORS)?"}
J["Lee el cuerpo JSON (máx. 2 MB)"]:::capa
RL{"Dentro del cupo?"}
R["Enrutador por prefijo /api/..."]:::capa
P{"Política de la ruta"}
K["Controlador o servicio"]:::capa
M[("MongoDB")]:::dato
RE[("Redis")]:::dato
AI["OpenAI y correo"]:::dato
E403["403 cors_forbidden"]:::no
E429["429 demasiadas peticiones"]:::no
E4xx["401, 403 o 503"]:::no
C --> H --> CO
CO -- no --> E403
CO -- "sí" --> J --> RL
RL -- no --> E429
RL -- "sí" --> R --> P
P -- rechaza --> E4xx
P -- permite --> K
K --> M
K --> RE
K --> AI
De arriba abajo: seguridad, CORS, tamaño del cuerpo, cupo de peticiones, política de la ruta y, al final, el controlador que lee o escribe en MongoDB, Redis u OpenAI.
El recorrido más importante de la API es el de una demo: alguien la pide, el equipo la aprueba, la persona activa su cuenta y la abre. El GIF muestra las peticiones reales de ese recorrido, una por cuadro:

GIF: ocho peticiones reales (solicitud, panel, aprobación, doble clic, revisión del enlace, activación, acceso a la demo y Mis demos) con las respuestas del backend local.
| Cuadro | Petición | Quién la hace | Qué demuestra |
|---|---|---|---|
| 1 | POST /api/demo-requests |
Visitante (sin sesión) | Se guarda la solicitud con código DR-AAAA-XXXXXX y estado pendiente
|
| 2 | GET /api/admin/demo-requests |
Equipo | El panel lista solicitudes con conteos por estado |
| 3 | POST /api/admin/demo-requests/:id/approve |
Admin o sales | Crea la cuenta prospect (invitado), un acceso por demo y el enlace de activación |
| 4 | El mismo approve otra vez |
Admin o sales | Un doble clic responde 409 already_processed y no duplica nada |
| 5 |
POST /api/auth/activate/check y POST /api/auth/login
|
Prospecto | El enlace se revisa sin gastarlo; antes de activar, el login responde account_not_activated
|
| 6 | POST /api/auth/activate |
Prospecto | Fija la contraseña y devuelve la sesión; el enlace ya no sirve una segunda vez |
| 7 | GET /api/demo-access/erp |
Servidor de la web | Sin sesión: sin_sesion; con la sesión de Laura: allowed: true, motivo grant
|
| 8 | GET /api/me/demos |
Prospecto | Sus accesos con días restantes y visitas |
| Tema | Regla |
|---|---|
| URL base | La web la toma de su variable NEXT_PUBLIC_API_URL (ver Operación y despliegue). En desarrollo el backend escucha en http://localhost:3001 (PORT). En esta página usamos $API para no repetirla |
| Formato | JSON en la petición y en la respuesta (Content-Type: application/json), salvo las subidas de archivos (multipart/form-data) y las descargas de Excel |
| Respuesta correcta |
{ "success": true, "data": …, "message"?: "…" }. Las listas paginadas traen data.items, data.total, data.page, data.pages y, en el panel de demos, data.conteos
|
| Respuesta de error |
{ "success": false, "code": "…", "message": "…" } y, según el caso, fields, errores, motivo o retryAfterSec (ver sección 5). Todas las respuestas 4xx y 5xx traen code: las rutas antiguas que responden a mano pasan por un middleware que completa code (según el estado HTTP) y message
|
| Sesión | Cabecera Authorization: Bearer <access token>. El access token es un JWT que dura 15 minutos (JWT_EXPIRES_IN); el refresh token dura 7 días (JWT_REFRESH_EXPIRES_IN) y se guarda en Redis, uno por cuenta
|
| Rol | Va dentro del token, pero las rutas del equipo y las de demos vuelven a leer el rol en MongoDB en cada petición: si a alguien le quitan el rol, pierde el acceso de inmediato |
| Fechas | ISO 8601 en UTC (2026-10-23T07:56:09.487Z). La web las muestra en hora de Colombia |
| Identificadores | Casi todo usa el _id de MongoDB (24 caracteres hexadecimales). Excepciones: pedidos (ORD-0001), entregables (DEL-0001), conversaciones (CONV-0001), solicitudes de demo (código DR-2026-XXXXXX, además del _id) y bots del chatbot (kbot_…) |
| Paginación |
?page=1&limit=20 (máximo 100) en los listados del panel de demos y la bitácora |
| CORS | Solo los orígenes https://koptup.com y https://www.koptup.com (más localhost:3000 y 3001 fuera de producción y los que agregue CORS_ORIGIN). Las peticiones sin cabecera Origin (curl, servidor a servidor) no pasan por CORS |
| Cabeceras propias |
X-Bot-Owner-Token (dueño de un bot del chatbot). El servidor de la web usa además una cabecera interna para informar la IP real del visitante |
| Tamaño del cuerpo | 2 MB para JSON y formularios; 15 MB solo en POST /api/chatbot/bots/:botId/docs (archivos en base64) |
Cada ruta declara una de estas políticas. El inventario completo vive en el código, en apps/backend/src/routes/POLICIES.md; aquí está resumido y en la sección 4 aparece ruta por ruta. El detalle de los roles está en Roles y permisos.
| Política | Quién pasa | Si no pasa |
|---|---|---|
| Pública | Cualquiera (con cupo de peticiones y, si usa IA, tope de gasto) | 429 o 503 |
| Autenticado | Un access token válido | 401 No token provided, Invalid token o TOKEN_EXPIRED
|
| Dueño del recurso | Sesión + el controlador comprueba que el registro es de esa cuenta (o que es del equipo) | 403 o 404 |
| Equipo | Roles admin, manager, sales
|
403 Forbidden - Insufficient permissions
|
| Admin | Rol admin
|
403 |
| Acceso a demo | Equipo (admin, manager, sales, developer) o quien tenga un acceso vigente a esa demo; las demos en modo publico quedan abiertas |
401 login_required, 403 demo_access_required o demo_disabled, 503 si MongoDB no responde |
| Dueño del bot | Quien presente el token del bot en X-Bot-Owner-Token
|
401 owner_token_required o 403 forbidden_not_owner
|
| Solo desarrollo | Admin, y solo fuera de producción | 404 en producción |
| Webhook firmado | Meta, con la cabecera X-Hub-Signature-256 (HMAC-SHA256 del cuerpo crudo con la clave secreta de la app) |
401 invalid_signature; 404 si el webhook no está configurado |
La política Acceso a demo es la que conecta la API con el sistema de demos. Así decide el servidor:
flowchart TD
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef no fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef dec fill:#fef3c7,stroke:#d97706,color:#78350f
A["Petición a una API de demo"] --> B{"Token válido y rol de equipo?"}:::dec
B -- "sí" --> OK1["Pasa (motivo staff)"]:::ok
B -- no --> C{"La demo existe y está activa?"}:::dec
C -- no --> N1["403 demo_disabled"]:::no
C -- "sí" --> D{"Modo de la demo"}:::dec
D -- publico --> OK2["Pasa (motivo publico)"]:::ok
D -- "solicitud o privado" --> E{"Hay sesión?"}:::dec
E -- no --> N2["401 login_required, motivo sin_sesion"]:::no
E -- "sí" --> F{"DemoGrant activo con expiresAt futuro?"}:::dec
F -- "sí" --> OK3["Pasa (motivo grant) y registra el uso"]:::ok
F -- no --> N3["403 demo_access_required, motivo expirado, revocado o sin_acceso"]:::no
La vigencia se compara con la hora actual en cada petición: un acceso vencido o revocado se niega al instante, sin esperar el proceso que corre cada hora.
La ruta pública GET /api/demo-access/:slug usa exactamente la misma regla, pero siempre responde 200 y pone la decisión en data. Así la ve la web al abrir /demo/<slug>:

Demo pública sin sesión (publico), demo con solicitud sin sesión (sin_sesion), con un acceso vigente (grant, con días restantes) y demo privada sin acceso (sin_acceso).
Lo que la web muestra en cada caso está en Flujos del prospecto y cliente.
Convenciones de las tablas: Auth es la política de la sección 3; :id es el _id de MongoDB salvo que se diga otra cosa; "→" separa lo que se envía de lo que se recibe.
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| GET | /health/live |
Pública | El proceso responde (aunque no haya base de datos) | → 200 { status: "alive", timestamp }
|
| GET | /health |
Pública | Listo para atender (MongoDB conectado). Healthcheck de Railway y del monitoreo | → 200 { status: "healthy", mongo: "connected", timestamp, uptime } o 503 { status: "unavailable", mongo: "disconnected" }
|
| GET | /api-docs |
Solo fuera de producción o con API_DOCS_ENABLED=true
|
Swagger UI | Página HTML |
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| POST | /register |
Pública · 5 por hora por IP | Crear una cuenta local (rol user) desde /register
|
{ email, password (mín. 8), name } → 201 { id, email, name }; 400 User already exists
|
| POST | /login |
Pública · 10 cada 15 min por IP y correo, 30 por IP | Iniciar sesión |
{ email, password } → { accessToken, refreshToken, user: { id, email, name, role } }; 401 Invalid credentials o account_not_activated
|
| POST | /refresh |
Pública (exige un refresh token válido) | Pedir un access token nuevo |
{ refreshToken } → { accessToken }; 401 Invalid refresh token
|
| POST | /logout |
Autenticado | Cerrar sesión (borra el refresh token de Redis) | → mensaje |
| GET |
/me y /profile
|
Autenticado | Datos vigentes de la cuenta (la web protege /admin y /dashboard con esta ruta) |
→ { id, email, name, role, phone, company, provider, accountStatus, created_at, last_login }
|
| PATCH | /me |
Autenticado | Editar nombre, teléfono y empresa propios |
{ name?, phone?, company? } (otros campos se rechazan) → la cuenta; 400 invalid_profile
|
| POST | /change-password |
Autenticado · 5 cada 15 min por cuenta | Cambiar la contraseña conociendo la actual |
{ currentPassword, newPassword } → sesión nueva; 400 wrong_password, invalid_password o no_local_password
|
| POST | /forgot-password |
Pública · 3 cada 15 min por IP y correo, 10 por IP | Enviar el enlace de recuperación (1 hora) |
{ email } → siempre el mismo mensaje, exista o no la cuenta |
| POST | /reset-password |
Pública · 10 cada 15 min por IP | Fijar una contraseña nueva con el enlace |
{ token, password } → mensaje; 400 token_invalid, token_used, token_replaced o token_expired
|
| POST | /activate/check |
Pública · 20 por minuto | Revisar el enlace de activación sin gastarlo |
{ token } → { nombre, emailEnmascarado, expiresAt }
|
| POST | /activate |
Pública · 10 cada 15 min por IP | Activar la cuenta invitada y fijar la contraseña |
{ token, password } → misma respuesta que el login; 400 token_* o invalid_password
|
| GET |
/google y /google/callback
|
Pública | Entrar con Google | Redirige a Google y luego a /auth/callback de la web con un código de un solo uso (60 s); 503 si Google no está configurado |
| POST | /google/exchange |
Pública · 20 por minuto | La web canjea ese código por la sesión |
{ code } → misma respuesta que el login; 400 token_used, token_expired o token_invalid
|
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| POST | /api/contact |
Pública · 5 por minuto | Formulario de /contact: guarda un Contact (origen contact-form) con la prueba de la autorización (consentimiento: fecha, versión de la política del servidor, hash de la IP y navegador) y avisa al dueño por correo y por WhatsApp sin esperar a los proveedores (el resultado por canal queda en Contact.notificaciones; ver 4.15). Si el campo trampa website llega lleno responde 200 sin guardar nada |
{ name, email, service, message, consentimiento: true, website?, phone?, company?, budget?, timeline?, quote? } → mensaje. Sin consentimiento: true → 400 consent_required (fields: ["consentimiento"]). Datos inválidos → 400 invalid_request con fields y errores ({ campo: mensaje }) en español; límites: nombre 120, empresa 160, teléfono 40, servicio 300 y mensaje 5.000 caracteres. timeline: now, 1m, 3m u open (se guarda como plazo). quote: { service, plan?, tier?, modality? } con la cotización que el visitante venía revisando (se guarda como cotizacion): service es sistema-rag, una solución del catálogo o un servicio que el portal enlaza; plan solo va con sistema-rag, y tier y modality solo con una solución del catálogo. Un valor desconocido o incoherente responde 400 |
| GET | /api/admin/contacts |
admin, manager
|
Bandeja de contactos del panel |
?status=new|read|responded → lista con source (contact-form, demo-rag o demo-request), plazo, cotizacion y notificaciones ({ email, whatsapp } con estado, intentos, ultimoIntento, proximoIntento, error y respaldo; null en los contactos anteriores al registro de avisos) |
| PATCH | /api/admin/contacts/:id/status |
admin, manager
|
Marcar leído o respondido |
{ status } → { id, status }
|
| POST | /api/admin/contacts/:id/notify |
admin · 10 cada 15 min por cuenta |
Reenviar el aviso al dueño de ese contacto: los canales que no salieron (fallido, pendiente vencido o no configurado) o, con ?todos=1, los dos. Queda en la bitácora (contact.notify_resend) |
→ { id, notificaciones, resultado: { email, whatsapp } } (cada uno con enviado, estado, omitido, error y, en WhatsApp, respaldo); 404 not_found; 409 contact_anonymized; 429 rate_limited
|
Las rutas de prueba POST /api/contact/test-whatsapp y /api/contact/test-email se retiraron (solo existían fuera de producción y responden 404): la prueba de los avisos es POST /api/admin/notifications/test (4.15).
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| POST | /api/demo-requests |
Pública: campo trampa website, autorización de datos obligatoria, 5 envíos válidos por hora por IP y 3 por día por email |
Formulario "Solicitar demo". Si el mismo email tiene una solicitud abierta de los últimos 30 días, le suma las demos en vez de crear otra |
{ nombre, empresa, cargo?, email, telefono?, pais, tamanoEmpresa, demos[1–10], casoDeUso (10–2000), consentimiento: true, versionPolitica?, origen? } → 201 { codigo, estado, fusionada }
|
| GET | /api/demo-catalog |
Pública (caché de 30 s) | Catálogo de las 28 demos | → [{ slug, nombre, nombreEn, accessMode, activo }] y fuente (bd o respaldo si MongoDB falla) |
| GET | /api/demo-access/:slug |
Pública con sesión opcional · 120 por minuto | ¿Esta sesión puede abrir la demo? Con un acceso vigente cuenta una visita (una nueva tras 30 minutos sin actividad) |
?registrar=0 (no cuenta la visita) → { slug, allowed, accessMode, activo, reason, expiresAt, diasRestantes }; 404 not_found; 401 TOKEN_EXPIRED
|
| GET | /api/me/demos |
Autenticado | Mis demos: los accesos de la propia cuenta | → [{ id, demoSlug, demoNombre, accessMode, estado, vigente, expiresAt, diasRestantes, ultimoAcceso, accesos, url }]
|
Los valores de reason son: staff, publico, grant, sin_sesion, sin_acceso, expirado, revocado, desactivada, no_existe y no_disponible.
Estas rutas se montan antes de /api/admin para que el rol sales pueda gestionar solicitudes y accesos sin entrar al resto del panel.
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| GET | /api/admin/demo-requests |
admin, sales, manager
|
Listar solicitudes |
?estado=&demo=&q=&page=&limit= → { items, total, page, pages, conteos }
|
| GET | /api/admin/demo-requests/:id |
admin, sales, manager
|
Detalle con la cuenta ligada, los accesos y el historial (bitácora) | → { request, demos, cuenta, grants, historial }
|
| PATCH | /api/admin/demo-requests/:id |
admin, sales
|
Pasar a en_revision o volver a pendiente, o agregar una nota |
{ estado?, nota? } → la solicitud; 409 invalid_transition
|
| POST | /api/admin/demo-requests/:id/approve |
admin, sales; con demos privadas, solo admin
|
Aprobar: crea o vincula la cuenta, un acceso por demo y, si hace falta, el enlace de activación |
{ demos?, dias? (1–365), nota?, mensaje? } → { request, user, grants, activationUrl, activationExpiresAt, loginUrl, email }; 409 already_processed; 403 requires_admin; 422 team_email
|
| POST | /api/admin/demo-requests/:id/reject |
admin, sales
|
Rechazar con motivo interno (y, si se pide, avisar con un mensaje aparte) |
{ motivo (mín. 3), notificar?, mensaje? } → { request, email, notificado }
|
| POST | /api/admin/demo-requests/:id/resend-activation |
admin, sales
|
Emitir un enlace de activación nuevo (invalida el anterior) | → { user, activationUrl, activationExpiresAt, email }; 409 not_approved o account_active
|
| GET | /api/admin/demo-grants |
admin, sales, manager
|
Listar accesos |
?estado=activo|por_vencer|expirado|revocado&demo=&q=&user=&request=&page=&limit= → { items, total, page, pages, conteos }
|
| POST | /api/admin/demo-grants |
admin, sales; con demos privadas, solo admin
|
Invitación directa, sin solicitud previa |
{ email, nombre?, empresa?, telefono?, demos[1–10], dias?, nota?, mensaje? } → 201, misma forma que approve
|
| POST | /api/admin/demo-grants/:id/extend |
admin, sales (privadas: admin) |
Sumar días (también reactiva un acceso vencido) |
{ dias (1–365) } → { grant, email }; 409 grant_revoked o active_grant_exists
|
| POST | /api/admin/demo-grants/:id/revoke |
admin, sales (privadas: admin) |
Cerrar el acceso |
{ motivo? } → { grant }; 409 grant_revoked
|
| POST | /api/admin/demo-grants/:id/resend-activation |
admin, sales
|
Enlace nuevo para la cuenta del acceso (no da accesos) | → igual que en solicitudes |
| GET | /api/admin/demo-catalog |
admin, sales, manager
|
Catálogo con vigencia por defecto y orden | → [{ slug, nombre, nombreEn, accessMode, activo, duracionDiasPorDefecto, orden, updatedAt }]
|
| PATCH |
/api/admin/demo-catalog/:slug (o PATCH / con slug) |
admin |
Cambiar modo, activa, vigencia, orden o nombres |
{ accessMode?, activo?, duracionDiasPorDefecto?, orden?, nombre?, nombreEn? } → la entrada; 422 fixed_mode si se intenta cerrar el chatbot RAG |
| GET | /api/admin/audit-log |
admin |
Bitácora de acciones del equipo (no tiene pantalla en el panel: se consulta por API o en el historial de cada solicitud) |
?accion=&entidadTipo=&entidadId=&page=&limit= → { items: [{ accion, actorEmail, actorRol, entidad, detalle, fecha }], total, page, pages }
|
| POST | /api/admin/privacy/search |
admin |
Derechos del titular (Ley 1581): todo lo que el sistema guarda de una persona. El email va en el cuerpo (no en la URL) |
{ email } → { correosBuscados, resumen: { categoría: n }, bloqueos, datos, plazos }. Email inválido → 400 con fields: ["email"]
|
| POST | /api/admin/privacy/export |
admin |
Lo mismo como archivo para entregar al titular |
{ email } → JSON adjunto datos-titular-AAAA-MM-DD.json, sin contraseñas ni hashes de enlaces |
| POST | /api/admin/privacy/erase |
admin |
Supresión irreversible |
{ email, confirmacion } (el mismo email) → { referencia: "SUP-AAAA-XXXXXXXX", eliminados, anonimizados }. 400 confirmation_mismatch; 404 not_found si no hay datos; 409 team_account o 409 contractual_records (con bloqueos) si no procede. Las tres rutas dejan en la bitácora privacy.search, privacy.export o privacy.erase sin datos personales |
Las pantallas que usan estas rutas están en el Manual del administrador; por ejemplo, el enlace de activación que devuelve approve es el que el panel muestra para copiar o enviar por WhatsApp.
Todo este router exige admin o manager, salvo el cambio de rol (solo admin).
| Método | Ruta | Para qué | Entrada → salida |
|---|---|---|---|
| GET | /orders |
Todos los pedidos |
?status= → lista con cliente, ítems, adjuntos y estado de aprobación |
| PATCH | /orders/:orderId/status |
Cambiar el estado de un pedido (solo el equipo cambia estados) |
{ status: pending|in_progress|shipped|completed|cancelled }; 400 con otro valor |
| POST | /orders/:orderId/approve |
Aprobar un pedido | → { id, approvalStatus: "approved" }
|
| POST | /orders/:orderId/reject |
Rechazar (también lo cancela) | { reason? } |
| POST | /orders/:orderId/invoice |
Crear la factura de un pedido (ver Limitaciones) | → 201 { id, invoiceNumber }
|
| GET | /invoices |
Todas las facturas | ?status= |
| GET | /deliverables |
Todos los entregables | ?status=&projectId= |
| GET |
/conversations y /conversations/:id
|
Conversaciones y su detalle | ?status= |
| GET | /users |
Usuarios |
?role=&search= → { id, name, email, role, provider, lastLogin, createdAt }
|
| PATCH | /users/:id/role |
Cambiar el rol (solo admin; nadie puede quitarse su propio rol de admin) |
{ role } → { id, email, role }; queda en la bitácora |
| GET |
/contacts, PATCH /contacts/:id/status y POST /contacts/:id/notify (solo admin) |
Ver 4.3 |
En estas rutas :orderId es el código del pedido (ORD-0001), no el _id.
Todas exigen sesión. Cada controlador filtra por la cuenta que llama (o permite al equipo, donde se indica).
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| GET | /api/projects/dashboard/stats |
Autenticado | Resumen del portal | → { totalProjects, activeProjects, tasks, recentActivity }
|
| GET | /api/projects |
Autenticado | Proyectos donde la cuenta es cliente o gerente | → lista |
| POST | /api/projects |
Autenticado | Crear un proyecto (si no se indica manager_id, quien lo crea queda como gerente) |
{ name, description?, client_id?, manager_id?, status?, priority?, budget?, start_date?, end_date?, estimated_hours? } |
| GET / PUT / DELETE | /api/projects/:id |
Cliente o gerente (borrar: solo el gerente) | Ver, editar o borrar | PUT acepta name, description, status, priority, budget, fechas, horas, progress
|
| GET |
/api/projects/:id/members, /api/projects/:id/tasks
|
Participante | Miembros y tareas | → listas |
| POST |
/api/projects/:id/tasks, PUT /api/projects/tasks/:id
|
Participante | Crear o editar tareas | { title, description?, status?, priority?, assigned_to?, due_date? … } |
| GET | /api/orders |
Dueño | Mis pedidos | ?status= |
| POST | /api/orders |
Autenticado | Crear un pedido con adjuntos (hasta 10 archivos de 20 MB) |
multipart: name, description?, items (JSON [{ name, quantity, price }]), projectId?, comments?, attachments[] → 201 { id: "ORD-…", amount, status }
|
| GET | /api/orders/:orderId |
Dueño | Ver el pedido | → incluye approvalStatus, adjuntos y editable (si el cliente aún puede cambiarlo) |
| GET | /api/orders/:orderId/attachments/:attachmentId |
Dueño del pedido o equipo (admin, manager, sales) |
Descargar un adjunto (lo usa Admin › Pedidos) | → el archivo como descarga; 404 not_found si el adjunto no existe; 410 file_unavailable si el archivo ya no está en el servidor (sin volumen, Railway lo borra al redesplegar) |
| PUT | /api/orders/:orderId |
Dueño, solo mientras el pedido está pendiente de revisión | Editar nombre, descripción y adjuntos (el estado lo cambia el equipo) | JSON o multipart: name?, description?, attachments[] (agrega), removeAttachments (ids a quitar) → el pedido; 403 status_team_only, 400 invalid_fields, 409 order_not_pending
|
| POST | /api/orders/:orderId/cancel |
Dueño | Cancelar, solo mientras el pedido está pendiente de revisión |
409 order_not_pending si el equipo ya lo revisó o está en curso |
| GET | /api/invoices |
Dueño | Mis facturas | ?status=&startDate=&endDate= |
| POST | /api/invoices |
admin, manager
|
Emitir una factura | { projectId, clientInfo, items, taxRate?, dueDate?, notes?, terms? } |
| GET | /api/invoices/:id |
Dueño o equipo | Detalle | — |
| POST | /api/invoices/:id/pay |
admin, manager
|
El equipo registra el pago que confirmó (transferencia, enlace); el cliente no marca sus facturas como pagadas | { paymentMethod, transactionId? } |
| GET | /api/invoices/payment-methods |
Autenticado | Métodos de pago que el equipo configuró en Admin › Configuración (hoy, las instrucciones de transferencia). El portal muestra "Cómo pagar" solo si hay alguno | → { metodos: [{ tipo: "transferencia", instrucciones }] } o []
|
| GET | /api/invoices/:id/download |
Dueño o equipo | Devuelve datos para descargar | → { downloadUrl, fileName, expiresIn }
|
| GET |
/api/deliverables, /api/deliverables/:id
|
Dueño (detalle: dueño o equipo) | Mis entregables | ?status=&projectId= |
| POST | /api/deliverables |
admin, manager, developer
|
Registrar un entregable | { projectId, title, type, fileUrl, fileName, fileSize, description?, fileType?, metadata? } |
| PUT | /api/deliverables/:id |
Dueño | Cambiar título o descripción | — |
| POST |
/api/deliverables/:id/approve, /reject
|
Dueño o equipo | Aprobar o rechazar (rechazar exige comments) |
{ comments? } |
| GET / POST | /api/messages/conversations |
Participante (crear: equipo admin, manager o sales) |
Mis conversaciones o crear una | POST { title, clientId, projectId?, orderId? }
|
| GET | /api/messages/conversations/:id |
Participante | Conversación con sus mensajes |
:id es CONV-…
|
| POST | /api/messages/conversations/:id/read |
Participante | Marcar como leída | — |
| POST | /api/messages/send |
Participante | Enviar un mensaje | { conversationId, content, type?, attachment? } |
| GET |
/api/notifications, /api/notifications/unread-count
|
Dueño | Mis notificaciones | ?type=&unreadOnly=&limit= |
| POST | /api/notifications |
Autenticado (para otra cuenta: equipo) | Crear una notificación | { type, title, message, actionUrl?, metadata?, targetUserId? } |
| POST |
/api/notifications/:id/read, /read-all; DELETE /api/notifications/:id
|
Dueño | Marcar o borrar | — |
Toda la ruta tiene su propio cupo: 180 peticiones por minuto por IP (GET /models no cuenta) y las preguntas al chat, otro de 60 por minuto por IP. Los bots, sus documentos y el historial se guardan en MongoDB (ver Modelos de datos); sin base de datos, las rutas /bots… responden 503 storage_unavailable.
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| GET | /models |
Pública (no cuenta en el cupo por IP) | Modelos permitidos, si hay IA y si existe POST /sample-bots
|
→ { available: [{ id, name, costInputUSDper1M, costOutputUSDper1M, enabled }], activeProvider, sharedSampleBots: true }
|
| POST | /bots |
Pública · 30 bots por hora por IP; máx. 20.000 bots (CHATBOT_MAX_BOTS) |
Crear un bot. kind: "sample" marca un bot de ejemplo de una demo: nunca guarda conversaciones y se borra a los 30 días sin uso |
{ name?, color?, position?, avatar?, welcome?, systemPrompt?, tone?, languages?, kind? } → 201 con el bot (incluye kind) y ownerToken (solo esta vez) |
| GET | /bots |
Pública | Bots del dueño (según X-Bot-Owner-Token) y bots de ejemplo |
?limit=&offset= → lista resumida |
| GET | /bots/:botId |
Pública | Configuración (completa solo para el dueño; sin prompt ni documentos para el resto) | — |
| PATCH / DELETE | /bots/:botId |
Dueño del bot (un bot antiguo sin dueño registrado: solo el equipo) | Editar o borrar el bot con sus documentos y conversaciones | — |
| POST | /bots/:botId/docs |
Dueño del bot | Subir archivos en base64 (máx. 5 por envío, 5 MB c/u, 50 por bot) |
{ files: [{ name, mime, contentBase64 }] } → { docs, added, errors }
|
| DELETE | /bots/:botId/docs/:docId |
Dueño del bot | Quitar un documento | — |
| POST | /bots/:botId/urls |
Dueño del bot | Indexar páginas web (máx. 5 por envío, 2 MB c/u) | { urls: [...] } |
| POST | /bots/:botId/chat |
Pública (bot existente) · 60 preguntas por minuto por IP | Preguntar. Sin IA, sin presupuesto o con error del proveedor responde en modo extractivo citando fragmentos, con una nota que lo explica y error = llm_not_configured, budget_exhausted, budget_unavailable o llm_provider_error. Los textos que escribe el servidor salen en lang (es o en; si no viene, el primer idioma del bot). Solo con channel: "widget" (lo envía /embed/chatbot fuera de la vista previa del Builder) y en un bot de usuario se guarda la pregunta en el historial, que vence a los 30 días |
{ message (≤ 2000), history?, model?, channel?, lang? } → { reply, sources, confidence, model, tokens?, costUSD?, error? }
|
| GET / DELETE | /bots/:botId/conversations |
Dueño del bot | Historial (últimos 200 turnos vigentes) o borrarlo | ?limit= |
| POST | /sample-bots |
Pública (cuenta en el límite de creación solo la primera vez) | Bot de ejemplo compartido de «Prueba el asistente»: el servidor lo identifica por la huella de su configuración y sus textos, lo crea e indexa una sola vez y lo reutiliza; no tiene dueño, no guarda conversaciones y se borra a los 30 días sin uso |
{ sampleKey, config, files: [{ name, mime (TXT/MD/CSV), contentBase64 (≤ 256 KB) }] (1 a 5) } → { botId, docs, docsCount, shared: true }
|
La API anterior "por sesión" (/session, /upload, /message, /info, /config, /messages, /documents) se retiró: responde 404 y, al arrancar, el backend borra lo que dejó (colección chatbots y uploads/chatbot).
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| GET | /status |
Pública | ¿Está encendida? Nunca muestra montos | → { enabled, reason, budgetExhausted, limits }
|
| POST | /documents |
Pública · 10 intentos cada 10 min y 3 documentos al día por IP | Subir un PDF, DOCX o TXT (5 MB, 30 páginas) con email y autorización de datos. Primero registra el lead (demo-rag) con la prueba de la autorización; si no se puede guardar, no procesa el documento
|
multipart: file, email, consent=true → 201 { document, limits }
|
| GET | /documents/:docId |
Pública | Estado del documento (preguntas usadas, vencimiento) | → { document }
|
| POST | /documents/:docId/questions |
Pública · 30 cada 10 min por IP; 10 por documento | Preguntar |
{ question (≤ 500) } → respuesta con citas |
| DELETE | /documents/:docId |
Pública | Borrar el documento antes de la hora | → { deleted }
|
Códigos propios: demo_disabled, budget_exhausted, capacity (503); invalid_format (415); file_too_large (413); too_many_pages (más de 30 páginas), too_much_text (más de 400.000 caracteres aunque tenga 30 páginas o menos), empty_document, unreadable_document (422); daily_limit, question_limit, rate_limited (429); document_not_found (404); llm_error (502, la pregunta no se descuenta); lead_unavailable (503: no se pudo registrar la prueba, el documento no se procesó y el cupo del día se devuelve).
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| POST | /generate |
Acceso a la demo linkedin-ads · 5 cada 10 min y 30 al día por cuenta (o por IP sin sesión) |
Generar post, anuncio y variantes con IA |
{ demo: { titulo, path, … }, angulo, tono } → { data, model, usage }; 503 ai_not_configured (sin clave), budget_exhausted (cupo del mes agotado) o limits_unavailable (Redis no responde: no se puede medir el cupo), con un message que lo explica; la web sigue con su generador local; 502 llm_error o invalid_output si falla el modelo |
Todas exigen acceso a la demo gestor-contenido (abierta por defecto), texto de máximo 10.000 caracteres, presupuesto disponible y cupo de 20 cada 10 minutos y 100 al día por IP (el equipo no tiene cupo).
| Método | Ruta | Para qué | Entrada |
|---|---|---|---|
| POST | /improve |
Mejorar un texto | { content, template? } |
| POST | /change-tone |
Cambiar el tono | { content, tone, template? } |
| POST | /adjust-length |
Ajustar la extensión | { content, targetWords, template? } |
| POST | /generate-versions |
Varias versiones | { content, numVersions, template? } |
| POST | /generate |
Generar desde una plantilla | { template, userInput } |
Todas exigen sesión y cada consulta se filtra por la cuenta que llama. Las que usan IA (subir, búsqueda semántica y explicaciones) tienen tope mensual DOCUMENTS_MONTHLY_BUDGET_USD y cupo diario por cuenta DOCUMENTS_AI_DAILY_LIMIT_PER_USER (ver 6.3); sin cupo, una subida guarda el documento sin análisis y lo dice (ai: { analyzed: false, code }).
| Método | Ruta | Para qué | Entrada |
|---|---|---|---|
| POST | /upload |
Subir un documento (PDF, DOCX, TXT o CSV de hasta 10 MB; 10 por minuto). Extrae texto y, si hay IA, resumen y búsqueda semántica |
multipart: file
|
| GET | / |
Listar mis documentos | filtros de carpeta, favoritos y papelera |
| POST | /search/semantic |
Buscar por significado | { query } |
| GET / POST | /folders |
Carpetas | { name } |
| GET | /stats |
Estadísticas | — |
| POST |
/:id/explain-similarity; GET /:id/explain
|
Explicaciones con IA | — |
| PATCH | /:id |
Renombrar, mover, favorito | — |
| DELETE | /:id |
Mandar a la papelera o borrar (solo el dueño; la confirmación la pide la interfaz) | ?permanent= |
| POST | /:id/restore |
Sacar de la papelera | — |
Estas rutas sirven a dos demos privadas (solo por invitación: Cuentas médicas y Sistema experto) y a la herramienta interna /liquidacion del equipo. Las que procesan con IA (/procesar-facturas-pdf, /procesar-modular, /aprendizaje/feedback, /api/expert/procesar* y /generar-excel, /api/cups/buscar-*, vectorizar, revectorizar, /cuentas/procesar-hibrido y /process) tienen tope mensual HEALTH_MONTHLY_BUDGET_USD y cupo diario por cuenta HEALTH_AI_DAILY_LIMIT_PER_USER (el equipo solo tiene el tope mensual). La interpretación de reglas de /api/reglas-facturacion (solo el equipo) también cuenta contra ese tope mensual.
| Prefijo | Rutas | Auth | Para qué |
|---|---|---|---|
/api/auditoria |
POST /facturas, /procesar-archivos, /procesar-facturas-pdf, /procesar-modular (hasta 10 archivos de 10 MB); GET /facturas, /facturas/:id, /facturas/:id/calificar, /facturas/:id/excel, /facturas/:id/excel-auditoria-medica; DELETE /facturas/:id; POST /facturas/:id/auditar, /facturas/:id/auditar-paso-a-paso; POST /sesion/:sesionId/siguiente, GET /sesion/:sesionId; POST /soportes; GET /tarifarios; PATCH /glosas/:id; GET /estadisticas; POST /aprendizaje/feedback/:decisionId; GET /aprendizaje/estadisticas, /aprendizaje/reporte
|
Acceso a la demo cuentas-medicas
|
Auditoría de facturas médicas: extracción, glosas, auditoría paso a paso, Excel y calibración con retroalimentación humana |
/api/documentos-conocimiento |
GET /config, POST /config/inicializar · PATCH /config/:documento_id · POST /config/resetear
|
Acceso a cuentas-medicas · Equipo · Admin |
Documentos normativos que usa el motor de auditoría |
/api/expert |
POST /procesar, /generar-excel, /procesar-y-descargar; GET /configuracion, /estadisticas · PUT /configuracion
|
Acceso a la demo sistema-experto · Equipo |
Sistema experto de auditoría y su configuración |
/api/cups |
GET /estadisticas, /incompletos, /estadisticas-vectorizacion; POST /buscar-semantica, /buscar-similares · POST /importar-csv, /importar-excel, /vectorizar, /revectorizar
|
Acceso a sistema-experto · Admin |
Catálogo de procedimientos CUPS con búsqueda semántica; la importación solo acepta nombres de archivo de la carpeta de importación del servidor |
/api (cuentas) |
/cuentas (CRUD), /cuentas/:id/upload (20 PDF de 50 MB), /cuentas/:id/files/:filename (DELETE y /toggle), /cuentas/procesar-hibrido, /cuentas/search/cups|medicamentos|diagnosticos|materiales, /cuentas/calcular-tarifa, /cuentas/calcular-costo-medicamentos, /ley100 (subir hasta 10 de 50 MB, listar, borrar, /toggle), POST /process, GET /export
|
Equipo | Procesamiento de cuentas médicas y documentos de la Ley 100 con salida en Excel |
/api/liquidacion |
POST/GET /radicados, GET /radicados/:id, POST /radicados/:id/documentos (20 PDF de 50 MB), POST /radicados/:id/liquidar, GET /radicados/:id/descargar-excel, DELETE /radicados/:id, GET /estadisticas
|
Equipo | Herramienta interna de liquidación de radicados |
/api/reglas-facturacion |
POST/GET /, GET /ejemplos, POST /previsualizar, GET/PATCH/DELETE /:id, PATCH /:id/toggle
|
Equipo | Reglas globales de facturación que usa /liquidacion
|
| Prefijo | Auth | Qué es |
|---|---|---|
/api/chat (/session, /message, /history/:sessionId) |
Solo desarrollo | Chat de eco antiguo |
En producción responde 404 Endpoint not found, igual que una ruta que no existe. El diagnóstico de WhatsApp que vivía en /api/test (/whatsapp, /whatsapp/status) se retiró: lo reemplazan GET /api/admin/notifications/status y POST /api/admin/notifications/test (4.15), que también funcionan en producción.
Cada contacto nuevo (POST /api/contact, POST /api/demo-requests y la subida de «Prueba con tu documento») avisa al dueño por correo (Resend por API HTTPS; SMTP solo como respaldo) y por WhatsApp (WhatsApp Cloud API de Meta con la plantilla koptup_aviso). El envío no frena la respuesta del formulario. Estas rutas no dependen de COMMERCE_ENABLED; las de /api/admin/notifications se montan antes de /api/admin. El recorrido completo está en el diagrama 7.7 y la configuración en Operación y despliegue › 4.5.
| Método | Ruta | Auth | Para qué | Entrada → salida |
|---|---|---|---|---|
| GET | /api/admin/notifications/status |
admin, manager
|
Estado de los dos canales para la tarjeta de Admin › Configuración. Nunca devuelve keys ni tokens; los destinos salen enmascarados | → { email: { proveedor: "resend"|"smtp"|null, configurado, destino, remitente, ultimoExito, ultimoFallo, ultimoError, faltan }, whatsapp: { proveedor: "meta"|null, configurado, destino, plantilla, webhook, ultimoExito, ultimoFallo, ultimoError, faltan }, latido: { activo, cadaDias, ultimo, proximo } }
|
| POST | /api/admin/notifications/test |
admin · 5 cada 15 min por cuenta |
Envía de verdad un aviso de prueba por cada canal configurado. Queda en la bitácora (notifications.test) |
→ { email: { configurado, enviado, id?, error? }, whatsapp: { configurado, enviado, id?, error?, respaldo? } }; 429 rate_limited
|
| POST | /api/admin/contacts/:id/notify |
admin · 10 cada 15 min por cuenta |
Reenviar el aviso de un contacto (ver 4.3) |
?todos=1 reenvía también los canales ya enviados |
| GET | /api/webhooks/whatsapp |
Pública · 600 por minuto por IP | Verificación del webhook por Meta |
?hub.mode=subscribe&hub.verify_token=…&hub.challenge=… → 200 con el challenge en texto si el token coincide (comparación en tiempo constante); si no, 403 forbidden
|
| POST | /api/webhooks/whatsapp |
Webhook firmado · 600 por minuto por IP | Estados de los mensajes enviados (entry[].changes[].value.statuses[]) |
Cuerpo crudo de hasta 1 MB con X-Hub-Signature-256 → 200 { success: true } de inmediato y procesa después; 401 invalid_signature; 400 invalid_json
|
Las dos rutas del webhook responden 404 not_found mientras falte WHATSAPP_WEBHOOK_VERIFY_TOKEN o WHATSAPP_APP_SECRET. Se montan antes del parser JSON global porque la firma se calcula sobre el cuerpo crudo, y no pasan por el límite general de /api.
Estados de Contact.notificaciones.<canal>.estado:
| Estado | Cuándo | Qué sigue |
|---|---|---|
pendiente |
Recién creado (el envío queda «reclamado» 10 minutos) o en curso | Si pasan 10 minutos sin resolverse (por ejemplo, un reinicio a mitad del envío), el job de reintentos lo retoma |
enviado |
El proveedor lo aceptó: Resend devolvió un id o Meta un wamid (se guarda en idProveedor). También si salió con un respaldo (respaldo: [email protected] o hello_world) |
Nada. En WhatsApp, el webhook lo puede pasar a entregado, leido o fallido
|
entregado, leido
|
Solo WhatsApp, confirmado por el webhook | Un estado nunca retrocede (un delivered tardío no pisa un read) |
fallido |
El proveedor lo rechazó, no respondió en 15 s o Meta informó failed por el webhook. error guarda el motivo (máximo 300 caracteres, sin secretos y con los correos enmascarados) |
El job lo reintenta con proximoIntento: esperas de 5 min, 15 min, 1 h, 3 h y 12 h, hasta 6 intentos, solo en contactos de los últimos 3 días |
no_configurado |
Faltaban las variables de ese canal cuando llegó el contacto (error dice cuáles) |
El reenvío manual lo intenta si ya se configuró |
Forma de la respuesta de GET /api/admin/notifications/status (valores de ejemplo, no de producción):
{
"success": true,
"data": {
"email": {
"proveedor": "resend", "configurado": true, "destino": "du***@ejemplo.co",
"remitente": "KopTup <[email protected]>",
"ultimoExito": "2026-10-14T15:02:11.000Z", "ultimoFallo": null, "ultimoError": null, "faltan": []
},
"whatsapp": {
"proveedor": "meta", "configurado": true, "destino": "+57 *** *** 1234",
"plantilla": "koptup_aviso", "webhook": false,
"ultimoExito": "2026-10-14T15:02:12.000Z", "ultimoFallo": null, "ultimoError": null, "faltan": []
},
"latido": { "activo": true, "cadaDias": 30, "ultimo": "2026-10-14T15:00:03.000Z", "proximo": "2026-11-13T15:00:03.000Z" }
}
}Con un canal sin configurar, configurado es false y faltan lista las variables (por ejemplo ["RESEND_API_KEY"] o ["WHATSAPP_API_TOKEN", "ADMIN_WHATSAPP_NUMBER"]). Si WHATSAPP_PROVIDER pide un proveedor retirado (twilio, ultramsg), proveedor es null y faltan es ["WHATSAPP_PROVIDER"].

Validación con mensajes por campo, API de demo sin sesión, sin acceso, sin el rol, acción repetida y enlace ya usado: respuestas reales del backend local.
| HTTP | code |
Cuándo pasa | Qué hace la web |
|---|---|---|---|
| 400 | invalid_request |
Un formulario no pasa la validación. Trae fields (lista) y errores ({ campo: mensaje } en español) |
Marca cada campo con su mensaje |
| 400 | consent_required |
Falta la autorización de datos (Ley 1581) | Resalta la casilla |
| 400 |
invalid_json, invalid_data, bad_request
|
Cuerpo que no es JSON, id mal formado o dato inválido | Mensaje genérico |
| 400 |
token_invalid, token_used, token_replaced, token_expired
|
Enlace de activación o de recuperación que no sirve | Pantalla de enlace inválido con la salida que corresponde |
| 400 |
upload_limit_file_size y similares |
El archivo supera el tamaño o hay demasiados | Mensaje en el formulario |
| 401 |
unauthorized, invalid_token, session_invalid
|
Ruta con sesión llamada sin token, con uno inválido o de una cuenta que ya no existe | Lleva a /login
|
| 401 | TOKEN_EXPIRED |
El access token venció | Pide uno nuevo con /api/auth/refresh y repite |
| 401 | account_not_activated |
Login de una cuenta invitada que no ha usado su enlace | Explica que debe activar la cuenta |
| 401 |
login_required (con motivo: sin_sesion) |
API de una demo con solicitud o privada, sin sesión | Pide iniciar sesión |
| 401 | invalid_signature |
Evento del webhook de WhatsApp sin firma X-Hub-Signature-256 válida |
— (lo recibe Meta) |
| 403 |
demo_access_required (con motivo) |
Hay sesión pero no hay acceso vigente: sin_acceso, expirado o revocado
|
Pantalla /demo-acceso/<slug>
|
| 403 | demo_disabled |
La demo está desactivada en el catálogo | "En mantenimiento" |
| 403 | forbidden |
Falta el rol | — |
| 403 | requires_admin |
sales intenta dar o cambiar acceso a una demo privada |
Avisa que solo un admin puede |
| 403 | cors_forbidden |
Origen no permitido | — |
| 404 | not_found |
Registro o ruta inexistente | — |
| 409 |
already_processed, invalid_transition, grant_revoked, active_grant_exists, account_active, not_approved
|
La acción ya se hizo o el estado no lo permite (por ejemplo, doble clic) | Refresca y muestra el estado real |
| 409 | contact_anonymized |
Reenviar el aviso de un contacto que ya se anonimizó | El botón no aparece en esos contactos |
| 413 | payload_too_large |
Cuerpo de más de 2 MB | — |
| 422 |
team_email, fixed_mode, unknown_demo (400) |
Email de una cuenta del equipo, intento de cerrar el chatbot RAG o demo que no existe | Mensaje en el panel |
| 429 | rate_limited |
Se agotó un cupo (mensaje en español); a veces trae Retry-After y retryAfterSec
|
Pide esperar |
| 500 | internal_error |
Error no previsto. En producción no se muestran detalles internos | Mensaje genérico |
| 503 | service_unavailable |
MongoDB no responde y la ruta necesita leer el rol o el acceso (falla cerrada) | Página de servicio no disponible |
| 503 |
ai_unavailable, ai_not_configured, budget_exhausted, limits_unavailable, demo_disabled
|
Falta la clave de IA, Redis no responde (no se puede medir el cupo) o se agotó el tope del mes | La demo pasa a su modo sin IA o lo explica |
| 503 |
google_not_configured, storage_unavailable
|
El inicio con Google no está configurado; el chatbot no tiene MongoDB | Mensaje y alternativa |
Los códigos de cada módulo (chatbot, demo RAG, LinkedIn) están en su sección. Una respuesta antigua sin código propio recibe el del estado HTTP (bad_request, unauthorized, forbidden, not_found, conflict, internal_error…); en producción, un 5xx nunca devuelve detalles internos.
Los cupos marcados "memoria" se cuentan en el proceso del backend; los marcados "Redis" se comparten entre instancias.
| Dónde aplica | Cupo por defecto | Ventana | Se cuenta por | Dónde | Variable |
|---|---|---|---|---|---|
Todo /api salvo los casos de abajo |
100 | 15 min | Usuario (con token válido), sesión de refresco o IP | Memoria |
RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_MS
|
GET /api/auth/me y GET /api/demo-access/*
|
120 | 1 min | Igual que el anterior | Memoria | AUTH_ME_RATE_LIMIT_MAX |
Todo /api/chatbot salvo GET /models
|
180 | 1 min | IP | Memoria | CHATBOT_API_RATE_LIMIT_MAX |
Preguntas al chat (POST /api/chatbot/bots/:botId/chat) |
60 | 1 min | IP | Memoria | CHATBOT_RATE_LIMIT_MAX |
POST /api/auth/login |
10 · 30 | 15 min | IP y correo normalizado · IP | Memoria |
LOGIN_RATE_LIMIT_MAX, LOGIN_IP_RATE_LIMIT_MAX
|
POST /api/auth/register |
5 | 1 hora | IP | Memoria | REGISTER_RATE_LIMIT_MAX |
POST /api/auth/forgot-password |
3 · 10 | 15 min | IP y correo normalizado · IP | Memoria | FORGOT_PASSWORD_RATE_LIMIT_MAX |
POST /api/auth/reset-password · POST /api/auth/activate
|
10 · 10 | 15 min | IP | Memoria |
RESET_PASSWORD_RATE_LIMIT_MAX, ACTIVATION_RATE_LIMIT_MAX
|
POST /api/auth/change-password |
5 | 15 min | Cuenta | Memoria | CHANGE_PASSWORD_RATE_LIMIT_MAX |
POST /api/auth/google/exchange |
20 | 1 min | IP | Memoria | — |
POST /api/contact |
5 | 1 min | IP | Memoria | — |
POST /api/admin/notifications/test (aviso de prueba real) |
5 | 15 min | Cuenta | Memoria | — |
/api/webhooks/whatsapp (no pasa por el límite general) |
600 | 1 min | IP | Memoria | — |
POST /api/auth/activate/check |
20 | 1 min | IP | Memoria | — |
Subidas de /api/documents/upload
|
10 | 1 min | IP | Memoria | — |
Crear bots (POST /api/chatbot/bots) |
30 | 1 hora | IP | Memoria | CHATBOT_CREATE_LIMIT_PER_HOUR |
POST /api/demo-requests (solo envíos válidos) |
5 por IP · 3 por email | 1 hora · 24 horas | IP · email normalizado (en hash) | Redis, con respaldo en memoria |
DEMO_REQUEST_LIMIT_PER_HOUR, DEMO_REQUEST_LIMIT_PER_EMAIL_DAY
|
| Demo RAG: subir documento | 10 intentos · 3 documentos | 10 min · día de Colombia | IP | Memoria · Redis | — |
| Demo RAG: preguntar | 30 · 10 por documento | 10 min · vida del documento | IP · documento | Memoria | — |
POST /api/linkedin-ads/generate |
5 · 30 | 10 min · 24 horas | Cuenta (o IP sin sesión) | Redis (sin Redis responde 503) | — |
/api/content/* (visitantes) |
20 · 100 | 10 min · 24 horas | IP | Redis (sin Redis responde 503) | — |
Funciones con IA de /api/documents
|
30 | 24 horas | Cuenta (el equipo no tiene cupo) | Redis (sin Redis responde 503) | DOCUMENTS_AI_DAILY_LIMIT_PER_USER |
| Funciones con IA de las APIs de salud | 20 | 24 horas | Cuenta (el equipo no tiene cupo) | Redis (sin Redis responde 503) | HEALTH_AI_DAILY_LIMIT_PER_USER |
Cada ruta de cuenta tiene su propio contador: agotar el del login no bloquea el registro, la recuperación ni la activación. Los cupos por correo cuentan el correo normalizado (mayúsculas, puntos y +etiqueta de Gmail no abren un contador nuevo). Todas las respuestas 429 traen code: "rate_limited", un mensaje en español y la cabecera Retry-After.
Detrás del proxy de Railway el servidor confía en un salto (TRUST_PROXY_HOPS, por defecto 1) para leer la IP real del visitante. Los cupos estándar se ven en las cabeceras RateLimit-* de cada respuesta:

La captura es anterior a la verificación final del 14 de octubre de 2026: hoy el chatbot informa 180 por minuto en sus rutas y 60 por minuto en las preguntas. La verificación de sesión informa 120 por minuto y la demo RAG publica sus límites en /status.
| Qué | Límite |
|---|---|
| Cuerpo JSON o de formulario | 2 MB (15 MB solo para subir documentos a un bot) |
Documentos del gestor (/api/documents/upload) |
10 MB (MAX_FILE_SIZE), 1 archivo; PDF, DOCX, TXT, CSV |
| Adjuntos de un pedido | 20 MB por archivo, 10 por pedido; documentos, imágenes, ZIP y RAR |
| Demo RAG | 5 MB y 30 páginas; PDF, DOCX, TXT |
| Bots del chatbot | 5 archivos de 5 MB por envío, 50 documentos y 5.000 fragmentos por bot, 5 URL por envío (2 MB, 8 s) |
| Auditoría médica | 10 MB por archivo, 10 por envío |
| Cuentas médicas, Ley 100 y liquidación | 50 MB por PDF; 20, 10 y 20 archivos por envío |
Seis funciones con IA tienen un tope mensual en dólares. El gasto se calcula con los tokens que informa el proveedor, se suma en Redis por mes (UTC) y la API nunca devuelve montos.
| Función | Variable | Tope por defecto | Clave en Redis | Cuando se agota |
|---|---|---|---|---|
| Demo RAG "Prueba con tu documento" | DEMO_MONTHLY_BUDGET_USD |
USD 50 | demo-rag:spend:AAAA-MM |
La demo se apaga: 503 budget_exhausted
|
| Chatbot RAG | CHATBOT_MONTHLY_BUDGET_USD |
USD 50 | chatbot:spend:AAAA-MM |
Responde en modo extractivo (cita fragmentos sin IA) |
| Generador para LinkedIn | LINKEDIN_ADS_MONTHLY_BUDGET_USD |
USD 20 | linkedin-ads:spend:AAAA-MM |
503 budget_exhausted; la web usa su generador local |
| Gestor de contenido | CONTENT_MONTHLY_BUDGET_USD |
USD 20 | content:spend:AAAA-MM |
503 budget_exhausted |
Gestor de documentos (/api/documents) |
DOCUMENTS_MONTHLY_BUDGET_USD |
USD 10 | documents:spend:AAAA-MM |
503 budget_exhausted; una subida se guarda sin análisis |
| APIs de salud (cuentas médicas, auditoría, sistema experto, CUPS y reglas de facturación) | HEALTH_MONTHLY_BUDGET_USD |
USD 30 | health:spend:AAAA-MM |
503 budget_exhausted |
flowchart LR
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef no fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef dec fill:#fef3c7,stroke:#d97706,color:#78350f
A["Petición que usaría IA"] --> B{"Hay clave de OpenAI?"}:::dec
B -- no --> X1["503 o modo sin IA"]:::no
B -- "sí" --> C{"Redis responde?"}:::dec
C -- no --> X2["Falla cerrada: 503 o modo sin IA"]:::no
C -- "sí" --> D{"Gasto del mes menor al tope?"}:::dec
D -- no --> X3["budget_exhausted"]:::no
D -- "sí" --> E{"Dentro del cupo?"}:::dec
E -- no --> X4["429 rate_limited"]:::no
E -- "sí" --> F["Llama al modelo y suma el costo al mes"]:::ok
Si no se puede medir el gasto, no se gasta: sin Redis, las funciones con IA públicas se apagan o pasan a su modo sin IA.
Un tope con valor inválido o negativo deja la función sin presupuesto (cero). En documentos y salud, el cliente de OpenAI de cada función suma el costo real de cada respuesta y no llama al modelo con el tope agotado o sin Redis, aunque el servicio que lo use no pase por la ruta con cupo.
Los cuatro diagramas siguientes cubren el recorrido completo solicitud → aprobación → activación → acceso. El mismo recorrido, visto por el usuario, está en Flujos del prospecto y cliente. El 7.7 muestra el aviso al dueño de un contacto nuevo, con sus reintentos y el webhook de WhatsApp.
sequenceDiagram
autonumber
actor V as Visitante
participant W as Web
participant A as API
participant R as Redis
participant M as MongoDB
actor D as Dueño
V->>W: Llena Solicitar demo y autoriza sus datos
W->>A: POST /api/demo-requests
alt Campo trampa lleno (bot)
A-->>W: 201 con un código, sin guardar nada
else Sin autorización de datos
A-->>W: 400 consent_required
else Datos inválidos
A-->>W: 400 invalid_request con errores por campo
else Datos válidos
A->>R: Suma 1 al cupo de la IP y al del email
alt Cupo agotado
A-->>W: 429 rate_limited con Retry-After
else Hay cupo
A->>M: Busca una solicitud abierta del mismo email (30 días)
alt Ya existe
A->>M: Le suma las demos y anota el reenvío
A-->>W: 201 fusionada true
else Es nueva
A->>M: Crea DemoRequest pendiente con la prueba del consentimiento
A-->>W: 201 codigo y estado pendiente
A->>M: Registra el lead en Contact (demo-request)
A-->>V: Acuse por correo (si hay correo configurado)
A--)D: Aviso al dueño por correo y WhatsApp, sin esperar
end
end
end
sequenceDiagram
autonumber
actor E as Equipo
participant A as API
participant M as MongoDB
participant S as Correo
E->>A: POST /api/admin/demo-requests/:id/approve
A->>M: Lee el rol vigente del que aprueba
alt Demo privada y no es admin
A-->>E: 403 requires_admin
else Puede aprobar
A->>M: Cambia pendiente o en_revision a aprobada (condicionado)
alt Otro ya la procesó
A-->>E: 409 already_processed
else La reclamó
A->>M: Vincula la cuenta del email o crea una prospect invitada
A->>M: Crea un DemoGrant por demo (o extiende el vigente)
A->>M: Emite el enlace de activación (72 h, solo se guarda su hash)
A->>S: Correo con el enlace si hay correo configurado (espera hasta 15 s)
A->>M: Registra demo_request.approve en la bitácora
A-->>E: 200 con activationUrl, accesos y si el correo salió
end
end
Si algo falla a mitad de camino, la API deshace lo que creó y devuelve la solicitud a su estado anterior.
sequenceDiagram
autonumber
actor P as Prospecto
participant W as Web
participant A as API
participant M as MongoDB
participant R as Redis
P->>W: Abre /activar/TOKEN
W->>A: POST /api/auth/activate/check
A->>M: Busca el hash del token sin gastarlo
A-->>W: Nombre y correo enmascarado, o token_invalid, token_used, token_replaced, token_expired
P->>W: Escribe su contraseña
W->>A: POST /api/auth/activate
A->>M: Marca el enlace como usado (atómico, un solo uso)
A->>M: Guarda la contraseña cifrada y pasa la cuenta a activo
A->>M: Invalida otros enlaces pendientes y registra user.activate
A->>R: Guarda el refresh token (7 días)
A-->>W: accessToken, refreshToken y usuario
W-->>P: Mis demos con aviso de bienvenida
sequenceDiagram
autonumber
actor P as Prospecto
participant W as Servidor de la web
participant A as API
participant M as MongoDB
P->>W: Abre /demo/erp
W->>A: GET /api/demo-access/erp con su token
A->>M: Catálogo (modo y activa) y DemoGrant de la cuenta
alt Acceso vigente
A->>M: Suma una visita si pasaron 30 min desde la última
A-->>W: allowed true, reason grant, días restantes
W-->>P: Muestra la demo
P->>A: Llamadas a la API de la demo, por ejemplo /api/linkedin-ads/generate
A->>M: Repite la revisión del acceso en cada llamada
A-->>P: Respuesta de la demo
else Sin acceso, vencido o revocado
A-->>W: allowed false con el motivo
W-->>P: Pantalla /demo-acceso/erp con la salida adecuada
end
sequenceDiagram
autonumber
participant W as Web
participant A as API
participant R as Redis
W->>A: POST /api/auth/login
A->>R: Guarda refresh_token de la cuenta (7 días, reemplaza el anterior)
A-->>W: accessToken de 15 min y refreshToken
W->>A: Peticiones con Authorization Bearer
A-->>W: 401 TOKEN_EXPIRED cuando vence
W->>A: POST /api/auth/refresh
A->>R: Compara con el guardado
A-->>W: accessToken nuevo
W->>A: POST /api/auth/logout
A->>R: Borra el refresh token
sequenceDiagram
autonumber
participant J as Proceso horario
participant R as Redis
participant M as MongoDB
participant S as Correo
J->>R: Toma el candado jobs:demo-grants:lock
alt Otra instancia lo tiene
J-->>J: Salta esta corrida
else Lo toma (o Redis no responde)
J->>M: Marca expirado los accesos activos con expiresAt pasado
opt Hay correo configurado
J->>M: Reclama los que vencen en 3 días sin recordatorio
J->>S: Un recordatorio por persona
end
J->>R: Suelta el candado
end
Corre cada hora (DEMO_GRANTS_JOB_INTERVAL_MS), la primera vez unos 30 segundos después de arrancar; DEMO_GRANTS_JOB_ENABLED=false lo apaga. La vigencia real no depende de él: la API compara la fecha en cada petición.
sequenceDiagram
autonumber
actor V as Visitante
participant A as API
participant M as MongoDB
participant RS as Resend
participant ME as Meta WhatsApp
actor D as Dueño
V->>A: POST /api/contact (o Solicitar demo, o Prueba con tu documento)
A->>M: Crea el Contact con cada canal pendiente o no_configurado
A-->>V: 200 sin esperar los avisos
par Correo
A->>RS: POST /emails con Idempotency-Key contact-id-email-hash del cuerpo y reply_to
alt 2xx con id
RS-->>A: id del correo
A->>M: email enviado con idProveedor
RS--)D: Correo a NOTIFY_EMAIL
else Error o sin respuesta en 15 s
A->>M: email fallido con error y proximoIntento
end
and WhatsApp
A->>ME: POST /v25.0/id-del-número/messages con la plantilla koptup_aviso
alt 2xx con wamid
ME-->>A: wamid
A->>M: whatsapp enviado con idProveedor
ME--)D: WhatsApp al número del dueño
else Error de plantilla 132xxx
A->>ME: Plantilla de respaldo hello_world
A->>M: whatsapp enviado con respaldo hello_world y el error
else Otro error
A->>M: whatsapp fallido con el código de Meta
end
end
loop Cada 5 min, contactos de los últimos 3 días
A->>M: Reclama los canales fallidos con proximoIntento vencido
A->>RS: Reintento (misma Idempotency-Key si el cuerpo no cambió)
A->>ME: Reintento de la plantilla
end
opt Webhook configurado
ME->>A: POST /api/webhooks/whatsapp firmado
A->>M: Busca por wamid y pasa a entregado, leido o fallido
A--)D: Si falló, correo de alerta con el código de Meta
end
El formulario responde sin esperar a los proveedores. Cada canal se envía y se guarda por separado; un reintento nunca toca un canal que ya salió. Antes de cada intento, el backend «reclama» el canal con una actualización condicionada, así que dos procesos nunca envían el mismo aviso a la vez. Una vez al mes, el job del latido manda un aviso de prueba por los dos canales.
Usa tu backend local y guarda la URL en una variable. Las respuestas son reales (recortadas donde dice …).
export API=http://localhost:3001curl -s "$API/api/demo-catalog"{
"success": true,
"fuente": "bd",
"data": [
{ "slug": "chatbot", "nombre": "Chatbot RAG con tus documentos", "nombreEn": "RAG chatbot over your documents", "accessMode": "publico", "activo": true },
{ "slug": "code-review-ia", "nombre": "Revisión de código con IA", "nombreEn": "AI code review", "accessMode": "publico", "activo": true },
…
]
}curl -s -X POST "$API/api/demo-requests" \
-H 'Content-Type: application/json' \
-d '{
"nombre": "Laura Martínez",
"empresa": "Agroinsumos del Llano SAS",
"cargo": "Gerente administrativa",
"email": "[email protected]",
"telefono": "+57 310 555 0101",
"pais": "Colombia",
"tamanoEmpresa": "11-50",
"demos": ["erp", "linkedin-ads"],
"casoDeUso": "Queremos controlar inventario y facturación de nuestras tres bodegas en un solo sistema.",
"consentimiento": true,
"origen": { "pagina": "/solicitar-demo", "utm": { "source": "linkedin", "campaign": "erp-pymes" } }
}'{
"success": true,
"message": "Recibimos tu solicitud. Te escribiremos al correo que indicaste.",
"data": { "codigo": "DR-2026-6T8CKS", "estado": "pendiente", "fusionada": false }
}Si falta la autorización de datos la respuesta es 400:
{
"fields": ["consentimiento"],
"success": false,
"code": "consent_required",
"message": "Debes autorizar el tratamiento de tus datos personales (Ley 1581 de 2012) para enviar la solicitud."
}# 1. Inicia sesión (la contraseña es la que la persona creó al activar su cuenta)
curl -s -X POST "$API/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{ "email": "[email protected]", "password": "<tu contraseña>" }'{
"success": true,
"message": "Login successful",
"data": {
"accessToken": "<oculto>",
"refreshToken": "<oculto>",
"user": { "id": "6ac89e19d28776d0718d6513", "email": "[email protected]", "name": "Laura Martínez", "role": "prospect" }
}
}# 2. Copia el accessToken en una variable y pide tus demos
export TOKEN='<accessToken>'
curl -s "$API/api/me/demos" -H "Authorization: Bearer $TOKEN"{
"success": true,
"data": [
{
"id": "6ac89e19d28776d0718d6516",
"demoSlug": "erp",
"demoNombre": "ERP modular",
"accessMode": "solicitud",
"demoActiva": true,
"estado": "activo",
"vigente": true,
"expiresAt": "2026-10-23T07:56:09.487Z",
"diasRestantes": 14,
"ultimoAcceso": "2026-10-09T07:56:10.018Z",
"accesos": 1,
"url": "/demo/erp"
},
…
]
}Con el mismo token, una API de una demo a la que la cuenta no tiene acceso responde 403:
curl -s "$API/api/auditoria/estadisticas" -H "Authorization: Bearer $TOKEN"{
"success": false,
"code": "demo_access_required",
"demo": "cuentas-medicas",
"motivo": "sin_acceso",
"message": "Tu cuenta no tiene acceso a esta demo. Solicítalo desde la página de la demo."
}
/health/live dice si el proceso está vivo; /health dice si puede atender (MongoDB conectado).
-
/health/liveresponde 200 siempre que el proceso esté vivo, aunque MongoDB esté caído: sirve para diagnosticar si el proceso arranca. -
/healthresponde 503 mientras MongoDB no esté conectado. Es el healthcheck del despliegue en Railway (un despliegue sin base no entra en servicio y la versión anterior sigue atendiendo) y lo revisa el monitoreo de GitHub Actions cada 15 minutos. Si el backend arranca sin base de datos, sigue vivo y las rutas que necesitan leer roles o accesos responden503 service_unavailable(falla cerrada). -
Swagger (
/api-docs) existe solo fuera de producción o conAPI_DOCS_ENABLED=true. Se genera con los comentarios@swaggerdesrc/routes/*.tsyconfig/swagger.tsle agrega el esquema de sesión (bearerAuth), qué rutas son públicas, las de solo desarrollo (marcadas como obsoletas) y el formato de error. Los comentarios no cubren todas las rutas: esta página es la referencia completa.
Qué hacer cuando el backend no responde está en Operación y despliegue.
| Qué | Dónde (en apps/backend/src) |
|---|---|
Arranque: variables, carpetas de subida, MongoDB, admin desde ADMIN_EMAIL (services/admin-bootstrap.service.ts), semilla del catálogo, Passport, jobs |
index.ts |
| Aplicación Express (middleware y montaje de rutas) | app.ts |
| Políticas de autorización |
middleware/access.ts y el inventario routes/POLICIES.md
|
| Sesión JWT |
middleware/auth.ts, controllers/auth.controller.ts
|
Errores y su traducción a { success: false, code, message }
|
middleware/errorHandler.ts (excepciones) y utils/http-error.ts (sendError y el middleware que completa las respuestas antiguas) |
| Cupos |
middleware/rateLimiter.ts, middleware/client-ip.ts, services/request-limits.service.ts
|
| Topes de gasto de IA y cupos en Redis | services/ai-budget.service.ts |
| Sistema de demos |
routes/demo-public.routes.ts, routes/admin-demos.routes.ts, services/demo-*.service.ts, services/magic-link.service.ts, jobs/demo-grants.job.ts, config/demos.ts
|
| Avisos al dueño (correo y WhatsApp) |
services/owner-notify.service.ts, services/email.service.ts (Resend y SMTP), services/whatsapp.service.ts (plantilla de Meta), routes/admin-notifications.routes.ts, routes/whatsapp-webhook.routes.ts (montado en app.ts antes del parser JSON), jobs/notify-retry.job.ts, jobs/notify-heartbeat.job.ts, models/Contact.ts (notificaciones) |
| Pruebas de la API (401/403/200 por política, flujo de demos) |
__tests__/integration/ con supertest |
Checklist para agregar una ruta:
- Elige la política (sección 3) y aplícala en el archivo de rutas con los middleware de
middleware/access.ts. Si la ruta es del equipo, usarequireRole(lee el rol en la base) y no solo el rol del token. - Valida la entrada (en el código nuevo se usa
zodyzodToAppErrorpara devolverfieldsyerrores). - Lanza
AppError(mensaje, status, code)o usasendError(res, status, code, mensaje)en vez de armar el JSON a mano: así la respuesta tiene siempresuccess,codey unmessageen español. - Envuelve los controladores asíncronos con
asyncHandler. - Si llama a OpenAI desde una ruta pública, pasa por
getBudgetStatusyrecordSpendy agrega un cupo en Redis. - Si la acción la hace el equipo, regístrala con
recordAudit. - Agrega la ruta a
routes/POLICIES.mdy una prueba de integración.
| Tema | Qué pasa hoy |
|---|---|
| Crear entregables, facturas y conversaciones |
POST /api/deliverables, POST /api/invoices, POST /api/admin/orders/:orderId/invoice y POST /api/messages/conversations responden 500 en main: los modelos exigen el número consecutivo (DEL-…, FAC-…, CONV-…) antes de que se ejecute el código que lo genera. Por eso hoy no se pueden crear entregables, facturas ni conversaciones por API; el modelo de mensajes tiene el mismo patrón |
| Conversación automática de un pedido | Al crear un pedido, la conversación con el equipo no se crea (el error se registra en el log y el pedido sí se guarda) |
| Pagos | No hay pago en línea de facturas: el portal muestra los datos de transferencia que configure el equipo y el equipo registra el pago con POST /api/invoices/:id/pay (admin o manager). Los pagos en línea están en desarrollo (ver Estado y próximos pasos) |
| Descargar factura |
GET /api/invoices/:id/download devuelve un enlace a sí misma; no se genera un PDF |
| Adjuntos de pedidos | Se guardan en el disco del servidor: sin un volumen en Railway se borran en cada despliegue y la descarga responde 410 file_unavailable (acciones pendientes del dueño) |
| Notificaciones | Ningún flujo las crea solo; solo existen las que se crean con POST /api/notifications
|
| Cupos en memoria | El cupo general, los de cada ruta de cuenta y el del chatbot se reinician en cada despliegue y no se comparten si hay varias instancias |
| Una sesión renovable por cuenta | El refresh token es uno por cuenta: iniciar sesión en otro navegador hace que el primero no pueda renovar y deba volver a entrar a los 15 minutos |
| Swagger incompleto | Los comentarios @swagger no cubren todas las rutas |
| Avisos por WhatsApp sin webhook | Sin WHATSAPP_APP_SECRET y WHATSAPP_WEBHOOK_VERIFY_TOKEN, el estado queda en enviado (Meta aceptó el mensaje) y nunca pasa a entregado, leido ni fallido
|
- Modelos de datos: las colecciones que esta API lee y escribe, con sus estados.
- Roles y permisos: qué puede hacer cada rol.
- Arquitectura: dónde corre el backend y con qué servicios habla.
- Flujos del prospecto y cliente y Manual del administrador: las pantallas que usan estas rutas.
- Operación y despliegue: variables de entorno y procedimientos ante fallas.
- Backend y API (plan): el plan original de esta parte.