Cómo crear un sistema de reservas de Google Calendar para varios usuarios en Webflow Cloud

Cómo crear reservas de Google Calendar multiusuario en Webflow Cloud

Aprende a crear una página de reservas en Webflow Cloud que lea los calendarios de Google de varios miembros de tu equipo.

Cómo crear reservas de Google Calendar multiusuario en Webflow Cloud

Ismail Ajagbe
Redactor técnico
Ver perfil del autor
Ismail Ajagbe
Redactor técnico
Ver perfil del autor
Índice de contenidos

Un sitio de marketing puede dirigir cada llamada a un miembro del equipo disponible, manteniendo las reglas de asignación, la disponibilidad del calendario y los detalles de los visitantes dentro del código de reserva que tú controlas en Webflow Cloud.

Cuando asignas un enlace de reserva a una sola persona, todo funciona a las mil maravillas. Sin embargo, cuando compartes responsabilidades en un equipo de ventas o soporte, las solicitudes de reserva entrantes siguen yendo por defecto a un único calendario. Esto obliga a alguien a comprobar la disponibilidad individual y a distribuir las llamadas manualmente, una por una.

En este desarrollo, trasladamos esa gestión al código. Crearemos una aplicación Next.js montada en una ruta de tu sitio de Webflow, para que los visitantes reserven sin salir de tu dominio. La aplicación consulta el calendario de Google de cada miembro del equipo en una sola solicitud y ofrece únicamente los espacios en los que al menos uno de ellos está libre.

Cuando un visitante hace una reserva, el evento se registra automáticamente en el calendario de quien toma el turno, rotando entre los miembros disponibles para que no siempre le toque a la misma persona.

¿Qué necesitas para crear un sistema de reservas de Google Calendar para varios usuarios en Webflow?

Necesitas seis requisitos previos:

  • Sitio de Webflow: Cualquier plan sirve siempre que el sitio sea compatible con Webflow Cloud; para un dominio personalizado se requiere el plan Premium o superior.
  • Proyecto de Google Cloud: Necesitas permisos para habilitar el acceso a la API de Google Calendar y crear una cuenta de servicio con una clave JSON.
  • Calendarios compartidos: Cada miembro del equipo debe aceptar compartir su calendario de Google con la dirección de correo electrónico de una cuenta de servicio.
  • Node.js y npm: Una versión de Node.js compatible con Webflow Cloud; Webflow Cloud solo realiza compilaciones con npm, por lo que cada comando de instalación utiliza npm install.
  • Next.js: Una versión de Next.js compatible con Webflow Cloud; create-next-app@latest instala una versión compatible, y es posible que los proyectos antiguos necesiten una actualización previa.
  • Código de la aplicación: El código de la aplicación, listo para desplegarse en Webflow Cloud utilizando el método que describe la documentación para desarrolladores de Webflow para aplicaciones propias.

Con estos seis requisitos listos, puedes configurar en orden la autenticación, la disponibilidad, las reservas y las rutas de despliegue.

6 pasos para crear un sistema de reservas de Google Calendar para varios usuarios en Webflow Cloud

Crea el flujo de reservas en seis etapas: autoriza los calendarios compartidos, estructura la aplicación, genera los tokens de Google, calcula la disponibilidad, crea las reservas y monta la página final en tu sitio de Webflow Cloud.

/api/availability combina la disponibilidad de todos los miembros del equipo en espacios reservables, mientras que /api/book gestiona la asignación y la creación de eventos.

1. Crea la cuenta de servicio y comparte los calendarios

Habilita el acceso a la API de Google Calendar en tu proyecto de Google Cloud, crea una cuenta de servicio, deja sus roles de proyecto vacíos y descarga una clave JSON para ella. Ponle a la cuenta un nombre como team-booking.

El acceso al calendario se obtiene compartiendo cada calendario con la dirección de correo electrónico de la cuenta. La documentación de la cuenta de servicio de Google explica paso a paso cómo crear la cuenta y su clave.

El archivo descargado contiene client_email y private_key, que son los únicos dos campos que utiliza la aplicación. Ambos alimentan al asistente de tokens en lib/google.ts. La pestaña Keys muestra la nueva clave, y el client_email de la cuenta es la dirección con la que compartes los calendarios.

Ahora comparte el calendario de cada compañero de equipo con esa client_email. En la configuración de uso compartido de Google Calendar de cada compañero, añade la dirección de la cuenta de servicio con el permiso "Realizar cambios en eventos". Si solo otorgas acceso de lectura, la ruta de disponibilidad funcionará pero la de reserva fallará, lo cual es una forma bastante confusa de descubrir el error.

Anota el ID de cada calendario desde su página de configuración; deberás incluir estos IDs en BOOKING_CALENDARS. Ahora ya tienes una clave JSON en tu disco y una lista de IDs de calendario, y la lista de uso compartido de cada compañero muestra la dirección de la cuenta de servicio con permisos de edición.

