Arquitectura de la Integración
Flujo de datos entre NovaSaaS, Cal.com y los sistemas externos
Embed Widget
Cal iframe en /reservar-demo y botón flotante en /app/* para onboarding
REST API Client
Crear event types, consultar slots y crear bookings desde el CRM interno
Webhooks HMAC
Validación x-cal-signature-256 y handlers para 4 eventos crÃticos
Round-Robin Team
3 AEs + 1 SE fijo, buffers automáticos, horario 9-17 CET
Embed Widget — Página /reservar-demo
Calendar inline en vista mensual + botón flotante en el dashboard
import Cal, { getCalApi } from "@calcom/embed-react"; import { useEffect } from "react"; // ——— Calendario inline en /reservar-demo ——— export function BookingWidget() { useEffect(() => { (async () => { const cal = await getCalApi(); cal("ui", { theme: "dark", styles: { branding: { brandColor: "#6366f1" } }, hideEventTypeDetails: false, layout: "month_view", }); })(); }, []); return ( <Cal calLink="novasaas-team/demo-producto" style={{ width: "100%", height: "700px", overflow: "scroll" }} config={{ layout: "month_view", // Pre-fill con datos del usuario autenticado (reduce fricción) name: "MarÃa GarcÃa", email: "maria@empresa.es", notes: "Plan Team - 15 usuarios", }} /> ); } // ——— Botón flotante para /app/* (clientes activos) ——— export function OnboardingFloatingButton() { useEffect(() => { (async () => { const cal = await getCalApi(); cal("floatingButton", { calLink: "novasaas-team/onboarding-45min", buttonText: "Reservar Onboarding", buttonColor: "#6366f1", buttonTextColor: "#ffffff", buttonPosition: "bottom-right", }); })(); }, []); return null; // El botón se renderiza solo }
Cliente REST API
Operaciones server-side: event types, disponibilidad y bookings programáticos
const CAL_API_URL = "https://api.cal.com/v2"; const CAL_API_KEY = process.env.CAL_API_KEY!; async function calFetch(path: string, options?: RequestInit) { const res = await fetch(`${CAL_API_URL}${path}`, { ...options, headers: { "Content-Type": "application/json", "cal-api-version": "2024-08-13", // Pinned para estabilidad "Authorization": `Bearer ${CAL_API_KEY}`, ...options?.headers, }, }); if (!res.ok) { const err = await res.json(); throw new Error(`Cal.com error: ${err.message}`); } return res.json(); } // Crear tipo de evento "Demo 30 min" con campos personalizados export async function createDemoEventType() { return calFetch("/event-types", { method: "POST", body: JSON.stringify({ title: "Demo NovaSaaS CRM", slug: "demo-producto", lengthInMinutes: 30, description: "Demo personalizada de NovaSaaS CRM para tu equipo", locations: [{ type: "integrations:google:meet" }], bookingFields: [ { name: "empresa", type: "text", label: "Nombre empresa", required: true }, { name: "empleados", type: "select", label: "Nº empleados", options: ["1-10", "11-50", "51-200", "200+"], required: true }, { name: "uso", type: "textarea", label: "Caso de uso principal" }, ], }), }); } // Consultar slots disponibles (para mostrar en UI propia) export async function getAvailableSlots( eventTypeId: number, from: Date, to: Date ) { const q = new URLSearchParams({ eventTypeId: String(eventTypeId), startTime: from.toISOString(), endTime: to.toISOString(), timeZone: "Europe/Madrid", }); return calFetch(`/slots?${q}`); } // Crear booking directo desde el CRM (ej. tras cualificar un lead) export async function bookDemo(params: { eventTypeId: number; start: string; lead: { name: string; email: string }; utmSource?: string; }) { return calFetch("/bookings", { method: "POST", body: JSON.stringify({ eventTypeId: params.eventTypeId, start: params.start, attendee: { name: params.lead.name, email: params.lead.email, timeZone: "Europe/Madrid", }, metadata: { source: params.utmSource ?? "crm-direct", crmLeadId: "LEAD-2026-0842", }, language: "es", }), }); }
Webhook Handler
Verificación HMAC + handlers para los 4 eventos del ciclo de vida de una reserva
BOOKING_CREATED
Nueva reserva confirmada. Accion: crear deal en CRM + notificar al equipo de ventas.
BOOKING_CANCELLED
Reserva cancelada. Accion: marcar deal como perdido + registrar motivo.
BOOKING_RESCHEDULED
Reserva reprogramada. Accion: actualizar deal en CRM con nueva fecha.
MEETING_ENDED
Reunion finalizada. Accion: disparar secuencia de follow-up por Resend.
import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; const WEBHOOK_SECRET = process.env.CAL_WEBHOOK_SECRET!; const CRM_BASE = "https://api.novasaas.io/crm"; const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL!; // Verificar firma HMAC-SHA256 — NUNCA procesar sin validar function verifySignature(payload: string, sig: string): boolean { const expected = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(payload) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(sig), Buffer.from(expected)) ; } export async function POST(req: NextRequest) { const payload = await req.text(); const sig = req.headers.get("x-cal-signature-256") ?? ""; if (!verifySignature(payload, sig)) return NextResponse.json({ error: "Firma inválida" }, { status: 401 }); const event = JSON.parse(payload); const p = event.payload; switch (event.triggerEvent) { case "BOOKING_CREATED": await Promise.all([ // Crear deal en NovaSaaS CRM fetch(`${CRM_BASE}/deals`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: `Demo: ${p.attendees[0]?.name}`, email: p.attendees[0]?.email, stage: "discovery", source: p.metadata?.source ?? "web-booking", }), }), // Notificar en Slack #demos-sales fetch(SLACK_WEBHOOK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: `:calendar: *Nueva demo reservada*\n:bust_in_silhouette: ${p.attendees[0]?.name} (${p.attendees[0]?.email})\n:clock1: ${new Date(p.startTime).toLocaleString("es-ES")}`, }), }), ]); break; case "BOOKING_CANCELLED": await fetch(`${CRM_BASE}/deals/${p.bookingId}`, { method: "PATCH", body: JSON.stringify({ stage: "lost", reason: p.cancellationReason, }), }); break; case "MEETING_ENDED": // Disparar secuencia de follow-up con Resend await fetch("https://api.resend.com/emails", { method: "POST", headers: { "Authorization": `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: "demos@novasaas.io", to: p.attendees[0]?.email, subject: "Gracias por tu tiempo — recursos y próximos pasos", html: "<p>Ha sido un placer mostrarte NovaSaaS CRM...</p>", }), }); break; } return NextResponse.json({ received: true }); }
Equipo de Ventas — Round-Robin
3 AEs en rotacion + 1 Sales Engineer fijo, buffers y horario CET
| ID | Nombre | Rol | Rotacion | TZ |
|---|---|---|---|---|
| 201 | Carlos Mendoza | Account Executive | ↻ Round-Robin | Europe/Madrid |
| 202 | Laura Vidal | Account Executive | ↻ Round-Robin | Europe/Madrid |
| 203 | Javier Torres | Account Executive | ↻ Round-Robin | Europe/Madrid |
| 204 | Ana Romero | Sales Engineer | 📌 Fijo siempre | Europe/Madrid |
export async function setupNovaSaaSTeam() { // Event type: "Product Demo" — 45 min con round-robin const eventType = await calFetch("/event-types", { method: "POST", body: JSON.stringify({ title: "Demo NovaSaaS — 45 min", slug: "demo-producto", lengthInMinutes: 45, schedulingType: "ROUND_ROBIN", // Rotar entre AEs hosts: [ { userId: 201, isFixed: false }, // Carlos — rotacion { userId: 202, isFixed: false }, // Laura — rotacion { userId: 203, isFixed: false }, // Javier — rotacion { userId: 204, isFixed: true }, // Ana — siempre presente ], beforeEventBuffer: 15, // 15 min antes afterEventBuffer: 10, // 10 min después minimumBookingNotice: 120, // Minimo 2h de antelacion slotInterval: 30, // Mostrar slots cada 30 min locations: [ { type: "integrations:google:meet" }, ], }), }); // Horario del equipo: L-V 9:00-17:30 CET con festivos españoles await calFetch("/schedules", { method: "POST", body: JSON.stringify({ name: "Horario NovaSaaS", timeZone: "Europe/Madrid", isDefault: true, availability: [ { days: [1,2,3,4,5], startTime: "09:00", endTime: "17:30" }, ], dateOverrides: [ // Festivos nacionales 2026 { date: "2026-10-12", startTime: null, endTime: null }, // Dia Hispanidad { date: "2026-11-01", startTime: null, endTime: null }, // Todos los Santos { date: "2026-12-25", startTime: null, endTime: null }, // Navidad { date: "2026-12-31", startTime: "09:00", endTime: "13:00" }, // Nochevieja — mañana ], }), }); return eventType; }
Instalacion y Variables de Entorno
Setup en 2 pasos: npm install + .env
# 1. Instalar el paquete embed para React npm install @calcom/embed-react # 2. Variables de entorno necesarias (.env.local) CAL_API_KEY=cal_live_xxxx CAL_WEBHOOK_SECRET=whsec_xxxx SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz RESEND_API_KEY=re_xxxx # 3. Registrar el webhook en Cal.com (apunta a tu dominio) # URL: https://app.novasaas.io/api/cal-webhook # Eventos: BOOKING_CREATED, BOOKING_CANCELLED, # BOOKING_RESCHEDULED, MEETING_ENDED
Guidelines de Implementacion
8 buenas practicas para una integracion robusta
Embed para reserva publica
El widget React gestiona zonas horarias, disponibilidad y confirmaciones automaticamente. No reinventes la rueda.
Pinear versión de API
Siempre incluir el header cal-api-version: 2024-08-13 para evitar breaking changes.
Verificar firma HMAC en webhooks
Siempre validar x-cal-signature-256 con timingSafeEqual para prevenir eventos falsos.
Buffers entre reuniones
Configurar beforeEventBuffer: 15 y afterEventBuffer: 10 para evitar reuniones consecutivas sin descanso.
Pre-rellenar datos del usuario
Pasar name, email y notas vÃa config del embed para reducir fricción al usuario autenticado.
Metadata para atribución
Incluir UTM params, plan de interés y ID de lead en metadata del booking para trazabilidad completa en CRM.
Manejar todos los eventos del ciclo de vida
No solo BOOKING_CREATED. Las cancelaciones y reprogramaciones necesitan limpieza en CRM y notificaciones.
Self-host para cumplimiento RGPD
Si se requiere residencia de datos en la UE, desplegar Cal.com con Docker en un servidor europeo.