ReservaYa ya cobra, pero Julián sigue haciendo trabajo de robot: confirma cada cita por WhatsApp a mano y llama el día antes para recordar. Son 40 mensajes al día. Y cuando se le olvida uno, esa silla se queda vacía.
Todo lo que Julián hace copiando y pegando mensajes, una API lo hace en milisegundos. Integrar es delegar: email, WhatsApp y recordatorios pasan a ser código.
1.1. Anatomía de una llamada a API externa
Ya consumiste APIs sin notarlo: Supabase y Wompi lo son. El patrón general de cualquier integración desde el servidor:
// El esqueleto de TODA integración externa
async function llamarApiExterna() {
const respuesta = await fetch("https://api.servicio.com/v1/recurso", {
method: "POST",
headers: {
// 1. Autenticación: la API key SIEMPRE desde variables de entorno
Authorization: `Bearer ${process.env.SERVICIO_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ dato: "valor" }),
});
// 2. Manejo de errores: las APIs externas FALLAN — planéalo
if (!respuesta.ok) {
const error = await respuesta.text();
console.error(`API servicio falló (${respuesta.status}):`, error);
return null; // el negocio decide qué hacer con el fallo
}
return respuesta.json();
}
Una integración externa nunca debe tumbar tu flujo principal. Si el email de confirmación falla, la cita igual queda confirmada (y el envío se reintenta). Si el flujo fuera al revés — cita condicionada al email — un fallo de un tercero paralizaría tu negocio.
| Concepto | Qué significa | Qué haces al respecto |
|---|---|---|
| API key | Tu credencial ante el servicio | Variable de entorno de servidor, jamás en el código ni en el navegador |
| Rate limit | Máximo de peticiones por minuto | Respeta el límite; agrupa envíos; maneja el error 429 |
| Timeout | La API no responde | Define un tiempo máximo y un plan B (cola de reintentos) |
| Sandbox | Entorno de prueba del servicio | Desarrolla siempre ahí; producción solo con el flujo probado |
Un email transaccional responde a una acción del usuario: confirmación de cita, recibo de pago, recuperación de clave. Se envía con servicios especializados (Resend, SendGrid, Brevo) — nunca desde tu Gmail personal, que no está hecho para eso y termina en spam.
2.1. Configurar Resend
-
Crea la cuenta y la API key
En
resend.comel plan gratuito da 100 emails/día — suficiente para la barbería. Copia la API key a.env.localcomoRESEND_API_KEY. -
Instala el SDK
npm install resend -
Verifica tu dominio (en producción)
Para enviar desde
citas@donbarbas.coagregarás 3 registros DNS (SPF, DKIM) en el módulo 6. Mientras tanto, Resend te presta un remitente de pruebas.
2.2. El email de confirmación de cita
// lib/notificaciones/email.ts
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
interface DatosConfirmacion {
para: string;
nombreCliente: string;
servicio: string;
barbero: string;
fecha: string; // ya formateada: "jueves 9 de julio, 3:00 pm"
}
export async function enviarConfirmacionCita(datos: DatosConfirmacion) {
try {
await resend.emails.send({
from: "Don Barbas <citas@donbarbas.co>",
to: datos.para,
subject: `✂️ Cita confirmada: ${datos.servicio} — ${datos.fecha}`,
html: `
<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
<h2>¡Listo, ${datos.nombreCliente}!</h2>
<p>Tu cita en <strong>Don Barbas</strong> quedó confirmada:</p>
<ul>
<li><strong>Servicio:</strong> ${datos.servicio}</li>
<li><strong>Barbero:</strong> ${datos.barbero}</li>
<li><strong>Fecha:</strong> ${datos.fecha}</li>
<li><strong>Dirección:</strong> Cra 35 #8A-15, Medellín</li>
</ul>
<p>Si no puedes asistir, cancela con 2 horas de anticipación
para conservar tu anticipo.</p>
</div>
`,
});
return { ok: true };
} catch (error) {
// La cita NO se cae porque el email falle
console.error("Fallo envío de confirmación:", error);
return { ok: false };
}
}
¿Y dónde se llama? En el webhook del módulo 4, justo después de confirmar la cita — el momento en que el negocio sabe que el pago es real:
// app/api/webhooks/wompi/route.ts (fragmento nuevo)
if (nuevoEstado === "APROBADO") {
await supabaseAdmin.from("citas")
.update({ estado: "confirmada" })
.eq("id", pago.cita_id);
// Integración: confirmar por email (sin bloquear la respuesta del webhook)
const { data: detalle } = await supabaseAdmin
.from("citas")
.select("inicia_en, perfiles(nombre), servicios(nombre), barberos(nombre)")
.eq("id", pago.cita_id)
.single();
if (detalle) {
enviarConfirmacionCita({ /* ...mapear campos... */ }).catch(console.error);
}
}
En Latinoamérica el email se ignora, pero WhatsApp se lee en minutos. La WhatsApp Business Platform (Cloud API de Meta) permite enviar mensajes desde código. Tiene reglas propias:
3.1. Enviar el recordatorio de cita
// lib/notificaciones/whatsapp.ts
// WhatsApp Cloud API de Meta — plantilla "recordatorio_cita" ya aprobada
export async function enviarRecordatorioWhatsApp(
celular: string, // formato internacional sin +: "573001234567"
nombre: string,
fecha: string,
barbero: string
) {
const url = `https://graph.facebook.com/v20.0/${process.env.WHATSAPP_PHONE_ID}/messages`;
const respuesta = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to: celular,
type: "template",
template: {
name: "recordatorio_cita", // plantilla aprobada en Meta
language: { code: "es_CO" },
components: [{
type: "body",
// Variables {{1}}, {{2}}, {{3}} de la plantilla
parameters: [
{ type: "text", text: nombre },
{ type: "text", text: fecha },
{ type: "text", text: barbero },
],
}],
},
}),
});
if (!respuesta.ok) {
console.error("WhatsApp falló:", await respuesta.text());
return { ok: false };
}
return { ok: true };
}
Si el papeleo de Meta te frena hoy, existe un atajo digno: el botón "Confirmar por WhatsApp" con https://wa.me/57300XXXXXXX?text=Hola,%20confirmo%20mi%20cita%20RSV-1234 abre el chat del negocio con mensaje pre-escrito — el cliente inicia la conversación y no necesitas API. Muchos negocios arrancan así y migran a la API cuando el volumen lo justifica.
Dos reglas de negocio de ReservaYa no las dispara ningún usuario — las dispara el reloj:
- Recordatorio: 24 horas antes de cada cita confirmada, enviar WhatsApp.
- Liberación: las citas en
pendiente_pagocon más de 15 minutos se cancelan y el horario vuelve a estar disponible.
Para esto existen los cron jobs: tareas que corren en un horario fijo. En Vercel se declaran en vercel.json y llaman a una ruta de tu app:
// vercel.json — en la raíz del proyecto
{
"crons": [
{ "path": "/api/cron/recordatorios", "schedule": "0 * * * *" },
{ "path": "/api/cron/liberar-citas", "schedule": "*/15 * * * *" }
]
}
// app/api/cron/recordatorios/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { enviarRecordatorioWhatsApp } from "@/lib/notificaciones/whatsapp";
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function GET(request: Request) {
// Solo Vercel Cron puede invocar esta ruta
if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "no autorizado" }, { status: 401 });
}
// Citas confirmadas que empiezan entre 23 y 25 horas desde ahora
// y que aún no tienen recordatorio enviado
const en23h = new Date(Date.now() + 23 * 3600_000).toISOString();
const en25h = new Date(Date.now() + 25 * 3600_000).toISOString();
const { data: citas } = await supabaseAdmin
.from("citas")
.select("id, inicia_en, perfiles(nombre, telefono), barberos(nombre)")
.eq("estado", "confirmada")
.is("recordatorio_enviado_en", null)
.gte("inicia_en", en23h)
.lte("inicia_en", en25h);
for (const cita of citas ?? []) {
const perfil = cita.perfiles as any;
if (!perfil?.telefono) continue;
const { ok } = await enviarRecordatorioWhatsApp(
`57${perfil.telefono}`,
perfil.nombre,
new Date(cita.inicia_en).toLocaleString("es-CO"),
(cita.barberos as any)?.nombre ?? "tu barbero"
);
// Marcamos el envío: idempotencia también en los crons
if (ok) {
await supabaseAdmin.from("citas")
.update({ recordatorio_enviado_en: new Date().toISOString() })
.eq("id", cita.id);
}
}
return NextResponse.json({ procesadas: citas?.length ?? 0 });
}
Agrega recordatorio_enviado_en timestamptz a la tabla citas (alter table citas add column recordatorio_enviado_en timestamptz;). Sin ella, cada corrida del cron reenviaría los mismos recordatorios. Es el mismo principio del webhook del módulo 4: toda tarea automática debe poder correr dos veces sin duplicar efectos.
- Bloquear el flujo principal esperando la integración. Solución: la cita se confirma primero; el email/WhatsApp se dispara después y sus fallos se registran, no se propagan.
- Enviar marketing por la plantilla transaccional. Solución: Meta suspende números que abusan. Las plantillas transaccionales son para eso: transacciones.
- Exponer una ruta de cron sin protección.
Solución: verifica
CRON_SECRETen el header. Una ruta de cron abierta es una API pública que cualquiera puede disparar. - Ignorar los fallos silenciosos.
Solución: registra cada envío fallido (tabla
notificaciones_fallidaso al menosconsole.error) y revisa los logs semanalmente.
La Dra. Sandra tiene un consultorio en Guadalajara. Sus datos: 30% de inasistencia en citas de valoración (gratuitas) y 10% en tratamientos (pagados). Cada silla vacía cuesta $800 MXN.
- Diseña la secuencia de notificaciones para cada tipo de cita: qué canal (email/WhatsApp), cuándo (al agendar, 48 h antes, 3 h antes) y qué dice cada mensaje.
- Escribe la plantilla de WhatsApp "recordatorio_valoracion" con sus variables {{1}}, {{2}}, {{3}}, lista para enviar a aprobación de Meta.
- Define la regla de negocio que reduce la inasistencia de valoraciones: ¿pedir confirmación con botón? ¿liberar el cupo si no confirma 24 h antes? Escríbela como "si X, entonces Y".
El catálogo de integraciones del módulo (recursos descargables) incluye precios y límites de cada servicio.
4.1. Mini-proyecto del módulo
Tu app ahora se comunica sola: email de confirmación vía Resend disparado desde el webhook de pago aprobado, recordatorio de WhatsApp (Cloud API o, como plan B, botón wa.me), cron de recordatorios cada hora con su columna de idempotencia, y cron que libera citas no pagadas cada 15 minutos. Haz una reserva de prueba con tu propio correo y verifica que el email llega después de aprobar el pago sandbox.
- Toda integración sigue el mismo esqueleto: fetch + API key en variable de entorno + manejo explícito del fallo.
- Las integraciones nunca bloquean el negocio: la cita se confirma aunque el email falle; el fallo se registra y reintenta.
- WhatsApp API tiene reglas propias: plantillas aprobadas para iniciar, ventana de 24 h para conversar, y wa.me como plan B sin papeleo.
- El reloj también dispara lógica de negocio: cron jobs protegidos con secreto e idempotentes, para recordar citas y liberar horarios.