2. Prepara la estructura de la aplicación Next.js y guarda las credenciales de Google

Usa npm para generar el proyecto con App Router y TypeScript.

Ejecuta la estructura desde un directorio vacío:

npx create-next-app@latest team-booking --typescript --app --eslint --no-src-dir --no-tailwind --import-alias "@/*"
cd team-booking

Los flags responden a las preguntas principales y mantienen las rutas app/ y @/lib que utiliza el código. El proyecto no necesita dependencias adicionales más allá de las que instala el generador.

Todos los valores de configuración residen en variables de entorno para que el mismo código funcione tanto localmente como en Webflow Cloud.

Webflow Cloud pone a disposición del proceso de compilación y de la aplicación desplegada en tiempo de ejecución tanto las variables secretas como las que no lo son, y oculta los secretos de los registros de compilación para que puedas almacenar la clave privada como una variable de entorno secreta. Localmente, los mismos valores se guardan en .env.local.

Las variables que lee la aplicación, con el formato que espera cada una:

Variable Valor Notas
GOOGLE_CLIENT_EMAIL El client_email de la clave JSON También se utiliza como la reclamación iss del JWT
GOOGLE_PRIVATE_KEY El private_key de la clave JSON Pégalo en una sola línea manteniendo intactas las secuencias \n literales
BOOKING_CALENDARS Ana=ana@example.com,Ben=ben@example.com Nombre para mostrar, =, ID del calendario; separados por comas
BOOKING_TIMEZONE Una zona IANA como America/New_York El horario laboral se evalúa en esta zona
BOOKING_START_HOUR 9 Primera hora disponible para reservar, en formato de 24 horas
BOOKING_END_HOUR 17 Los espacios deben terminar antes de esta hora
NEXT_PUBLIC_BASE_PATH Vacío localmente; la ruta de montaje (por ejemplo, /book) en Webflow Cloud Antepone un prefijo a cada petición del lado del cliente
Variable → Valor → Notas
GOOGLE_CLIENT_EMAIL
El client_email de la clave JSON
También se utiliza como la reclamación iss del JWT
GOOGLE_PRIVATE_KEY
El private_key de la clave JSON
Pégalo en una sola línea manteniendo intactas las secuencias \n literales
BOOKING_CALENDARS
Ana=ana@example.com,Ben=ben@example.com
Nombre para mostrar, =, ID del calendario; separados por comas
BOOKING_TIMEZONE
Una zona IANA como America/New_York
El horario laboral se evalúa en esta zona
BOOKING_START_HOUR
9
Primera hora disponible para reservar, en formato de 24 horas
BOOKING_END_HOUR
17
Los espacios deben terminar antes de esta hora
NEXT_PUBLIC_BASE_PATH
Vacío localmente; la ruta de montaje (por ejemplo, /book) en Webflow Cloud
Antepone un prefijo a cada petición del lado del cliente

Un funcional se ve así, sustituyendo los valores por los tuyos:.env.local

GOOGLE_CLIENT_EMAIL=team-booking@your-project.iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----\n"
BOOKING_CALENDARS=Ana=ana@example.com,Ben=ben@example.com,Chloe=chloe@example.com
BOOKING_TIMEZONE=America/New_York
BOOKING_START_HOUR=9
BOOKING_END_HOUR=17
NEXT_PUBLIC_BASE_PATH=

Añade a si el generador no lo hizo, y luego ejecuta . La página predeterminada de Next.js se renderiza en , lo que confirma la cadena de herramientas antes de que cualquier código de Google entre en juego..env.local.gitignorenpm run devhttp://localhost:3000

3. Genera un token de acceso de Google con Web Crypto

El entorno de ejecución de Workers lee la clave privada desde una variable de entorno. El asistente de tokens utiliza una petición fetch estándar contra el punto de conexión de tokens de Google y firma el JWT (JSON Web Token) de la cuenta de servicio con .crypto.subtle

La clave privada se importa como PKCS#8, el JWT se firma con RS256 y la aserción firmada se intercambia por un token de portador (bearer token) utilizando la concesión . La documentación de la cuenta de servicio de Google describe este flujo de portador JWT. PKCS#8 es el formato de clave privada en el archivo JSON; RS256 es la firma RSA con SHA-256.jwt-bearer

El asistente de tokens se encuentra en :lib/google.ts

const TOKEN_URL = 'https://oauth2.googleapis.com/token';
// Only the two calls this app makes: free/busy reads and event inserts.
const SCOPE = 'https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.freebusy';

let cached: { token: string; expiresAt: number } | null = null;

