Cal.com API v2 Next.js 14 TypeScript
⚡ Guía de Integración

Cal.com × NovaSaaS CRM

Integración completa del sistema de reservas Cal.com: embed widget, API REST, webhooks y gestión de equipo con round-robin. Stack: Next.js 14 + TypeScript.

📅 4 módulos de integración 👥 3 AEs + 1 SE round-robin 🔗 API v2 pinned 2024-08-13 🏘 Self-host ready (Docker)

Arquitectura de la Integración

Flujo de datos entre NovaSaaS, Cal.com y los sistemas externos

🌐
Visitante / Cliente Reserva demo/onboarding
📅
Cal.com Cloud / Embed React
Webhook /api/cal-webhook
📊
NovaSaaS CRM + Slack + Resend
📆

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

1

Embed Widget — Página /reservar-demo

Calendar inline en vista mensual + botón flotante en el dashboard

src/components/BookingWidget.tsx TSX
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
}
2

Cliente REST API

Operaciones server-side: event types, disponibilidad y bookings programáticos

src/cal/client.ts TS
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",
    }),
  });
}
3

Webhook Handler

Verificación HMAC + handlers para los 4 eventos del ciclo de vida de una reserva

Eventos manejados y acciones desencadenadas

BOOKING_CREATED

Nueva reserva confirmada. Accion: crear deal en CRM + notificar al equipo de ventas.

CRM: crear deal Slack: #demos-sales

BOOKING_CANCELLED

Reserva cancelada. Accion: marcar deal como perdido + registrar motivo.

CRM: deal perdido Log: motivo cancelacion
🔄

BOOKING_RESCHEDULED

Reserva reprogramada. Accion: actualizar deal en CRM con nueva fecha.

CRM: actualizar fecha
💌

MEETING_ENDED

Reunion finalizada. Accion: disparar secuencia de follow-up por Resend.

Email: secuencia follow-up CRM: fase → post-demo
app/api/cal-webhook/route.ts TS
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 });
}
4

Equipo de Ventas — Round-Robin

3 AEs en rotacion + 1 Sales Engineer fijo, buffers y horario CET

👥 Configuracion del equipo NovaSaaS Sales
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
src/cal/team-setup.ts TS
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

Terminal bash
# 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

1
Embed para reserva publica

El widget React gestiona zonas horarias, disponibilidad y confirmaciones automaticamente. No reinventes la rueda.

2
Pinear versión de API

Siempre incluir el header cal-api-version: 2024-08-13 para evitar breaking changes.

3
Verificar firma HMAC en webhooks

Siempre validar x-cal-signature-256 con timingSafeEqual para prevenir eventos falsos.

4
Buffers entre reuniones

Configurar beforeEventBuffer: 15 y afterEventBuffer: 10 para evitar reuniones consecutivas sin descanso.

5
Pre-rellenar datos del usuario

Pasar name, email y notas vía config del embed para reducir fricción al usuario autenticado.

6
Metadata para atribución

Incluir UTM params, plan de interés y ID de lead en metadata del booking para trazabilidad completa en CRM.

7
Manejar todos los eventos del ciclo de vida

No solo BOOKING_CREATED. Las cancelaciones y reprogramaciones necesitan limpieza en CRM y notificaciones.

8
Self-host para cumplimiento RGPD

Si se requiere residencia de datos en la UE, desplegar Cal.com con Docker en un servidor europeo.