ConstruyendoCRM

Acopla: tarjetas para HubSpot con un backend que no confía en nadie

Tarjetas dentro de la ficha de HubSpot con datos peruanos —RUC, DNI y tipo de cambio— alimentadas por un backend propio que funciona en cualquier plan, verifica cada firma y obliga por tipos a confirmar toda escritura.

La idea

Quien trabaja en HubSpot en Perú termina copiando el RUC de un cliente en SUNAT, pegando la razón social y buscando el tipo de cambio del día en otra pestaña. Acopla pone esos datos dentro de la ficha: una tarjeta que consulta el RUC o el DNI y muestra el tipo de cambio, sin salir del CRM.

Se instala una sola app en el portal, se activan las tarjetas que hacen falta y mi backend las alimenta. La tarjeta vive en HubSpot; la lógica y los datos, en mi servidor.

Cómo está montado

  • Backend propio, no funciones serverless de HubSpot. Esas exigen el plan Enterprise; con un backend externo las tarjetas funcionan en cualquier plan. Lo probé en cuentas Professional y gratuitas.
  • Una app, un despliegue. Todos los portales reciben la versión nueva a la vez; no hay un despliegue por cliente. Un worker publica con el CLI de HubSpot y la prueba primero en un entorno de staging.
  • La firma de HubSpot es la autenticación. El portal se deduce de la firma, nunca del cuerpo de la petición. La comparación es en tiempo constante —el SDK compara con ===, por eso escribí la mía—, con una ventana de 5 minutos y la URL reconstruida desde mi propia configuración, no desde cabeceras que alguien puede falsear.
  • El backend proyecta y la tarjeta solo pinta. Cada tarjeta se describe con una especificación JSON validada con zod; el backend devuelve los bloques ya resueltos y la tarjeta es un intérprete que no decide nada.
  • Siempre HTTP 200. HubSpot reintenta automáticamente ante un 5XX, así que los errores viajan dentro de la respuesta. Una sola llamada por tarjeta, porque hay un límite de peticiones simultáneas por portal.
  • Multi-tenant con Row Level Security en Postgres y los tokens de cada portal cifrados con AES-256-GCM, con datos asociados propios de cada cliente.

Y una regla que no depende de la buena memoria de nadie: toda escritura lleva confirmación y auditoría, y el tipo no permite otra cosa.

export const ActionSpecSchema = z.object({
  id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
  type: z.literal('updateProperties'),
  objectType: z.enum(['companies', 'contacts']),
  label: z.string().min(1),
  mapper: z.string().min(1),
  map: z.record(z.string(), z.string()).optional(),
  // Regla de CARD-SPEC: toda escritura lleva confirmación y auditoría. No es opcional.
  confirm: z.literal(true),
  audit: z.literal(true),
});

Lo que ya funciona

Una consola para conectar el portal, activar tarjetas y ver el uso; el despliegue automático con su paso por staging; y cuatro tarjetas: RUC, DNI, tipo de cambio y una consulta HTTP genérica. El percentil 95 de una consulta completa ronda 1.3 s.

Lo que todavía no

La instalación probada de punta a punta por alguien que no sea yo, un portal Professional real en uso, pruebas de aislamiento entre clientes en todos los endpoints, copias de seguridad fuera del servidor y la publicación en el Marketplace de HubSpot.

Lo aprendido

Una app no está instalada hasta que intercambias el token. Si el callback recibe el código y no lo intercambia, las tarjetas no aparecen y nada te dice por qué.

Medir antes de diseñar. HubSpot reintenta un 5XX una sola vez, a los ~1.25 s y con la misma firma. Por eso no puedo rechazar firmas repetidas dentro de la ventana, y las escrituras se deduplican con una clave de idempotencia.

Cambiar de opinión con datos. Para las tarjetas a medida empecé generando código; un intérprete de unas 130 líneas resultó servir para cualquier especificación sin perder el enlace con los datos. Y separar cada solución en su propia app obligaba a reinstalar, lo que invalidaba el token: volví a una sola app.

Siguiente paso

Terminar el rediseño de la consola y abrir un generador de tarjetas a medida: describes la tarjeta que necesitas y se arma sobre la misma especificación.