function base64url(input: ArrayBuffer | string): string {
  const bytes =
    typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input);
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function importPrivateKey(pem: string): Promise<CryptoKey> {
  const body = pem
    .replace(/\\n/g, '\n')
    .replace('-----BEGIN PRIVATE KEY-----', '')
    .replace('-----END PRIVATE KEY-----', '')
    .replace(/\s+/g, '');
  const raw = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
  return crypto.subtle.importKey(
    'pkcs8',
    raw,
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['sign'],
  );
}

export async function getAccessToken(): Promise<string> {
  if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token;

  const clientEmail = process.env.GOOGLE_CLIENT_EMAIL;
  const privateKey = process.env.GOOGLE_PRIVATE_KEY;
  if (!clientEmail || !privateKey) {
    throw new Error('GOOGLE_CLIENT_EMAIL and GOOGLE_PRIVATE_KEY must be set');
  }

  const now = Math.floor(Date.now() / 1000);
  const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const claims = base64url(
    JSON.stringify({ iss: clientEmail, scope: SCOPE, aud: TOKEN_URL, iat: now, exp: now + 3600 }),
  );
  const unsigned = `${header}.${claims}`;

  const key = await importPrivateKey(privateKey);
  const signature = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    key,
    new TextEncoder().encode(unsigned),
  );
  const assertion = `${unsigned}.${base64url(signature)}`;

  const res = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      assertion,
    }),
  });
  if (!res.ok) {
    throw new Error(`Google token request failed: ${res.status} ${await res.text()}`);
  }

  const data = (await res.json()) as { access_token: string; expires_in: number };
  cached = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 };
  return cached.token;
}

La línea es la que te salva más adelante: la clave llega desde una variable de entorno como una sola línea que contiene pares literales de barra invertida-n, y el analizador PEM necesita saltos de línea reales..replace(/\\n/g, '\n')

El objeto a nivel de módulo puede permitir que una instancia activa reutilice un token; cuando está vacío, el asistente solicita uno nuevo. Ambas rutas utilizan el mismo token y el ámbito configurado en .cachedcalendarSCOPE

Ejecuta ; si termina sin errores, el asistente estará listo para las rutas que lo invoquen.npx tsc --noEmit

4. Crea el gestor de rutas de disponibilidad

Una única solicitud de libre/ocupado cubre todos los calendarios y proporciona los bloques de ocupación necesarios para generar espacios abiertos con los nombres de quienes están libres en cada uno. El punto de conexión de consulta de libre/ocupado de Google acepta hasta 50 ID de calendario y devuelve los rangos de ocupación por calendario, por lo que una sola solicitud cubre toda la lista en .BOOKING_CALENDARS

La referencia documenta un campo por calendario, como , para cualquier calendario que no pudo leer. Ignora ese campo; así, un compañero de equipo que nunca compartió su calendario aparecerá sin bloques de ocupación y se mostrará libre en todos los espacios. Por eso, ambas rutas a continuación descartan cualquier calendario que reporte un error.errorsnotFound

Comienza con el envoltorio de la API de Calendar en , que reutiliza:lib/calendar.tsapp/api/book/route.ts

import { getAccessToken } from './google';

export type Teammate = { name: string; calendarId: string };
export type Busy = { start: string; end: string };
export type CalendarEntry = { busy: Busy[]; errors?: { domain: string; reason: string }[] };

const CALENDAR_API = 'https://www.googleapis.com/calendar/v3';

export function teammates(): Teammate[] {
  return (process.env.BOOKING_CALENDARS ?? '')
    .split(',')
    .map((entry) => entry.trim())
    .filter(Boolean)
    .map((entry) => {
      const [name, calendarId] = entry.split('=');
      return { name: name.trim(), calendarId: calendarId.trim() };
    });
}

async function authedHeaders() {
  const token = await getAccessToken();
  return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}

export async function freeBusy(
  timeMin: string,
  timeMax: string,
  calendarIds: string[],
): Promise<Record<string, CalendarEntry>> {
  const res = await fetch(`${CALENDAR_API}/freeBusy`, {
    method: 'POST',
    headers: await authedHeaders(),
    body: JSON.stringify({ timeMin, timeMax, items: calendarIds.map((id) => ({ id })) }),
  });
  if (!res.ok) throw new Error(`freeBusy failed: ${res.status} ${await res.text()}`);
  const data = (await res.json()) as { calendars: Record<string, CalendarEntry> };
  return data.calendars;
}

export async function createEvent(calendarId: string, event: Record<string, unknown>) {
  const res = await fetch(
    `${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events`,
    { method: 'POST', headers: await authedHeaders(), body: JSON.stringify(event) },
  );
  if (!res.ok) throw new Error(`events.insert failed: ${res.status} ${await res.text()}`);
  return (await res.json()) as { id: string; htmlLink?: string };
}

Ambas llamadas a la API comparten ahora una ruta de token y una estructura de error, y la variable de entorno proporciona la lista de equipo.

Para cada punto de conexión de espacio candidato, el código solicita la hora local y el día de la semana en , lo cual se mantiene correcto a pesar de los cambios de horario de verano.Intl.DateTimeFormatBOOKING_TIMEZONE

Coloca la lógica de los espacios en :lib/availability.ts

import type { Busy, CalendarEntry, Teammate } from './calendar';

export const SLOT_MS = 30 * 60 * 1000;

export type Slot = { start: string; end: string; available: string[] };

function localParts(date: Date, timeZone: string) {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone,
    hour: 'numeric',
    minute: 'numeric',
    hour12: false,
    weekday: 'short',
  }).formatToParts(date);
  const rawHour = Number(parts.find((p) => p.type === 'hour')?.value ?? 0);
  const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? 0);
  const weekday = parts.find((p) => p.type === 'weekday')?.value ?? '';
  return { hour: rawHour === 24 ? 0 : rawHour, minute, weekday };
}

function overlaps(start: number, end: number, busy: Busy[]) {
  return busy.some((b) => start < Date.parse(b.end) && end > Date.parse(b.start));
}

export function isBookableSlot(startMs: number): boolean {
  if (startMs % SLOT_MS !== 0) return false;
  const timeZone = process.env.BOOKING_TIMEZONE ?? 'UTC';
  const startHour = Number(process.env.BOOKING_START_HOUR ?? 9);
  const endHour = Number(process.env.BOOKING_END_HOUR ?? 17);
  const start = localParts(new Date(startMs), timeZone);
  const end = localParts(new Date(startMs + SLOT_MS), timeZone);
  if (start.weekday === 'Sat' || start.weekday === 'Sun') return false;
  if (end.weekday === 'Sat' || end.weekday === 'Sun') return false;
  const startMinutes = start.hour * 60 + start.minute;
  const endMinutes = end.hour * 60 + end.minute;
  return startMinutes >= startHour * 60 && endMinutes <= endHour * 60;
}

export function openSlots(
  timeMin: string,
  timeMax: string,
  team: Teammate[],
  calendars: Record<string, CalendarEntry>,
): Slot[] {
  const first = Math.ceil(Date.parse(timeMin) / SLOT_MS) * SLOT_MS;
  const last = Date.parse(timeMax);
  const slots: Slot[] = [];

  for (let t = first; t + SLOT_MS <= last; t += SLOT_MS) {
    if (!isBookableSlot(t)) continue;

    const available = team
      .filter((m) => !overlaps(t, t + SLOT_MS, calendars[m.calendarId]?.busy ?? []))
      .map((m) => m.name);

    if (available.length > 0) {
      slots.push({
        start: new Date(t).toISOString(),
        end: new Date(t + SLOT_MS).toISOString(),
        available,
      });
    }
  }
  return slots;
}

Cada espacio ahora incluye la lista de compañeros de equipo libres durante ese tiempo, lo cual la ruta de reserva utiliza para la asignación, y el cliente puede usar para mostrar cuántas personas están disponibles. Los fines de semana se omiten en el código, sin ninguna variable de entorno que los controle, y los espacios se alinean con límites UTC fijos, por lo que las zonas con incrementos de desfase fuera de ese límite pueden tener espacios desplazados.

Cálculo de espacios de tiempo reservables

El gestor de rutas descarta a cualquier miembro del equipo cuyo calendario devuelva errores. También rechaza rangos de tiempo invertidos o no admitidos y limita la ventana solicitada al máximo definido en la ruta, para que un visitante no pueda pedir un conjunto excesivamente grande de espacios en una sola llamada.

La ruta de disponibilidad en app/api/availability/route.ts conecta ambos módulos:

import { NextResponse } from 'next/server';
import { freeBusy, teammates } from '@/lib/calendar';
import { openSlots } from '@/lib/availability';

const MAX_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;

function parseIsoUtc(value: string | null): number | null {
  if (!value || !ISO_UTC.test(value)) return null;
  const timestamp = Date.parse(value);
  if (Number.isNaN(timestamp)) return null;
  const expected = value.includes('.') ? value : value.replace('Z', '.000Z');
  return new Date(timestamp).toISOString() === expected ? timestamp : null;
}

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const timeMin = searchParams.get('timeMin');
  const timeMax = searchParams.get('timeMax');
  const minMs = parseIsoUtc(timeMin);
  const maxMs = parseIsoUtc(timeMax);

  if (timeMin === null || timeMax === null || minMs === null || maxMs === null || maxMs <= minMs) {
    return NextResponse.json(
      { error: 'timeMin and timeMax must be increasing ISO 8601 UTC timestamps' },
      { status: 400 },
    );
  }
  if (maxMs - minMs > MAX_WINDOW_MS) {
    return NextResponse.json({ error: 'Request at most 14 days at a time' }, { status: 400 });
  }

  const team = teammates();
  const calendars = await freeBusy(timeMin, timeMax, team.map((m) => m.calendarId));
  const reachable = team.filter((m) => !calendars[m.calendarId]?.errors?.length);
  const slots = openSlots(timeMin, timeMax, reachable, calendars);

  return NextResponse.json({ slots, unreachable: team.length - reachable.length });
}

Con npm run dev en ejecución, abre http://localhost:3000/api/availability?timeMin=2026-10-05T00:00:00Z&timeMax=2026-10-07T00:00:00Z en un navegador.

Un cuerpo JSON con una matriz slots de ventanas de duración fija dentro del horario laboral y sin calendarios inalcanzables confirma que la ruta completa de solicitud de disponibilidad funciona. Si la respuesta informa de calendarios inalcanzables, es que una entrada en BOOKING_CALENDARS aún no se ha compartido con la cuenta de servicio.

5. Crea el gestor de rutas de reserva

La lista de disponibilidad que ve un visitante puede tener minutos de antigüedad, y es posible que un miembro del equipo haya aceptado una reunión mientras tanto. La ruta de reserva ejecuta su propia consulta de disponibilidad (libre/ocupado) solo para el rango solicitado antes de crear el evento en el calendario de un miembro del equipo.

Cualquier miembro del equipo que esté libre es apto para la asignación; si ninguno lo está, la ruta devuelve un 409 y el cliente puede actualizar la lista. La llamada de inserción de eventos de Google toma el cuerpo del evento directamente.

La nueva comprobación reduce la ventana, pero no la cierra. Dos visitantes que envían el mismo espacio al mismo tiempo obtienen la misma respuesta de libre/ocupado. Como el anfitrión se elige por posición y no al azar, ambas solicitudes eligen al mismo miembro del equipo: una doble reserva garantizada, no algo ocasional.

Para un tráfico bajo, es un caso extremo poco frecuente que puedes solucionar manualmente. Para algo con más movimiento, registra cada reserva en Webflow Cloud SQLite con una restricción única en el anfitrión y el inicio del espacio, e inserta esa fila antes de llamar a Google, para que la segunda solicitud falle en la base de datos en lugar de en el calendario de alguien.

La ruta divide la hora de inicio del espacio por la duración del mismo y toma ese número módulo el recuento de miembros del equipo libres, de modo que los espacios consecutivos rotan entre el equipo en lugar de recaer siempre en el primer nombre de la lista. Un recuento de reparto equitativo real necesitaría almacenamiento; el módulo distribuye los espacios de la mañana y la tarde entre diferentes personas sin necesidad de estado.

Enrutamiento y creación del evento de reserva

Crea :app/api/book/route.ts

import { NextResponse } from 'next/server';
import { createEvent, freeBusy, teammates } from '@/lib/calendar';
import { SLOT_MS, isBookableSlot } from '@/lib/availability';

type BookingBody = { start: string; end: string; name: string; email: string; notes?: string };

export async function POST(request: Request) {
  const body = (await request.json().catch(() => null)) as BookingBody | null;
  if (!body?.start || !body?.end || !body?.name || !body?.email) {
    return NextResponse.json(
      { error: 'start, end, name and email are required' },
      { status: 400 },
    );
  }

  const startMs = Date.parse(body.start);
  const endMs = Date.parse(body.end);
  if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs || startMs < Date.now()) {
    return NextResponse.json({ error: 'Invalid or past time range' }, { status: 400 });
  }
  if (endMs - startMs !== SLOT_MS) {
    return NextResponse.json({ error: 'Bookings must be exactly one slot long' }, { status: 400 });
  }
  if (!isBookableSlot(startMs)) {
    return NextResponse.json({ error: 'That time is outside bookable hours' }, { status: 400 });
  }
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
    return NextResponse.json({ error: 'Enter a valid email address' }, { status: 400 });
  }

  const team = teammates();
  const calendars = await freeBusy(body.start, body.end, team.map((m) => m.calendarId));
  const free = team.filter((m) => {
    const entry = calendars[m.calendarId];
    return entry && !entry.errors?.length && entry.busy.length === 0;
  });
  if (free.length === 0) {
    return NextResponse.json({ error: 'That slot was just taken' }, { status: 409 });
  }

  const host = free[Math.floor(startMs / SLOT_MS) % free.length];
  const timeZone = process.env.BOOKING_TIMEZONE ?? 'UTC';

  const event = await createEvent(host.calendarId, {
    summary: `Intro call with ${body.name}`,
    description: [
      'Booked from the website.',
      `Name: ${body.name}`,
      `Email: ${body.email}`,
      body.notes ? `Notes: ${body.notes}` : '',
    ].join('\n'),
    start: { dateTime: new Date(startMs).toISOString(), timeZone },
    end: { dateTime: new Date(endMs).toISOString(), timeZone },
  });

  return NextResponse.json({ host: host.name, eventId: event.id });
}

La ruta gestiona la validación y la creación del evento, con una nueva comprobación de libre/ocupado justo antes de la escritura. El nombre y el correo electrónico del visitante se incluyen en la descripción del evento; el evento no tiene asistentes, por lo que Google no envía ninguna invitación al visitante y el código no envía ninguna confirmación. Esto es deliberado.

Añadir al visitante como asistente es la mejora obvia, pero si una cuenta de servicio simple puede invitar a personas depende de la delegación a nivel de dominio, algo que esta versión evita; pruébalo con tu propia cuenta antes de confiar en ello.

La forma más fiable de confirmar la reserva al visitante es a través de tu propio correo electrónico, por ejemplo, con Postmark en Webflow Cloud.

Ambas rutas aceptan solicitudes sin autenticación y la ruta de reserva escribe en calendarios reales. Trata esta implementación como una versión de prueba privada y mantenla así hasta que se implementen la protección contra bots y los límites de tasa.

Un despliegue público también necesita controles de cuota que detengan el tráfico abusivo. La ruta ya rechaza cualquier inicio fuera del horario laboral, en fin de semana o fuera de los límites de un espacio, así como cualquier correo electrónico mal formado, antes de llamar a createEvent.

Prueba de la ruta de reserva con cURL

Pruébala con curl frente a un espacio de disponibilidad devuelto después de reemplazar las marcas de tiempo por unas reales:

curl -X POST http://localhost:3000/api/book \
  -H "Content-Type: application/json" \
  -d '{"start":"2026-10-05T14:00:00.000Z","end":"2026-10-05T14:30:00.000Z","name":"Test Visitor","email":"visitor@example.com"}'

La respuesta nombra al anfitrión y devuelve el ID del evento de Google, y el evento aparece en el calendario de ese miembro del equipo. Enviar la misma solicitud de nuevo reserva al siguiente miembro del equipo libre, ya que solo el primero está ocupado ahora.

La ruta devuelve un 409 una vez que todos los miembros del equipo en BOOKING_CALENDARS están ocupados para ese espacio, y configurar la variable en una sola entrada te permite ver el 409 en la segunda solicitud.

6. Conecta la página de reservas y despliégala en Webflow Cloud

Antepón a las peticiones del navegador una explícita, ya que Webflow Cloud monta la aplicación en una ruta de tu sitio. La página es un componente de cliente que busca los espacios disponibles para la próxima semana y publica el elegido.NEXT_PUBLIC_BASE_PATH

Webflow Cloud inyecta la ruta de montaje durante el tiempo de compilación para el enrutamiento de la aplicación; deja sin cambios.next.config

Reemplaza con esto:app/page.tsx

'use client';

import { useEffect, useState } from 'react';

const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? '';

type Slot = { start: string; end: string; available: string[] };
type ApiResponse = { slots?: Slot[]; host?: string; error?: string };

export default function BookingPage() {
  const [slots, setSlots] = useState<Slot[]>([]);
  const [selected, setSelected] = useState<Slot | null>(null);
  const [status, setStatus] = useState('');

  useEffect(() => {
    let cancelled = false;

    async function loadSlots() {
      setSlots([]);
      setSelected(null);
      setStatus('Loading availability...');

      const timeMin = new Date();
      const timeMax = new Date(timeMin.getTime() + 7 * 24 * 60 * 60 * 1000);

      try {
        const res = await fetch(
          `${BASE}/api/availability?timeMin=${timeMin.toISOString()}&timeMax=${timeMax.toISOString()}`,
        );
        const data = (await res.json().catch(() => null)) as ApiResponse | null;

        if (!res.ok) throw new Error(data?.error ?? 'Could not load availability');
        if (!data || !Array.isArray(data.slots)) throw new Error('Invalid availability response');

        if (!cancelled) {
          setSlots(data.slots);
          setStatus('');
        }
      } catch {
        if (!cancelled) {
          setSlots([]);
          setSelected(null);
          setStatus('Could not load availability');
        }
      }
    }

    void loadSlots();
    return () => {
      cancelled = true;
    };
  }, []);

  async function book(formData: FormData) {
    if (!selected) return;
    setStatus('Booking...');

    try {
      const res = await fetch(`${BASE}/api/book`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          start: selected.start,
          end: selected.end,
          name: formData.get('name'),
          email: formData.get('email'),
        }),
      });
      const data = (await res.json().catch(() => null)) as ApiResponse | null;

      if (!res.ok) {
        setStatus(data?.error ?? 'Could not complete booking');
        return;
      }
      if (!data || typeof data.host !== 'string') {
        throw new Error('Invalid booking response');
      }

      setStatus(`Booked with ${data.host}`);
    } catch {
      setStatus('Could not complete booking');
    }
  }

  return (
    <main>
      <h1>Book a call</h1>
      <ul>
        {slots.map((slot) => (
          <li key={slot.start}>
            <button type="button" onClick={() => setSelected(slot)}>
              {new Date(slot.start).toLocaleString()} ({slot.available.length} available)
            </button>
          </li>
        ))}
      </ul>
      {selected && (
        <form action={book}>
          <input name="name" placeholder="Your name" required />
          <input name="email" type="email" placeholder="you@example.com" required />
          <button type="submit">Confirm {new Date(selected.start).toLocaleTimeString()}</button>
        </form>
      )}
      <p>{status}</p>
    </main>
  );
}

La página se renderiza en la hora local del visitante a través de , mientras que el servidor sigue evaluando el horario laboral en la zona horaria del equipo. Como resultado, los visitantes de otras regiones ven las horas correctas sin necesidad de código de conversión por tu parte.toLocaleString

Prepara el proyecto sin . En Webflow Cloud, configura un proyecto y un entorno para esta aplicación siguiendo la documentación para desarrolladores de Webflow, elige una ruta de montaje como , y añade las variables de , con configurado como la ruta de montaje..env.local/book.env.localNEXT_PUBLIC_BASE_PATH

Almacena como un secreto para que se oculte de los registros de compilación y, a continuación, despliégalo en un entorno de prueba privado.GOOGLE_PRIVATE_KEY

La lista de variables del entorno debería mostrar coincidiendo con la ruta de montaje.NEXT_PUBLIC_BASE_PATH

Cuando finalice la compilación, abre en tu despliegue de prueba de Webflow Cloud. La lista de espacios se cargará, al seleccionar uno se mostrará el formulario y, al confirmarlo, se creará un evento en el calendario de un compañero y aparecerá el mensaje "Reservado con" seguido de su nombre./book

¿Por qué falla la reserva de Google Calendar en Webflow Cloud?

Los fallos se agrupan en cuatro puntos: Google rechaza el JWT firmado, un calendario que la cuenta de servicio no puede ver, una petición del cliente que ignora la ruta de montaje y una directiva de tiempo de ejecución de Next.js que el adaptador OpenNext se niega a compilar.

Los dos primeros se pueden reproducir localmente con ; el fallo de la ruta de montaje depende del montaje en Webflow Cloud, y el fallo de la directiva de tiempo de ejecución ocurre durante la compilación en Webflow Cloud.npm run dev

Google devuelve un error 400 invalid_grant cuando la aplicación solicita un token

Causa: Google devuelve después de que el JWT firmado llega a su punto final de token y la aserción es rechazada. Comprueba si pertenece a una cuenta de servicio diferente a la de la clave. Verifica también que la clave no haya sido eliminada o rotada en la pestaña de Claves de la cuenta. La documentación de cuentas de servicio de Google define los valores permitidos para y .invalid_grantGOOGLE_CLIENT_EMAILiatexp

Una clave corrupta o con doble escape nunca llega a Google: o lanzan un error dentro de primero, y la línea existe para evitarlo.atobcrypto.subtle.importKeylib/google.ts.replace(/\\n/g, '\n')

Solución: Abre la cuenta de servicio en la consola de Google Cloud y confirma que su correo electrónico coincide con y que el ID de clave en tu archivo JSON sigue apareciendo en la pestaña de Claves. Si la clave fue eliminada, crea una nueva y reemplaza ambas variables con las del nuevo archivo.GOOGLE_CLIENT_EMAIL

Mantén en , ya que lo establece. Vuelve a desplegar después de cambiar cualquiera de las variables y lee el texto completo del error que incluye el asistente en su mensaje de excepción. Si el error proviene de o , vuelve a pegar el valor de directamente desde el archivo JSON con sus marcadores y y el final.expiat + 3600lib/google.tsimportKeyatobprivate_key-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----\n

Un compañero aparece libre en todos los espacios, incluso en los que sabes que están ocupados

Causa: Una respuesta de disponibilidad puede incluir un array junto con un array vacío para un calendario ilegible. Cualquier código que solo lea trata ese calendario como totalmente abierto, por lo que la persona que nunca compartió su calendario se convierte en el miembro del equipo con más disponibilidad.errorsbusybusy

La solicitud en sí puede tener éxito, por lo que no se activa nada en el manejo de errores de la ruta. filtra en precisamente por esta razón, y el recuento de en la respuesta es tu advertencia temprana.app/api/availability/route.tserrorsunreachable

Solución: Comprueba el campo en la respuesta de disponibilidad. Si es mayor que cero, identifica qué entrada en está afectada llamando al endpoint de libre/ocupado con un ID de calendario a la vez, y luego pide a ese compañero que comparta el calendario con la cuenta de servicio con el nivel de permiso "Realizar cambios en eventos".unreachableBOOKING_CALENDARS

Para un calendario secundario, confirma que el ID copiado de su página de configuración coincide carácter por carácter con la variable. Un error tipográfico en el correo electrónico es otra causa común, y a simple vista parecen idénticos.

La página desplegada no muestra espacios y /api/availability devuelve un error 404

Causa: Un prefijo de ruta de montaje (mount-path) faltante o incorrecto explica este 404. La documentación de Webflow Cloud sobre cómo traer tu propia aplicación (bring-your-own-app) detalla este requisito. Next.js integra los valores de en el paquete del cliente durante la compilación, y como Webflow Cloud hace que las variables de entorno estén disponibles en ese momento, cualquier cambio requiere un nuevo despliegue.NEXT_PUBLIC_

Solución: Configura en el entorno de Webflow Cloud con la ruta de montaje exacta, incluyendo la barra diagonal inicial y sin una al final, y confirma que ambas llamadas en utilizan el prefijo .NEXT_PUBLIC_BASE_PATHfetchapp/page.tsxBASE

Vuelve a desplegar para que el nuevo valor se compile en el paquete y recarga la página con la pestaña de red abierta; la URL de la solicitud debería mostrar ahora . Si cambias el nombre de la ruta de montaje más adelante, la variable deberá actualizarse también./book/api/availability

La compilación en Webflow Cloud falla en un Route Handler que establece el tiempo de ejecución edge

Causa: La documentación de Webflow Cloud usa el término "edge" para dos conceptos distintos. Webflow Cloud se ejecuta en Cloudflare Workers, que es una plataforma edge, y la documentación describe esto como un despliegue "usando el tiempo de ejecución Edge".

El objetivo de tiempo de ejecución de Next.js, configurado con , es una función independiente de Next.js, y Webflow Cloud despliega Next.js a través del adaptador OpenNext Cloudflare, que no es compatible con ella.edgeexport const runtime = 'edge'

Un lector que sigue la frase de la plataforma hasta su aparente conclusión añade la directiva y la compilación se rompe. La misma página sigue recomendando actualmente la directiva para rutas de API, por lo que seguir la documentación al pie de la letra reproduce el fallo. Considero cualquier exportación de en un proyecto de Webflow Cloud como un error que debe eliminarse.runtime = 'edge'

Solución: Elimina cada línea de los Route Handlers, páginas y diseños, y vuelve a compilar. Webflow Cloud ya ejecuta toda la aplicación en Workers, por lo que la directiva no aporta nada. Si añades middleware más adelante, mantén el nombre de archivo .export const runtime = 'edge'middleware.ts

La documentación del framework de Webflow para Next.js indica: "El middleware de tiempo de ejecución de Node.js no es compatible. Solo el middleware de tiempo de ejecución Edge funciona en el entorno de ejecución de Workers".

Qué puedes construir a continuación con Google Calendar y Webflow

Si los especialistas en marketing necesitan cambiar la lista de anfitriones sin realizar un nuevo despliegue, traslada a una colección de CMS que la aplicación lea a través de la API REST de Webflow en el momento de la solicitud. Antes de crear la lista basada en CMS, utiliza la documentación para desarrolladores de Webflow como referencia para las llamadas a la API de CMS y el tiempo de ejecución de Webflow Cloud.BOOKING_CALENDARS

Preguntas frecuentes

¿Esto funciona con calendarios personales de Gmail o el equipo necesita Google Workspace?

Ambos funcionan y el equipo puede combinarlos: comparte un calendario personal de Gmail o uno de Workspace con la cuenta de servicio y otorga permisos de "Realizar cambios en eventos". El inconveniente está en el lado de Workspace. Si el administrador limita el uso compartido externo a solo libre/ocupado, los usuarios no pueden conceder acceso de edición a la cuenta de servicio, y el administrador no puede hacer una excepción para una sola cuenta: la configuración se aplica a toda la organización, unidad organizativa o grupo, y elevarla permite a los usuarios compartir la edición con cualquier persona externa.

¿Cómo evito que los bots llenen los calendarios del equipo?

Mantén tu despliegue privado hasta que añadas tanto un desafío de formulario verificado como límites de tasa persistentes. Debes validar el token del desafío en antes de realizar la solicitud de libre/ocupado. Almacena los contadores en la misma base de datos SQLite indexada por IP o correo electrónico (no en el almacén de clave-valor, cuyas escrituras pueden tardar hasta 60 segundos en propagarse), ya que un contador de ámbito de módulo no puede cubrir de forma fiable las solicitudes gestionadas por diferentes instancias de Worker./api/book


Última actualización
October 2, 2026
Categoría

Artículos relacionados


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo

Empieza gratis

Prueba Webflow todo el tiempo que quieras con nuestro plan Starter gratuito. Compra un plan de sitio de pago para publicar, alojar y desbloquear funciones adicionales.

Empieza, es gratis
Ver demostración

Prueba Webflow todo el tiempo que quieras con nuestro plan Starter gratuito. Compra un plan de sitio de pago para publicar, alojar y desbloquear funciones adicionales.